# dig-merkle — Normative Specification
This document is the authoritative contract for `dig-merkle`, the DIG Network canonical CHIP-0035
DataLayer coin expert crate. An independent reimplementation could be built against this spec. It is
normative: it states what IS and what an implementation MUST/SHOULD do. Cross-references:
`SYSTEM.md` (cross-repo interaction map) and the docs.dig.net protocol pages.
## 1. Scope & invariants
dig-merkle builds the exact `CoinSpend`s for every lifecycle operation of a Chia CHIP-0035 DataLayer
singleton — the on-chain coin that anchors a `.dig` capsule's merkle root — and reports the exact
signatures a caller must produce. It is a pure library: no keys, no signing, no network.
Four invariants hold across the entire crate:
- **INV-1 — No network.** dig-merkle performs NO network or chain I/O of its own and depends on no
network or async-runtime crate. Spend-builders are pure transforms of their inputs. Read/resolve
operations (`resolve_owner_did`) delegate ALL chain access to a caller-supplied `ChainSource` — an
injected, synchronous read interface the caller implements over its own client — so dig-merkle
itself still opens no socket and holds no client.
- **INV-2 — No keys.** dig-merkle never accepts, holds, derives, or logs a secret key. It computes
what must be signed (`required_signatures`); the caller's signer produces the signatures.
- **INV-3 — Unsigned output.** Every operation returns an unsigned `MerkleCoinSpend` — the coin
spends plus the recreated child `DataStore`. Signatures are always the caller's responsibility.
- **INV-4 — SDK byte-source-of-truth.** Every puzzle, layer, and coin-spend byte is produced by
`chia-wallet-sdk` (pinned to the 0.34 / chia-protocol 0.36.1 family, `chip-0035` feature).
dig-merkle adds workflow ergonomics on top; it never re-implements a puzzle or hand-rolls a spend
bundle, and re-exports the SDK's DataStore types verbatim (no shadow copy).
## 2. The DataLayer-coin model
A DataLayer coin is a CHIP-0035 **singleton** (an NFT-state-layer singleton with the DataLayer
metadata updater). Its structure:
- **`launcher_id == store_id`.** The singleton launcher coin id IS the DIG `store_id`. It is
permanent and uniquely names the store for the coin's entire lineage.
- **`DigDataStoreMetadata`** carries the anchored state. It mirrors the SDK's `DataStoreMetadata`
shape but REPLACES the exact-byte `bytes`/`"b"` field with a power-of-2 `size_bucket` (`"sz"`), and
adds `program_hash` (`"p"`):
- `root_hash: Bytes32` — the `.dig` capsule's merkle root (the anchored value). REQUIRED; first atom.
- `label: Option<String>`, `description: Option<String>` — human metadata (CLVM keys `l`, `d`).
- `size_proof: Option<String>` — an optional size attestation (CLVM key `sp`).
- `program_hash: Option<Bytes32>` — the CLVM tree-hash of the program/puzzle associated with the
store/capsule (CLVM key `p`, appended after `sp`, only when `Some`). dig-merkle STORES and ECHOES
it only — it never computes it (producers compute it via `clvm_utils::tree_hash`/`ToTreeHash`).
- `size_bucket: Option<SizeBucket>` — the store's size as a power-of-2 bucket (CLVM key `sz`,
appended LAST, only when `Some`). This is the ONE size field — it REPLACES the SDK's exact-byte
`"b"` field: **dig-merkle never emits `"b"`**. A `SizeBucket` is a validated exponent `k ∈ 0..=10`
mapping to `2^k MB`, where **1 MB = 1 MiB = 2^20 bytes** — so the ladder is 1 MB (k=0) … 1024 MB =
1 GB (k=10). On the wire the value is the exponent encoded as a MINIMAL CLVM integer (NC-8): the
empty atom for `k=0`, a single byte `0x01`..`0x0a` for `k=1`..`10`. `SizeBucket::for_byte_len(bytes)`
is the CANONICAL byte→bucket mapping for the MINT INPUT path (the smallest `k` with `2^(k+20) ≥
bytes`; 0/1 byte → k=0, exactly 1 GiB → k=10, `> 2^30` → error) so dig-store never re-derives the
ladder and drifts.
- There is NO `bytes` field. `"b"` is deliberately dropped (pre-release clean replacement: there are
no on-chain DIG stores carrying `"b"`).
**INV-4 byte-identity + SDK interop.** With `size_bucket == None && program_hash == None` the CLVM
encoding is IDENTICAL to the SDK's `DataStoreMetadata` with `bytes == None` (it emits `l`/`d`/`sp`
only, never `"b"`), so a plain DIG store is byte-for-byte an ordinary DataLayer store. An SDK-typed
reader decoding a `p`/`sz`-bearing store ignores the unknown keys; a `DigDataStoreMetadata` reader
decoding an SDK store parses `root`/`l`/`d`/`sp` and IGNORES the SDK's `"b"` (a foreign `"b"` is not
a DIG size-proof) — yielding `size_bucket == None`. Reading an SDK store therefore still succeeds
(it decodes); the honest answer for a non-capsule is simply no bucket.
**`sz` decode is fail-closed (canonical minimal form).** A decoder accepts ONLY the canonical
minimal encoding of a value in `0..=10`: the empty atom (k=0) or a single byte `0x01`..`0x0a`. A
non-minimal encoding (a leading-zero atom like `[0x00]` or `[0x00,0x05]`), a byte `> 10`, or any
multi-byte atom is REJECTED (`FromClvmError::Custom`) — so the on-wire size has exactly one valid
representation and no producer can encode it two ways.
- **`delegated_puzzles: Vec<DelegatedPuzzle>`** grants write authority beyond the owner:
- `Admin(TreeHash)` — full control (may change the delegation set + root).
- `Writer(TreeHash)` — may update the root but not the delegation set.
- `Oracle(Bytes32, u64)` — anyone may spend the coin to read it, paying the fixed fee.
- **The owner** is the standard p2 (`Owner::Standard`) or a custom inner puzzle (`Owner::Custom`,
e.g. a DID-authorized delegated puzzle) that guards spending. `Owner::Custom` carries its own
conditions, so it is valid only for operations whose conditions the caller can build in advance —
the mint path rejects it (§3.1).
Spending the coin recreates it as its child with a (possibly) new root, delegation set, or owner —
or melts it (no child). This is the DIG anchor: publishing a new capsule root is a DataLayer update
that recreates the singleton with the new `root_hash`.
## 3. Operations catalogue
Every operation returns an unsigned `MerkleCoinSpend { coin_spends, child }` (INV-3) and states its
AGG_SIG requirement. U1 ships the foundation only; the operations below are the designed surface,
each landing in its own unit against the foundation.
### 3.1 mint
```
mint_datastore(parent_coin, owner, root_hash, label, description, size_proof,
program_hash, size_bucket, owner_puzzle_hash, delegated_puzzles, fee)
-> MerkleResult<MerkleCoinSpend>
```
There is NO `bytes` param — size is the `size_bucket`. `program_hash: Option<Bytes32>` and
`size_bucket: Option<SizeBucket>` are optional: with both `None` the mint is byte-identical to a plain
DataLayer store; `Some(h)` anchors the program tree-hash (CLVM key `p`) and `Some(bucket)` anchors the
size bucket (CLVM key `sz`). The returned `MerkleCoinSpend.child` is a `DataStore<DigDataStoreMetadata>`.
Launches a new DataLayer store singleton over `chia_wallet_sdk::driver::Launcher::mint_datastore`
(INV-4). `parent_coin` funds AND parents the launcher: its `coin_id` becomes the launcher's parent,
so `launcher_id == store_id` derives from it. Taking a `parent_coin` (not a full launcher) lets a
DID-authorized launch composed by a caller work here **without a `dig-did` dependency**; the edge
stays one-way (dig-identity → dig-merkle). A DID-rooted launch does NOT use `mint_datastore_with_kind`
with an `Owner::Custom` inner spend — that variant emits only the conditions the caller baked in, and
the launch conditions are built inside this call. `mint_datastore_with_kind` therefore REJECTS
`Owner::Custom` with `MerkleError::UnsupportedOwner`; a custom-owner caller uses
`mint_datastore_launch_with_kind` (§3.1a).
The construction, byte-for-byte:
1. `Launcher::new(parent_coin.coin_id(), 1).mint_datastore(ctx, DigDataStoreMetadata{root_hash,
label, description, size_proof, program_hash, size_bucket}, owner_puzzle_hash,
delegated_puzzles)` yields the launch conditions + the eve `DataStore`.
2. **Two-memo launcher-hint override (load-bearing).** The raw SDK mint emits only a single default
launcher hint, which matches NO store already on chain. dig-merkle rewrites the launcher
`CREATE_COIN` (the one to the singleton launcher puzzle hash
`eff07522495060c066f66f32acc2a77e3a3e737aca8baea4d1a64ea4cdc13da9`) so its memos are EXACTLY
`[digstore_owner_hint(owner_puzzle_hash), launcher_hint_for(kind)]` (first = indexed
kind-agnostic owner-discovery hint, second = the kind discriminator, §9) — replicating
chip35_dl_coin `store.rs` and digstore-chain `singleton.rs`. This override is the DEFAULT
behaviour, not opt-in. `mint_datastore` mints `StoreKind::File`, whose discriminator is the
unchanged `DATASTORE_LAUNCHER_HINT` — byte-identical to existing stores; `mint_datastore_with_kind`
selects a kind (#1263).
3. Change above `fee + 1` mojos returns to `owner_puzzle_hash`, hinted. The `fee` is paid
**implicitly** as (coins in − coins out) — there is NO explicit `RESERVE_FEE`, matching the
on-chain producers. The `fee + 1` reservation is a CHECKED add: a `fee` so large that `fee + 1`
would overflow `u64::MAX` fails closed with `MerkleError::Chain` rather than wrapping around.
4. `parent_coin` is spent with `owner`'s inner puzzle (`Owner::Standard` → `StandardLayer`). An
`Owner::Custom` mint is REJECTED at step 0 with `MerkleError::UnsupportedOwner`, before any
construction: a pre-built inner spend cannot emit the conditions built in step 1, so accepting it
would return a bundle that never creates the launcher coin.
### §3.1a `mint_datastore_launch_with_kind` — the composable launch
```rust
#[non_exhaustive]
pub struct DatastoreLaunch {
pub parent_conditions: Conditions,
pub datastore: DataStore<DigDataStoreMetadata>,
/// Whether this launch WROTE the launcher memos (the owner-discovery hint AND the
/// `StoreKind` discriminator, §9). `true` for a direct launch; `false` for an
/// intermediate launch, whose launcher `CREATE_COIN` this crate does not author.
/// Measured from the rewrite, never inferred.
pub launcher_memos_written: bool,
}
pub fn mint_datastore_launch_with_kind(
ctx: &mut SpendContext, kind: StoreKind, launcher: Launcher, root_hash: Bytes32,
label: Option<String>, description: Option<String>, size_proof: Option<String>,
program_hash: Option<Bytes32>, size_bucket: Option<SizeBucket>,
owner_puzzle_hash: Bytes32, delegated_puzzles: Vec<DelegatedPuzzle>,
) -> MerkleResult<DatastoreLaunch>;
```
Performs steps 1–2 above into the CALLER's `ctx` and returns the conditions the caller's parent-coin
spend MUST emit (the launcher `CREATE_COIN` with the two memos, plus the launcher's coin-announcement
assertion) — no change and no fee, which belong to whoever pays.
- The `ctx` MUST be the caller's own: `Conditions` hold CLVM node pointers valid only in the
allocator that built them, and the launcher-coin and eve-DataStore spends are staged into that same
context.
- The function MUST NOT drain the context. The caller adds its parent-coin spend and drains ONCE.
- `mint_datastore_with_kind` is defined as this function plus a standard-p2 parent spend carrying the
change, so both paths emit identical bytes.
**The launcher is the CALLER's, because the legal shape depends on the parent (#2418).** The launch
composition MUST be one of exactly two shapes, and the function verifies before returning that the
built `parent_conditions` actually reach `launcher.coin()`, failing closed with `MerkleError::Chain`
otherwise:
1. **Direct — an ORDINARY (non-singleton) parent.** `Launcher::new(parent_coin.coin_id(), 1)`. The
`parent_conditions` contain the launcher `CREATE_COIN` itself, and the two-memo owner-discovery
hint (§9) is written onto it. This is what `mint_datastore_with_kind` uses.
2. **Via an intermediate — a SINGLETON parent** (a DID, another DataStore, a vault singleton).
`IntermediateLauncher::new(parent_coin.coin_id(), 0, 1).create(ctx)?`. The `parent_conditions`
contain a `CREATE_COIN` for a ZERO-amount intermediate coin, whose own (already staged) spend
creates the 1-mojo launcher. The intermediate coin is matched by puzzle hash and amount; the
launcher coin is verified by full coin id. The parent coin's id is not an input, so a caller
that names the wrong parent when constructing `IntermediateLauncher` is not detected here.
A singleton's inner puzzle MAY emit exactly ONE odd-amount `CREATE_COIN` — its own successor — so
shape 1 from a singleton parent builds cleanly and is REJECTED on chain (a CLVM raise). Shape 2 is
therefore MANDATORY for a singleton parent, and shape 1 MUST NOT be used with one.
**Launcher legality.** Before building anything, the supplied `launcher` MUST be checked and the
launch MUST fail closed with `MerkleError::Chain` unless BOTH hold:
- `launcher.coin().puzzle_hash` is the singleton launcher puzzle hash. Any other puzzle hash never
mints a singleton, and the §9 memo override — which matches that same launcher coin — would
silently write no memos.
- `launcher.singleton_amount()` is ODD. This is the SINGLETON's amount, NOT the launcher coin's: the
launcher coin's amount is not an invariant (a 0-amount launcher minting a 1-mojo singleton is
legal), while an EVEN singleton amount produces a store that is ACCEPTED on chain and permanently
frozen — every later `update_root` and `melt` raises, so it can never be spent again and its mojos
are burned.
The odd-amount property MUST additionally be re-checked on the amount the launch ACTUALLY minted
(`datastore.coin.amount`) after construction, so the guard holds however the `Launcher` was
configured. Build time is the only point at which a frozen store is still reversible.
**The §9 memo override MUST target the launcher by full COIN ID**, not by the launcher puzzle hash: a
caller-supplied `Launcher` may carry base conditions containing another store's launcher
`CREATE_COIN`, and stamping that one too would index a store this launch does not own under this
owner hint and `StoreKind`.
**A launcher created via an intermediate carries NO owner-discovery memos.** The two-memo hint lives
on the launcher `CREATE_COIN`, which under shape 2 is emitted by the intermediate coin's own fixed
puzzle; dig-merkle cannot write memos onto it. Such a store is NOT discoverable by a launcher-memo
scan (§9) and — this includes its `StoreKind` discriminator — is discovered by the §3.7 lineage walk
instead. The `kind` argument is therefore ACCEPTED BUT NOT HONOURED under shape 2; the launch reports
this as `DatastoreLaunch.launcher_memos_written == false`, which a caller MUST check when
memo-scannability matters.
**The two shapes trade memo-scannability against lineage-resolvability, and a DID-rooted launch MUST
choose one.** Neither shape delivers both, and the choice is the caller's:
- **Shape 2 (intermediate)** is LINEAGE-RESOLVABLE — `resolve_owner_did` (§3.7) traverses the
intermediate hop and names the owning DID — but NOT memo-scannable: it writes no launcher memos, so
neither the owner hint nor the `StoreKind` discriminator reaches the chain.
- **`DID coin -> ordinary EVEN-amount coin -> launcher -> store`, with the ordinary coin launching by
shape 1**, is MEMO-SCANNABLE — both memos are written — but NOT lineage-resolvable: the launcher's
creator is an ordinary coin, which is neither a DID nor the recognised intermediate launcher, so
§3.7 returns `Ok(None)` and the store reports as not-DID-owned. This chain is legal on chain
because the one-odd-`CREATE_COIN` restriction binds the *singleton's* inner puzzle, not an
ordinary coin (**#2463**).
§3.7 MUST NOT be extended over that ordinary hop by inspection alone: an ordinary coin's outputs are
knowable only by EXECUTING its puzzle — the chain-supplied CLVM the walk exists to never run — and
accepting the parent claim unexamined would let any store whose launcher's parent happened to be
DID-created falsely claim DID ownership.
**Neither shape puts a DID reference on chain in a memo.** The owner-discovery hint encodes
`owner_puzzle_hash` (§9), not a DID, so a memo scan identifies the OWNER PUZZLE, never the DID; DID
attribution comes only from the §3.7 lineage walk.
**Bundle balance.** Under shape 2 a zero-amount coin creates a 1-mojo launcher, so the surrounding
spend bundle MUST supply that mojo from another spend. Chia balances a bundle in aggregate, not per
coin; a bundle without the surplus is rejected as a minting coin.
**Signing.** This function returns no coin spends of its own — the caller composes and signs the
parent spend it authorizes. The staged launcher and eve-DataStore spends require no signature; a
standard-p2 parent spend requires exactly one `AGG_SIG_ME` over the owner's synthetic key (never
`AGG_SIG_UNSAFE`), and a DID-authorized or otherwise custom parent owns its own requirement.
**Root encoding.** The anchored `root_hash` is the first atom of the NFT-state-layer metadata CLVM
`(root_hash . (("l" . label)? ("d" . description)? ("sp" . size_proof)? ("p" . program_hash)?
("sz" . size_exponent)?))`, produced by `DigDataStoreMetadata::to_clvm` (which mirrors the SDK's
`l`/`d`/`sp` keys but NEVER emits `"b"`, then appends `("p" . program_hash)` and finally
`("sz" . size_exponent)` LAST, each only when `Some` — never hand-rolled).
### 3.2 update
`update_root(store, owner, new_metadata)` recreates the coin with a new `root_hash` (and optional
metadata), preserving `launcher_id`, delegation set, and owner. Authorized by the owner OR a
`Writer`/`Admin` delegated puzzle. **Signing:** one `AGG_SIG_ME` over the owner key.
`Owner::Custom` MUST be REJECTED with `MerkleError::UnsupportedOwner`: the metadata-update and
recreation conditions are built inside the call, so a pre-built inner spend cannot contain them and
the returned bundle would ignore `new_metadata` or melt the store by omitting the recreation.
**`program_hash` on update:** metadata is replaced wholesale, so an update that means to KEEP a
store's `program_hash` MUST re-send it in `new_metadata`; omitting it DROPS the anchor (sets it back
to `None`).
### 3.3 delegation
`set_delegated_puzzles(store, owner, new_delegated_puzzles)` grants/revokes `Admin`/`Writer`/`Oracle`
authority. **Admin-only:** only the owner or an `Admin` delegated puzzle may change the set; a
`Writer` attempt MUST fail with `MerkleError::Permission`. **Signing:** the authorizing inner
puzzle's `AGG_SIG_ME`.
### 3.4 oracle
`oracle_spend(store)` spends the `Oracle` delegated puzzle so any party may read the coin on-chain,
paying the fixed oracle fee to the oracle puzzle hash. **Signing:** none from dig-merkle's owner
(the oracle puzzle is keyless); the caller supplies the fee.
### 3.5 melt
`melt(store, owner)` terminally spends the coin, producing no child (`child == None`). **Signing:**
the owner's `AGG_SIG_ME`. `Owner::Custom` MUST be REJECTED with `MerkleError::UnsupportedOwner`: the
`MELT_SINGLETON` condition is built inside the call, so a pre-built inner spend cannot contain it and
the returned bundle would melt nothing while reporting success.
**Authority MUST be gated before the spend is built.** `melt` MUST reject with
`MerkleError::NotTheOwner`, before it constructs a `SpendContext` or any condition, unless
`StandardArgs::curry_tree_hash(pk)` equals `store.info.owner_puzzle_hash` — the same commitment the
store's own puzzle enforces on chain for an owner spend. The gate MUST key on
`info.owner_puzzle_hash` and NOT on `coin.puzzle_hash`: a store carrying delegated puzzles wears a
delegation layer curried OVER the owner's p2 hash, so a gate on the coin's hash would refuse the
legitimate owner of a delegated store. Both store shapes — with and without delegated puzzles — MUST
admit their owner and refuse everyone else.
This is not a theft barrier; the chain is. A melt built with the wrong key can never confirm, because
the caller cannot produce the required `AGG_SIG_ME`. The gate exists because a melt is
**irreversible**: the builder MUST NOT hand back a fully-formed destructive spend against a store the
caller does not own, whose only symptom is an opaque mempool rejection distant from its cause.
**The melted amount is unrecoverable by construction.** The singleton top layer admits AT MOST ONE
odd-amount `CREATE_COIN`, and the melt magic condition `(51 () -113)` occupies it. The coin's amount
therefore cannot be paid out in this spend and becomes an implicit fee to the farmer — one mojo for a
conventional store. No recovery path exists at any layer, and none MUST be added.
**`Owner::Custom` is unusable across the whole public API.** A `Spend` holds CLVM node pointers valid
only in the allocator that built them, and no public operation exposes its `SpendContext` for a caller
to build one in. A custom/DID-authorized parent composes a launch through §3.1a instead, building its
own spend in its own context.
### 3.6 read
`read(store)` / `parse_coin_spend(...)` parse the current on-chain `DigDataStoreMetadata` +
delegation set from a coin/puzzle without spending. No signing.
`did_ref_from_spend(spend) -> MerkleResult<Option<DidRef>>` is the pure, network-free core of
owner-DID discovery (§3.7): it recognises whether a coin spend is a DID spend (via the SDK's
`Did::parse`, INV-4) and, if so, returns its `DidRef { launcher_id }`. No signing, no chain access.
Fail-closed, and the two failures MUST stay DISTINCT: a genuine non-DID puzzle yields `Ok(None)`,
while a spend the coin did not commit to — a `puzzle_reveal` that does not hash to `coin.puzzle_hash`
— yields `Err(MerkleError::Chain)`. "Not a DID" is an answer; "the source lied about the puzzle" is
not.
### 3.7 resolve_owner_did
```
resolve_owner_did<C: ChainSource>(store_id, chain) -> MerkleResult<Option<DidRef>>
```
Recovers the DID that OWNS a store (the complement of `dig-did` #1219: dig-did MINTS a DID-owned
store, this READS the ownership back). A store rooted in a DID has its launcher coin created by
spending a DID-authorized coin; `resolve_owner_did` walks that lineage up — one creator hop, or two
through an intermediate-launcher coin — and recognises the creator as a DID:
1. `chain.coin_spend(store_id)?` → the launcher coin's spend (`store_id == launcher_id`). Missing →
`Ok(None)`.
2. `parent_id = launcher_spend.coin.parent_coin_info` — the coin that created the launcher.
3. `chain.coin_spend(parent_id)?` → the creator's spend. Missing → `Ok(None)`.
4. `did_ref_from_spend(&creator_spend)` → `Some(DidRef{launcher_id})` if the creator was a DID.
5. Otherwise, IF the creator is the intermediate-launcher coin of a singleton-parent launch (§3.1a),
ONE further hop: `chain.coin_spend(creator_spend.coin.parent_coin_info)` →
`did_ref_from_spend(..)`. Missing → `Ok(None)`.
Every spend the walk consumes MUST be bound to the coin it claims to be, in BOTH directions: the
spend's `coin.coin_id()` MUST equal the id that was asked for, AND its `puzzle_reveal` MUST hash to
`coin.puzzle_hash`. A coin id is derived from the coin's own fields, so the first binding alone is
satisfied by any reveal whatsoever; without the second, a source that returns a genuine coin paired
with a forged reveal can have a DID attributed to a store that has none. A violation of EITHER binding
MUST yield `Err(MerkleError::Chain)`, never `Ok(None)`.
**The walk is BOUNDED at two creator hops, and the second MUST be earned.** A singleton parent
interposes an intermediate coin (§3.1a), so the DID sits two hops above the launcher; without step 5
a DID-rooted store resolves as NOT DID-owned. Step 5 MUST NOT be a general parent walk: an unbounded
climb over coin records an untrusted `ChainSource` controls is a DoS, and it would attribute an
ordinary store to a DID that merely created its funding coin. The step is taken ONLY when the creator
IS that intermediate, recognised by its PUZZLE:
- the creator coin's amount is 0, and its `puzzle_reveal` hashes to its own `coin.puzzle_hash`;
- that reveal uncurries to the `nft_intermediate_launcher` mod hash with its curried
`launcher_puzzle_hash` bound to the singleton launcher puzzle hash; and
- the launcher that puzzle NECESSARILY creates — `Coin::new(creator.coin_id(), launcher_puzzle_hash,
1)`, DERIVED analytically because the puzzle is fixed — IS this store's launcher, by full coin id.
The curried `mint_number`/`mint_total` are deliberately unconstrained: they vary the curried puzzle
hash but not the behaviour.
**Recognition MUST be by puzzle hash with an analytic launcher derivation, and MUST NOT be by shape**
(e.g. "a 0-amount coin whose spend creates exactly one coin"), for two reasons. First, the walk MUST
run NO chain-supplied CLVM: the spend comes from an untrusted `ChainSource`, so evaluating it executes
an attacker's program — a few bytes of non-terminating puzzle burn CPU while the walk still returns
`Ok(None)`, surfacing to the caller as latency and never as an error. Uncurrying parses; it does not
execute. Second, shape is a looser bind: it admits ANY puzzle that happens to emit that one condition,
not just the intermediate launcher the walk means to traverse.
Anything else, and any parse failure, stops the walk at `Ok(None)`. A DID three or more hops above the
launcher is NOT reported. This is the honest-answer path; it is distinct from a source that ANSWERS
with something the coin did not commit to, which is `Err(MerkleError::Chain)` (above).
It is **fail-closed** and **READ-ONLY** — it never signs, spends, or broadcasts. Fail-closed splits
two ways, and the split MUST be preserved: a chain that answers honestly with "no DID" — a missing
spend, a non-DID creator, or a creator the walk may not climb past — is `Ok(None)`, never an error for
"not DID-owned"; a source that cannot be consulted, or that answers with a spend the coin did not
commit to (a wrong `coin_id`, or a `puzzle_reveal` that does not hash to `coin.puzzle_hash`), is
`Err(MerkleError::Chain)`. All chain access is delegated
to the caller-supplied `ChainSource`, the CANONICAL `dig_chainsource_interface::ChainSource` read
interface — a reference-DOWN pure leaf crate below dig-merkle, NOT a local trait — with the single
synchronous method `coin_spend(coin_id: Bytes32) -> MerkleResult<Option<CoinSpend>>` (INV-1: the
caller implements it over its own client; dig-merkle opens no socket).
Both layers ship: the pure DID-detection helper `did_ref_from_spend` and the `resolve_owner_did`
lineage-walk wrapper over `dig_chainsource_interface::ChainSource` (`dig-chainsource-interface` on
crates.io, a reference-DOWN pure leaf — no `git` dependency).
## 4. Signing boundary
`required_signatures(coin_spends, constants) -> MerkleResult<Vec<RequiredSignature>>` is the sole
bridge to a signer. It wraps `chia_sdk_signer::RequiredSignature::from_coin_spends` over a private
`Allocator`, collecting every `AGG_SIG_*` condition each coin spend's puzzle emits and returning the
precise (public key, message) pairs the caller must sign. It is pure and key-free (INV-2); an empty
coin-spend slice yields an empty requirement set (never an error). A puzzle-evaluation failure or an
infinity public key yields `MerkleError::Signer`.
The consumer pattern is fixed:
```text
build MerkleCoinSpend -> required_signatures(&spend.coin_spends, &constants)
-> caller signs each message -> assemble SpendBundle -> broadcast
```
## 5. Hydration & lineage (fail-closed)
To spend an existing DataLayer coin, a caller reconstructs a spendable `DataStore` from its parent
coin spend (`DataStore::from_spend`) and the `LineageProof` a singleton child requires
(`child_lineage_proof`). Hydration is **fail-closed**:
- A coin whose puzzle does not parse as a DataLayer singleton yields `MerkleError::NotDataStore`.
- A missing lineage proof yields `MerkleError::MissingLineage` — dig-merkle never fabricates one.
- A missing required hint memo yields `MerkleError::MissingHint`.
dig-merkle never guesses missing chain state; the caller supplies the real parent spend.
**Reveal binding is MANDATORY (NC-9).** BEFORE any value is extracted from a parent spend, `hydrate`
MUST verify `tree_hash(puzzle_reveal) == spend.coin.puzzle_hash` and reject a mismatch with
`Err(MerkleError::Chain)`. A `coin_id` binding cannot substitute for this: `coin_id` is derived from
the coin's own fields, so a hostile source can pair a victim's genuine coin with a different store's
`puzzle_reveal` and `solution` and have hydration report the ATTACKER's `launcher_id` and
`root_hash`. Because the check precedes the parse, a forged spend also never reaches the CLVM
execution on the non-launcher branch. The failure is a REFUSAL, never a skip or an empty result — a
caller must not be able to read a substituted answer as "no data on chain".
**Child lineage proof — the DataLayer updater path (INV-4).** `child_lineage_proof` derives the
`parent_inner_puzzle_hash` a child singleton must attest to by reconstructing the store's NFT-state-
layer tree hash the SAME way a real on-chain DataLayer coin is built: currying
`DL_METADATA_UPDATER_PUZZLE_HASH` (the DataLayer metadata updater), NOT the NFT-default updater that
the SDK's generic `DataStoreInfo::inner_puzzle_hash` currys. The inner puzzle under the state layer is
the delegation layer when the store carries admin/writer/oracle delegated puzzles, else the bare owner
puzzle hash. Deriving it any other way yields a `parent_inner_puzzle_hash` that a child spend fails
consensus against (`AssertMyParentIdFailed`).
**Launcher-lineage discovery (§3.7).** Owner-DID discovery is the same fail-closed principle applied
to a READ: `resolve_owner_did` walks the launcher lineage via the injected `ChainSource`
(`coin_spend(store_id)` → its `parent_coin_info` → `coin_spend(parent)`, plus at most one further hop
through an intermediate-launcher coin) and recognises a DID creator with `did_ref_from_spend`. Any
missing spend, or a non-DID creator the walk may not climb past, yields `Ok(None)` — never a
fabricated result and never an error for "not DID-owned". A source that ANSWERS with a spend the coin
did not commit to yields `Err(MerkleError::Chain)` instead (below).
**Store-id binding is MANDATORY per hop (NC-9).** The injected `ChainSource` is trusted only to
return CONFIRMED spends, never to return the RIGHT coin — a hostile or buggy source (the public
`rpc.dig.net` gateway is attacker-influenceable, §5.3) can answer a read with a DIFFERENT store's
valid, DID-rooted launcher. `resolve_owner_did` therefore binds every hop to the requested identity
and fails CLOSED on any mismatch — it MUST NOT return a `Some(DidRef)` it cannot bind to `store_id`:
- The launcher spend returned for `store_id` MUST satisfy `launcher_spend.coin.coin_id() == store_id`
(a DIG store id IS its launcher coin id). A mismatch is rejected with `Err(MerkleError::Chain)`.
- EVERY creator spend returned for a `coin_id` MUST satisfy `spend.coin.coin_id() == coin_id`,
including the second hop's. A mismatch is rejected with `Err(MerkleError::Chain)`.
- EVERY spend the walk parses MUST satisfy `tree_hash(puzzle_reveal) == spend.coin.puzzle_hash`. A
coin id is derived from the coin's own fields, so the two bindings above are satisfied by ANY reveal;
without this one a source can pair a genuine coin with a real DID's reveal and have that DID
attributed to a store with no DID link. A mismatch is rejected with `Err(MerkleError::Chain)`.
A substituted answer is deliberately surfaced as `Err(Chain)`, not `Ok(None)`, so a hostile-source
substitution is distinguishable from a genuinely non-DID-owned store.
## 6. Error taxonomy
`MerkleError` (all fallible operations return `MerkleResult<T>`):
| `Driver(DriverError)` | a chia-wallet-sdk driver op fails (currying, spend, CLVM eval); wrapped verbatim |
| `Signer(String)` | the signing calculator fails (bad puzzle/solution, infinity key) |
| `Parse(String)` | a coin/puzzle/solution is not the expected shape |
| `NotDataStore` | a puzzle parsed but is not a DataLayer singleton |
| `MissingLineage` | hydration lacks the required lineage proof (fail-closed) |
| `MissingHint` | a parsed coin lacks the required hint memo (fail-closed) |
| `Permission(String)` | a delegated-puzzle op lacks its required authority (e.g. writer→admin) |
| `Chain(String)` | a chain-level precondition is violated (e.g. launcher mismatch, an unbound spend) |
| `UnsupportedOwner(&'static str)` | the operation builds the conditions its spend must emit, so `Owner::Custom` cannot authorize it (§3.1, §3.2, §3.5) |
| `NotTheOwner` | an irreversible operation was asked for with a key that does not curry to the store's `owner_puzzle_hash` (§3.5); raised before any spend is built |
| `EmptyCoins` | an operation was given an empty coin set |
| `InvalidSize(String)` | a size-bucket exponent or byte length falls outside the `0..=10` ladder (§2) |
## 7. Security properties
- **Custody:** dig-merkle holds no key and signs nothing (INV-2). A caller cannot accidentally leak
a key through this crate because it accepts none. The signing boundary (§4) returns only the
public data a signer needs.
- **Determinism:** every function is a pure transform (INV-1); given identical inputs it produces
byte-identical coin spends, so a spend can be independently reproduced and audited.
- **Fail-closed:** hydration and permission checks reject on missing/invalid state (§5, §6) rather
than producing an unspendable or over-authorized bundle.
- **Irreversible operations refuse in the builder:** `melt` (§3.5) verifies the caller's key controls
the store BEFORE any spend exists, so an unauthorized destructive spend is never constructed. The
refusal's warrant is the supplied `DataStore`; a store obtained through `hydrate` carries the real
owner, because hydration binds a parsed puzzle reveal to the coin's puzzle hash (§5).
## 8. Back-compat (CLAUDE.md §5.1 — additive only)
A `.dig` root coin is a permanent, on-chain-anchored artifact; content published under a store id
stays readable forever. dig-merkle's read/hydrate path MUST therefore be additive and
backward-compatible:
- **Newer readers accept ALL older coins.** The parser dispatches on the on-chain shape and keeps
handling every prior DataLayer layout — it MUST NOT hard-reject an older coin.
- **The legacy launcher path is retained.** The SDK's `from_memos` / `OldDlLauncherKvList` legacy
key-value-list launcher parsing MUST remain supported; dig-merkle never drops it.
- **Metadata additions are additive; the `"b"`→`"sz"` size swap is a clean pre-release replacement.**
The SDK keys `root_hash`/`l`/`d`/`sp` never change meaning or encoding. The `program_hash` key `p`
and the `size_bucket` key `sz` are NEW optional keys appended after the SDK keys (`p` after `sp`,
then `sz` LAST), omitted when absent. dig-merkle NEVER emits the SDK's exact-byte `"b"` key — size
is the `"sz"` bucket instead; this is a clean replacement, safe because no on-chain DIG store carries
`"b"` (pre-release). SDK-typed readers ignore `p`/`sz` (the SDK's `_ => ()` tolerance), and a
dig reader decoding a `p`/`sz`-free store (including an SDK store carrying `"b"`, which is ignored)
yields `program_hash == None` / `size_bucket == None` — reading an SDK store still succeeds. The
`sz` value is the size exponent in canonical MINIMAL CLVM form (NC-8); a non-minimal or out-of-range
encoding is rejected fail-closed.
- **Prove it.** The test suite keeps golden coin-spend fixtures of each released layout; every
format change MUST include a test decoding the golden fixtures byte-identically. The mint golden
test (`launcher_carries_the_two_memo_owner_discovery_hint`) pins the launcher `CREATE_COIN` memos to
`[digstore_owner_hint(owner_ph), DATASTORE_LAUNCHER_HINT]`, and `metadata_clvm_encodes_root_as_first_atom`
pins the root as the first metadata atom — the proof a minted coin matches stores already on chain.
The program_hash key is proved byte-identical by `mint_none_program_hash_is_byte_identical` at the
coin level. The size_bucket replacement is proved by `metadata_none_size_bucket_is_byte_identical_to_sdk`
(empty → identical to an SDK `bytes == None` store), `sdk_reader_parses_size_bucket_store` (our
`sz` store is a valid SDK `DataStoreMetadata`), `sdk_store_with_b_still_decodes` (an SDK `"b"` store
decodes, `"b"` ignored → `size_bucket == None`), the NC-8 pin `sz_atom_is_minimally_encoded`, the
round-trip `sz_roundtrips`, the key-order `sz_is_last_key`, and the fail-closed
`sz_decode_rejects_non_minimal_and_oversized`.
## 9. Conformance
- **Byte-agreement with chip35.** dig-merkle's DataLayer coin MUST be byte-identical to the existing
DataLayer coin in `chip35_dl_coin` (both build over the same `chia-wallet-sdk` primitives, INV-4).
A coin dig-merkle mints/updates MUST be spendable by, and produce the same on-chain state as, the
chip35 implementation.
- **Signature construction** MUST match `chia_sdk_signer::RequiredSignature::from_coin_spends`
exactly (dig-merkle only wraps it).
- **Owner-hint domain.** The owner/delegation hint-memo domain is the fixed constant
`DIGSTORE_OWNER_HINT_DOMAIN = b"dig:datastore:owner:v1"` (defined in dig-merkle, not imported), and
MUST match across every DIG consumer that resolves a DataLayer owner hint.
`digstore_owner_hint(owner_ph) = sha256(DIGSTORE_OWNER_HINT_DOMAIN ‖ owner_ph)` — byte-identical to
chip35_dl_coin + digstore-chain.
- **Launcher-hint kind discriminator (`memo[1]`).** The second launcher memo names the store's
`StoreKind`:
- `StoreKind::File` → `DATASTORE_LAUNCHER_HINT = sha256("datastore") =
aa7e5b234e1d55967bf0a316395a2eab6cb3370332c0f251f0e44a5afb84fc68` — the pre-existing default,
byte-identical across all DIG producers (chip35_dl_coin, digstore-chain).
- `StoreKind::DidProfile` → `DID_PROFILE_LAUNCHER_HINT = sha256("dig:datastore:profile:v1") =
9c1d6b6d5d530dd613f4d7d2ced6b704ae8423377e4d567518493159c1d21d01` (#1263).
`launcher_hint_for(kind)` maps a kind to its discriminator (write side); `from_launcher_hint(memo)`
classifies a store by its `memo[1]` (read side), returning `None` for an unrecognised value. The
kind set is ADDITIVE (SPEC §8/§5.1): the `File` bytes never change, and a legacy store — every
store minted before #1263 — carries `DATASTORE_LAUNCHER_HINT` and so classifies as
`StoreKind::File`.
- **Launcher memos.** A minted store's launcher `CREATE_COIN` carries exactly
`[digstore_owner_hint(owner_ph), launcher_hint_for(kind)]`, in that order — `memo[0]` is the
kind-agnostic owner hint, `memo[1]` is the kind discriminator. `mint_datastore` mints a
`StoreKind::File` store (byte-identical to existing on-chain stores); `mint_datastore_with_kind`
selects the kind.
- **Root metadata shape.** `root_hash` is the first atom of the metadata CLVM
`(root_hash . optional-kv-pairs)`; optional keys are `l`/`d`/`sp`/`p`/`sz` (dig-merkle never emits
`"b"`). `p` (program_hash) is appended after `sp`, then `sz` (size_bucket) is ALWAYS appended LAST —
each only when present.
- **Size-bucket ladder.** `sz` carries the size exponent `k ∈ 0..=10` mapping to `2^k MB` (1 MB =
1 MiB = 2^20 bytes; 1 MB..1 GB), encoded as a minimal CLVM integer (empty atom for k=0, one byte
`0x01`..`0x0a` otherwise). `SizeBucket::for_byte_len` is the canonical byte→bucket mapping. This
ladder is the canonical shared contract dig-store's SIZE PROOF consumes; it MUST NOT drift.
- **Empty byte-identity + SDK interop.** A `DigDataStoreMetadata` with `program_hash == None &&
size_bucket == None` MUST serialize byte-identically to the SDK's `DataStoreMetadata` with
`bytes == None` (both emit only the present `l`/`d`/`sp` keys). An SDK reader decoding a DIG store
drops the unknown `p`/`sz`; a dig reader decoding an SDK store parses `root_hash`/`l`/`d`/`sp`, drops
the SDK's `"b"`, and yields `size_bucket == None` / `program_hash == None`.
- **Dependency layer.** dig-merkle depends ONLY on `chia-wallet-sdk` +
`chia-protocol`/`chia-puzzle-types`/`clvm-traits`/`chia-sha2` + external utility crates
(thiserror, hex-literal), plus the single canonical leaf `dig-chainsource-interface` (the
`ChainSource` read interface consumed by §3.7; a reference-DOWN pure leaf BELOW dig-merkle,
crates.io-published). It MUST NOT depend on any other `dig-*` crate, and MUST NEVER depend on
`dig-identity` (the edge is one-way, dig-identity → dig-merkle — the reverse is a cycle).