From 8848d359bc3d234f30e37196efbed9373d6c8827 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 22 Jun 2026 09:37:04 +0200 Subject: [PATCH 1/4] Add TIP-0007: Spend-Time Scripts for Ootle Stealth UTXOs Migrates the spend-time scripts proposal from the now-superseded tari-project/tips repository (tari-project/tips#6, originally TIP-0006) into the RFCs repository. - Renumbered TIP-0006 -> TIP-0007, since TIP-0006 is already claimed here (PR #178). 0007 is the next open TIP number. - Reformatted to the TIP-0001 header preamble (metadata table, P-TIP-RFC-7 identifier, Status: Proposed, Type: Architecture) and added a Change History section. Document content is otherwise unchanged. - Added the TIP to src/SUMMARY.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/SUMMARY.md | 1 + src/TIP-0007_ootle_spend_scripts.md | 693 ++++++++++++++++++++++++++++ 2 files changed, 694 insertions(+) create mode 100644 src/TIP-0007_ootle_spend_scripts.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 149de37d..56950848 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -5,6 +5,7 @@ - [P-TIP-PROC-1: Tari Improvement Proposals](TIP-0001_tari_improvement_proposals.md) - [A-TIP-PROC-2: The Tari Community Charter](TIP-0002_tari_community_charter.md) - [P-TIP-PROC-3: Community Forums](TIP-0003_community_forums.md) +- [P-TIP-RFC-7: Spend-Time Scripts for Ootle Stealth UTXOs](TIP-0007_ootle_spend_scripts.md) - [RFC-0001: An overview of the Tari network](RFC-0001_overview.md) - [The Tari Base Layer](base_layer.md) - [RFC-0110: Base nodes](RFC-0110_BaseNodes.md) diff --git a/src/TIP-0007_ootle_spend_scripts.md b/src/TIP-0007_ootle_spend_scripts.md new file mode 100644 index 00000000..2858fb35 --- /dev/null +++ b/src/TIP-0007_ootle_spend_scripts.md @@ -0,0 +1,693 @@ +# P-TIP-RFC-7: Spend-Time Scripts for Ootle Stealth UTXOs + +| TIP | [P-TIP-RFC-7](#p-tip-rfc-7-spend-time-scripts-for-ootle-stealth-utxos) | +|---------------|----------------------------------------------------------------------------------| +| Title | Spend-time scripts for Ootle stealth UTXOs (script-capable spend conditions) | +| Last Modified | 2026-06-22 | +| Authors | Stanley Bondi | +| Status | Proposed | +| Type | Architecture | +| Created | 2026-06-17 | +| References | Migrated from [tari-project/tips#6](https://github.com/tari-project/tips/pull/6) | + +> **Scope.** This proposal targets Tari Ootle (the programmable smart-contract layer); the +> "stealth UTXOs" it concerns are Ootle confidential outputs. All `crates/...` paths refer to +> the [tari-ootle repository](https://github.com/tari-project/tari-ootle); line numbers are +> indicative and may drift. + +## Abstract + +This TIP makes an Ootle stealth UTXO spendable under **programmable conditions** rather than on the +spender's credentials alone. It does two things. First, it adds a `TemplateFunction` spend +condition — a **stateless, restricted WASM predicate** that answers *"is this particular spend +valid?"* by introspecting the spending `StealthTransferStatement`: its inputs, its outputs +(including each output's own committed condition), and revealed amounts, together with transaction +context (signer public keys and the current epoch). The predicate runs in a sandboxed execution mode +that forbids state mutation, cross-template calls and non-determinism, modelled directly on the +engine's existing resource `AuthHook` mechanism. + +Second, it restructures how a condition is committed. The inline `SpendCondition` field is removed; +an output is authorised by a **key path** (a `spend_key`, the signature condition) and/or a +**condition tree** — a Merklized set (MAST) of alternative `SpendCondition` leaves committed under a +`condition_root`, of which a `TemplateFunction` predicate is one leaf kind alongside `AccessRule` +(§9, §10). The spender reveals only the leaf they exercise. + +Because a `TemplateFunction` predicate can read the spending transaction's outputs and its own +invoking condition, it subsumes covenants: a covenant is simply a predicate that asserts properties +of the outputs it produces, including (recursively) that an output commits the same condition. This +TIP therefore does **not** introduce a separate covenant field; covenants are delivered as +`template_lib` helper patterns over a `TemplateFunction` leaf. A dedicated declarative covenant field +is explicitly deferred (see [Rationale](#rationale)). + +## Motivation + +Ootle stealth UTXOs currently support exactly two spend conditions +(`crates/template_lib_types/src/stealth/unspent_output.rs:65`): + +```rust +pub enum SpendCondition { + Signed(RistrettoPublicKeyBytes), + AccessRule(AccessRule), +} +``` + +Both are evaluated against the `AuthorizationScope` — the set of virtual-proof badges derived +from the transaction signers (`crates/engine/src/runtime/auth.rs:18`). They express *who* may +spend, never *under what conditions* or *to what effect*. Whole classes of standard UTXO +behaviour are therefore inexpressible at the output level: + +- **Timelocks** — "spendable only at/after epoch N" (absolute) or relative to creation. +- **Hash/secret locks and atomic swaps** — "spendable by revealing a preimage / a signature + over an agreed message", the building block for cross-chain and off-chain protocols. +- **Value-constrained spends** — "at most X may leave per spend", "change must return here". +- **Covenants** — "these funds may only be spent into outputs that carry this same condition" + (vaults, congestion-control, non-transferable balances, recovery wallets). + +Today the only way to approximate these is to route funds through a stateful component and +gate them with `AccessRule`/component methods. That forces a confidential UTXO to become (or +be escorted by) a public component, defeating the privacy and self-custody properties that +make stealth UTXOs valuable, and it cannot express constraints on the *spending transaction* +at all. + +A spend script restores Bitcoin-/Simplicity-style programmability at the output level, while +reusing the WASM template toolchain authors already know, and without a new bytecode VM. + +## Specification + +### Overview of the spend-validation path (unchanged surface) + +A stealth transfer is processed by `ResourceManager::stealth_transfer` +(`crates/template_lib/src/resource/manager.rs:548`) which the engine dispatches via +`ResourceAction::StealthTransfer`. Inside the engine this reaches +`WorkingState::validate_and_spend_stealth_utxos` +(`crates/engine/src/runtime/working_state.rs:292`), which locks each input UTXO and calls +`validate_spend_condition` (`:321`) per input. That function is the sole enforcement point and +is where a `TemplateFunction` condition is evaluated. + +Critically, `validate_and_spend_stealth_utxos` already holds the entire +`StealthTransferStatement` — so all data a script needs to introspect is in scope at the point +of validation; no new plumbing of transaction data is required. + +### Technical Details + +#### 1. The `SpendCondition` enum (repurposed as the condition-tree leaf) + +`crates/template_lib_types/src/stealth/unspent_output.rs`: + +```rust +/// A spend condition is no longer an inline UTXO field; it is the v0 leaf payload of the +/// condition tree (§9). The key path (`spend_key`, §10) is the signature condition, so the +/// former `Signed` variant is removed. +pub enum SpendCondition { + #[n(0)] + AccessRule(#[n(0)] AccessRule), + /// Spend is gated on a stateless WASM predicate over the spending transfer. + #[n(1)] + TemplateFunction(#[n(0)] TemplateFunction), +} + +#[derive(Debug, Clone, Encode, Decode, CborLen, PartialEq, Eq)] +pub struct TemplateFunction { + /// The template providing the predicate. Templates are immutable substates, so the + /// referenced code cannot change under the output once committed. + #[n(0)] + pub template: TemplateAddress, + /// The stateless (`is_mut == false`) predicate function on that template. + #[n(1)] + pub function: FunctionName, + /// Bound parameters, **positional — one CBOR-encoded value per leading (non-`SpendContext`) + /// parameter**, matching the engine's `Vec` call ABI. Baked into the leaf at + /// creation; the spender cannot alter them. + #[n(2)] + pub args: Vec, +} +``` + +A `TemplateFunction` condition fully commits `{template, function, args}`, so a spender cannot +substitute a different predicate. `args` lets one template function serve as a parameterised +predicate family (e.g. a single `timelock` function bound with different unlock epochs per output). + +> **Committed output model (decided).** The output does not store an inline `SpendCondition`. The +> decided integration (§10) replaces that field with two committed fields, `spend_key` and +> `condition_root`, where `condition_root` is the root of a condition tree (§9) whose leaves are +> `SpendCondition` values. §1–§8 describe the per-predicate mechanism, which is unchanged; it now +> applies to the leaf reached via the script-path witness. A leaf with `id = 0` carries a +> Borsh-encoded `SpendCondition` (this enum). + +#### 2. Predicate ABI (author-facing) + +A spend script is an ordinary `is_mut == false` template **function** (no `&self`). It takes +its bound args plus a `SpendContext` handle, and rejects the spend by panicking (returning normally authorises it): + +```rust +#[template(stateless)] +mod vault_covenant { + pub fn enforce(unlock_epoch: u64, ctx: SpendContext) { + // timelock: panic (reject the spend) until the unlock epoch + assert!(ctx.current_epoch() >= unlock_epoch, "timelock not met"); + // covenant: every stealth output must commit to this same condition set + ctx.require_output_preserves_condition(); + } +} +``` + +`SpendContext` is a thin `template_lib` wrapper over a new introspection host op (below). The +exact signature the engine requires — `is_mut == false`, a unit (`()`) return, and a trailing +`SpendContext` argument — and the two points at which it is enforced are specified in §3. (The +`#[template(stateless)]` form is a proposed authoring convenience; the predicate can equally be +written as a receiver-less function on a normal `#[template]` struct.) + +> Failure convention: a spend script signals rejection the way every Ootle template does — by +> **panicking** (e.g. `assert!`), never by returning a value or a `Result`. Returning normally +> authorises the spend; any panic aborts it. This matches the resource `AuthHook`, which +> likewise returns unit and denies by aborting. Deliberate rejections and script bugs (panic / +> out-of-gas) are indistinguishable by design — both simply invalidate the spend, exactly as a +> failed Bitcoin script does. + +#### 3. Function signature requirements and validation timing + +A spend script is matched against its `FunctionDef` +(`crates/template_abi/src/template_def.rs`) exactly as the resource auth hook is matched in +`check_resource_auth_hook` (`crates/engine/src/runtime/impl.rs:404`). The required shape: + +| Requirement | Rule | +|---|---| +| Mutability | `func.is_mut == false` — a mutable predicate could take a write lock and cause side effects, defeating the read-only guarantee (see §6) | +| Return | `func.output == Type::Unit` — the predicate rejects by panicking, not by returning a value | +| Context arg | the **last** argument's type is `Type::Other { name: "SpendContext" }` (matched via `arg_type.other()`, just as the hook matches `"AuthHookCaller"`); the engine injects this argument | +| Bound args | the **leading** arguments (all but the last) are the bound parameters. `TemplateFunction.args` is **positional** — one CBOR-encoded value per leading parameter — so `args.len()` must equal `func.arguments.len() - 1`, and element `i` must decode to leading parameter `i` | + +Putting `SpendContext` last lets one rule cover 0, 1 or N bound args. `Type::Unit` and +`Type::Other` already exist in the ABI (`template_def.rs:120,150`), so the match is a +straightforward extension of the existing hook validator: + +```rust +fn validate_spend_script_signature(func: &FunctionDef) -> Result<(), RuntimeError> { + if func.is_mut { + return Err(/* spend script must not be mutable */); + } + if !matches!(func.output, Type::Unit) { + return Err(/* spend script must return unit (it rejects by panicking) */); + } + let ctx = func.arguments.last().ok_or(/* missing SpendContext arg */)?; + if ctx.arg_type.other() != Some("SpendContext") { + return Err(/* last argument must be SpendContext */); + } + // the leading args (all but the last) are the bound-arg types `TemplateFunction.args` decodes to + Ok(()) +} +``` + +The signature is validated at **two** points, which answer different questions: + +**(T2) Spend time — authoritative, mandatory.** Immediately before invoking, the engine +re-resolves the `LoadedTemplate`, fetches the `FunctionDef`, and runs +`validate_spend_script_signature`. This is the security gate and cannot be skipped: a revealed +leaf condition is untrusted data supplied by the spender, so the engine must not assume any +earlier node validated it. Were this omitted, a malformed condition could drive the engine to +call an `is_mut == true` function (acquiring a write lock inside a supposedly read-only +predicate), call a non-existent function, or misinterpret a non-unit return. A failure here +rejects the spend (`SpendScriptRejected`); the output remains unspent. + +**(T1) Creation time — mandatory.** When a transaction *creates* an output committing a +`TemplateFunction` condition (as a condition-tree leaf) — whether the genesis output or a covenant +continuation produced by a spend — the engine resolves the referenced template, runs the same +`validate_spend_script_signature`, and checks that `args` has one well-formed-CBOR element per bound parameter (`args.len() == func.arguments.len() - 1`), and **rejects +the creating transaction** if any of these fail. This mirrors `check_resource_auth_hook`, which +validates an auth hook when the resource is created. The purpose is to make malformed +conditions un-creatable rather than discovered later as **permanently locked funds**: a typo'd +function name or wrong signature surfaces to the output's *creator* at construction, not to the +*recipient* when they first try (and fail) to spend. + +Because templates are **immutable, permanent, global substates** — `SubstateId::Template` is +`is_read_only`/`is_global` (`crates/engine_types/src/substate.rs:337`) and no removal or +deactivation path exists — a signature that validates at T1 is guaranteed to still validate at +T2. T1 validation is therefore a durability guarantee, not merely advisory, and there is no +"template not yet published" skip case analogous to the auth hook's +component-may-not-exist-yet handling (`impl.rs:300-316`): a `TemplateFunction` condition references +a pre-existing published template, so creation requires it to already resolve. + +> Limitation: as with any instruction argument, the host can verify the function *shape*, the +> `args` element count, and that each element is well-formed CBOR, but it cannot fully +> type-check an element against its declared parameter type — that conformance is enforced at +> the WASM deserialization boundary (the dispatcher's `decode_exact::` per arg, +> `crates/template_macros/src/template/dispatcher.rs`), where a mismatch panics and (at T2) +> rejects the spend. + +#### 4. Introspection host op (`SpendContext`) + +Add one `EngineOp` (`crates/template_abi/src/ops.rs:27`), e.g. +`SpendContextInvoke = 0x11`, dispatched to a new `RuntimeInterface` method +`spend_context_invoke(action: SpendContextAction) -> InvokeResult`. **Scope: the current +transfer statement only** (per design decision; see Rationale). Read-only actions: + +| Action | Returns | +|---|---| +| `Inputs` | `Vec` — commitments being spent, + `revealed_amount` | +| `Outputs` | `Vec` — each output's `commitment`, `minimum_value_promise`, `spend_key`, `condition_root`, `UtxoTag` | +| `CurrentInput` | index + commitment of the input whose condition is executing | +| `InvokingCondition` | the `SpendCondition::TemplateFunction(..)` leaf that invoked this predicate (enables recursive covenants) | +| `Signers` | transaction signer public keys (already reachable via `CallerContextInvoke`) | +| `Epoch` | current epoch (already reachable via `ConsensusInvoke` → timelocks) | + +These views expose only data already present in `StealthTransferStatement` + +`AuthorizationScope` + consensus context. Confidential *values* remain hidden (only +commitments and `minimum_value_promise` are visible), preserving the privacy model — a script +reasons about commitments and spend conditions, not plaintext amounts, exactly as the balance +proof does. + +#### 5. Invocation flow + +Evaluation is hoisted to the `RuntimeInterfaceImpl` layer (`crates/engine/src/runtime/impl.rs`), +where the template provider (`self.template_provider`) and the re-entrant runtime pointer +already live — this is precisely how `invoke_resource_access_hook` (`impl.rs:292`) works today. For +a `condition_root` output (§10) the leaf to evaluate is the one named by the input's script-path +witness, its inclusion in the root having been verified first (§9); the flow below then runs for that +single leaf. + +1. `validate_and_spend_stealth_utxos` resolves, per input, the spend path from its witness: a + key-path witness is checked as a signature against `spend_key`; a revealed `AccessRule` leaf runs + the existing authorization-scope check inline (no WASM cost). +2. For a revealed `TemplateFunction` leaf, the engine resolves the `LoadedTemplate`, re-runs + `validate_spend_script_signature` (the authoritative T2 check from §3), assembles the call + arg list as `[ args[0], …, args[n-1], encode(SpendContext) ]` — the positional bound args + followed by the engine-injected context, exactly as `invoke_resource_access_hook` appends + `auth_caller` — and invokes the predicate via the existing `invoke_template_function` path + (`impl.rs:382`) inside a **read-only restricted frame** (§6). The generated dispatcher + decodes each slot positionally with `decode_exact::`. +3. The restricted frame is a `PushCallFrame::Static` (`crates/engine/src/runtime/scope.rs`) + with `read_only = true`, `allow_cross_template_calls = false`, and an **empty authorization + scope** (the script must not borrow the spender's badges). +4. Returning normally authorises the spend. Any panic — a deliberate `assert!`/`panic!` + rejection, out-of-gas, or an attempted state mutation (`WriteInReadOnlyContext`) — aborts it + as `RuntimeError::SpendScriptRejected`, carrying the abort reason. Rejection is always a + panic, never a return value. + +#### 6. Read-only restricted execution mode (the sandbox) + +A spend script is a **pure predicate**: it must not be able to mutate ledger state as a side +effect of being evaluated. Without this it would be a re-entrancy and consistency hazard — a +predicate that runs partway through validating a transfer could itself withdraw from a vault, +mint, or recall, and it would also be non-deterministic across fee-estimation / dry-run / block +re-execution. The restriction has two layers; the first is the load-bearing one. + +**(a) A first-class read-only mode anchored on the lock layer.** Every state mutation in the +engine — vault withdraw/deposit, mint, recall, freeze, component-state writes, new-substate +creation — funnels through `write_lock_substate` / `get_*_mut` / `new_substate` on +`WorkingState` (e.g. vault `Withdraw` takes `write_lock_substate(SubstateId::Vault(..))`, +`impl.rs:1648`). A `read_only` flag on the active `CallFrame`, set when a spend script is +entered, makes **`write_lock_substate` (and `new_substate`) return +`RuntimeError::WriteInReadOnlyContext`**. This is a *whitelist-grade* guarantee against +mutation: because the write lock is the single chokepoint, blocking it neutralises every +mutating host op at once — including any future op — regardless of which `*_invoke` requested +it. A spend script may therefore call `vault_invoke(Withdraw)` directly on a hard-coded +`VaultId` (no cross-template call is needed to reach the `VaultInvoke` host op, so +`allow_cross_template_calls = false` alone does **not** stop it), but the underlying write lock +is refused and the spend fails. + +**(b) A `SpendScriptGuardModule` deny-list for the few effects that bypass the lock layer.** +Every host op also funnels through `invoke_modules_on_runtime_call(..)` +(`crates/engine/src/runtime/module.rs`). The module rejects the non-lock-mediated effects and +sources of non-determinism: `call_invoke`, `generate_random_invoke`, `generate_uuid`, +`publish_template`, `pay_fee`, `emit_event`, and proof/bucket creation. This list is small and +stable because layer (a) already covers all state writes. + +**Allowed (read-only / deterministic):** `spend_context_invoke`; `signature_invoke` +(`EngineOp 0x10` — Schnorr verification → hash-locks, m-of-n, adaptor conditions); +`consensus_invoke` (epoch); `caller_context_invoke` (signers); `emit_log`. In practice a spend +script needs no substate access at all — its world is the injected `SpendContext` plus pure +host ops — so read-only here means "no writes"; even reads beyond `SpendContext` are +unreachable without cross-template/method calls, which are disabled. + +Together, (a) + (b) make the predicate deterministic (no randomness; epoch is fixed for the +duration of execution) and provably side-effect-free. + +#### 7. Metering and fees + +Spend scripts reuse the existing WASM metering (`crates/engine/src/wasm/metering.rs`): points +are charged at `per_wasm_point_cost` and bounded by `MAX_WASM_POINTS_PER_CALL` per invocation +and `MAX_WASM_POINTS_PER_TRANSACTION` cumulatively. The **spender pays** for script execution, +as they do for every other instruction. A per-spend-script cap may be set stricter than the +generic per-call cap if profiling warrants it. + +A `TemplateFunction` leaf — `template`, `function`, and especially `args` — is committed into the +output created by a `StealthTransfer` instruction, so its serialized size **must contribute to the +creating transaction's weight**. Today `calc_stealth_statement_weight` +(`crates/transaction/src/v1/transaction.rs:284`) weights stealth outputs by count only +(`100 + inputs + outputs * 2`) and never inspects the bytes inside an output; this proposal +extends it so each output additionally contributes the byte length of its committed condition data +(notably `TemplateFunction` `args`). Without this, a large script/args could be broadcast for free and +used to bloat outputs. The **creator** pays this weight at creation time; the persisted bytes +are separately charged per-byte storage at substate creation, and execution at spend time is +metered via WASM points (above) against the **spender**. + +#### 8. Covenants as a library pattern + +No engine covenant field is added. `template_lib` ships helpers built on `SpendContext`: + +- `ctx.require_output_preserves_condition()` — recursive "stay in the vault" covenant. +- `ctx.require_timelock(unlock_epoch)` — absolute epoch lock. +- `ctx.require_output_to(spend_condition, min_value)` — "funds must flow to X". + +These are thin wrappers asserting over `ctx.outputs()` / `ctx.invoking_condition()`. Authors +compose them; the engine treats them as ordinary predicate logic. + +#### 9. Condition trees (MAST): committing to a set of alternative conditions + +A single committed condition gates on exactly one predicate, in the clear. Many real conditions are +*disjunctions* — "spendable by the timelock path **or** the recovery-key path **or** the covenant +path" — and committing every alternative inline is both larger on-ledger and a privacy leak: it +discloses spend paths that are never exercised. + +A **Merklized condition tree (MAST)** lets an output commit to a *set* of alternative spend +conditions under a single 32-byte root. The output stores only the root; at spend time the spender +reveals the one leaf they are exercising plus a Merkle inclusion proof, and the engine: + +1. recomputes the root from the revealed leaf + proof and checks it equals the committed root, then +2. evaluates that single leaf via the §5 invocation path — T2 signature validation, the read-only + sandbox and metering are all unchanged. + +The unused alternatives are never revealed. + +**Leaf format.** A leaf is `(id, payload)`: `id` is an encoding-version discriminant and `payload` +its body. `id = 0` is a Borsh-encoded `SpendCondition` (§1) — i.e. an `AccessRule` or a +`TemplateFunction`. Reserving `id` keeps the tree forward-compatible with a future breaking leaf +encoding (`id = 1`) without changing the tree structure. + +**Tree construction (consensus-critical).** A canonical binary Merkle tree with: + +- **Domain separation** — distinct leaf and branch hash domains (`SpendScriptLeaf` / + `SpendScriptBranch`, engine-domain Blake2b), so an internal node can never be reinterpreted as a + leaf (second-preimage resistance). +- **Lexicographic pair ordering** — each branch hashes its two children in byte order, so an + inclusion proof is a bare list of sibling hashes with **no left/right direction bits**. +- **Canonical set commitment** — leaf hashes are sorted and must be unique, so the root is a + function of the *set* of leaves, independent of authoring order. +- **Promote-unpaired** — an odd node at a level is carried up unchanged; it is never duplicated, + which would let a tree with a repeated node collide with a smaller tree's root (CVE-2012-2459). + +A single-leaf tree's root is the leaf hash itself, with an empty proof, so the common one-script +case carries no proof overhead. Tree construction and proof verification are **native-only** (the +engine at spend time, the wallet at construction); a template never builds or verifies a tree, so no +hashing crosses into WASM. The inclusion proof — a `Vec` of sibling hashes — rides in the +spend witness and is weight-/metering-charged like any other spend data (§7). + +The integration of this root into the committed output is **decided** (§10): it is an output-level +`condition_root` field — not an inline `SpendCondition` field and not a taproot-style key tweak. This +section specifies the tree and proof mechanism, which is independent of that choice. + +#### 10. Output spend model: `spend_key` + `condition_root` (decided integration) + +The decided committed model replaces the inline `SpendCondition` field on the stealth output with +two separate committed fields — the authorization key and the condition-tree root (§9): + +```rust +pub struct StealthUnspentOutput { + // …commitment, sender nonce, encrypted data, minimum_value_promise, tag… + /// Key-path authorization. `None` = no key path. + pub spend_key: Option, + /// Condition-tree root over the alternative conditions (§9). `None` = no conditions. + pub condition_root: Option, +} +``` + +- **Invariant.** At least one of `spend_key` / `condition_root` is `Some`; `{None, None}` is an + unspendable (burn) output and is rejected at creation. +- `condition_root == None` is a pure payment **or** — once the deferred tweak below lands — a + tweak-hidden condition; these share a single anonymity set. +- `condition_root == Some(root)` is a **transparent, introspectable** condition set / covenant, and + is therefore a deliberately distinguishable class (see the rationale). + +**Witness-driven spends.** Each spent input carries a `SpendWitness` that selects the spend path; the +committed output only gates which witnesses are *admissible*, and the spender always chooses: + +| Witness | Admissible when | Checked | +|---|---|---| +| Key-path | `spend_key = Some(pk)` | a signature under `pk` | +| Script-path | `condition_root = Some(root)` | reveal one leaf + its `MerkleProof`; recompute and check the root (§9), then evaluate that leaf via §5 | + +Because the spender chooses the witness, "script-only" (no key path) must be enforced +*cryptographically* — by `spend_key: None` (or, for the deferred hidden case, a NUMS key whose +discrete log is unknown) — never by assuming the spender will decline the key path. + +**Mapping today's conditions.** The signature condition becomes the key path (`spend_key = +Some(pk)`); a `TemplateFunction` predicate and an `AccessRule` are **sibling leaf kinds** in the +condition tree, each carried as an `id = 0` `SpendCondition` leaf. The §2–§8 mechanism — ABI, T1/T2 +signature validation, the read-only sandbox, metering, and the covenant helpers — is unchanged and +now applies to the *revealed leaf*. Accordingly the `Outputs` introspection view (§4) and the +covenant helpers (§8) expose each output's `{spend_key, condition_root}` in place of a single +`SpendCondition`. + +**Deferred: key-path / script-path tweak.** A Taproot-style key tweak — folding the root into the +spend key as `Q = P + H_tweak(P‖root)·G` so a script-bearing output is indistinguishable from a plain +payment — is intentionally **deferred, not discarded**. It is additive: reserve a `ScriptPathTweaked` +witness variant and accept a tweaked `spend_key` with a `None` `condition_root`, opt-in per output, +with no format break. A NUMS internal key is needed only for the hidden-and-condition-only case, and +the tweak hash MUST bind `P` (`H_tweak(P‖root)`) or forgery is trivial. Why the field ships first — +and why the tweak is not needed for covenants — is in the Rationale ("Why a committed +`condition_root` field, not a key-path tweak"). + +### API changes + +- `tari_template_lib_types`: the output's committed spend fields `spend_key: + Option` and `condition_root: Option` (§10) replacing the inline + `SpendCondition`; the repurposed `SpendCondition` leaf enum (`AccessRule` / `TemplateFunction`); + `SpendWitness` (key-path / script-path) and + `MerkleProof` (pure data, no hashing — §9); and the `SpendContext` view types. TS bindings + regenerated (hand-edited per project convention). +- `tari_template_abi`: new `EngineOp::SpendContextInvoke`; `SpendContextAction` enum. +- `tari_template_lib`: `SpendContext` API + covenant helpers; builder support so resource/ + output construction can attach a `TemplateFunction` condition. +- `tari_engine`: `read_only` `CallFrame` mode + `write_lock_substate`/`new_substate` guard; + `validate_spend_script_signature` (T1 + T2); `RuntimeInterface::spend_context_invoke`; + `SpendScriptGuardModule`; spend-script invocation in `validate_spend_condition`; new + `RuntimeError` variants. +- `tari_engine_types`: MAST script-tree primitive (§9) — `MerkleTree`, `MerkleProof`, the + `SpendScriptLeaf`/`SpendScriptBranch` hash domains, and inclusion-proof verification — used by the + engine at spend time and the wallet at construction + (`crates/engine_types/src/stealth/script_tree.rs`). +- `tari_ootle_transaction`: extend `calc_stealth_statement_weight` so each stealth output's + weight includes the serialized size of its committed condition data (charging `TemplateFunction` + `args` at creation, `crates/transaction/src/v1/transaction.rs:284`). +- Wallet (`crates/wallet/ootle-rs/src/stealth/builder.rs`): allow constructing outputs with a + `TemplateFunction` spend condition; expose the new condition in produced `StealthUnspentOutput`s. + +## Rationale + +### Why reuse the AuthHook pattern instead of a new VM + +The engine already invokes engine-chosen, restricted, stateless predicates: the resource +`AuthHook` (`invoke_resource_access_hook`, `impl.rs:292`; signature-validated at +`check_resource_auth_hook`, `impl.rs:404`). It is validated to be `is_mut == false`, has a +fixed argument signature, runs by re-entering `TransactionProcessor::call_*` via the runtime +pointer, and signals denial by aborting. A spend script is the same shape with a different +trigger (stealth-transfer validation) and a different injected context (transaction +introspection rather than `AuthHookCaller`). Reusing this path avoids a second execution +engine, a new bytecode/VM, and a separate metering/fee story — and inherits the existing +determinism and sandboxing primitives (`allow_cross_template_calls`, the runtime-call module +hook, WASM points). + +A dedicated bytecode language (Bitcoin Script / Simplicity / Miniscript) was considered and +rejected for v1: it would duplicate tooling, demand its own interpreter and cost model, and +deliver a strict subset of what a sandboxed WASM function already expresses. The WASM +restriction set gives the same safety properties (bounded, deterministic, side-effect-free) +without the second toolchain. + +### Why a spend script subsumes covenants (and we add no covenant field) + +A covenant is a constraint on the *outputs a spend produces*. With `ctx.outputs()` exposing +each output's committed `{spend_key, condition_root}`, and `ctx.invoking_condition()` exposing the +predicate's own identity, a predicate can assert "every output carries the same condition I do" — a recursive +covenant — purely as predicate logic. Output-introspection is the one capability that collapses +"script" and "covenant" into a single mechanism (it is exactly what Bitcoin historically +lacked and what CTV/APO/Simplicity add). Given that, a separate covenant field would be +redundant *for expressiveness*. + +We deliberately defer a declarative covenant field. It would be justified only by needs this +proposal does not yet target: + +1. **Cheap covenant + native authorization.** A `TemplateFunction` leaf folds authorization into + WASM; pairing a native key-path or `AccessRule` check with a covenant without paying WASM cost + would want a covenant as a *separate, composable* field rather than a condition-tree leaf. +2. **Off-chain analyzability.** A declarative covenant (`Timelock`, `PreserveSpendCondition`, + `ValueBounds`) can be read and explained by wallets/indexers without executing arbitrary + WASM; an opaque predicate cannot. +3. **Engine-enforced propagation.** A first-class covenant could make recursive propagation an + engine guarantee instead of relying on each script author to recurse correctly. + +If any of these become priorities, a follow-up TIP can add a composable `Covenant` field that +sits *alongside* any authorization variant, keeping "who may spend" (authorization) and "what +the spend must produce" (covenant) cleanly orthogonal. The library-helper approach here is +forward-compatible with that. + +### Why "current transfer statement" introspection scope + +Whole-transaction introspection (every instruction's outputs / the final substate diff, +Bitcoin-style) is strictly more powerful but the final substate set is not known until +`finalize`, forcing a two-phase or deferred-check evaluation model. The current +`StealthTransferStatement` already contains the inputs and outputs of the transfer being +authorized — the natural and sufficient scope for confidential-transfer covenants — and it is +in hand at the existing validation point with zero extra plumbing. Whole-transaction scope can +be a later extension if a concrete use case demands it. + +### Why a committed `condition_root` field, not a key-path tweak + +Bitcoin Taproot folds the script-tree root into the output key via a tweak `Q = P + +H_tweak(P‖root)·G`, because a P2TR output *is* a single 32-byte key with nowhere else to put the +commitment. Ootle outputs are structs, so the root can be a first-class field — which pays off in two places: +wallet scanning and covenants. + +The decisive reason is **wallet scanning**. A wallet recognises its own outputs by the published +one-time key: it derives the expected `P' = H(v·N)·G + S` from the sender nonce `N` and compares it +to the output's `spend_key`. A tweak publishes `Q = P + H_tweak(P‖root)·G` instead, and recovering +`P` requires `root` — precisely what the tweak hides. Empty-root (pure-payment) outputs stay +recognisable, but a script-bearing output sent to a wallet is invisible to this check: the sender +must carry `root` in the output's encrypted data and the wallet must decrypt to recover it. Worse, +because payment and script outputs are indistinguishable by design, the wallet cannot tell which is +which up front, so — absent a view tag — it must trial-decrypt every output that fails the +payment-key check. A committed `spend_key` keeps recognition identical to today's stealth scan for +every output, script-bearing or not, with `root` readable in the clear. + +A second reason is **covenant introspectability**. With a tweak the root is hidden inside `Q`, +entangled with the output's one-time internal key `P`, which is **not published** (only `Q` is). A +covenant constraining a freshly-created child output therefore cannot read that child's root: it +must either compare the whole published `Q` — which forces every covenant output to reuse an +identical key (linkable, single-recipient) — or have the spender supply each output's one-time `P` +as extra witness and recompute `Q` through a native host op (feasible, since `P` is a throwaway +stealth key, but extra plumbing on every check). A committed `condition_root` field publishes the +root directly: the covenant reads it as plain data, with no key and no reconstruction, while each +output keeps an independent spend key. + +This is **not** a WASM-cost argument. The tweak's elliptic-curve reconstruction would run in native +engine code — a host op, exactly like the other `SpendContext` host ops — and never executes inside +the WASM sandbox. The obstacle is solely that the root is unreadable behind an unpublished per-output +key, regardless of where the curve arithmetic runs. + +Reinforcing reasons: + +- **No single-key constraint.** Unlike P2TR, we are not forced to fold everything into one key; a + struct field is available. +- **No multiparty need.** The tweak's unique payoff — composing additively with MuSig / key + aggregation and adaptor signatures — is not on the roadmap, and key-path spends and adaptor swaps + already operate on the `spend_key` point without it. +- **Cheap and non-breaking.** `Option` with cbor/serde defaults, versus consensus-critical + EC verification plumbing. + +The accepted cost is that `Some` versus `None` is observable, so transparent-covenant outputs are a +distinguishable class (hidden only within the `None` population). The tweak remains available later +(§10) for the `None` case if hidden scripts or multiparty become priorities. + +### Privacy + +Scripts see commitments, spend conditions, tags and `minimum_value_promise` — never plaintext +confidential values. This matches what the balance proof and existing validation already +operate on, so spend scripts add no information leakage beyond what the output structure +already exposes on-ledger. + +## Backwards Compatibility + +The §10 output model is a **breaking re-encoding** of the stealth output: the inline +`SpendCondition` field is removed in favour of `spend_key` and `condition_root`, and the +`SpendCondition` enum itself is repurposed as the condition-tree leaf payload — dropping `Signed` +(now the key path) and renaming the predicate variant to `TemplateFunction`. Existing +`Signed`/`AccessRule` outputs therefore do **not** decode unchanged. Ootle is pre-launch with no +live network, so this breaking change is acceptable and requires no migration of stored substates — +there is no prior on-ledger data to preserve. `AccessRule` retains its inline, WASM-free evaluation +(now as a condition-tree leaf), and a key-path spend keeps the existing signature check. + +## Test Cases + +Engine tests (`crates/engine/tests/stealth.rs` is the existing home for spend-condition tests): + +1. **Timelock** — output with a `TemplateFunction(timelock, unlock_epoch=E)` condition; spend at + epoch `< E` rejected (`SpendScriptRejected`), at `>= E` succeeds. +2. **Recursive covenant** — output with a "preserve spend condition" script; a spend whose + outputs carry the same condition succeeds; a spend that changes an output's condition is + rejected. +3. **Hash/sig lock** — script requires a Schnorr signature over a fixed message via + `signature_invoke`; valid signature spends, invalid/missing rejects. +4. **Read-only enforcement** — a script that calls `vault_invoke(Withdraw)` (or otherwise + triggers `write_lock_substate` / `new_substate`) fails with `WriteInReadOnlyContext` → + `SpendScriptRejected`, and crucially leaves the targeted vault unchanged. Covers the + "directly reach a host op without a cross-template call" attack. +5. **Sandbox deny-list** — a script attempting `call_invoke`, `generate_random_invoke`, + `emit_event`, `pay_fee` or `publish_template` aborts via `SpendScriptGuardModule`. +6. **T1 creation rejection** — creating an output whose `TemplateFunction` names a missing function, + a mutable (`is_mut == true`) function, a non-unit return, a missing `SpendContext` arg, or + malformed `args` CBOR causes the *creating* transaction to be rejected (not the later spend). +7. **Determinism / metering** — a script that exceeds `MAX_WASM_POINTS_PER_CALL` fails with + `SpendScriptRejected`; fees charged to the spender are observable in the receipt. +8. **Signature-binding** — a spender cannot substitute a different `{template, function, args}` + than the one committed in the output's condition. +9. **Value constraint** — "change must return to this condition" script enforced over + `ctx.outputs()`. +10. **Weight** — an output with a large-`args` `TemplateFunction` condition raises the *creating* + transaction's weight (and fee) proportionally, versus an equivalent key-path output. +11. **Condition tree (MAST)** — an output committing to a multi-leaf condition root spends by + revealing one leaf + inclusion proof: a valid proof for a committed leaf spends and evaluates that leaf; a + proof for a non-committed leaf, a tampered sibling, or a wrong root is rejected. A single-leaf + tree has an empty proof and its root equals the leaf hash. The root is independent of leaf + authoring order, and leaf/branch domain separation prevents an internal node from being passed + off as a leaf. + +## Implementation + +Suggested phasing (spec/rationale first, per TIP process): + +1. Types + ABI: `SpendCondition::TemplateFunction`, `TemplateFunction`, `EngineOp::SpendContextInvoke`, + `SpendContextAction`, view types; TS bindings; extend `calc_stealth_statement_weight` to + weight each output's spend-condition bytes. +2. Read-only mode foundation: `read_only` flag on `CallFrame`; `WriteInReadOnlyContext` guard + on `write_lock_substate` / `get_*_mut` / `new_substate`. (Independently useful — also the + basis for any future read-only/view transaction.) +3. Engine: `validate_spend_script_signature` wired into both output creation (T1, rejects the + creating tx) and `validate_spend_condition` (T2); `spend_context_invoke`; + `SpendScriptGuardModule`; the read-only restricted-frame invocation; new `RuntimeError` + variants (`SpendScriptRejected`, `WriteInReadOnlyContext`). +4. `template_lib`: `SpendContext` + covenant helpers + builder support. +5. Wallet builder support for constructing `TemplateFunction`-conditioned outputs. +6. Tests above; docs/examples (a `vault`/`timelock` example template). + +A complementary authoring-ergonomics change — a `#[template(stateless)]` macro option that lets +a template expose only stateless functions with no component — is tracked separately in the +tari-ootle repository. It is not required for this proposal (a spend script is just a +receiver-less function), but it makes the §2 example valid as written. + +## References + +All `crates/...` paths refer to the +[tari-ootle repository](https://github.com/tari-project/tari-ootle). + +- `crates/template_lib_types/src/stealth/unspent_output.rs:65` — `SpendCondition` today. +- `crates/engine/src/runtime/working_state.rs:292,321` — spend-condition enforcement. +- `crates/engine/src/runtime/impl.rs:292,404` — `invoke_resource_access_hook` / + `check_resource_auth_hook` (the AuthHook precedent this design mirrors). +- `crates/engine/src/runtime/scope.rs` — `CallFrame.allow_cross_template_calls`, + `PushCallFrame::Static` (where the proposed `read_only` flag lives). +- `crates/engine/src/runtime/impl.rs:1648` — vault `Withdraw` taking `write_lock_substate`, the + mutation chokepoint the read-only mode guards. +- `crates/template_abi/src/ops.rs:27` — `EngineOp`; `crates/template_abi/src/template_def.rs` + — `FunctionDef.is_mut`. +- `crates/engine/src/wasm/metering.rs`, `crates/engine/src/fees/fee_module.rs` — WASM metering + and fees. +- `crates/engine_types/src/stealth/script_tree.rs` — reference MAST implementation: tree + construction, domain-separated hashing, and inclusion-proof verification (§9). +- Bitcoin CTV (BIP-119), `SIGHASH_ANYPREVOUT` (BIP-118), Simplicity — prior art on + transaction introspection and covenants. Taproot / MAST (BIP-341) — prior art on committing to a + tree of alternative scripts and revealing only the path taken. + +## Copyright + +This document is released under the BSD 3-Clause License. + +## Change History + +### 2026-06-17 + +* Document created in the (now-superseded) `tari-project/tips` repository as TIP-0006 + ([tari-project/tips#6](https://github.com/tari-project/tips/pull/6)). + +### 2026-06-22 + +* Migrated to the `tari-project/rfcs` repository. Renumbered TIP-0006 → TIP-0007 (TIP-0006 was + already claimed in this repository) and reformatted to the TIP-0001 header preamble. From 36a0c1d4e5663835a8361751a9e70e658cc32710 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 22 Jun 2026 10:08:16 +0200 Subject: [PATCH 2/4] docs(TIP-0007): link PR #180 in Change History per TIP-0001 format Co-Authored-By: Claude Opus 4.8 (1M context) --- src/TIP-0007_ootle_spend_scripts.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/TIP-0007_ootle_spend_scripts.md b/src/TIP-0007_ootle_spend_scripts.md index 2858fb35..3b4a5685 100644 --- a/src/TIP-0007_ootle_spend_scripts.md +++ b/src/TIP-0007_ootle_spend_scripts.md @@ -691,3 +691,4 @@ This document is released under the BSD 3-Clause License. * Migrated to the `tari-project/rfcs` repository. Renumbered TIP-0006 → TIP-0007 (TIP-0006 was already claimed in this repository) and reformatted to the TIP-0001 header preamble. +* [Pull Request #180](https://github.com/tari-project/rfcs/pull/180) From 6840dd56e5a207e30cd798f1cb89082cb0b021e3 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Tue, 23 Jun 2026 10:46:24 +0200 Subject: [PATCH 3/4] docs(TIP-0007): move into src/rfc/ootle and fix mdbook test - Relocate the TIP to src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md, following the type-based placement convention from #182 (type code in filename, e.g. TIP-PROC-0005), and update the SUMMARY.md link. - Mark the illustrative Rust blocks as `rust,ignore` so `mdbook test` does not try to compile them (they reference Ootle types not in scope), matching the existing RFC convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/SUMMARY.md | 2 +- .../ootle/TIP-RFC-0007_ootle_spend_scripts.md} | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/{TIP-0007_ootle_spend_scripts.md => rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md} (99%) diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 442609e4..45a78fbb 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -6,7 +6,7 @@ - [A-TIP-PROC-2: The Tari Community Charter](TIP-0002_tari_community_charter.md) - [P-TIP-PROC-3: Community Forums](TIP-0003_community_forums.md) - [A-TIP-0005: Adjust Bridge Limits](Process/TIP-PROC-0005_adjust_bridge_limits.md) -- [P-TIP-RFC-7: Spend-Time Scripts for Ootle Stealth UTXOs](TIP-0007_ootle_spend_scripts.md) +- [P-TIP-RFC-7: Spend-Time Scripts for Ootle Stealth UTXOs](rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md) - [RFC-0001: An overview of the Tari network](RFC-0001_overview.md) - [The Tari Base Layer](base_layer.md) - [RFC-0110: Base nodes](RFC-0110_BaseNodes.md) diff --git a/src/TIP-0007_ootle_spend_scripts.md b/src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md similarity index 99% rename from src/TIP-0007_ootle_spend_scripts.md rename to src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md index 3b4a5685..bdafe5f5 100644 --- a/src/TIP-0007_ootle_spend_scripts.md +++ b/src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md @@ -44,7 +44,7 @@ is explicitly deferred (see [Rationale](#rationale)). Ootle stealth UTXOs currently support exactly two spend conditions (`crates/template_lib_types/src/stealth/unspent_output.rs:65`): -```rust +```rust,ignore pub enum SpendCondition { Signed(RistrettoPublicKeyBytes), AccessRule(AccessRule), @@ -94,7 +94,7 @@ of validation; no new plumbing of transaction data is required. `crates/template_lib_types/src/stealth/unspent_output.rs`: -```rust +```rust,ignore /// A spend condition is no longer an inline UTXO field; it is the v0 leaf payload of the /// condition tree (§9). The key path (`spend_key`, §10) is the signature condition, so the /// former `Signed` variant is removed. @@ -139,7 +139,7 @@ predicate family (e.g. a single `timelock` function bound with different unlock A spend script is an ordinary `is_mut == false` template **function** (no `&self`). It takes its bound args plus a `SpendContext` handle, and rejects the spend by panicking (returning normally authorises it): -```rust +```rust,ignore #[template(stateless)] mod vault_covenant { pub fn enforce(unlock_epoch: u64, ctx: SpendContext) { @@ -181,7 +181,7 @@ Putting `SpendContext` last lets one rule cover 0, 1 or N bound args. `Type::Uni `Type::Other` already exist in the ABI (`template_def.rs:120,150`), so the match is a straightforward extension of the existing hook validator: -```rust +```rust,ignore fn validate_spend_script_signature(func: &FunctionDef) -> Result<(), RuntimeError> { if func.is_mut { return Err(/* spend script must not be mutable */); @@ -401,7 +401,7 @@ section specifies the tree and proof mechanism, which is independent of that cho The decided committed model replaces the inline `SpendCondition` field on the stealth output with two separate committed fields — the authorization key and the condition-tree root (§9): -```rust +```rust,ignore pub struct StealthUnspentOutput { // …commitment, sender nonce, encrypted data, minimum_value_promise, tag… /// Key-path authorization. `None` = no key path. From 05c345b80239bcba0209d567ba81d82a4bb430c4 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Wed, 24 Jun 2026 16:33:02 +0200 Subject: [PATCH 4/4] docs(TIP-0007): align spec with implemented design on engine-utxo-mast-execution Reconcile TIP-RFC-0007 with the as-built stealth spend-condition code: - Output model is a single SpendAuthorization enum (Key/Script/KeyAndScript), making the unspendable state unrepresentable, replacing the two-Option (spend_key/condition_root) draft. - SpendCondition leaf is a flat, non-empty conjunction of AtomicCondition atoms (AccessRule/TemplateFunction/Builtin/Covenant). - Reverse the covenant deferral: document native BuiltinPredicate (timelock/hashlock) and Covenant (output-preservation/value-routing/ value-conservation) atoms plus the CovenantBalanceClaim value-conservation proof. - Remove creation-time (T1) predicate validation: the MAST commits only the condition_root, so a TemplateFunction leaf is validated only at spend (T2). - Add the spend-witness data blob; correct MAST naming (condition_tree.rs, ConditionLeaf/ConditionBranch), host-op actions, the sandbox deny-list (enforce_read_only_restrictions), the weight charge (spend-witness bytes to the spender), and the RuntimeError variants. --- .../ootle/TIP-RFC-0007_ootle_spend_scripts.md | 727 +++++++++++------- 1 file changed, 447 insertions(+), 280 deletions(-) diff --git a/src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md b/src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md index bdafe5f5..0dc6ad6c 100644 --- a/src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md +++ b/src/rfc/ootle/TIP-RFC-0007_ootle_spend_scripts.md @@ -3,7 +3,7 @@ | TIP | [P-TIP-RFC-7](#p-tip-rfc-7-spend-time-scripts-for-ootle-stealth-utxos) | |---------------|----------------------------------------------------------------------------------| | Title | Spend-time scripts for Ootle stealth UTXOs (script-capable spend conditions) | -| Last Modified | 2026-06-22 | +| Last Modified | 2026-06-24 | | Authors | Stanley Bondi | | Status | Proposed | | Type | Architecture | @@ -21,28 +21,33 @@ This TIP makes an Ootle stealth UTXO spendable under **programmable conditions** spender's credentials alone. It does two things. First, it adds a `TemplateFunction` spend condition — a **stateless, restricted WASM predicate** that answers *"is this particular spend valid?"* by introspecting the spending `StealthTransferStatement`: its inputs, its outputs -(including each output's own committed condition), and revealed amounts, together with transaction -context (signer public keys and the current epoch). The predicate runs in a sandboxed execution mode -that forbids state mutation, cross-template calls and non-determinism, modelled directly on the -engine's existing resource `AuthHook` mechanism. +(including each output's own committed `SpendAuthorization`), and revealed amounts, together with the +current epoch. The predicate runs in a sandboxed execution mode that forbids state mutation, +cross-template calls and non-determinism, modelled directly on the engine's existing resource +`AuthHook` mechanism. Second, it restructures how a condition is committed. The inline `SpendCondition` field is removed; -an output is authorised by a **key path** (a `spend_key`, the signature condition) and/or a -**condition tree** — a Merklized set (MAST) of alternative `SpendCondition` leaves committed under a -`condition_root`, of which a `TemplateFunction` predicate is one leaf kind alongside `AccessRule` -(§9, §10). The spender reveals only the leaf they exercise. - -Because a `TemplateFunction` predicate can read the spending transaction's outputs and its own -invoking condition, it subsumes covenants: a covenant is simply a predicate that asserts properties -of the outputs it produces, including (recursively) that an output commits the same condition. This -TIP therefore does **not** introduce a separate covenant field; covenants are delivered as -`template_lib` helper patterns over a `TemplateFunction` leaf. A dedicated declarative covenant field -is explicitly deferred (see [Rationale](#rationale)). +an output's `SpendAuthorization` is a **key path** (a one-time `spend_key`, the signature condition) +and/or a **condition tree** — a Merklized set (MAST) of alternative `SpendCondition` leaves committed +under a `condition_root`, of which a `TemplateFunction` predicate is one leaf kind alongside +`AccessRule`, native builtins, and native covenants (§9, §10). The spender reveals only the leaf they +exercise. + +A `TemplateFunction` predicate can read the spending transaction's outputs and the `condition_root` +it was invoked under, so it *can* express covenants — a covenant is simply a predicate that asserts +properties of the outputs it produces, including (recursively) that an output commits the same +condition. The earlier draft of this proposal therefore deferred a dedicated covenant field and +delivered covenants only as `template_lib` helper patterns over a `TemplateFunction` leaf. **That +deferral has since been reversed**: the implemented design adds two native, declarative leaf kinds — +`BuiltinPredicate` (timelocks, hashlocks) and `Covenant` (output-preservation, value-routing, +value-conservation) — alongside `TemplateFunction`, because they are cheaper (no WASM cost), +off-chain analyzable, and engine-enforced. The `template_lib` covenant helpers remain for bespoke +WASM predicates (see [Rationale](#rationale)). ## Motivation -Ootle stealth UTXOs currently support exactly two spend conditions -(`crates/template_lib_types/src/stealth/unspent_output.rs:65`): +Before this proposal, an Ootle stealth UTXO supported exactly two spend conditions +(`crates/template_lib_types/src/stealth/unspent_output.rs`): ```rust,ignore pub enum SpendCondition { @@ -77,20 +82,22 @@ reusing the WASM template toolchain authors already know, and without a new byte ### Overview of the spend-validation path (unchanged surface) A stealth transfer is processed by `ResourceManager::stealth_transfer` -(`crates/template_lib/src/resource/manager.rs:548`) which the engine dispatches via +(`crates/template_lib/src/resource/manager.rs`) which the engine dispatches via `ResourceAction::StealthTransfer`. Inside the engine this reaches `WorkingState::validate_and_spend_stealth_utxos` -(`crates/engine/src/runtime/working_state.rs:292`), which locks each input UTXO and calls -`validate_spend_condition` (`:321`) per input. That function is the sole enforcement point and -is where a `TemplateFunction` condition is evaluated. +(`crates/engine/src/runtime/working_state.rs`), which locks each input UTXO and then runs the +per-input authorization check `verify_input_authorizations` +(`crates/engine/src/runtime/impl.rs`). For a script-path input that delegates to +`verify_script_path_authorization` → `evaluate_condition_leaf`, the sole enforcement point and +where a `TemplateFunction`, builtin or covenant condition is evaluated. -Critically, `validate_and_spend_stealth_utxos` already holds the entire -`StealthTransferStatement` — so all data a script needs to introspect is in scope at the point -of validation; no new plumbing of transaction data is required. +Critically, this path already holds the entire `StealthTransferStatement` — so all data a script +needs to introspect is in scope at the point of validation; no new plumbing of transaction data is +required. ### Technical Details -#### 1. The `SpendCondition` enum (repurposed as the condition-tree leaf) +#### 1. The `SpendCondition` leaf (a flat conjunction of atomic conditions) `crates/template_lib_types/src/stealth/unspent_output.rs`: @@ -98,15 +105,31 @@ of validation; no new plumbing of transaction data is required. /// A spend condition is no longer an inline UTXO field; it is the v0 leaf payload of the /// condition tree (§9). The key path (`spend_key`, §10) is the signature condition, so the /// former `Signed` variant is removed. -pub enum SpendCondition { +/// +/// A leaf is a flat, non-empty **conjunction** (logical AND): the spend is admissible only if +/// every atom holds. The condition tree itself is the disjunction (each leaf is an alternative +/// spend path), so AND is the only combinator a leaf needs. The conjunction is flat — atoms do +/// not nest — so evaluation depth is bounded by construction. +pub struct SpendCondition(Box<[AtomicCondition]>); + +/// The atomic spend conditions a leaf may conjoin. +pub enum AtomicCondition { + /// Spend is gated on a native access rule evaluated against the transaction's auth scope. #[n(0)] AccessRule(#[n(0)] AccessRule), /// Spend is gated on a stateless WASM predicate over the spending transfer. #[n(1)] TemplateFunction(#[n(0)] TemplateFunction), + /// Spend is gated on a native, consensus-fixed local predicate (timelock or hashlock); no + /// deployed template, evaluated natively. + #[n(2)] + Builtin(#[n(0)] BuiltinPredicate), + /// Spend is gated on a native, consensus-fixed constraint over the spending transaction's + /// outputs and value flow; no deployed template, evaluated natively. + #[n(3)] + Covenant(#[n(0)] Covenant), } -#[derive(Debug, Clone, Encode, Decode, CborLen, PartialEq, Eq)] pub struct TemplateFunction { /// The template providing the predicate. Templates are immutable substates, so the /// referenced code cannot change under the output once committed. @@ -121,18 +144,46 @@ pub struct TemplateFunction { #[n(2)] pub args: Vec, } + +/// A native, consensus-fixed local predicate. No deployed template; evaluated natively. +pub enum BuiltinPredicate { + /// Absolute epoch lock: admissible once the current epoch is at or after `unlock_epoch`. + #[n(0)] AfterEpoch(#[n(0)] u64), + /// Absolute epoch deadline: admissible only while the current epoch is strictly before it. + #[n(1)] BeforeEpoch(#[n(0)] u64), + /// Hashlock: the witness `data` blob must be a preimage whose `alg` digest equals `hash`. + #[n(2)] HashLock { #[n(0)] hash: Hash32, #[n(1)] alg: HashAlg }, +} + +/// A native, consensus-fixed constraint over the spending transaction's outputs. No deployed +/// template; evaluated natively. +pub enum Covenant { + /// "Stay in the vault": every stealth output must preserve the invoking `condition_root`, + /// and there must be at least one such output. + #[n(0)] OutputPreservesCondition, + /// Value-flow: at least one stealth output commits `condition_root` and promises `min_value`. + #[n(1)] OutputTo { #[n(0)] condition_root: Hash32, #[n(1)] min_value: u64 }, + /// Value-conservation: the invoking partition's committed value is conserved into outputs + /// carrying its `condition_root`, save for an exact cleartext outflow of at most `max_revealed` + /// (zero = full conservation). Proven by a `CovenantBalanceClaim` (§8). + #[n(2)] BalancePreserved(#[n(0)] u64), +} ``` A `TemplateFunction` condition fully commits `{template, function, args}`, so a spender cannot substitute a different predicate. `args` lets one template function serve as a parameterised predicate family (e.g. a single `timelock` function bound with different unlock epochs per output). +A `BuiltinPredicate` and a `Covenant` are likewise fully committed — a builtin is the cheap native +path for the common timelock/hashlock cases, and a covenant the cheap native path for the common +value-routing cases — so the bespoke `TemplateFunction` path is needed only when the standard library +does not already cover the constraint. > **Committed output model (decided).** The output does not store an inline `SpendCondition`. The -> decided integration (§10) replaces that field with two committed fields, `spend_key` and -> `condition_root`, where `condition_root` is the root of a condition tree (§9) whose leaves are -> `SpendCondition` values. §1–§8 describe the per-predicate mechanism, which is unchanged; it now -> applies to the leaf reached via the script-path witness. A leaf with `id = 0` carries a -> Borsh-encoded `SpendCondition` (this enum). +> decided integration (§10) replaces that field with a single committed `SpendAuthorization` field — +> an enum that is a `spend_key` key path, a `condition_root` script path (the root of a condition +> tree, §9, whose leaves are `SpendCondition` values), or both. §1–§8 describe the per-predicate +> mechanism, which is unchanged; it applies to the leaf reached via the script-path witness. A leaf +> with `id = 0` carries a Borsh-encoded `SpendCondition` (this type). #### 2. Predicate ABI (author-facing) @@ -153,9 +204,10 @@ mod vault_covenant { `SpendContext` is a thin `template_lib` wrapper over a new introspection host op (below). The exact signature the engine requires — `is_mut == false`, a unit (`()`) return, and a trailing -`SpendContext` argument — and the two points at which it is enforced are specified in §3. (The -`#[template(stateless)]` form is a proposed authoring convenience; the predicate can equally be -written as a receiver-less function on a normal `#[template]` struct.) +`SpendContext` argument — and where it is enforced are specified in §3. (The `#[template(stateless)]` +form is an authoring convenience — implemented in `crates/template_macros` as a `stateless` template +option — that declares a component-less template of receiver-less `pub fn` items; the predicate can +equally be written as a receiver-less function on a normal `#[template]` struct.) > Failure convention: a spend script signals rejection the way every Ootle template does — by > **panicking** (e.g. `assert!`), never by returning a value or a `Result`. Returning normally @@ -198,86 +250,88 @@ fn validate_spend_script_signature(func: &FunctionDef) -> Result<(), RuntimeErro } ``` -The signature is validated at **two** points, which answer different questions: +The signature is validated at **spend time only** (T2): **(T2) Spend time — authoritative, mandatory.** Immediately before invoking, the engine re-resolves the `LoadedTemplate`, fetches the `FunctionDef`, and runs -`validate_spend_script_signature`. This is the security gate and cannot be skipped: a revealed -leaf condition is untrusted data supplied by the spender, so the engine must not assume any -earlier node validated it. Were this omitted, a malformed condition could drive the engine to -call an `is_mut == true` function (acquiring a write lock inside a supposedly read-only -predicate), call a non-existent function, or misinterpret a non-unit return. A failure here -rejects the spend (`SpendScriptRejected`); the output remains unspent. - -**(T1) Creation time — mandatory.** When a transaction *creates* an output committing a -`TemplateFunction` condition (as a condition-tree leaf) — whether the genesis output or a covenant -continuation produced by a spend — the engine resolves the referenced template, runs the same -`validate_spend_script_signature`, and checks that `args` has one well-formed-CBOR element per bound parameter (`args.len() == func.arguments.len() - 1`), and **rejects -the creating transaction** if any of these fail. This mirrors `check_resource_auth_hook`, which -validates an auth hook when the resource is created. The purpose is to make malformed -conditions un-creatable rather than discovered later as **permanently locked funds**: a typo'd -function name or wrong signature surfaces to the output's *creator* at construction, not to the -*recipient* when they first try (and fail) to spend. - -Because templates are **immutable, permanent, global substates** — `SubstateId::Template` is -`is_read_only`/`is_global` (`crates/engine_types/src/substate.rs:337`) and no removal or -deactivation path exists — a signature that validates at T1 is guaranteed to still validate at -T2. T1 validation is therefore a durability guarantee, not merely advisory, and there is no -"template not yet published" skip case analogous to the auth hook's -component-may-not-exist-yet handling (`impl.rs:300-316`): a `TemplateFunction` condition references -a pre-existing published template, so creation requires it to already resolve. - -> Limitation: as with any instruction argument, the host can verify the function *shape*, the -> `args` element count, and that each element is well-formed CBOR, but it cannot fully -> type-check an element against its declared parameter type — that conformance is enforced at -> the WASM deserialization boundary (the dispatcher's `decode_exact::` per arg, -> `crates/template_macros/src/template/dispatcher.rs`), where a mismatch panics and (at T2) -> rejects the spend. +`validate_spend_script_signature` (`crates/engine/src/runtime/impl.rs`). This is the security gate +and cannot be skipped: a revealed leaf condition is untrusted data supplied by the spender, so the +engine must not assume it was validated earlier. Were this omitted, a malformed condition could drive +the engine to call an `is_mut == true` function (acquiring a write lock inside a supposedly read-only +predicate), call a non-existent function, or misinterpret a non-unit return. A failure here rejects +the spend (`SpendScriptRejected`); the output remains unspent. + +**No creation-time (T1) validation of the predicate.** An earlier draft of this proposal mandated a +mirror check at output *creation*, rejecting the creating transaction if the committed +`TemplateFunction` named a missing/mutable/non-unit/wrong-arity function — so a malformed predicate +surfaced to the *creator* rather than to the *recipient* as permanently locked funds. The MAST +integration (§9, §10) **precludes this**: a created output commits only the `condition_root`, not its +leaves. The leaf (and hence the `TemplateFunction` it contains) is revealed only at spend, so the +engine has nothing to validate at creation — `validate_stealth_outputs_statement` +(`crates/engine_types/src/stealth/outputs.rs`) checks commitments, nonces and range/balance proofs +but is structurally blind to the hidden conditions. The locked-funds risk is therefore real and is +borne by whoever constructs the leaf set: it must be caught **wallet-side at construction**, not by +the engine. (Because templates are immutable, permanent, global substates — `SubstateId::Template` is +`is_read_only`/`is_global`, with no removal or deactivation path — a predicate that resolves and +validates once will keep validating, so a wallet-side check at construction is durable.) + +> Limitation: at spend time the host can verify the function *shape*, the `args` element count, and +> that each element is well-formed CBOR, but it cannot fully type-check an element against its +> declared parameter type — that conformance is enforced at the WASM deserialization boundary (the +> dispatcher's `decode_exact::` per arg, `crates/template_macros/src/template/dispatcher.rs`), +> where a mismatch panics and rejects the spend. #### 4. Introspection host op (`SpendContext`) -Add one `EngineOp` (`crates/template_abi/src/ops.rs:27`), e.g. -`SpendContextInvoke = 0x11`, dispatched to a new `RuntimeInterface` method -`spend_context_invoke(action: SpendContextAction) -> InvokeResult`. **Scope: the current -transfer statement only** (per design decision; see Rationale). Read-only actions: +Add one `EngineOp` (`crates/template_abi/src/ops.rs`), `SpendContextInvoke = 0x11`, dispatched to a +new `RuntimeInterface` method `spend_context_invoke(action: SpendContextAction) -> InvokeResult`. +**Scope: the current transfer statement only** (per design decision; see Rationale). The +`SpendContextAction` variants (`crates/template_lib/src/args/types.rs`): | Action | Returns | |---|---| -| `Inputs` | `Vec` — commitments being spent, + `revealed_amount` | -| `Outputs` | `Vec` — each output's `commitment`, `minimum_value_promise`, `spend_key`, `condition_root`, `UtxoTag` | -| `CurrentInput` | index + commitment of the input whose condition is executing | -| `InvokingCondition` | the `SpendCondition::TemplateFunction(..)` leaf that invoked this predicate (enables recursive covenants) | -| `Signers` | transaction signer public keys (already reachable via `CallerContextInvoke`) | -| `Epoch` | current epoch (already reachable via `ConsensusInvoke` → timelocks) | - -These views expose only data already present in `StealthTransferStatement` + -`AuthorizationScope` + consensus context. Confidential *values* remain hidden (only -commitments and `minimum_value_promise` are visible), preserving the privacy model — a script -reasons about commitments and spend conditions, not plaintext amounts, exactly as the balance -proof does. +| `Inputs` | `Vec` — each input's `commitment` | +| `Outputs` | `Vec` — each output's `commitment`, `minimum_value_promise`, `auth` (`SpendAuthorization`), `tag` | +| `CurrentInput` | `CurrentInputView` — index, commitment, and the committed `condition_root` of the input whose condition is executing (the `condition_root` enables recursive covenants, subsuming the dropped `InvokingCondition` action) | +| `RevealedInputAmount` | total cleartext amount being spent | +| `RevealedOutputAmount` | total cleartext amount being output | +| `AssertCovenantBalanced { max_revealed }` | verifies the current partition's `CovenantBalanceClaim` value-conservation proof (§8); backs the `require_balance_preserved*` helpers | +| `WitnessData` | the raw spender-supplied witness `data` blob (§10) the leaf may interpret | + +The current epoch is **not** a `SpendContext` action; a script reads it via the existing +`Consensus::current_epoch()` host op (`consensus_invoke`). There is no `Signers` action — a spend +script reasons about the transfer, not the transaction's signer set. + +These views expose only data already present in `StealthTransferStatement` + consensus context. +Confidential *values* remain hidden (only commitments and `minimum_value_promise` are visible), +preserving the privacy model — a script reasons about commitments and spend authorizations, not +plaintext amounts, exactly as the balance proof does. #### 5. Invocation flow Evaluation is hoisted to the `RuntimeInterfaceImpl` layer (`crates/engine/src/runtime/impl.rs`), where the template provider (`self.template_provider`) and the re-entrant runtime pointer -already live — this is precisely how `invoke_resource_access_hook` (`impl.rs:292`) works today. For -a `condition_root` output (§10) the leaf to evaluate is the one named by the input's script-path -witness, its inclusion in the root having been verified first (§9); the flow below then runs for that -single leaf. - -1. `validate_and_spend_stealth_utxos` resolves, per input, the spend path from its witness: a - key-path witness is checked as a signature against `spend_key`; a revealed `AccessRule` leaf runs - the existing authorization-scope check inline (no WASM cost). -2. For a revealed `TemplateFunction` leaf, the engine resolves the `LoadedTemplate`, re-runs +already live — this is precisely how `invoke_resource_access_hook` works today. For a script-path +input (§10) the leaf to evaluate is the one named by the input's script-path witness, its inclusion +in the committed `condition_root` having been verified first (§9, `verify_inclusion`); the flow below +then runs for that single leaf's conjunction of atoms. + +1. `verify_input_authorizations` resolves, per input, the spend path from its witness: a key-path + witness is checked as a signature against the output's `spend_key`; a revealed `AccessRule` atom + runs the existing authorization-scope check inline, and `Builtin`/`Covenant` atoms are evaluated + natively (`evaluate_builtin` / `evaluate_covenant`) — all with no WASM cost. +2. For a revealed `TemplateFunction` atom, the engine resolves the `LoadedTemplate`, runs `validate_spend_script_signature` (the authoritative T2 check from §3), assembles the call arg list as `[ args[0], …, args[n-1], encode(SpendContext) ]` — the positional bound args followed by the engine-injected context, exactly as `invoke_resource_access_hook` appends `auth_caller` — and invokes the predicate via the existing `invoke_template_function` path - (`impl.rs:382`) inside a **read-only restricted frame** (§6). The generated dispatcher - decodes each slot positionally with `decode_exact::`. -3. The restricted frame is a `PushCallFrame::Static` (`crates/engine/src/runtime/scope.rs`) - with `read_only = true`, `allow_cross_template_calls = false`, and an **empty authorization - scope** (the script must not borrow the spender's badges). + inside a **read-only restricted frame** (§6). The generated dispatcher decodes each slot + positionally with `decode_exact::`. +3. The restricted frame is not a freshly-constructed `PushCallFrame::Static`; instead the spend + script's call frame is restricted **post-push** by `restrict_to_read_only` + (`crates/engine/src/runtime/scope.rs`), which sets the frame's `read_only` flag and disables + `allow_cross_template_calls` together, so the script can neither write state nor borrow the + spender's badges via a cross-template call. 4. Returning normally authorises the spend. Any panic — a deliberate `assert!`/`panic!` rejection, out-of-gas, or an attempted state mutation (`WriteInReadOnlyContext`) — aborts it as `RuntimeError::SpendScriptRejected`, carrying the abort reason. Rejection is always a @@ -294,9 +348,9 @@ re-execution. The restriction has two layers; the first is the load-bearing one. **(a) A first-class read-only mode anchored on the lock layer.** Every state mutation in the engine — vault withdraw/deposit, mint, recall, freeze, component-state writes, new-substate creation — funnels through `write_lock_substate` / `get_*_mut` / `new_substate` on -`WorkingState` (e.g. vault `Withdraw` takes `write_lock_substate(SubstateId::Vault(..))`, -`impl.rs:1648`). A `read_only` flag on the active `CallFrame`, set when a spend script is -entered, makes **`write_lock_substate` (and `new_substate`) return +`WorkingState` (e.g. vault `Withdraw` takes `write_lock_substate(SubstateId::Vault(..))`). A +`read_only` flag on the active `CallFrame`, set when a spend script is entered (checked via +`WorkingState::is_read_only_context`), makes **`write_lock_substate` (and `new_substate`) return `RuntimeError::WriteInReadOnlyContext`**. This is a *whitelist-grade* guarantee against mutation: because the write lock is the single chokepoint, blocking it neutralises every mutating host op at once — including any future op — regardless of which `*_invoke` requested @@ -305,19 +359,20 @@ it. A spend script may therefore call `vault_invoke(Withdraw)` directly on a har `allow_cross_template_calls = false` alone does **not** stop it), but the underlying write lock is refused and the spend fails. -**(b) A `SpendScriptGuardModule` deny-list for the few effects that bypass the lock layer.** -Every host op also funnels through `invoke_modules_on_runtime_call(..)` -(`crates/engine/src/runtime/module.rs`). The module rejects the non-lock-mediated effects and -sources of non-determinism: `call_invoke`, `generate_random_invoke`, `generate_uuid`, -`publish_template`, `pay_fee`, `emit_event`, and proof/bucket creation. This list is small and -stable because layer (a) already covers all state writes. +**(b) An inline deny-list for the few effects that bypass the lock layer.** Host-op dispatch in +read-only context also runs `enforce_read_only_restrictions` (`crates/engine/src/runtime/impl.rs`) — +an inline guard, not a separate `RuntimeModule`. It rejects the non-lock-mediated effects and +sources of non-determinism with `RuntimeError::ForbiddenInReadOnlyContext { operation }`: +`call_invoke`, `generate_random_invoke`, `generate_uuid`, `emit_event`, and proof/bucket creation +(`proof_invoke` / `bucket_invoke`). This list is small and stable because layer (a) already covers +all state writes (template publication and any other substate-creating op is stopped there). **Allowed (read-only / deterministic):** `spend_context_invoke`; `signature_invoke` (`EngineOp 0x10` — Schnorr verification → hash-locks, m-of-n, adaptor conditions); -`consensus_invoke` (epoch); `caller_context_invoke` (signers); `emit_log`. In practice a spend -script needs no substate access at all — its world is the injected `SpendContext` plus pure -host ops — so read-only here means "no writes"; even reads beyond `SpendContext` are -unreachable without cross-template/method calls, which are disabled. +`consensus_invoke` (epoch); `emit_log`. In practice a spend script needs no substate access at all — +its world is the injected `SpendContext` plus pure host ops — so read-only here means "no writes"; +even reads beyond `SpendContext` are unreachable without cross-template/method calls, which are +disabled. Together, (a) + (b) make the predicate deterministic (no randomness; epoch is fixed for the duration of execution) and provably side-effect-free. @@ -330,27 +385,61 @@ and `MAX_WASM_POINTS_PER_TRANSACTION` cumulatively. The **spender pays** for scr as they do for every other instruction. A per-spend-script cap may be set stricter than the generic per-call cap if profiling warrants it. -A `TemplateFunction` leaf — `template`, `function`, and especially `args` — is committed into the -output created by a `StealthTransfer` instruction, so its serialized size **must contribute to the -creating transaction's weight**. Today `calc_stealth_statement_weight` -(`crates/transaction/src/v1/transaction.rs:284`) weights stealth outputs by count only -(`100 + inputs + outputs * 2`) and never inspects the bytes inside an output; this proposal -extends it so each output additionally contributes the byte length of its committed condition data -(notably `TemplateFunction` `args`). Without this, a large script/args could be broadcast for free and -used to bloat outputs. The **creator** pays this weight at creation time; the persisted bytes -are separately charged per-byte storage at substate creation, and execution at spend time is -metered via WASM points (above) against the **spender**. +A condition leaf — including a `TemplateFunction` and its `args` — is **not** committed in the output; +under the MAST integration the output commits only a fixed-size `condition_root` (§9, §10). The leaf, +its Merkle inclusion proof, and any witness `data` blob are revealed in the **spend witness** +(`SpendWitness::ScriptPath`) at spend time, so their serialized size is charged to the **spender**. +`calc_stealth_statement_weight` (`crates/transaction/src/v1/transaction.rs`) reflects this: + +```rust,ignore +// 100 base + 1·inputs + 8·outputs + (serialized spend-witness bytes / 3) +WEIGHT_PER_TRANSFER // 100 (resource lock + balance-proof verification) + + inputs.len() * 1 // per-input commitment aggregation / lookup + + outputs.len() * 8 // per-output range proof + ElGamal viewable-balance proof (dominant) + + witness_bytes / 3 // serialized SpendWitness: revealed leaf + inclusion proof + data +``` + +So a large revealed leaf/args raises the *spending* transaction's weight (and fee), not the creating +one — a malformed or oversized leaf cannot be broadcast for free. Witness size is additionally bounded +by `STEALTH_LIMITS` (`crates/engine_types/src/limits.rs`): `max_witness_data_len` (4096), +`max_inclusion_proof_len` (32), and `max_conditions_per_conjunction` (16). Execution of a +`TemplateFunction` leaf at spend time is separately metered via WASM points (above), also against the +spender. + +#### 8. Covenants: native leaves *and* a library pattern + +Covenants are delivered **two** ways, which share their evaluation logic. + +**Native `Covenant` leaves (the cheap, analyzable path).** A `Covenant` atom (§1) is evaluated +natively by the engine (`evaluate_covenant`, `crates/engine/src/runtime/impl.rs`), with no WASM cost: + +- `OutputPreservesCondition` — recursive "stay in the vault": every stealth output must be re-locked + under exactly the invoking `condition_root` (no key-path escape), and there must be at least one. +- `OutputTo { condition_root, min_value }` — "funds must flow to X": at least one output is locked + under `condition_root` and promises at least `min_value`. +- `BalancePreserved(max_revealed)` — value-conservation: the invoking `condition_root` partition's + committed value is conserved into outputs carrying that root, save for an exact cleartext outflow of + at most `max_revealed` (zero = full conservation). -#### 8. Covenants as a library pattern +`BalancePreserved` is proven without revealing confidential values by a **`CovenantBalanceClaim`** +(`StealthTransferStatement.covenant_claims`): a Schnorr sub-balance proof over one input partition +(the inputs/outputs sharing a `condition_root`), keyed by `partition_input_index` and binding the +partition's `condition_root` into the signature so a claim cannot be validated against the wrong +partition. The engine verifies it via `validate_covenant_balance_proof` +(`crates/engine_types/src/crypto/covenant.rs`). -No engine covenant field is added. `template_lib` ships helpers built on `SpendContext`: +**`template_lib` helpers (the bespoke path).** For covenants that must also run custom logic, a +`TemplateFunction` predicate can assert the same properties through `SpendContext` helpers: -- `ctx.require_output_preserves_condition()` — recursive "stay in the vault" covenant. -- `ctx.require_timelock(unlock_epoch)` — absolute epoch lock. -- `ctx.require_output_to(spend_condition, min_value)` — "funds must flow to X". +- `ctx.require_output_preserves_condition()` / `ctx.require_output_to(condition_root, min_value)` — + back the `OutputPreservesCondition` / `OutputTo` predicates; the host and guest evaluate them + identically through the shared `outputs_preserve_condition` / `has_output_to` functions. +- `ctx.require_timelock(unlock_epoch)` — absolute epoch lock (over `ctx.current_epoch()`). +- `ctx.require_balance_preserved()` / `ctx.require_balance_preserved_with_allowance(max_revealed)` — + the value-conservation covenant, verified via the `AssertCovenantBalanced` host action (§4). -These are thin wrappers asserting over `ctx.outputs()` / `ctx.invoking_condition()`. Authors -compose them; the engine treats them as ordinary predicate logic. +Authors reach for a native leaf when a standard constraint suffices, and a `TemplateFunction` only +when they need to compose it with bespoke predicate logic. #### 9. Condition trees (MAST): committing to a set of alternative conditions @@ -370,14 +459,16 @@ reveals the one leaf they are exercising plus a Merkle inclusion proof, and the The unused alternatives are never revealed. **Leaf format.** A leaf is `(id, payload)`: `id` is an encoding-version discriminant and `payload` -its body. `id = 0` is a Borsh-encoded `SpendCondition` (§1) — i.e. an `AccessRule` or a -`TemplateFunction`. Reserving `id` keeps the tree forward-compatible with a future breaking leaf -encoding (`id = 1`) without changing the tree structure. +its body. `id = 0` (`SPEND_CONDITION_LEAF_ID`) is a Borsh-encoded `SpendCondition` (§1) — a flat +conjunction of `AccessRule` / `TemplateFunction` / `Builtin` / `Covenant` atoms. Reserving `id` keeps +the tree forward-compatible with a future breaking leaf encoding (`id = 1`) without changing the tree +structure. -**Tree construction (consensus-critical).** A canonical binary Merkle tree with: +**Tree construction (consensus-critical).** A canonical binary Merkle tree +(`crates/engine_types/src/stealth/condition_tree.rs`) with: -- **Domain separation** — distinct leaf and branch hash domains (`SpendScriptLeaf` / - `SpendScriptBranch`, engine-domain Blake2b), so an internal node can never be reinterpreted as a +- **Domain separation** — distinct leaf and branch hash domains (`EngineHashDomainLabel::ConditionLeaf` + / `ConditionBranch`, engine-domain Blake2b), so an internal node can never be reinterpreted as a leaf (second-preimage resistance). - **Lexicographic pair ordering** — each branch hashes its two children in byte order, so an inclusion proof is a bare list of sibling hashes with **no left/right direction bits**. @@ -392,85 +483,115 @@ engine at spend time, the wallet at construction); a template never builds or ve hashing crosses into WASM. The inclusion proof — a `Vec` of sibling hashes — rides in the spend witness and is weight-/metering-charged like any other spend data (§7). -The integration of this root into the committed output is **decided** (§10): it is an output-level -`condition_root` field — not an inline `SpendCondition` field and not a taproot-style key tweak. This -section specifies the tree and proof mechanism, which is independent of that choice. +The integration of this root into the committed output is **decided** (§10): the root is committed as +the `condition_root` carried in the output's `SpendAuthorization` — not an inline `SpendCondition` +field and not a taproot-style key tweak. This section specifies the tree and proof mechanism, which is +independent of that choice. -#### 10. Output spend model: `spend_key` + `condition_root` (decided integration) +#### 10. Output spend model: `SpendAuthorization` (decided integration) -The decided committed model replaces the inline `SpendCondition` field on the stealth output with -two separate committed fields — the authorization key and the condition-tree root (§9): +The decided committed model replaces the inline `SpendCondition` field on the stealth output with a +single `SpendAuthorization` field — an enum over the authorization key and the condition-tree root +(§9): ```rust,ignore pub struct StealthUnspentOutput { - // …commitment, sender nonce, encrypted data, minimum_value_promise, tag… - /// Key-path authorization. `None` = no key path. - pub spend_key: Option, - /// Condition-tree root over the alternative conditions (§9). `None` = no conditions. - pub condition_root: Option, + pub output: UnspentOutput, // commitment, sender nonce, encrypted data, minimum_value_promise, … + pub auth: SpendAuthorization, + pub tag: UtxoTag, +} + +/// Modelled as an enum (not a pair of `Option`s) so the unspendable `{no key, no conditions}` state +/// is **unrepresentable** — by construction in memory and at the decode boundary, with no runtime +/// invariant to enforce. +pub enum SpendAuthorization { + /// Key path only: a one-time stealth public key, spendable by a signature. + #[n(0)] Key(#[n(0)] RistrettoPublicKeyBytes), + /// Script path only: the condition-tree root (§9). + #[n(1)] Script(#[n(0)] Hash32), + /// Either path is admissible; the per-input `SpendWitness` selects which. + #[n(2)] KeyAndScript { #[n(0)] spend_key: RistrettoPublicKeyBytes, #[n(1)] condition_root: Hash32 }, } ``` -- **Invariant.** At least one of `spend_key` / `condition_root` is `Some`; `{None, None}` is an - unspendable (burn) output and is rejected at creation. -- `condition_root == None` is a pure payment **or** — once the deferred tweak below lands — a - tweak-hidden condition; these share a single anonymity set. -- `condition_root == Some(root)` is a **transparent, introspectable** condition set / covenant, and - is therefore a deliberately distinguishable class (see the rationale). +- **Invariant.** The unspendable (burn) output is **unrepresentable** — `SpendAuthorization` has no + `{no key, no conditions}` variant — so no creation-time check is needed (contrast the earlier + two-`Option` draft, which had to reject `{None, None}` at creation). +- `Key(..)` is a pure payment **or** — once the deferred tweak below lands — a tweak-hidden condition; + these share a single anonymity set. +- `Script(..)` / `KeyAndScript { .. }` is a **transparent, introspectable** condition set / covenant, + and is therefore a deliberately distinguishable class (see the rationale). -**Witness-driven spends.** Each spent input carries a `SpendWitness` that selects the spend path; the -committed output only gates which witnesses are *admissible*, and the spender always chooses: +**Witness-driven spends.** Each spent input (`StealthInput`) carries a `SpendWitness` that selects the +spend path; the committed `SpendAuthorization` only gates which witnesses are *admissible*, and the +spender always chooses: -| Witness | Admissible when | Checked | +| Witness | Admissible when `auth` has | Checked | |---|---|---| -| Key-path | `spend_key = Some(pk)` | a signature under `pk` | -| Script-path | `condition_root = Some(root)` | reveal one leaf + its `MerkleProof`; recompute and check the root (§9), then evaluate that leaf via §5 | +| `KeyPath` | a `spend_key` (`Key` / `KeyAndScript`) | a signature under `spend_key` | +| `ScriptPath { leaf, proof, data }` | a `condition_root` (`Script` / `KeyAndScript`) | recompute the root from `leaf` + `proof` and check it equals the committed `condition_root` (§9), then evaluate `leaf` via §5; `data` is the spender-supplied blob the leaf interprets | + +The `data` blob is **not** committed in `condition_root` (only the leaf is), so it cannot change which +predicate runs, only satisfy one — a hashlock reads it as raw bytes (and must be the sole consumer in +its leaf), a `TemplateFunction` reads it via `SpendContext::data`. (`SpendWitness` reserves index +`#[n(2)]` for the deferred `ScriptPathTweaked` variant below.) Because the spender chooses the witness, "script-only" (no key path) must be enforced -*cryptographically* — by `spend_key: None` (or, for the deferred hidden case, a NUMS key whose -discrete log is unknown) — never by assuming the spender will decline the key path. +*cryptographically* — by committing `Script(root)` rather than `KeyAndScript { .. }` (or, for the +deferred hidden case, a NUMS key whose discrete log is unknown) — never by assuming the spender will +decline the key path. -**Mapping today's conditions.** The signature condition becomes the key path (`spend_key = -Some(pk)`); a `TemplateFunction` predicate and an `AccessRule` are **sibling leaf kinds** in the -condition tree, each carried as an `id = 0` `SpendCondition` leaf. The §2–§8 mechanism — ABI, T1/T2 -signature validation, the read-only sandbox, metering, and the covenant helpers — is unchanged and -now applies to the *revealed leaf*. Accordingly the `Outputs` introspection view (§4) and the -covenant helpers (§8) expose each output's `{spend_key, condition_root}` in place of a single -`SpendCondition`. +**Mapping the former conditions.** The old `Signed` condition becomes the key path +(`SpendAuthorization::Key`); `AccessRule`, `TemplateFunction`, the new `Builtin` and `Covenant` are +**sibling atom kinds** in a `SpendCondition` leaf carried as an `id = 0` leaf under a `condition_root`. +The §2–§8 mechanism — ABI, T2 signature validation, the read-only sandbox, metering, and the covenant +helpers — applies to the *revealed leaf*. Accordingly the `Outputs` introspection view (§4) exposes +each output's `auth: SpendAuthorization` in place of a single `SpendCondition`. **Deferred: key-path / script-path tweak.** A Taproot-style key tweak — folding the root into the spend key as `Q = P + H_tweak(P‖root)·G` so a script-bearing output is indistinguishable from a plain payment — is intentionally **deferred, not discarded**. It is additive: reserve a `ScriptPathTweaked` -witness variant and accept a tweaked `spend_key` with a `None` `condition_root`, opt-in per output, -with no format break. A NUMS internal key is needed only for the hidden-and-condition-only case, and -the tweak hash MUST bind `P` (`H_tweak(P‖root)`) or forgery is trivial. Why the field ships first — -and why the tweak is not needed for covenants — is in the Rationale ("Why a committed +`SpendWitness` variant (the reserved `#[n(2)]` slot) and accept a tweaked `Key`-path output, opt-in +per output, with no format break. A NUMS internal key is needed only for the hidden-and-condition-only +case, and the tweak hash MUST bind `P` (`H_tweak(P‖root)`) or forgery is trivial. Why the field ships +first — and why the tweak is not needed for covenants — is in the Rationale ("Why a committed `condition_root` field, not a key-path tweak"). ### API changes -- `tari_template_lib_types`: the output's committed spend fields `spend_key: - Option` and `condition_root: Option` (§10) replacing the inline - `SpendCondition`; the repurposed `SpendCondition` leaf enum (`AccessRule` / `TemplateFunction`); - `SpendWitness` (key-path / script-path) and - `MerkleProof` (pure data, no hashing — §9); and the `SpendContext` view types. TS bindings - regenerated (hand-edited per project convention). -- `tari_template_abi`: new `EngineOp::SpendContextInvoke`; `SpendContextAction` enum. -- `tari_template_lib`: `SpendContext` API + covenant helpers; builder support so resource/ - output construction can attach a `TemplateFunction` condition. -- `tari_engine`: `read_only` `CallFrame` mode + `write_lock_substate`/`new_substate` guard; - `validate_spend_script_signature` (T1 + T2); `RuntimeInterface::spend_context_invoke`; - `SpendScriptGuardModule`; spend-script invocation in `validate_spend_condition`; new - `RuntimeError` variants. -- `tari_engine_types`: MAST script-tree primitive (§9) — `MerkleTree`, `MerkleProof`, the - `SpendScriptLeaf`/`SpendScriptBranch` hash domains, and inclusion-proof verification — used by the - engine at spend time and the wallet at construction - (`crates/engine_types/src/stealth/script_tree.rs`). -- `tari_ootle_transaction`: extend `calc_stealth_statement_weight` so each stealth output's - weight includes the serialized size of its committed condition data (charging `TemplateFunction` - `args` at creation, `crates/transaction/src/v1/transaction.rs:284`). -- Wallet (`crates/wallet/ootle-rs/src/stealth/builder.rs`): allow constructing outputs with a - `TemplateFunction` spend condition; expose the new condition in produced `StealthUnspentOutput`s. +- `tari_template_lib_types`: the output's committed `auth: SpendAuthorization` field (`Key` / + `Script` / `KeyAndScript`, §10) replacing the inline `SpendCondition`; the repurposed + `SpendCondition` leaf (a flat conjunction) and its `AtomicCondition` atom kinds (`AccessRule` / + `TemplateFunction` / `Builtin(BuiltinPredicate)` / `Covenant(Covenant)`), plus `HashAlg`; + `SpendWitness` (`KeyPath` / `ScriptPath { leaf, proof, data }`) and `MerkleProof` (pure data, no + hashing — §9); `CovenantBalanceClaim` on `StealthTransferStatement`; the `SpendContext` view types + (`StealthInputView` / `StealthOutputView` / `CurrentInputView`). TS bindings regenerated + (hand-edited per project convention). +- `tari_template_abi`: new `EngineOp::SpendContextInvoke = 0x11` and `SignatureInvoke = 0x10`; + `SpendContextAction` enum. +- `tari_template_lib`: `SpendContext` API + covenant/timelock helpers + (`crates/template_lib/src/spend_context.rs`); the `#[template(stateless)]` macro option + (`crates/template_macros`). +- `tari_engine`: `read_only` `CallFrame` mode (`restrict_to_read_only`) + `write_lock_substate` / + `new_substate` guard; `validate_spend_script_signature` (T2 only); + `RuntimeInterface::spend_context_invoke`; the inline `enforce_read_only_restrictions` deny-list; + native `evaluate_builtin` / `evaluate_covenant`; `verify_input_authorizations` / + `evaluate_condition_leaf` enforcement; new `RuntimeError` variants (`SpendScriptRejected`, + `WriteInReadOnlyContext`, `ForbiddenInReadOnlyContext`, `SpendConditionNotMet`, + `SpendContextUnavailable`). +- `tari_engine_types`: MAST condition-tree primitive (§9) — `MerkleTree`, `MerkleProof`, the + `ConditionLeaf`/`ConditionBranch` hash domains and inclusion-proof verification + (`crates/engine_types/src/stealth/condition_tree.rs`); the native hashlock digest + (`stealth/hashlock.rs`); and the covenant balance-proof verifier + (`crypto/covenant.rs::validate_covenant_balance_proof`). +- `tari_ootle_transaction`: `calc_stealth_statement_weight` charges the serialized spend-witness bytes + (revealed leaf + inclusion proof + `data`) at `witness_bytes / 3` to the spender, with outputs at a + flat weight 8 each (`crates/transaction/src/v1/transaction.rs`); `STEALTH_LIMITS` + (`crates/engine_types/src/limits.rs`) bounds per-witness and per-transaction sizes. +- Wallet (`crates/wallet/ootle-rs/src/stealth/`): `Output::with_spend_script(TemplateFunction)` and + `Output::with_spend_conditions(Vec)` via the `PayTo` enum + (`StealthPublicKey` / `AccessRule` / `TemplateFunction` / `Conditions`) build the condition tree and + the output's `SpendAuthorization`. ## Rationale @@ -493,32 +614,34 @@ deliver a strict subset of what a sandboxed WASM function already expresses. The restriction set gives the same safety properties (bounded, deterministic, side-effect-free) without the second toolchain. -### Why a spend script subsumes covenants (and we add no covenant field) - -A covenant is a constraint on the *outputs a spend produces*. With `ctx.outputs()` exposing -each output's committed `{spend_key, condition_root}`, and `ctx.invoking_condition()` exposing the -predicate's own identity, a predicate can assert "every output carries the same condition I do" — a recursive -covenant — purely as predicate logic. Output-introspection is the one capability that collapses -"script" and "covenant" into a single mechanism (it is exactly what Bitcoin historically -lacked and what CTV/APO/Simplicity add). Given that, a separate covenant field would be -redundant *for expressiveness*. - -We deliberately defer a declarative covenant field. It would be justified only by needs this -proposal does not yet target: - -1. **Cheap covenant + native authorization.** A `TemplateFunction` leaf folds authorization into - WASM; pairing a native key-path or `AccessRule` check with a covenant without paying WASM cost - would want a covenant as a *separate, composable* field rather than a condition-tree leaf. -2. **Off-chain analyzability.** A declarative covenant (`Timelock`, `PreserveSpendCondition`, - `ValueBounds`) can be read and explained by wallets/indexers without executing arbitrary - WASM; an opaque predicate cannot. -3. **Engine-enforced propagation.** A first-class covenant could make recursive propagation an - engine guarantee instead of relying on each script author to recurse correctly. - -If any of these become priorities, a follow-up TIP can add a composable `Covenant` field that -sits *alongside* any authorization variant, keeping "who may spend" (authorization) and "what -the spend must produce" (covenant) cleanly orthogonal. The library-helper approach here is -forward-compatible with that. +### Why native covenant leaves *and* a spend script (the deferral, reversed) + +A covenant is a constraint on the *outputs a spend produces*. With `ctx.outputs()` exposing each +output's committed `auth: SpendAuthorization`, and `CurrentInput` exposing the predicate's own +`condition_root`, a `TemplateFunction` predicate can assert "every output carries the same condition I +do" — a recursive covenant — purely as predicate logic. Output-introspection is the one capability +that collapses "script" and "covenant" into a single mechanism (it is exactly what Bitcoin +historically lacked and what CTV/APO/Simplicity add). So a covenant *can* be expressed by a spend +script alone, and on expressiveness grounds a separate covenant field is redundant. + +An earlier draft therefore deferred a declarative covenant field, listing three needs that would +justify one. **All three turned out to matter, so the field was added** as the native `Covenant` atom +(§1, §8): + +1. **Cheap covenant + native authorization.** A `TemplateFunction` leaf folds the covenant into WASM + and is metered per spend. A native `Covenant` atom — conjoined in a `SpendCondition` leaf with a + native `AccessRule` or with the key path — pairs authorization and covenant with **no WASM cost**. +2. **Off-chain analyzability.** The declarative `OutputPreservesCondition` / `OutputTo` / + `BalancePreserved` variants can be read and explained by wallets/indexers without executing + arbitrary WASM; an opaque predicate cannot. +3. **Engine-enforced propagation.** Native evaluation (`evaluate_covenant`) and the + `CovenantBalanceClaim` value-conservation proof make propagation and conservation an **engine + guarantee**, not something each script author must recurse correctly to uphold. + +The two mechanisms are kept orthogonal: a `Covenant` atom says "what the spend must produce", an +`AccessRule`/key path says "who may spend", and a leaf may conjoin them. The `template_lib` helpers +remain for covenants that must also run bespoke `TemplateFunction` logic, sharing the same evaluation +code as the native path so host and guest agree. ### Why "current transfer statement" introspection scope @@ -570,12 +693,13 @@ Reinforcing reasons: - **No multiparty need.** The tweak's unique payoff — composing additively with MuSig / key aggregation and adaptor signatures — is not on the roadmap, and key-path spends and adaptor swaps already operate on the `spend_key` point without it. -- **Cheap and non-breaking.** `Option` with cbor/serde defaults, versus consensus-critical - EC verification plumbing. +- **Cheap and non-breaking.** A committed `condition_root` (a `SpendAuthorization` enum variant) is a + plain struct/enum field, versus consensus-critical EC verification plumbing. -The accepted cost is that `Some` versus `None` is observable, so transparent-covenant outputs are a -distinguishable class (hidden only within the `None` population). The tweak remains available later -(§10) for the `None` case if hidden scripts or multiparty become priorities. +The accepted cost is that a key-only `SpendAuthorization::Key` output is distinguishable from a +script-bearing `Script` / `KeyAndScript` one, so transparent-covenant outputs are a distinguishable +class (hidden only within the `Key` population). The tweak remains available later (§10) for the `Key` +case if hidden scripts or multiparty become priorities. ### Privacy @@ -587,70 +711,85 @@ already exposes on-ledger. ## Backwards Compatibility The §10 output model is a **breaking re-encoding** of the stealth output: the inline -`SpendCondition` field is removed in favour of `spend_key` and `condition_root`, and the -`SpendCondition` enum itself is repurposed as the condition-tree leaf payload — dropping `Signed` -(now the key path) and renaming the predicate variant to `TemplateFunction`. Existing -`Signed`/`AccessRule` outputs therefore do **not** decode unchanged. Ootle is pre-launch with no -live network, so this breaking change is acceptable and requires no migration of stored substates — -there is no prior on-ledger data to preserve. `AccessRule` retains its inline, WASM-free evaluation -(now as a condition-tree leaf), and a key-path spend keeps the existing signature check. +`SpendCondition` field is removed in favour of a single `auth: SpendAuthorization` field (`Key` / +`Script` / `KeyAndScript`), and the `SpendCondition` type itself is repurposed as the condition-tree +leaf payload — now a flat conjunction of `AtomicCondition` atoms (`AccessRule` / `TemplateFunction` / +`Builtin` / `Covenant`), with the former `Signed` variant dropped (it becomes the `Key` path). Existing +`Signed`/`AccessRule` outputs therefore do **not** decode unchanged. Ootle is pre-launch with no live +network, so this breaking change is acceptable and requires no migration of stored substates — there +is no prior on-ledger data to preserve. `AccessRule` retains its inline, WASM-free evaluation (now as a +condition-tree atom), and a key-path spend keeps the existing signature check. ## Test Cases Engine tests (`crates/engine/tests/stealth.rs` is the existing home for spend-condition tests): -1. **Timelock** — output with a `TemplateFunction(timelock, unlock_epoch=E)` condition; spend at - epoch `< E` rejected (`SpendScriptRejected`), at `>= E` succeeds. -2. **Recursive covenant** — output with a "preserve spend condition" script; a spend whose - outputs carry the same condition succeeds; a spend that changes an output's condition is - rejected. -3. **Hash/sig lock** — script requires a Schnorr signature over a fixed message via - `signature_invoke`; valid signature spends, invalid/missing rejects. +1. **Timelock** — both paths: a native `Builtin(AfterEpoch(E))` leaf and a + `TemplateFunction(timelock, unlock_epoch=E)` leaf; spend at epoch `< E` is rejected, at `>= E` + succeeds. +2. **Recursive covenant** — both paths: a native `Covenant(OutputPreservesCondition)` leaf and a + "preserve spend condition" script; a spend whose outputs are re-locked under the same + `condition_root` succeeds; a spend that changes or key-escapes an output is rejected. +3. **Hash/sig lock** — a native `Builtin(HashLock)` leaf satisfied by the witness `data` preimage; + and a script requiring a Schnorr signature via `signature_invoke`. Valid preimage/signature + spends, invalid/missing rejects. 4. **Read-only enforcement** — a script that calls `vault_invoke(Withdraw)` (or otherwise triggers `write_lock_substate` / `new_substate`) fails with `WriteInReadOnlyContext` → `SpendScriptRejected`, and crucially leaves the targeted vault unchanged. Covers the "directly reach a host op without a cross-template call" attack. 5. **Sandbox deny-list** — a script attempting `call_invoke`, `generate_random_invoke`, - `emit_event`, `pay_fee` or `publish_template` aborts via `SpendScriptGuardModule`. -6. **T1 creation rejection** — creating an output whose `TemplateFunction` names a missing function, - a mutable (`is_mut == true`) function, a non-unit return, a missing `SpendContext` arg, or - malformed `args` CBOR causes the *creating* transaction to be rejected (not the later spend). + `emit_event`, or proof/bucket creation aborts with `ForbiddenInReadOnlyContext` → + `SpendScriptRejected`. +6. **Malformed predicate rejected at spend (T2)** — a revealed `TemplateFunction` leaf naming a + missing function, a mutable (`is_mut == true`) function, a non-unit return, a missing + `SpendContext` arg, or malformed `args` CBOR fails `validate_spend_script_signature` and rejects + the *spend* (the output stays unspent). There is no creation-time rejection: the leaf is hidden in + the MAST at creation, so this risk is a wallet-side construction concern. 7. **Determinism / metering** — a script that exceeds `MAX_WASM_POINTS_PER_CALL` fails with `SpendScriptRejected`; fees charged to the spender are observable in the receipt. 8. **Signature-binding** — a spender cannot substitute a different `{template, function, args}` - than the one committed in the output's condition. -9. **Value constraint** — "change must return to this condition" script enforced over - `ctx.outputs()`. -10. **Weight** — an output with a large-`args` `TemplateFunction` condition raises the *creating* - transaction's weight (and fee) proportionally, versus an equivalent key-path output. -11. **Condition tree (MAST)** — an output committing to a multi-leaf condition root spends by - revealing one leaf + inclusion proof: a valid proof for a committed leaf spends and evaluates that leaf; a - proof for a non-committed leaf, a tampered sibling, or a wrong root is rejected. A single-leaf - tree has an empty proof and its root equals the leaf hash. The root is independent of leaf - authoring order, and leaf/branch domain separation prevents an internal node from being passed - off as a leaf. + than the one committed in the revealed leaf. +9. **Value constraint** — `Covenant(OutputTo { condition_root, min_value })` (and the + `require_output_to` helper) enforced over `ctx.outputs()`. +10. **Value conservation** — `Covenant(BalancePreserved(max_revealed))` with a `CovenantBalanceClaim`: + a partition that conserves committed value (with cleartext outflow ≤ `max_revealed`) spends; an + over-revealed or unbalanced partition, or a claim bound to the wrong `condition_root`, is rejected. +11. **Weight** — a script-path spend revealing a large leaf/`args` raises the *spending* transaction's + weight (and fee) proportionally via `witness_bytes / 3`, versus an equivalent key-path spend. +12. **Conjunction** — an `All`-style leaf (a multi-atom `SpendCondition`) requires every atom to hold; + a non-empty/flat structure is enforced and an empty or over-large conjunction is rejected. +13. **Condition tree (MAST)** — an output committing to a multi-leaf condition root spends by + revealing one leaf + inclusion proof: a valid proof for a committed leaf spends and evaluates that + leaf; a proof for a non-committed leaf, a tampered sibling, or a wrong root is rejected. A + single-leaf tree has an empty proof and its root equals the leaf hash. The root is independent of + leaf authoring order, and leaf/branch domain separation prevents an internal node from being + passed off as a leaf. ## Implementation Suggested phasing (spec/rationale first, per TIP process): -1. Types + ABI: `SpendCondition::TemplateFunction`, `TemplateFunction`, `EngineOp::SpendContextInvoke`, - `SpendContextAction`, view types; TS bindings; extend `calc_stealth_statement_weight` to - weight each output's spend-condition bytes. -2. Read-only mode foundation: `read_only` flag on `CallFrame`; `WriteInReadOnlyContext` guard - on `write_lock_substate` / `get_*_mut` / `new_substate`. (Independently useful — also the - basis for any future read-only/view transaction.) -3. Engine: `validate_spend_script_signature` wired into both output creation (T1, rejects the - creating tx) and `validate_spend_condition` (T2); `spend_context_invoke`; - `SpendScriptGuardModule`; the read-only restricted-frame invocation; new `RuntimeError` - variants (`SpendScriptRejected`, `WriteInReadOnlyContext`). -4. `template_lib`: `SpendContext` + covenant helpers + builder support. -5. Wallet builder support for constructing `TemplateFunction`-conditioned outputs. +1. Types + ABI: the `SpendAuthorization` output model; `SpendCondition` (conjunction) and its + `AtomicCondition` atoms (`AccessRule` / `TemplateFunction` / `Builtin` / `Covenant`); `SpendWitness` + (`KeyPath` / `ScriptPath { leaf, proof, data }`); `CovenantBalanceClaim`; + `EngineOp::SpendContextInvoke` + `SpendContextAction`; view types; TS bindings; + `calc_stealth_statement_weight` charging spend-witness bytes; `STEALTH_LIMITS`. +2. Read-only mode foundation: `read_only` flag on `CallFrame` (`restrict_to_read_only`); + `WriteInReadOnlyContext` guard on `write_lock_substate` / `get_*_mut` / `new_substate`. + (Independently useful — also the basis for any future read-only/view transaction.) +3. Engine: the MAST `verify_inclusion` gate; `validate_spend_script_signature` (T2) in + `evaluate_condition_leaf`; native `evaluate_builtin` / `evaluate_covenant` + + `validate_covenant_balance_proof`; `spend_context_invoke`; the inline + `enforce_read_only_restrictions` deny-list; new `RuntimeError` variants (`SpendScriptRejected`, + `WriteInReadOnlyContext`, `ForbiddenInReadOnlyContext`, `SpendConditionNotMet`, + `SpendContextUnavailable`). +4. `template_lib`: `SpendContext` + covenant/timelock helpers; the `#[template(stateless)]` macro. +5. Wallet builder support (`PayTo`, `with_spend_script` / `with_spend_conditions`). 6. Tests above; docs/examples (a `vault`/`timelock` example template). -A complementary authoring-ergonomics change — a `#[template(stateless)]` macro option that lets -a template expose only stateless functions with no component — is tracked separately in the -tari-ootle repository. It is not required for this proposal (a spend script is just a +The complementary authoring-ergonomics change — a `#[template(stateless)]` macro option that lets a +template expose only stateless functions with no component — is **implemented** in the tari-ootle +repository (`crates/template_macros`). It is not required for this proposal (a spend script is just a receiver-less function), but it makes the §2 example valid as written. ## References @@ -658,20 +797,29 @@ receiver-less function), but it makes the §2 example valid as written. All `crates/...` paths refer to the [tari-ootle repository](https://github.com/tari-project/tari-ootle). -- `crates/template_lib_types/src/stealth/unspent_output.rs:65` — `SpendCondition` today. -- `crates/engine/src/runtime/working_state.rs:292,321` — spend-condition enforcement. -- `crates/engine/src/runtime/impl.rs:292,404` — `invoke_resource_access_hook` / - `check_resource_auth_hook` (the AuthHook precedent this design mirrors). -- `crates/engine/src/runtime/scope.rs` — `CallFrame.allow_cross_template_calls`, - `PushCallFrame::Static` (where the proposed `read_only` flag lives). -- `crates/engine/src/runtime/impl.rs:1648` — vault `Withdraw` taking `write_lock_substate`, the - mutation chokepoint the read-only mode guards. -- `crates/template_abi/src/ops.rs:27` — `EngineOp`; `crates/template_abi/src/template_def.rs` - — `FunctionDef.is_mut`. -- `crates/engine/src/wasm/metering.rs`, `crates/engine/src/fees/fee_module.rs` — WASM metering - and fees. -- `crates/engine_types/src/stealth/script_tree.rs` — reference MAST implementation: tree - construction, domain-separated hashing, and inclusion-proof verification (§9). +- `crates/template_lib_types/src/stealth/unspent_output.rs` — `SpendAuthorization`, + `SpendCondition` / `AtomicCondition`, `TemplateFunction`, `BuiltinPredicate`, `Covenant`. +- `crates/template_lib_types/src/stealth/spend_witness.rs` — `SpendWitness`, `MerkleProof`; + `statement.rs` — `StealthTransferStatement`, `CovenantBalanceClaim`; `spend_context.rs` — the views. +- `crates/engine/src/runtime/working_state.rs` — `validate_and_spend_stealth_utxos`, + `is_read_only_context`, the `write_lock_substate` / `new_substate` read-only guard. +- `crates/engine/src/runtime/impl.rs` — `verify_input_authorizations`, + `verify_script_path_authorization`, `evaluate_condition_leaf`, `validate_spend_script_signature`, + `evaluate_builtin`, `evaluate_covenant`, `spend_context_invoke`, `enforce_read_only_restrictions`, + and `invoke_resource_access_hook` / `check_resource_auth_hook` (the AuthHook precedent this design + mirrors). +- `crates/engine/src/runtime/scope.rs` — `CallFrame.read_only` / `allow_cross_template_calls`, + `restrict_to_read_only`. +- `crates/template_abi/src/ops.rs` — `EngineOp::SpendContextInvoke` (0x11) / `SignatureInvoke` (0x10); + `crates/template_abi/src/template_def.rs` — `FunctionDef.is_mut`. +- `crates/template_lib/src/spend_context.rs` — author-facing `SpendContext` + covenant helpers; + `crates/template_lib/src/args/types.rs` — `SpendContextAction`. +- `crates/engine/src/wasm/metering.rs` — WASM metering; `crates/engine_types/src/limits.rs` — + `STEALTH_LIMITS`; `crates/transaction/src/v1/transaction.rs` — `calc_stealth_statement_weight`. +- `crates/engine_types/src/stealth/condition_tree.rs` — reference MAST implementation (tree + construction, `ConditionLeaf`/`ConditionBranch` domain-separated hashing, `verify_inclusion`, §9); + `stealth/hashlock.rs` — native hashlock digest; `crypto/covenant.rs` — + `validate_covenant_balance_proof`. - Bitcoin CTV (BIP-119), `SIGHASH_ANYPREVOUT` (BIP-118), Simplicity — prior art on transaction introspection and covenants. Taproot / MAST (BIP-341) — prior art on committing to a tree of alternative scripts and revealing only the path taken. @@ -692,3 +840,22 @@ This document is released under the BSD 3-Clause License. * Migrated to the `tari-project/rfcs` repository. Renumbered TIP-0006 → TIP-0007 (TIP-0006 was already claimed in this repository) and reformatted to the TIP-0001 header preamble. * [Pull Request #180](https://github.com/tari-project/rfcs/pull/180) + +### 2026-06-24 + +* Aligned the specification with the implemented design on the tari-ootle + `engine-utxo-mast-execution` branch: + * Output model is a single `SpendAuthorization` enum (`Key` / `Script` / `KeyAndScript`) that makes + the unspendable state unrepresentable, replacing the two-`Option` (`spend_key` / `condition_root`) + draft. + * A `SpendCondition` leaf is a flat, non-empty conjunction of `AtomicCondition` atoms. + * **Reversed the covenant deferral**: added native `BuiltinPredicate` (timelock/hashlock) and + `Covenant` (output-preservation / value-routing / value-conservation) atoms, plus the + `CovenantBalanceClaim` Schnorr value-conservation proof — superseding the "covenants as a library + pattern only" framing. + * **Removed creation-time (T1) predicate validation**: the MAST commits only the `condition_root`, so + a `TemplateFunction` leaf is validated only at spend time (T2). + * Added the spend-witness `data` blob; corrected the MAST naming + (`condition_tree.rs`, `ConditionLeaf`/`ConditionBranch`), the host-op actions, the sandbox + deny-list (`enforce_read_only_restrictions`, not a `RuntimeModule`), the weight charge + (spend-witness bytes to the spender), and the `RuntimeError` variants.