From 1024d962c469628e39c8e729af34b6523fe2afee Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 29 Jul 2026 14:11:11 +0200 Subject: [PATCH 1/4] chore: add .editorconfig for LF line endings --- .editorconfig | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..600be65 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +# Two trailing spaces are a hard line break in Markdown +trim_trailing_whitespace = false From 54e3e34d43f1ed2fbe1a21b013ba646dfdcf051c Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 29 Jul 2026 14:37:09 +0200 Subject: [PATCH 2/4] remove attestation --- src/getting-started/anatomy.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/getting-started/anatomy.md b/src/getting-started/anatomy.md index db43734..273d88e 100644 --- a/src/getting-started/anatomy.md +++ b/src/getting-started/anatomy.md @@ -7,7 +7,6 @@ of metadata fields followed by the data sections. ```json { "manifest_version": "0.1.0", - "attestation_version": "1", "protocol": "p2pk-simplicity", "description": "Pay-to-public-key using a Simplicity checksig program on Liquid.", "chain": "liquid", @@ -28,7 +27,6 @@ of metadata fields followed by the data sections. | `protocol` | yes | Kebab-case protocol identifier, e.g. `"simplicity-lending"`. | | `description` | yes | Free-text summary of the whole protocol. | | `chain` | no | `"bitcoin"`, `"liquid"`/`"elements"`, or `"cross-chain"`. Defaults to `"elements"`. | -| `attestation_version` | no | Schema version for any signatures added to the document. | | `simplicity_hl_version` | no | SimplicityHL compiler version the scripts require. | | `source` | no | Relative path to the top-level `.simf` file. | | `confidential_outputs` | no | File-level default for output blinding. See [Outputs & destinations](../recipes/03-outputs-and-destinations.md). | From c8b87f6a5db8ee2caa421718fdc2526e43c14c37 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 4 Sep 2026 16:35:04 +0200 Subject: [PATCH 3/4] update to v0.2.0 --- .github/workflows/check.yml | 26 + .github/workflows/deploy.yml | 4 + .gitignore | 1 + README.md | 26 + schema/README.md | 14 + schema/txmanifest.schema.json | 1171 +++++++++++++++++ scripts/check_manifests.py | 536 ++++++++ src/SUMMARY.md | 2 +- src/appendix/cli-reference.md | 25 +- src/appendix/field-types.md | 2 +- src/appendix/formula-language.md | 5 +- src/appendix/glossary.md | 60 +- src/appendix/wallet-implementation.md | 169 ++- src/getting-started/anatomy.md | 169 +-- src/getting-started/setup.md | 6 +- src/getting-started/what-is-a-manifest.md | 2 +- src/recipes/00-splitting-utxos.md | 17 +- src/recipes/01-hello-world-p2pk.md | 31 +- src/recipes/01b-hello-world-receive.md | 8 +- src/recipes/02-params-and-validations.md | 172 +-- src/recipes/03-outputs-and-destinations.md | 29 +- src/recipes/04-witnesses.md | 5 +- src/recipes/04b-last-will.md | 47 +- src/recipes/07-formulas-and-derived-params.md | 6 +- src/recipes/08-issuance-and-nfts.md | 6 +- src/recipes/09-hooks-and-tapleaf.md | 4 +- src/recipes/10-instance-state-constructors.md | 11 +- src/walkthrough/lending-accept.md | 40 +- src/walkthrough/lending-issuance.md | 51 +- src/walkthrough/lending-offer.md | 43 +- src/walkthrough/lending-protocol.md | 91 +- src/walkthrough/lending-settlement.md | 49 +- 32 files changed, 2308 insertions(+), 520 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 schema/README.md create mode 100644 schema/txmanifest.schema.json create mode 100644 scripts/check_manifests.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..08c4fe2 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,26 @@ +name: Check manifests + +on: + pull_request: + push: + branches-ignore: ["main"] # main is covered by the deploy workflow's gate + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check JSON examples against the manifest schema + run: python3 scripts/check_manifests.py + + - name: Build the book + env: + MDBOOK_VERSION: "0.4.52" + run: | + mkdir -p "$HOME/.local/bin" + curl -sSL "https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xz -C "$HOME/.local/bin" + "$HOME/.local/bin/mdbook" build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fe0a59e..d75f948 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,6 +27,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # A book whose examples no longer match the format should not publish. + - name: Check JSON examples against the manifest schema + run: python3 scripts/check_manifests.py + - name: Install mdBook run: | mkdir -p "$HOME/.local/bin" diff --git a/.gitignore b/.gitignore index 586e396..d85c8d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ book .claude meta +__pycache__/ diff --git a/README.md b/README.md index 668bb59..e71bf8b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,32 @@ protocol. > appendix are complete; several later recipes (05–10) and the lending walkthrough > are still stubs being filled in. +**Format version: 0.2.0.** Every example is written against manifest format +`0.2.0`, and `manifest_version` is enforced at parse time — a `0.1.x` file is a +hard error in a current wallet, not a warning. + +## Checking the examples + +Every ```` ```json ```` block in `src/` is validated against +[`schema/txmanifest.schema.json`](schema/), copied verbatim from the reference +wallet (see [`schema/README.md`](schema/README.md) for the exact ref): + +```sh +python3 scripts/check_manifests.py # check the book +python3 scripts/check_manifests.py --self-test path/to/wallet/examples +``` + +The checker needs no third-party packages. `--self-test` validates the checker +itself against the wallet's own example manifests, and against deliberately +corrupted copies of them, so a validator that accepted everything would fail. +CI runs the check on every push and blocks the deploy if it fails. + +A block is anchored to what it is meant to be with an HTML comment above the +fence — ``, `` for a slice of a +document, `` for a named map, or +`` for JSON that is not part of a manifest (a state file, a +params file). The book's `...` and `…` elisions are understood. + ## Building the book You need [mdBook](https://rust-lang.github.io/mdBook/guide/installation.html): diff --git a/schema/README.md b/schema/README.md new file mode 100644 index 0000000..8a6f8b9 --- /dev/null +++ b/schema/README.md @@ -0,0 +1,14 @@ +# Vendored schema + +`txmanifest.schema.json` is copied verbatim from the reference wallet: + + repo: git@github.com:stringhandler/txmanifest-wallet.git + path: schema/txmanifest.schema.json + ref: main @ 5624dfc77f742d66798d469de1bb79c8f09fcf0b + format: manifest_version 0.2.0 + +It is generated in that repo by `txmanifest_lib/examples/gen_schema.rs`, so it is +the machine-readable source of truth for what a manifest may contain. The book's +JSON examples are checked against it by `scripts/check_manifests.py`. + +To refresh it, re-copy the file and update the ref above. diff --git a/schema/txmanifest.schema.json b/schema/txmanifest.schema.json new file mode 100644 index 0000000..56ed66d --- /dev/null +++ b/schema/txmanifest.schema.json @@ -0,0 +1,1171 @@ +{ + "$id": "https://raw.githubusercontent.com/stringhandler/txmanifest-wallet/main/schema/txmanifest.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "Action": { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "allow_change": { + "allOf": [ + { + "$ref": "#/definitions/AllowChange" + } + ], + "description": "Whether the engine may append a change output this action did not declare.\n\n**Every output a transaction carries must be written in the manifest. The network fee is the single exception, because it has no manifest spelling.** A change output is not an exception: its address and amount are chosen by the engine, so silently adding one moves value to a destination the manifest never named, in an amount nobody wrote down. That is how an oversized collateral input once turned 88,735 satoshis into a miner's fee without a word of warning.\n\nSo the default is [`AllowChange::None`]: a surplus in any asset — including L-BTC — is an error, and the action must size its inputs to what it spends. Relax it only where the surplus genuinely cannot be predicted:\n\n- `\"none\"` (default) — no change may be added; any surplus is an error. - `\"lbtc_only\"` — the engine may return an L-BTC surplus to the wallet. Use this for ordinary funding actions, where the fee is only known after the size is. A surplus in any other asset is still an error. - `\"any\"` — the engine may return a surplus in any asset.\n\nThis governs *undeclared* change. An output with `\"destination\": \"change\"` is declared, and permits change for its own asset regardless of this setting." + }, + "create_instance": { + "anyOf": [ + { + "$ref": "#/definitions/InstanceCreate" + }, + { + "type": "null" + } + ], + "description": "Constructor-only: defines the new instance written to the instance file." + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "inputs": { + "items": { + "$ref": "#/definitions/Input" + }, + "type": [ + "array", + "null" + ] + }, + "intent": { + "description": "One-line statement of what this action does, shown as the first clear-signing screen. Supports `{ref}` and `{ref:symbol}` interpolation against the execution context (see `preview::interpolate`); asset-typed refs must carry `:symbol` so a wallet can substitute a friendly name (enforced by `validate::check_ui`).\n\nNamed for the `intent` field in Ethereum's ERC-7730 clear-signing metadata, which plays the same role. Author-supplied, so only as trustworthy as the manifest's own signature chain — never a substitute for what a hardware device verifies. It IS covered by the registry hash (see `crate::canonical`).", + "type": [ + "string", + "null" + ] + }, + "on_post_broadcast": { + "anyOf": [ + { + "$ref": "#/definitions/HookBlock" + }, + { + "type": "null" + } + ], + "description": "Method-level hook: runs after broadcast (captures txids, asset IDs)." + }, + "on_pre_broadcast": { + "anyOf": [ + { + "$ref": "#/definitions/HookBlock" + }, + { + "type": "null" + } + ], + "description": "Method-level hook: runs after inputs are resolved, before PSET is built." + }, + "outputs": { + "items": { + "$ref": "#/definitions/Output" + }, + "type": [ + "array", + "null" + ] + }, + "params": { + "additionalProperties": { + "$ref": "#/definitions/ParamDef" + }, + "description": "Runtime action parameters (Spec §5). Prompted, or set by hooks.", + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, + "AllowChange": { + "description": "Which assets an action lets the engine return a surplus in, via a change output the manifest did not declare. See [`Action::allow_change`].\n\nSpelled as an enum rather than a boolean because the useful middle case — \"return leftover L-BTC, but never move a protocol asset I did not account for\" — is the one most funding actions want, and a boolean cannot say it.", + "oneOf": [ + { + "description": "No undeclared change. A surplus in any asset fails the build.", + "enum": [ + "none" + ], + "type": "string" + }, + { + "description": "Only the policy asset (L-BTC) may be returned.", + "enum": [ + "lbtc_only" + ], + "type": "string" + }, + { + "description": "Any asset may be returned.", + "enum": [ + "any" + ], + "type": "string" + } + ] + }, + "BlindingFactors": { + "additionalProperties": false, + "description": "The blinding factors of one confidential output or input.\n\nA wallet normally draws both factors at random, which is right when nothing but the receiver ever reads them. It is wrong when a *covenant* reads them: a program that checks its own outputs' commitments (deadcat_v3 requires each recreated reissuance token to advance both factors by exactly one) can only be satisfied by factors the spender chose deliberately. Elements' `blind_last` offers no way to say which, so the engine runs its own blinding pass whenever this field appears.\n\nEach factor is a 32-byte scalar written as a small decimal (`\"1\"`), a `0x`-prefixed hex string of up to 64 chars, or a reference (`params.X`, `instance.X`) resolving to either — which is how a factor an operator reads off an explorer or a side file reaches the build.\n\n**On an output** it pins what the builder would otherwise choose. Omitting one leaves it random; omitting both makes the field a no-op. One confidential output must keep a free `value_bf`: the transaction's blinding factors have to sum to zero and the builder solves the last free one to make that true, so pinning every one of them leaves the transaction unbalanceable. In practice that free output is the change.\n\n**On a covenant input** it is not a choice but a statement of fact — the factors the UTXO being spent was created with. They are what lets the engine rebuild the confidential prevout the sighash and the introspection jets need, and (for a reissuance) the `assetBlindingNonce` Elements demands. Both halves are required, and a wrong value is caught before signing: the rebuilt commitments simply will not be the ones on chain.\n\nThe factors are public to anyone who reads them here, so this trades the output's confidentiality for reissuability: it hides nothing, it only keeps the commitment well-formed. Elements has no explicit reissuance token (`confidential_validation.cpp` rebuilds the spent token's generator from the blinding nonce and byte-compares it), so a token that must stay reissuable must stay blinded, with a factor its next spender can reproduce.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "asset_bf": { + "description": "Asset blinding factor (`abf`). Also the value Elements requires as the `assetBlindingNonce` of any later reissuance spending this output." + }, + "value_bf": { + "description": "Value blinding factor (`vbf`)." + } + }, + "type": "object" + }, + "ComputeSpec": { + "anyOf": [ + { + "description": "Simple expression: `\"$params.COLLATERAL_ASSET_ID\"`, `\"instance.DEBT - 1\"`.", + "type": "string" + }, + { + "allOf": [ + { + "$ref": "#/definitions/ParamCompute" + } + ], + "description": "Structured compute — `tapleaf`, `simf_fn`, or an explicit `expr`." + } + ], + "description": "How a value is computed: either a plain expression string or a structured spec.\n\nUsed in two places, deliberately the same shape: `create_instance.fields` values and [`ParamDef::compute`].\n\nHand-deserialized rather than `#[serde(untagged)]`, for the same reason as [`UiSpec`]: untagged collapses every inner failure into `data did not match any variant of untagged enum ComputeSpec`, which hides the one thing the author needs to know. Dispatching on the JSON shape lets [`ParamCompute`]'s error — naming the offending key or the unknown `type` — reach the surface." + }, + "ContractTemplate": { + "additionalProperties": false, + "description": "A contract template: typed field declarations and named methods.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "actions": { + "additionalProperties": { + "$ref": "#/definitions/Action" + }, + "description": "Actions callable on an instance of this template. Structurally identical to the top-level `actions` — the only difference is that these run against an instance, so their formulas may reference `instance.*`. An action carrying a `create_instance` block constructs a new instance of this template.", + "type": "object" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "fields": { + "additionalProperties": { + "$ref": "#/definitions/FieldDef" + }, + "description": "Field declarations — names and types only. Values are set by constructors.", + "type": "object" + } + }, + "type": "object" + }, + "FieldDef": { + "additionalProperties": false, + "description": "A field declaration inside a contract template. Just a name and type; no compute here.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "HookBlock": { + "additionalProperties": false, + "description": "A hook: a flat map of setter targets to the values they take.\n\nOne type serves every hook position — an action's `on_pre_broadcast` / `on_post_broadcast` and an input's `on_resolved` — because they only ever differed in when they run, never in shape.\n\nTargets use dot-path notation: `\"instance.FOO\"` — sets a contract-template field `\"params.FOO\"` — sets an action param\n\nValues are [`ComputeSpec`], the same type `create_instance.fields` uses, so all three \"name → how to produce a value\" maps in the format read alike. In hook position only the expression forms are meaningful; `validate` rejects the rest (see `validate::check_hook`).\n\nWithin an input's own `on_resolved`, two bare keywords are self-referential: `\"asset\"` resolves to that input's computed issuance asset ID (or its UTXO asset for non-issuance inputs), and `\"reissuance_token\"` to the computed reissuance token asset ID.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "set": { + "additionalProperties": { + "$ref": "#/definitions/ComputeSpec" + }, + "type": "object" + } + }, + "required": [ + "set" + ], + "type": "object" + }, + "Input": { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "amount_sat": true, + "asset": true, + "blinding": { + "anyOf": [ + { + "$ref": "#/definitions/BlindingFactors" + }, + { + "type": "null" + } + ], + "description": "The blinding factors of the covenant UTXO this input spends, when it is confidential. Both halves are required. See [`BlindingFactors`]." + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "from_address": { + "description": "For `utxo_source: \"wallet\"` inputs: constrain coin selection to UTXOs whose scriptPubKey equals this address's. A reference (`instance.X` / `params.X`) or a literal address string. Use this to pin an input to a committed address — e.g. so a covenant's collateral is spent from the exact address whose hash it commits to.", + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "issuance": { + "description": "An Elements asset issuance carried by this input.\n\n- `{\"kind\": \"new\", \"asset_amount_sat\": , \"inflation_amount_sat\": }` — mint a brand-new asset, whose id is derived from this input's outpoint. Either amount may be `0` (reissuance tokens only, or a fixed supply with no reissuance rights). - `{\"kind\": \"reissue\", \"asset_amount_sat\": , \"entropy\": }` — mint more of an existing asset by spending its reissuance token.\n\nA reissuance needs the **issuance entropy** of the original mint — `fast_merkle_root([sha256d(defining outpoint), contract_hash])`, the value the asset id itself is derived from. It cannot be recovered from anything on chain: the reissuance token UTXO carries no trace of the outpoint that created it. So a constructor has to capture it at the one moment it exists, and hand it back later:\n\n```json // in the minting action's create_instance: \"YES_ISSUANCE_ENTROPY\": \"$inputs.yes_defining_in.issuance_entropy\" // in the reissuing action's input: \"issuance\": { \"kind\": \"reissue\", \"asset_amount_sat\": \"params.PAIRS\", \"entropy\": \"instance.YES_ISSUANCE_ENTROPY\", \"issued_asset\": \"instance.YES_TOKEN_ASSET\" } ```\n\n`issued_asset` is optional and is a **check**, not an input: the engine re-derives the asset id from the entropy and refuses to build if the two disagree. An entropy is opaque, and the byte order block explorers print is the reverse of the one used here — without the check a transposed value still builds a broadcastable transaction that reissues the wrong asset.\n\nFailing that, the entropy may come from `provided_inputs..issuance_entropy` in the instance file. That works, but it travels with an outpoint override which pins the input for *every* action sharing its id — long after the pin is correct." + }, + "on_resolved": { + "anyOf": [ + { + "$ref": "#/definitions/HookBlock" + }, + { + "type": "null" + } + ], + "description": "Inline hook evaluated after this input's UTXO is resolved and its issuance attrs (asset, reissuance_token) are computed." + }, + "optional": { + "description": "When `true`, the transaction proceeds even if this UTXO is not found. Spec §6.\n\n⚠️ **Parsed but NOT enforced** — the engine has no optional-input path, so a missing UTXO fails resolution regardless. `examples/dex` marks its `fee_input` optional and does not get that behaviour.", + "type": [ + "boolean", + "null" + ] + }, + "required_index": { + "description": "Required transaction input index: `0`-based absolute, or negative to count from the end (`-1` = last). Spec §6.\n\n⚠️ **Parsed but NOT enforced.** Nothing in the engine reads this field; inputs land in declaration order and that ordering happens to satisfy the covenants. Manifests assert an index here 106 times and none of it is checked, so a reordering that breaks a covenant's introspection would surface only as an on-chain failure. See `validate.rs` for where a static check belongs.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "sequence": { + "description": "Per-input `nSequence`. Drives BIP68 relative timelocks (the `check_lock_distance` / `check_lock_duration` Simplicity jets). Accepts: - `{\"relative_blocks\": }` — block-based relative lock (≤ 65535 blocks) - `{\"relative_seconds\": }` — time-based relative lock, rounded up to 512s units - a bare integer / expression — raw nSequence value\n\nOmitted → the input stays at `Sequence::MAX` (relative locktime disabled)." + }, + "ui": { + "anyOf": [ + { + "$ref": "#/definitions/UiSpec" + }, + { + "type": "null" + } + ], + "description": "Clear-signing UI hint for this input (net-effect debit line)." + }, + "utxo_source": { + "description": "\"wallet\" or {\"utxo_type\": \"...\"} or conditional object" + }, + "witnesses": { + "description": "Simplicity witnesses for this input: map of witness name → definition.\n\nMust name **every** witness the input's program declares, and nothing else. A definition is either an object carrying a `type` — `simplicityhl` (a concrete value), `Signature` (a BIP340 signature the engine computes), `taproot_leaf` (a leaf selector, which is not a program witness and so is exempt from both halves of that rule) — or the bare string `\"unused\"` for a witness this spending path does not depend on, which supplies the zero its pruned branch wants.\n\nNothing is inferred from an omission. Anything left out is an error, at `validate` time against the `.simf` and again at run time against the compiled program." + } + }, + "required": [ + "id", + "utxo_source" + ], + "type": "object" + }, + "InstanceCreate": { + "additionalProperties": false, + "description": "Describes the new instance written after broadcast.\n\nAn action carrying this block **is** a constructor — there is no separate flag. The instance is always of the contract template the action is declared in, so the template is not named here: `create_instance` is only legal inside `contract_templates..actions.*`, and always creates a ``.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "fields": { + "additionalProperties": { + "$ref": "#/definitions/ComputeSpec" + }, + "description": "Maps field names to their initial values. Each value is either a string expression (`\"$params.FOO\"`) or a compute spec (`{ \"compute\": \"tapleaf\", ... }`).", + "type": "object" + } + }, + "required": [ + "fields" + ], + "type": "object" + }, + "Output": { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "amount_sat": true, + "asset": true, + "blinding": { + "anyOf": [ + { + "$ref": "#/definitions/BlindingFactors" + }, + { + "type": "null" + } + ], + "description": "Pin this confidential output's blinding factors instead of letting the builder pick them. See [`BlindingFactors`]." + }, + "condition": { + "type": [ + "string", + "null" + ] + }, + "confidential": { + "description": "Whether this output is blinded. The only place confidentiality is declared: a `utxo_type` describes an address, and two outputs paying the same covenant address need not agree — deadcat_v3's state-1 address holds blinded reissuance tokens beside an explicit collateral UTXO, because the program introspects one as a Pedersen commitment and the other as a plain amount.\n\nDefaults to `true` for wallet and address destinations on Liquid, and to `false` for covenant (`utxo_type`) destinations, where a Simplicity program usually has to read the value and asset. `true` on a covenant output is not supported yet and is an error rather than a silent downgrade — the address it produces would be right and the UTXO at it unspendable by the paths that expect a commitment.", + "type": [ + "boolean", + "null" + ] + }, + "data": { + "description": "OP_RETURN payload, for `destination: {\"type\":\"op_return\"}` outputs. Either a `concat(ref, …)` string, or an object `{\"parts\": [ … ]}` of typed fields (for exact binary layouts — LE integers, `program_id`, asset-internal bytes). Evaluated to raw bytes and embedded after `OP_RETURN`. Omit for a bare data-less OP_RETURN (NFT burns)." + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "destination": { + "allOf": [ + { + "$ref": "#/definitions/OutputDestination" + } + ], + "description": "Where this output's value goes. See [`OutputDestination`] for the accepted forms." + }, + "id": { + "type": "string" + }, + "optional": { + "type": [ + "boolean", + "null" + ] + }, + "required_index": { + "description": "Required transaction output index; same semantics and same caveat as [`Input::required_index`] (Spec §7) — parsed, never enforced.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "ui": { + "anyOf": [ + { + "$ref": "#/definitions/UiSpec" + }, + { + "type": "null" + } + ], + "description": "Clear-signing UI hint for this output (net-effect credit line)." + } + }, + "required": [ + "destination", + "id" + ], + "type": "object" + }, + "OutputDestination": { + "anyOf": [ + { + "description": "`change`, `wallet`, a literal address, or a `params.X` / `instance.X` reference that resolves to one.", + "examples": [ + "change", + "wallet", + "params.receive_address" + ], + "type": "string" + }, + { + "description": "The covenant address derived for a declared `utxo_type`.", + "properties": { + "args": { + "additionalProperties": { + "type": "string" + }, + "description": "Binds the utxo_type's declared `params` for this site. Values are expressions in the ACTION's scope (`params.X`, `instance.X`, a literal). Every param without a default has to be bound here.", + "type": "object" + }, + "compile_params": { + "additionalProperties": { + "type": "string" + }, + "description": "Per-site compile-param overrides for this destination, resolved against the action's params.", + "type": "object" + }, + "utxo_type": { + "type": "string" + } + }, + "required": [ + "utxo_type" + ], + "type": "object" + }, + { + "description": "P2TR output built from a 32-byte script hash.", + "properties": { + "script_hash": { + "description": "32-byte hex, or a reference resolving to it.", + "type": "string" + } + }, + "required": [ + "script_hash" + ], + "type": "object" + }, + { + "description": "`op_return` / `burn` embed the output's own `data` field (bare OP_RETURN when absent). `fee` declares the fee leg and produces no PSET output of its own.", + "properties": { + "type": { + "enum": [ + "op_return", + "burn", + "fee" + ] + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "description": "Conditional destination. Parsed but NOT implemented — the engine has no arm for it and skips the output entirely.", + "required": [ + "if" + ], + "type": "object" + } + ], + "description": "Where this output's value goes. A string is `change` (wallet change, amount auto-computed), `wallet` (a fresh receive address), or an address / `params.X` reference resolving to one." + }, + "ParamCompute": { + "description": "Auto-computation spec for a derived compile param or action param.\n\nDispatched by `type`, the same discriminator every other tagged object in the format uses (`script.type`, `destination.type`, a witness's `type`). Note this is the *method* of computation; the value's data type is `ParamDef::type_`, one level up. The legacy key `lang` is still accepted as an alias for the discriminator: - `\"expr\"`: arithmetic expression over other compile params (`pow(base, exp)` supported) - `\"tapleaf\"`: compile a `.simf` file and return its Simplicity tapleaf hash (32 bytes hex) - `\"simf_fn\"`: call a named function in a `.simf` file and use its return value - `\"wallet\"`: take the value from the executing wallet rather than the manifest, with `wallet` selecting which ([`WalletValue`])\n\nThe `wallet` variant differs from the others in kind: `expr`, `tapleaf` and `simf_fn` are reproducible by anyone holding the manifest, whereas a `wallet_*` value depends on who is running the action. They live here anyway because from an author's point of view they answer the same question — where does this value come from, if not the user? — and having two fields for that (the old `source`) meant two things to check and a name that collided with `script.source`, a file path.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "expr": { + "type": "string" + }, + "type": { + "enum": [ + "expr" + ], + "type": "string" + } + }, + "required": [ + "expr", + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "depends_on": { + "default": null, + "description": "Subset of compile-param names this simf actually consumes (auto-populate only). When set, the tapleaf is computed as soon as exactly these params are resolved, instead of waiting for ALL compile params. Use this to break apparent circular dependencies when the simf does not use every manifest-level compile param.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "extra_leaves": { + "description": "Optional taproot storage leaves to fold into the tap tree BEFORE hashing the scriptPubKey. When present, the computed value is `sha256(spk WITH these leaves)` instead of the storage-less script hash — used to key a `script_auth` covenant to a covenant-with-storage (e.g. the pending lending offer's own script hash, offer out[3]). Leaf payload item value-refs resolve against the in-progress create_instance fields (then ctx), so they may reference sibling computed fields such as `CURRENT_DEBT`.", + "items": { + "$ref": "#/definitions/TaprootLeafSpec" + }, + "type": [ + "array", + "null" + ] + }, + "params": { + "additionalProperties": { + "$ref": "#/definitions/TapleafParam" + }, + "description": "Explicit param map for the simf. Each entry combines the value (a compile-param reference or string literal) with an optional manifest type hint. Omit entirely to pass ALL current compile params (auto-populate mode).", + "type": "object" + }, + "simf": { + "type": "string" + }, + "type": { + "enum": [ + "tapleaf" + ], + "type": "string" + } + }, + "required": [ + "simf", + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "`sha256(scriptPubKey)` of an address — the exact value the Simplicity `output_script_hash` / `input_script_hash` jets return for a UTXO paying it.\n\nAn address and its script hash are two views of one destination: the covenant commits to the hash, the transaction pays to the address, and if they ever disagree the spend fails on-chain. Deriving one from the other is the only way to keep that true — a manifest that asks for both separately is asking to be given two values that must match and cannot be checked.\n\nBlinding is irrelevant here: a confidential address has the same scriptPubKey as its unconfidential form, so both hash alike (`script_hash_of_address` pins this).", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "address": { + "description": "An address, or a reference resolving to one (`params.payout_address`).", + "type": "string" + }, + "type": { + "enum": [ + "script_hash" + ], + "type": "string" + } + }, + "required": [ + "address", + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A value a **hook** supplies later in this run — declared here, set by an `on_resolved` / `on_pre_broadcast` block targeting `params.`.\n\nThis exists so a hook cannot invent an identifier. Without it, `\"set\": { \"params.YES_TOKN_ASSET\": \"asset\" }` is accepted, fills a slot nobody reads, and surfaces as a wrong covenant address much later; with it, `validate` rejects the typo and the declaration carries the `type` that byte-order handling depends on.\n\nIt lives under `compute` rather than as a separate `deferred: true` flag because `compute` already means exactly \"this value is derived, do not prompt for it\" — the only thing that differs here is *who* derives it. A second flag would need its own prompt-suppression path and would have to define what it means alongside a `compute` that is also present.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "type": { + "enum": [ + "hook" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A value taken from the executing wallet rather than the manifest.\n\nGrouped under one tag rather than spread across three so that \"is this wallet-derived?\" is a single check on `compute` before dispatching on `wallet` — and so adding a new wallet-derived value does not grow the top-level variant list.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "type": { + "enum": [ + "wallet" + ], + "type": "string" + }, + "wallet": { + "$ref": "#/definitions/WalletValue" + } + }, + "required": [ + "type", + "wallet" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Call a named function in a `.simf` file after inputs are resolved. The function is compiled with `compile_params` as param:: constants. Its runtime input is read from `input` (a dot-path into ctx, e.g. `\"params.STATE_BYTES\"`). The return value is stored as the param value.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "compile_params": { + "default": [], + "description": "Compile-time param names from ctx to pass as `param::` constants to the function.", + "items": { + "type": "string" + }, + "type": "array" + }, + "fn": { + "default": null, + "description": "Name of the function to call. If omitted the file must define exactly one function.", + "type": [ + "string", + "null" + ] + }, + "input": { + "description": "Dot-path to the runtime input value, e.g. `\"params.STATE_BYTES\"`. Omit for zero-argument functions.", + "type": [ + "string", + "null" + ] + }, + "simf": { + "type": "string" + }, + "type": { + "enum": [ + "simf_fn" + ], + "type": "string" + } + }, + "required": [ + "simf", + "type" + ], + "type": "object" + } + ] + }, + "ParamDef": { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "compute": { + "anyOf": [ + { + "$ref": "#/definitions/ComputeSpec" + }, + { + "type": "null" + } + ], + "description": "How this param's value is derived. When present the user is never prompted.\n\nEither a bare expression string — `\"instance.PRINCIPAL_AMOUNT * 2\"` — or a structured spec for the cases an expression cannot express (`tapleaf`, `simf_fn`). The bare form is what `formula` used to be; they were two ways to say \"this value is computed, do not ask\", so they are now one." + }, + "default": { + "description": "Default value shown as a pre-fill in the prompt.", + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SimplicityHl": { + "additionalProperties": false, + "description": "SimplicityHL toolchain settings — how the `.simf` programs are compiled, as distinct from what the protocol does.\n\nDeliberately carries **no** compiler-version field. SimplicityHL has its own `simc \"\";` source directive, which the compiler enforces fail-fast before lexing, across the entry file and every reachable dependency — none of which a manifest key can do. Tooling that wants the requirement without compiling can read it via `version::SimcDirective::requirement_of`. Declaring it here as well would only create a second place to disagree.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "debug_symbols": { + "default": false, + "description": "Whether covenant `.simf` programs are compiled with debug symbols included.\n\nThis changes the program's CMR **and therefore every covenant address**, because `assert!`/`panic!` embed source info into `fail`-node commitments. Set it to match the toolchain of any protocol this manifest must interoperate with — e.g. `true` for simplicity-lending / `smplx-sdk`, which compiles with debug symbols on.\n\nDefaults to `false` (production; debug symbols are a transitional feature).", + "type": "boolean" + }, + "unstable_features": { + "description": "Unstable SimplicityHL compiler features this manifest's programs are allowed to use — the manifest form of `simc -Z `, one entry per feature:\n\n```json \"simplicity_hl\": { \"unstable_features\": [\"enums\"] } ```\n\nThe compiler rejects gated syntax unless the feature is enabled, so a program using `enum` fails to compile until `\"enums\"` is listed here. Enabling a feature the programs don't use is harmless: this only lifts a restriction, it never changes generated code, and therefore never changes a CMR or covenant address.\n\nManifest-wide rather than per-`utxo_type`, mirroring `simc`'s own per-invocation `-Z` flag — the whole point of a gate is that a reader can see, in one place, which unstable syntax this protocol depends on.\n\nDefaults to empty: nothing unstable is enabled.", + "items": { + "$ref": "#/definitions/UnstableFeatureName" + }, + "type": "array" + } + }, + "type": "object" + }, + "TapleafParam": { + "additionalProperties": false, + "description": "A single entry in a `ParamCompute::Tapleaf` params map. Combines the value reference (compile-param name or literal) with an optional type hint.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "type": { + "description": "Manifest type, e.g. `\"liquid.asset_id\"`, `\"u64\"`, `\"bool\"`. When absent, the type is inferred from the compile-param of the same name.", + "type": [ + "string", + "null" + ] + }, + "value": { + "description": "A compile-param name reference OR a string literal like `\"1\"`, `\"true\"`.", + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "TaprootLeafKind": { + "description": "The hashing scheme for a [`TaprootLeafSpec`]'s payload.", + "oneOf": [ + { + "description": "Elements taproot data leaf — the only scheme the engine implements.", + "enum": [ + "tapdata" + ], + "type": "string" + } + ] + }, + "TaprootLeafPayloadItem": { + "anyOf": [ + { + "description": "Hex literal taken as raw bytes, e.g. \"0x01\". Whole bytes only.", + "pattern": "^(?:0[xX])?(?:[0-9a-fA-F]{2})*$", + "type": "string" + }, + { + "description": "Computed value, resolved against the run's params/instance fields and encoded per `type` / `endian` / `pad_to`.", + "properties": { + "align": { + "description": "Which end of the padded field the value occupies. Defaults to `right`.", + "enum": [ + "left", + "right" + ] + }, + "endian": { + "description": "Byte order for the integer types. Defaults to little-endian.", + "enum": [ + "be", + "le" + ] + }, + "pad_to": { + "description": "Pad the encoded value to this width in bytes — 32 for a slot the program hashes with `sha_256_ctx_8_add_32`.", + "type": "integer" + }, + "type": { + "enum": [ + "u8", + "u16", + "u32", + "u64", + "bytes32", + "bytes", + "pubkey" + ] + }, + "value": { + "description": "`params.X`, `instance.X`, `.`, a bare param name, or a literal.", + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + { + "description": "Reference to a `state_vars` entry; its `default_value` is encoded as a single u8.", + "properties": { + "state_var": { + "type": "string" + } + }, + "required": [ + "state_var" + ], + "type": "object" + } + ], + "description": "One item of a taproot leaf payload. Items are concatenated, in order, into the bytes that get hashed as the leaf." + }, + "TaprootLeafSpec": { + "additionalProperties": false, + "description": "Describes one additional taproot leaf appended to the Simplicity program leaf.\n\nEach leaf's payload is hashed as `tapdata` — `SHA256(SHA256(\"TapData\") ‖ SHA256(\"TapData\") ‖ payload)`, which is the value a program computes with `jet::tapdata_init()`, `sha_256_ctx_8_add_*` and `finalize` — then folded into the tap tree with `TapBranch/elements` in declaration order, matching `jet::build_tapbranch`. The payload's **width must match what the `.simf` hashes**: `sha_256_ctx_8_add_32` wants exactly 32 bytes, `add_8` exactly 8. A mismatch yields a perfectly valid address that the covenant then refuses to recognize as its own.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "payload": { + "description": "Ordered payload items, concatenated into this leaf's byte string.", + "items": { + "$ref": "#/definitions/TaprootLeafPayloadItem" + }, + "type": "array" + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/TaprootLeafKind" + } + ], + "description": "How the payload is hashed. Only `tapdata` is implemented, and it was previously accepted as a free string — so any other spelling was silently hashed as tapdata anyway, producing an address whose derivation nobody had written down." + } + }, + "required": [ + "payload", + "type" + ], + "type": "object" + }, + "UiDetail": { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "group": { + "description": "Override the net-effect account/bucket heading (else derived from source/destination).", + "type": [ + "string", + "null" + ] + }, + "hide": { + "default": false, + "description": "Suppress this leg from the net-effect diff (e.g. pure protocol data).", + "type": "boolean" + }, + "label": { + "description": "Human-readable one-line description of this leg — the **only** signer-facing text for it (`description` is not a fallback; see `preview::input_label`).\n\nCapped at [`crate::validate::MAX_UI_LABEL`] characters so it fits one net-effect row alongside the amount and asset symbol. The cap reaches the schema as a `maxLength` — injected by `crate::schema` from that constant rather than written here as a literal, so the two cannot drift — and an editor flags an over-long label while typing rather than at validate time.", + "maxLength": 64, + "type": [ + "string", + "null" + ] + }, + "role": { + "description": "Optional semantic tag (e.g. \"collateral\", \"auth_nft\").", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "UiSpec": { + "anyOf": [ + { + "description": "Shorthand for `{ \"label\": \"...\" }`.", + "maxLength": 64, + "type": "string" + }, + { + "$ref": "#/definitions/UiDetail" + } + ], + "description": "Per-input / per-output UI hint. Accepts either a bare label string (`\"collateral locked\"`) or a detailed object for finer control.\n\nHand-deserialized rather than `#[serde(untagged)]`: an untagged enum reports only `data did not match any variant of untagged enum UiSpec`, swallowing the real reason. Dispatching on the JSON shape lets `UiDetail`'s own error through, so a misspelled key names itself." + }, + "UnstableFeatureName": { + "description": "Unstable SimplicityHL compiler feature (`simc -Z `).\n- imports — Module system syntax: 'use' imports, 'mod' modules, 'as' aliases, 'crate::' paths\n- enums — Enum syntax: 'enum' declarations and 'EnumName::Variant' match expressions", + "enum": [ + "imports", + "enums" + ], + "type": "string" + }, + "UtxoParamDef": { + "additionalProperties": false, + "description": "One entry of a [`UtxoType::params`] interface.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "default": { + "description": "Value to use when a site binds no `args` entry for this param.\n\nEvaluated in **instance scope**: a literal, or `instance.X` naming a field fixed when the contract was instantiated. Action scope is deliberately unreachable — a value that varies per run is exactly what a site must bind explicitly.\n\nWithout a default, every site must bind it, and `validate` says which ones don't.", + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Manifest type, used as the compile-param type hint (`u64`, `bytes32`, `liquid.asset_id`, …) — the same vocabulary action params use.", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "UtxoScript": { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "compile_params": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Per-utxo-type compile param remappings: simf_param_name → compile_param_reference. e.g. `{ \"SCRIPT_HASH\": \"LENDING_COV_HASH\" }` passes the value of LENDING_COV_HASH to the simf as SCRIPT_HASH.", + "type": "object" + }, + "extra_leaves": { + "items": { + "$ref": "#/definitions/TaprootLeafSpec" + }, + "type": [ + "array", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "UtxoType": { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "asset": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "params": { + "additionalProperties": { + "$ref": "#/definitions/UtxoParamDef" + }, + "description": "This type's parameter interface — everything the address derivation may read.\n\nDeclaring it switches the type to a **closed scope**: `script.compile_params` and `extra_leaves` resolve `params.X` against *these* params and nothing else. A site binds them with `args` (`{\"utxo_type\": \"t\", \"args\": {\"STATE\": \"params.x\"}}`), whose values are expressions evaluated in the *action's* scope.\n\nWithout it, the type keeps the legacy behaviour: leaves and compile params resolve against whatever is ambient at each mention. That is what makes one `utxo_type` derive two different addresses in two actions — `params.foo` means one thing where the action declares `foo` and something else where it does not — with no error, because an address is a hash and a wrong one looks exactly like a right one.", + "type": [ + "object", + "null" + ] + }, + "script": { + "anyOf": [ + { + "$ref": "#/definitions/UtxoScript" + }, + { + "type": "null" + } + ] + }, + "state_vars": true + }, + "required": [ + "description" + ], + "type": "object" + }, + "WalletValue": { + "description": "Which wallet-derived value a [`ParamCompute::Wallet`] spec resolves to.", + "oneOf": [ + { + "description": "The wallet's x-only BIP340 pubkey. The wallet chooses the derivation path.", + "enum": [ + "key" + ], + "type": "string" + }, + { + "description": "`sha256(scriptPubKey)` of the wallet's index-0 explicit output — the committed payout target a covenant checks repayment against.", + "enum": [ + "script_hash" + ], + "type": "string" + }, + { + "description": "The explicit address matching [`WalletValue::ScriptHash`]. The two are a pair: the covenant commits to the hash, the wallet receives at the address, so they must be derived together.", + "enum": [ + "address" + ], + "type": "string" + } + ] + } + }, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "$schema": { + "description": "Editor hint pointing at this schema; ignored by the engine.", + "type": "string" + }, + "actions": { + "additionalProperties": { + "$ref": "#/definitions/Action" + }, + "description": "Standalone actions that require no template instance (e.g. Prepare).", + "type": "object" + }, + "chain": { + "type": [ + "string", + "null" + ] + }, + "contract_templates": { + "additionalProperties": { + "$ref": "#/definitions/ContractTemplate" + }, + "description": "Contract template definitions. Each template has typed fields and actions. An action carrying a `create_instance` block is a constructor for its template.", + "type": [ + "object", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "manifest_version": { + "description": "Version of the manifest **format** this file is written against, as specified by ELIP-205 — not the version of any tool that reads it. Checked against [`FORMAT_VERSION`] at parse time; see [`check_format_version`] for the compatibility rule.", + "type": "string" + }, + "protocol": { + "type": "string" + }, + "simplicity_hl": { + "anyOf": [ + { + "$ref": "#/definitions/SimplicityHl" + }, + { + "type": "null" + } + ], + "description": "SimplicityHL toolchain settings for this manifest's `.simf` programs." + }, + "utxo_types": { + "additionalProperties": { + "$ref": "#/definitions/UtxoType" + }, + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "manifest_version", + "protocol" + ], + "title": "Transaction Manifest (txmanifest.json)", + "type": "object" +} diff --git a/scripts/check_manifests.py b/scripts/check_manifests.py new file mode 100644 index 0000000..47f9e50 --- /dev/null +++ b/scripts/check_manifests.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +"""Check the book's JSON examples against the transaction-manifest schema. + +Every ```json fence in src/ is parsed. A block is validated against the schema +when we know what it is meant to be: + + * a block containing "manifest_version" is a whole manifest, validated + against the root schema; + * a block preceded by an HTML comment naming a schema definition, e.g. + + + ```json + { ... } + ``` + + is validated against "#/definitions/Action". Where the block shows several + named entries, each value is checked against that definition; + `map:Action` instead says the entries' own values are Actions, for a block + that shows a named map such as `"params": { ... }`; + * a block preceded by is a slice of a whole + document, e.g. `"utxo_types": { ... }`, checked against the root schema + with required fields relaxed; + * a block preceded by is prose illustration (a PSET + dump, a state file, an error payload) and is only checked for being + well-formed JSON. + +Anything else is reported as unanchored: it parses, but nothing checks its +field names. Run with --list-unanchored to see them. + +Every markdown file is also scanned for vocabulary that 0.2.0 removed, which +catches stale field names in prose and in expression strings where a schema +cannot reach. + +No third-party dependencies: the schema uses a small enough slice of +draft-07 (type/properties/required/additionalProperties/$ref/anyOf/allOf/ +oneOf/enum/items/format/maxLength) to check directly. Run --self-test to +validate the checker against the reference wallet's own example manifests. + +Exit status is 1 if anything failed. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SCHEMA_PATH = ROOT / "schema" / "txmanifest.schema.json" +SRC = ROOT / "src" + +# Names 0.2.0 dropped, and what replaced each. The list follows +# `removed_legacy_fields_are_rejected` in the reference wallet's manifest.rs: +# every one of these is a hard parse error there, so a book example carrying one +# does not run. +RETIRED = [ + (r'"?attestation_version"?', "attestation_version", "removed; delete it"), + (r'"confidential_outputs"', "confidential_outputs", + "removed; set `confidential` per output"), + (r'"classes"', "classes", 'renamed to "contract_templates"'), + (r'"methods"', "methods", 'renamed to "actions"'), + (r'"class"\s*:', "create_instance.class", + "removed; an instance is always of the enclosing template"), + (r"\bis_constructor\b", "is_constructor", + "removed; an action carrying create_instance is a constructor"), + (r'"lifecycle"', "lifecycle", "removed in 0.2.0; nothing enforced it"), + (r'"validations"', "validations", + "removed in 0.2.0, deferred to a later revision"), + (r'"errors"\s*:', "errors", "removed; nothing read the table"), + (r'"simplicity_hl_version"', "simplicity_hl_version", + "folded into the simplicity_hl object"), + (r'"compile_debug_symbols"', "compile_debug_symbols", + 'folded into simplicity_hl as "debug_symbols"'), + (r'"deploy"\s*:', "deploy", "superseded by create_instance"), + (r'"derived"\s*:', "derived", "superseded by compute"), + (r'"formula"\s*:', "formula", "merged into compute, whose bare-string form it is"), + (r'"hooks"\s*:', "hooks", + "removed; use per-input on_resolved and action-level on_pre_broadcast"), + # Only the *param* spelling went. A witness may still carry + # "source": { "type": "wallet", "key": ... } — see examples/p2pk. + (r'"source"\s*:\s*\{\s*"type"\s*:\s*"wallet_key"', "params.NAME.source", + 'folded into compute: { "type": "wallet", "wallet": "key" }'), + (r"\bcompile_params\.", "compile_params.NAME", + "the instance namespace is now instance.NAME"), + (r"\bargs\.[A-Za-z_]", "args.NAME", + "the args namespace is gone; params is the only runtime namespace"), + (r'"manifest_version"\s*:\s*"0\.1\.', + 'manifest_version "0.1.x"', 'the format version is now "0.2.0"'), +] + +FENCE = re.compile(r"^```json\s*$") +FENCE_END = re.compile(r"^```\s*$") +ANCHOR = re.compile(r"") + + +class Failure(Exception): + pass + + +def load_schema() -> dict: + with SCHEMA_PATH.open(encoding="utf-8") as fh: + return json.load(fh) + + +# --- a small draft-07 subset validator ------------------------------------ + + +class Validator: + def __init__(self, schema: dict): + self.root = schema + + def resolve(self, schema: dict) -> dict: + while isinstance(schema, dict) and "$ref" in schema: + ref = schema["$ref"] + if not ref.startswith("#/"): + raise Failure(f"cannot resolve non-local $ref {ref!r}") + node = self.root + for part in ref[2:].split("/"): + node = node[part] + rest = {k: v for k, v in schema.items() if k != "$ref"} + schema = {**node, **rest} + return schema + + def check(self, value, schema, path: str, errors: list[str], require: bool = True) -> None: + # draft-07 allows a bare boolean as a schema: true accepts anything, + # false rejects everything. + if schema is True: + return + if schema is False: + errors.append(f"{path}: no value is permitted here") + return + + schema = self.resolve(schema) + + for combinator in ("allOf",): + for sub in schema.get(combinator, []): + self.check(value, sub, path, errors, require) + + for combinator in ("anyOf", "oneOf"): + branches = schema.get(combinator) + if not branches: + continue + attempts = [] + for sub in branches: + sub_errors: list[str] = [] + self.check(value, sub, path, sub_errors, require) + if not sub_errors: + break + attempts.append(sub_errors) + else: + # Report the branch this value was clearly trying to be, rather + # than the shortest complaint: a mistake inside a `tapleaf` spec + # otherwise surfaces as "expected string", naming the branch the + # value never resembled. + best = None + if isinstance(value, dict) and isinstance(value.get("type"), str): + for sub, sub_errors in zip(branches, attempts): + tag = self.resolve(sub).get("properties", {}).get("type", {}) + if value["type"] in (tag.get("enum") or []): + best = sub_errors + break + errors.extend(best if best is not None else min(attempts, key=len)) + return + + types = schema.get("type") + if types is not None: + if isinstance(types, str): + types = [types] + if not any(self._is_type(value, t) for t in types): + errors.append(f"{path}: expected {'/'.join(types)}, got {self._name(value)}") + return + + if "enum" in schema and value not in schema["enum"]: + allowed = ", ".join(json.dumps(v) for v in schema["enum"]) + errors.append(f"{path}: {json.dumps(value)} is not one of {allowed}") + + if isinstance(value, str) and "maxLength" in schema and len(value) > schema["maxLength"]: + errors.append(f"{path}: longer than {schema['maxLength']} characters") + + if isinstance(value, list) and "items" in schema: + item_schema = schema["items"] + if isinstance(item_schema, dict): + for i, item in enumerate(value): + self.check(item, item_schema, f"{path}[{i}]", errors, require) + + if isinstance(value, dict): + props = schema.get("properties", {}) + for name in schema.get("required", []) if require else []: + if name not in value: + errors.append(f"{path}: missing required field {name!r}") + extra = schema.get("additionalProperties") + for key, sub_value in value.items(): + child = f"{path}.{key}" if path else key + if key in props: + self.check(sub_value, props[key], child, errors, require) + elif key in ("$comment", "$schema"): + continue # authoring keys, legal anywhere + elif isinstance(extra, dict): + self.check(sub_value, extra, child, errors, require) + elif extra is False and props: + errors.append(f"{path}: unknown field {key!r}") + + @staticmethod + def _is_type(value, name: str) -> bool: + if name == "null": + return value is None + if name == "boolean": + return isinstance(value, bool) + if name == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if name == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if name == "string": + return isinstance(value, str) + if name == "array": + return isinstance(value, list) + if name == "object": + return isinstance(value, dict) + return True + + @staticmethod + def _name(value) -> str: + return { + type(None): "null", + bool: "boolean", + int: "integer", + float: "number", + str: "string", + list: "array", + dict: "object", + }.get(type(value), type(value).__name__) + + +# --- extracting blocks from the book -------------------------------------- + + +class Block: + def __init__(self, path: pathlib.Path, line: int, text: str, anchor: str | None): + self.path = path + self.line = line + self.text = text + self.anchor = anchor + + @property + def where(self) -> str: + return f"{self.path.relative_to(ROOT).as_posix()}:{self.line}" + + +def extract(path: pathlib.Path) -> list[Block]: + lines = path.read_text(encoding="utf-8").splitlines() + blocks: list[Block] = [] + i = 0 + while i < len(lines): + if FENCE.match(lines[i]): + start = i + 1 + j = start + while j < len(lines) and not FENCE_END.match(lines[j]): + j += 1 + anchor = None + # The anchor is the nearest preceding non-blank line. + k = i - 1 + while k >= 0 and not lines[k].strip(): + k -= 1 + if k >= 0: + found = ANCHOR.search(lines[k]) + if found: + anchor = found.group(1) + blocks.append(Block(path, start, "\n".join(lines[start:j]), anchor)) + i = j + 1 + else: + i += 1 + return blocks + + +ELISIONS = [ + (re.compile(r"…"), "..."), + (re.compile(r'"\.\.\."\s*:\s*"\.\.\."\s*,?'), ""), + (re.compile(r",(?=\s*[}\]])"), ""), + (re.compile(r"\{\s*\.\.\.\s*\}"), "{}"), + (re.compile(r"\[\s*\.\.\.\s*\]"), "[]"), + (re.compile(r",\s*\.\.\.\s*(?=[}\]])"), ""), + (re.compile(r"(?<=[{\[])\s*\.\.\.\s*,"), ""), +] + + +def normalize(text: str) -> tuple[str, bool]: + """Replace the book's `...` elisions with empty JSON, reporting whether any + were found. An elided block is missing fields on purpose, so the caller + stops enforcing `required` on it.""" + elided = False + for pattern, replacement in ELISIONS: + text, count = pattern.subn(replacement, text) + elided = elided or bool(count) + return text, elided + + +def decode_sequence(text: str): + """Decode one or more JSON values written back to back. + + Some examples list sibling objects with no enclosing array, one per line, to + show two spellings side by side. Each is a document in its own right. + """ + decoder = json.JSONDecoder() + values, index, length = [], 0, len(text) + while True: + while index < length and text[index] in " \t\r\n,": + index += 1 + if index >= length: + return values or None + try: + value, index = decoder.raw_decode(text, index) + except json.JSONDecodeError: + return None + values.append(value) + + +def parse_block(text: str): + """Parse a block into a list of (name, value) targets. + + Three shapes appear in the book: a whole document, several documents in a + row, and a single named entry shown in its parent's context — + + "LockCollateral": { ... } + + which is not JSON on its own until it is wrapped in braces. + + Returns (targets, is_members, elided, error). + """ + normalized, elided = normalize(text) + + values = decode_sequence(normalized) + if values is not None: + return [(None, v) for v in values], False, elided, None + + try: + members = json.loads("{" + normalized.strip().rstrip(",") + "}") + except json.JSONDecodeError: + # Report the failure against the block as written, not as normalized. + try: + json.loads(text) + except json.JSONDecodeError as exc: + return None, False, elided, exc + return None, False, elided, None + return list(members.items()), True, elided, None + + +def check_block(block: Block, validator: Validator) -> tuple[str, list[str]]: + """Return (status, errors) where status is checked / unanchored / prose.""" + targets, is_members, elided, exc = parse_block(block.text) + if targets is None: + detail = f": {exc}" if exc else "" + return "checked", [f"{block.where}: not valid JSON{detail}"] + + if block.anchor == "none": + return "prose", [] + + if block.anchor == "manifest": + # A slice of a manifest shown in place, e.g. `"utxo_types": { ... }`. + # Validate it as the document it is part of, with required fields off: + # the surrounding manifest supplies them. + schema = validator.root + elided = True + if is_members: + targets = [(None, dict(targets))] + elif block.anchor: + # `map:Name` says the block shows a named map whose *values* are Names, + # e.g. `"params": { "A": {...}, "B": {...} }`. + as_map = block.anchor.startswith("map:") + name = block.anchor[4:] if as_map else block.anchor + definitions = validator.root.get("definitions", {}) + if name not in definitions: + return "checked", [f"{block.where}: no such schema definition {name!r}"] + schema = {"$ref": f"#/definitions/{name}"} + if as_map: + targets = [ + (f"{outer}.{key}" if outer else key, item) + for outer, value in targets + for key, item in (value.items() if isinstance(value, dict) else []) + ] + elif not is_members and any( + isinstance(v, dict) and "manifest_version" in v for _, v in targets + ): + schema = validator.root + else: + return "unanchored", [] + + errors: list[str] = [] + for name, target in targets: + found: list[str] = [] + validator.check(target, schema, "", found, require=not elided) + prefix = f"{name}: " if name else "" + errors.extend(f"{block.where}: {prefix}{e.lstrip('.')}" for e in found) + return "checked", errors + + +def scan_retired(path: pathlib.Path) -> list[str]: + problems = [] + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for pattern, label, advice in RETIRED: + if re.search(pattern, line): + where = f"{path.relative_to(ROOT).as_posix()}:{number}" + problems.append(f"{where}: retired `{label}` - {advice}") + return problems + + +# --- self-test against the reference wallet ------------------------------- + + +def self_test(validator: Validator, examples: pathlib.Path) -> int: + manifests = sorted(examples.glob("*/txmanifest.json")) + if not manifests: + print(f"no manifests under {examples}", file=sys.stderr) + return 1 + bad = 0 + for manifest in manifests: + value = json.loads(manifest.read_text(encoding="utf-8")) + errors: list[str] = [] + validator.check(value, validator.root, "", errors) + if errors: + bad += 1 + print(f"FAIL {manifest.parent.name}") + for error in errors[:10]: + print(f" {error}") + else: + print(f"ok {manifest.parent.name}") + print(f"\n{len(manifests) - bad}/{len(manifests)} reference manifests validate") + + # Accepting every manifest proves nothing on its own — a validator that + # never says no would score the same. Corrupt a known-good manifest in each + # way the book is likely to be wrong, and require a complaint about each. + sample = json.loads(manifests[0].read_text(encoding="utf-8")) + + def an_action(doc): + """A (container, key) pair naming some action, wherever it lives.""" + for key in doc.get("actions", {}): + return doc["actions"], key + for template in doc.get("contract_templates", {}).values(): + for key in template.get("actions", {}): + return template["actions"], key + return None, None + + have_action = an_action(sample)[1] is not None + + def mutations(): + yield "unknown top-level field", lambda d: d.update(attestation_version="1") + yield "missing manifest_version", lambda d: d.pop("manifest_version") + yield "manifest_version wrong type", lambda d: d.update(manifest_version=2) + yield "legacy 'classes'", lambda d: d.update(classes={}) + yield "legacy 'methods'", lambda d: d.update(contract_templates={"T": {"methods": {}}}) + if have_action: + def set_on_action(doc, **fields): + container, key = an_action(doc) + container[key].update(fields) + + yield ("is_constructor on an action", + lambda d: set_on_action(d, is_constructor=True)) + yield ("bad allow_change value", + lambda d: set_on_action(d, allow_change="sometimes")) + for name in sample.get("utxo_types", {}): + yield ("confidential on a utxo_type", + lambda d, n=name: d["utxo_types"][n].update(confidential=False)) + break + + lax = 0 + print() + for label, mutate in mutations(): + broken = json.loads(json.dumps(sample)) + mutate(broken) + errors = [] + validator.check(broken, validator.root, "", errors) + if errors: + print(f"rejected {label}") + else: + lax += 1 + print(f"NOT REJECTED {label} — the checker is too lax") + + return 1 if bad or lax else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--list-unanchored", action="store_true", + help="list JSON blocks nothing is checking") + parser.add_argument("--self-test", metavar="EXAMPLES_DIR", + help="validate the checker against a wallet examples/ directory") + args = parser.parse_args() + + validator = Validator(load_schema()) + + if args.self_test: + return self_test(validator, pathlib.Path(args.self_test)) + + files = sorted(SRC.rglob("*.md")) + errors: list[str] = [] + unanchored: list[Block] = [] + counts = {"checked": 0, "unanchored": 0, "prose": 0} + + for path in files: + per_file = scan_retired(path) + for block in extract(path): + status, block_errors = check_block(block, validator) + counts[status] += 1 + per_file.extend(block_errors) + if status == "unanchored": + unanchored.append(block) + # Report a file's problems in the order a reader would meet them. + per_file.sort(key=lambda line: int(line.split(":")[1])) + errors.extend(per_file) + + for error in errors: + print(error) + + total = sum(counts.values()) + print( + f"\n{len(files)} files, {total} JSON blocks: " + f"{counts['checked']} checked, {counts['unanchored']} unanchored, " + f"{counts['prose']} prose" + ) + + if args.list_unanchored: + print("\nunanchored blocks (add above the fence):") + for block in unanchored: + first = block.text.strip().splitlines()[0][:60] if block.text.strip() else "" + print(f" {block.where} {first}") + + if errors: + print(f"\n{len(errors)} problem(s)") + return 1 + print("\nno problems") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 396d20b..1de865e 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -13,7 +13,7 @@ - [Splitting a UTXO](./recipes/00-splitting-utxos.md) - [Hello World: Pay-to-Public-Key](./recipes/01-hello-world-p2pk.md) - [Hello World, Part 2: Spending the output](./recipes/01b-hello-world-receive.md) -- [Parameters & validations](./recipes/02-params-and-validations.md) +- [Parameters](./recipes/02-params-and-validations.md) - [Outputs & destinations](./recipes/03-outputs-and-destinations.md) - [Witnesses](./recipes/04-witnesses.md) - [Worked example: a Last Will covenant](./recipes/04b-last-will.md) diff --git a/src/appendix/cli-reference.md b/src/appendix/cli-reference.md index 0c9efd1..735d6c7 100644 --- a/src/appendix/cli-reference.md +++ b/src/appendix/cli-reference.md @@ -8,13 +8,16 @@ the install options). Manifest paths are relative to your current directory. ## Commands ### `validate ` -Statically check a manifest's schema and report obvious problems — without -touching the network, wallet, or filesystem. Catches unknown `utxo_type` -references, outputs missing a required `amount_sat`, duplicate input/output/ -validation ids, malformed destinations, unknown validation rule types, -`create_instance` referencing a missing class, unreferenced UTXO types, and -lifecycle transitions that don't match any action. Exits non-zero if any errors -are found (warnings alone still exit zero). +Statically check a manifest and report obvious problems — without touching the +network, wallet, or filesystem. Catches unknown `utxo_type` references, outputs +missing a required `amount_sat`, duplicate input and output ids, malformed +destinations, hooks writing to targets nothing declares, unreferenced UTXO +types, and clear-signing text a wallet could not render. Exits non-zero if any +errors are found (warnings alone still exit zero). + +Note that a misspelled or unknown field does not reach `validate`: it is a hard +parse error, raised by any command that reads the manifest at all. So is a +`manifest_version` from a different format revision. ```sh txw validate examples/p2pk/txmanifest.json @@ -25,9 +28,9 @@ txw validate examples/p2pk/txmanifest.json ### `describe ` Explore a manifest interactively. Presents a menu of the contract's overview, -classes, and standalone actions; drill into any class to list its fields and -methods, and into any action to see its params, inputs, outputs, witnesses, and -validations — without reading the raw JSON. When stdout is not a terminal (e.g. +contract templates, and standalone actions; drill into any template to list its +fields and actions, and into any action to see its params, inputs, outputs and +witnesses — without reading the raw JSON. When stdout is not a terminal (e.g. piped to a file or `less`), it prints a full non-interactive dump of everything instead. @@ -46,7 +49,7 @@ broadcast. | `--params ` | — | Flat JSON `string→string` overrides (takes precedence over auto-discovered file). | | `--wallet ` | `wallet.json` | Wallet for input selection and signing. | | `--data-dir ` | platform data dir | Where wallet state is persisted. | -| `--instance ` | `.instance.json` | Instance file (compile params locked at deploy). | +| `--instance ` | `.instance.json` | Instance file (template field values locked at deploy). | | `--state ` | `.state.json` | State file tracking live UTXOs. | | `--manual-inputs` | off | Prompt for every input instead of auto-selecting. | | `--export-pset ` | — | Write signed PSET/tx to a file instead of broadcasting. | diff --git a/src/appendix/field-types.md b/src/appendix/field-types.md index ca289dd..005a152 100644 --- a/src/appendix/field-types.md +++ b/src/appendix/field-types.md @@ -1,6 +1,6 @@ # Field type reference -The type strings used in `compile_params`, class `fields`, and action `params`. +The type strings used in a contract template's `fields` and an action's `params`. | Type string | Rust equivalent | Description | |-------------|-----------------|-------------| diff --git a/src/appendix/formula-language.md b/src/appendix/formula-language.md index a0aac93..6ebd72a 100644 --- a/src/appendix/formula-language.md +++ b/src/appendix/formula-language.md @@ -1,7 +1,7 @@ # Formula language reference Formulas are string expressions evaluated at transaction build time. They appear -in output/input `amount_sat`, validation `expr`, hook `set` values, and witness +in output/input `amount_sat`, a param's `compute`, hook `set` values, and witness `expr`. ## Operators @@ -17,9 +17,8 @@ in output/input `amount_sat`, validation `expr`, hook `set` values, and witness | Syntax | Description | |--------|-------------| -| `compile_params.NAME` | Compile parameter by name | +| `instance.NAME` | Contract-template field, from the instance file | | `params.NAME` | Action parameter by name | -| `args.NAME` | Action argument by name | | `input_id.amount_sat` | Satoshi amount of a resolved input | | `input_id.asset` | Asset ID of a resolved input (hex string) | | `input_id.present` | Boolean — whether an optional input was found | diff --git a/src/appendix/glossary.md b/src/appendix/glossary.md index 6cecada..61e14ff 100644 --- a/src/appendix/glossary.md +++ b/src/appendix/glossary.md @@ -1,38 +1,58 @@ # Glossary -**Action / Method** — A single transaction recipe in a manifest: its inputs, -outputs, witnesses, and validations. *Action* (top-level) and *method* (inside a -class) are structurally identical. +**Action** — A single transaction recipe in a manifest: its inputs, outputs, and +the witnesses each input supplies. An action inside a contract template is +identical to a top-level one, except that its formulas may also reference +`instance.*`. -**Attestation** — A BIP340 signature over the finalized manifest by a -developer, auditor, or counterparty. Tampering invalidates it. +**`allow_change`** — An action's bound on undeclared change: `"none"` (the +default), `"lbtc_only"`, or `"any"`. Every output a transaction carries must be +written in the manifest; the network fee is the one exception. -**Class** — A typed contract definition with named `fields` and `methods`. Each -deployed instance of a class has its own instance file. +**Blinding factor** — The 32-byte scalar hiding an asset or an amount in a +confidential output. An output's `blinding` block pins what the builder would +otherwise pick at random — needed by any covenant that verifies its own UTXOs as +Pedersen commitments. **CMR (Commitment Merkle Root)** — The 32-byte hash of a compiled Simplicity program. Doubles as the program's on-chain identity. -**`canonical_cmr`** — The CMR of a program with all parameters zeroed. A stable -identifier for the program's *structure*, independent of instance parameters. +**Compute spec** — How a param's value is produced instead of being prompted for: +a bare formula string, or a structured `expr`, `tapleaf`, `script_hash`, +`wallet`, `simf_fn` or `hook`. -**Compile param** — A value baked into a covenant script at deploy time. Changing -one changes the script's address. Stored in the instance file. +**Contract template** — A typed contract definition with named `fields` and +`actions`, under the top-level `contract_templates` map. Each deployed instance +of a template has its own instance file. -**Manifest** — The static JSON protocol definition (`txmanifest.json`). +**Constructor** — An action carrying a `create_instance` block, which writes a +new instance file when it runs. There is no separate flag: the block is what +makes the action a constructor, and it is only legal inside a contract template. **Covenant** — A script that constrains how its output may be spent — e.g. by introspecting the spending transaction's inputs and outputs. -**Derived param** — A compile param computed by the tool rather than supplied: -from a formula, or from the outpoint of an issuance input. +**`debug_symbols`** — The `simplicity_hl` flag deciding whether covenants compile +with debug information. It changes every program's CMR, and therefore every +address derived from it, so two tools that disagree about it see different +contracts. + +**Field** — A contract template's compile-time value, baked into a covenant +script at deploy time and stored per deployment in the instance file. Changing +one changes the script's address. Referenced as `instance.NAME`. -**Instance file** — Per-deployment compile params and class field values +**Instance file** — Per-deployment contract-template field values (`.instance.json`). +**Manifest** — The static JSON protocol definition (`txmanifest.json`). + **NUMS point** — "Nothing Up My Sleeve" — a public key with no known private key, used as a Taproot internal key to make the key-path provably unspendable. +**Param** — An action's runtime value: prompted for, computed, or set by a hook. +It affects only the transaction being built, never an address. Referenced as +`params.NAME`. `params` is the only runtime value namespace. + **`provided_inputs`** — UTXOs pre-filled inline in the instance file, letting a wallet spend a counterparty's output it never indexed. @@ -42,11 +62,15 @@ PSBT). **State file** — The live on-chain UTXO set for one instance (`.state.json`), updated after every broadcast. -**Tapleaf compute spec** — A field value (`compute: "tapleaf"`) that compiles a +**Tapleaf compute spec** — A compute spec (`"type": "tapleaf"`) that compiles a `.simf` file with params to produce a covenant script hash. **UTXO type** — A named on-chain state with a known script, so a wallet can recognise the protocol's outputs. -**Witness** — A value supplied to satisfy a Simplicity program when spending: -a signature, a path selector, a leaf selector, or a computed value. +**Witness** — A value supplied to satisfy a Simplicity program when spending: a +signature, a path selector, a leaf selector, or a computed value. Witnesses are +declared on the input whose script they satisfy. + +**`$comment` / `$schema`** — Authoring keys, legal on any object at any depth and +carrying no protocol meaning. A tool strips both before interpreting a manifest. diff --git a/src/appendix/wallet-implementation.md b/src/appendix/wallet-implementation.md index 8385d1a..a846b3e 100644 --- a/src/appendix/wallet-implementation.md +++ b/src/appendix/wallet-implementation.md @@ -19,89 +19,94 @@ The following steps are executed in order for each action execution. ### 1. Parse -Read `compile_params.user_provided` and the target action's `params` and `args`. -Determine which values the user must supply upfront. Values already fixed by -`provided_inputs.params` are excluded from user prompting. +Read the target action's `params`. For an action declared inside a contract +template, also load that instance's `fields` from its instance file — those are +reached as `instance.NAME`. A param carrying a `compute` block is never prompted +for; nor is one already fixed by `provided_inputs.params`. -### 2. User inputs args and params +### 2. User supplies the remaining params -Prompt the user for all required `args` and `params` values not already covered -by `provided_inputs`. Present `description` fields as guidance text. +Prompt for every `param` not covered by the previous step. Present each param's +`description` as guidance text. + +`params` is the only runtime value namespace. The parallel `args` namespace was +removed in format 0.2.0. ### 3. Input selection For each input in the action's `inputs` array, attempt to auto-select a UTXO -satisfying the input's `utxo_source`, `asset`, and `amount_sat` constraints. -Inputs already fixed by `provided_inputs.inputs` are used verbatim — do not -prompt for these. +satisfying the input's `utxo_source`, `asset`, `from_address` and `amount_sat` +constraints. Inputs already fixed by `provided_inputs.inputs` are used verbatim — +do not prompt for these. - For ambiguous cases (multiple candidates) or when auto-select is disabled by wallet policy, prompt the user to choose. -- User may opt into auto-select depending on wallet implementation. - Validate each `provided_inputs` UTXO against chain state: confirm it is unspent and its `script_pubkey` matches the expected script. +- A pinned outpoint reads its amount and asset from the chain, which outrank + anything the manifest or the operator supplies. -### 4. `on_input_resolved` hooks run +### 4. Hooks run -Execute all hooks declared in `hooks.on_input_resolved`, in declaration order -(the order they appear in the file). This is the only ordering guarantee. +As each input resolves, run that input's `on_resolved` block. Once every input is +resolved, run the action's `on_pre_broadcast` block. Hooks run in declaration +order — the order they appear in the file — which is the only ordering guarantee. -Each hook is keyed by input id and runs a SimplicityHL program that sets one or -more `compile_params.DERIVED_PARAM` values. The execution context available to -each hook: +A hook is a `set` map of target → value, written in the formula language rather +than SimplicityHL. Targets are `instance.NAME` (write a contract-template field) +or `params.NAME`. Within an input's own `on_resolved`, the bare keyword `asset` +resolves to that input's asset — including one it has just issued — and +`reissuance_token` to the matching reissuance token. -- Resolved input outpoints (txid, vout), amounts, and assets for all inputs - resolved so far. -- All `compile_params` set to date, including values set by earlier hooks in the - same pass. +The context available to a hook is: -All hooks must complete before any validation runs. Subsequent validations and -output formulas may depend on the derived params set here. +- Resolved input outpoints (txid, vout), amounts, and assets for every input + resolved so far. +- All `instance` and `params` values set so far, including those set by earlier + hooks in the same pass. **On-chain context jets** (`current_index`, `input_script_hash`, etc.) are **not** available at build time. Those jets execute only during on-chain script evaluation, not during transaction construction. -### 5. Outputs constructed - -Build the transaction outputs from the action's `outputs` array. Evaluate each -`amount_sat` formula using the now-complete `compile_params` context -(user-provided plus all hook-derived values), resolved input amounts, and action -`args`/`params`. Resolve output `destination` fields to concrete scriptPubKeys. - -### 6. Fee rate chosen and applied - -Estimate the transaction fee or prompt the user for a fee rate. Apply the fee to -the transaction, adjusting any `"change"` output accordingly. +### 5. Computed params resolved -### 7. `on_validate` hook runs (if present) +Evaluate every param whose `compute` block has not yet produced a value: -If the action declares an `on_validate` hook, run the full SimplicityHL program -against the current transaction state. The program returns `Option`: +| `compute.type` | Produces | +|---|---| +| bare string, or `expr` | The value of a formula-language expression | +| `tapleaf` | A covenant script hash, by compiling a `.simf` | +| `script_hash` | `sha256(scriptPubKey)` of an address | +| `wallet` | A wallet-derived `key`, `script_hash` or `address` | +| `simf_fn` | The return value of a named function in a `.simf` | +| `hook` | Nothing — the value was supplied by a hook in step 4 | -- `None` — validation passes; continue. -- `Some(n)` — validation fails with error code `n`; look up `n` in the top-level - `errors` map and surface the description to the user. Flow returns to step 3. +Where two covenants each commit to the other's hash, seed with 32 zero bytes and +iterate to convergence. -### 8. 1-liner validations run +### 6. Outputs constructed -Execute each entry in the action's `validations` array, in declaration order. -Each validation evaluates its `rule` against the current transaction state. +Build the transaction outputs from the action's `outputs` array. Evaluate each +`amount_sat` formula against the now-complete context — `instance`, `params`, +and resolved input amounts — and resolve each `destination` to a concrete +scriptPubKey. Blind any output marked `confidential`, honouring a `blinding` +block where one pins the factors. -- A failing `arithmetic` or `simplicity_hl` validation produces an error code - from the entry's `error.code` field. -- A failing `utxo_exists` validation produces the same. +### 7. Fee rate chosen and applied -### 9. On any validation error +Estimate the transaction fee or prompt the user for a fee rate. Apply the fee to +the transaction, adjusting any `"change"` output accordingly. -Look up the error code (string key) in the top-level `errors` map to obtain the -English-language description. Surface this to the user. The user adjusts their -inputs or params and flow returns to step 3. +A surplus that no declared output absorbs is governed by the action's +`allow_change`, which defaults to `"none"`: the action must size its inputs to +what it spends, and an unexplained remainder is an error rather than a silent +change output. -### 10. Fee review / adjustment → PSET created +### 8. Fee review / adjustment → PSET created Present the user with the final fee amount. If the user adjusts the fee rate, -rerun from step 6. Signatures are not yet present at this point, so there is no +rerun from step 7. Signatures are not yet present at this point, so there is no witness-invalidation problem. Once the user confirms, construct the PSET. **This is the boundary between @@ -109,14 +114,16 @@ manifest-level reasoning and standard Elements/Bitcoin wallet machinery.** A wallet that does not implement tx-manifest can receive the PSET from this point onwards and handle signing and broadcast normally. -### 11. Wallet signs +### 9. Wallet signs -Populate witnesses into the PSET per the action's `witnesses` map. For each -witness descriptor, produce the required data (signatures, preimages, -SimplicityHL-typed values, etc.) as specified by the `source` type. Pre-computed -witnesses from `provided_inputs.witnesses` are included verbatim. +Populate witnesses into the PSET from each **input's** `witnesses` map — witnesses +satisfy a specific input's script, so they are declared on the input, not on the +action. The map must name every witness the input's program declares and nothing +else; a witness this spending path does not use is written as the bare string +`"unused"`. Pre-computed witnesses from `provided_inputs.witnesses` are included +verbatim. -### 12. Simplicity dry-run +### 10. Simplicity dry-run Execute the covenant scripts on all inputs against the signed PSET. This is a local simulation of on-chain script execution; it does not broadcast. @@ -125,30 +132,34 @@ A dry-run failure indicates a bug in the manifest or wallet implementation, **not a user error**. Surface it as an internal error with the relevant input index and script. Do not ask the user to retry. -This step is distinct from the manifest validations in steps 7–8. Manifest -validations are pre-flight business-logic checks expressible without a full -Simplicity interpreter. The dry-run is the final cryptographic and covenantal -correctness check, confirming that the on-chain scripts will accept the -constructed transaction. +Format 0.2.0 has no pre-flight `validations` block, so the dry-run is where a +manifest's assumptions are actually tested. It is a cryptographic and covenantal +check: it confirms the on-chain scripts will accept the constructed transaction. -### 13. Broadcast +### 11. Broadcast Finalise and extract the transaction from the PSET. Broadcast to the network, or hand off to an external broadcast service. ---- +### 12. Post-broadcast + +Run the action's `on_post_broadcast` block, which is where values only known once +the transaction exists — txids, newly issued asset IDs — are captured. Then +update the state file atomically with respect to the broadcast: remove spent +UTXOs, add the new covenant outputs, and write the instance file if the action +carried a `create_instance`. + ## Execution context for SimplicityHL code -The following are available to all SimplicityHL code at build time (hooks and -validations): +The following are available to all SimplicityHL code at build time: | Available | Description | |---|---| | Resolved input outpoints | txid and vout for each resolved input | | Resolved input amounts and assets | `amount_sat` and `asset` for each resolved input | -| `compile_params` | All user-provided values plus any values set by hooks that have already run | -| Action `args` and `params` | Runtime values supplied by the user | +| `instance.NAME` | Contract-template fields, plus any values set by hooks that have already run | +| `params.NAME` | Runtime values supplied by the user or computed for the action | The following are **not** available at build time: @@ -160,24 +171,12 @@ The following are **not** available at build time: ## Error codes -Error codes are `u16` values. The manifest's top-level `errors` field maps -numeric codes to English-language descriptions: - -```json -"errors": { - "1001": "Collateral amount is below the minimum required for this loan.", - "1002": "Loan has not yet expired; liquidation is not permitted." -} -``` - -Both `on_validate` (step 7) and per-entry `validations` (step 8) produce error -codes. The wallet looks up the code in `errors` and displays the description to -the user. +Format 0.2.0 has no error-code table. The top-level `errors` field — a code → +description lookup — was removed along with the `validations` block that +produced the codes, because nothing ever read it. -**Localisation.** Other locales are provided as separate JSON files sharing the -same numeric keys — the manifest itself carries only the English -descriptions. Wallet implementations that support multiple locales load the -appropriate locale file and index into it by the same code. +A wallet's job is therefore to report the failure it actually hit: a parse error +naming the offending key, an unsatisfied covenant, an input it could not resolve. --- diff --git a/src/getting-started/anatomy.md b/src/getting-started/anatomy.md index 273d88e..f95be77 100644 --- a/src/getting-started/anatomy.md +++ b/src/getting-started/anatomy.md @@ -6,16 +6,14 @@ of metadata fields followed by the data sections. ```json { - "manifest_version": "0.1.0", + "manifest_version": "0.2.0", "protocol": "p2pk-simplicity", "description": "Pay-to-public-key using a Simplicity checksig program on Liquid.", "chain": "liquid", - "compile_params": { ... }, - "utxo_types": { ... }, - "classes": { ... }, - "actions": { ... }, - "lifecycle": { ... } + "utxo_types": { ... }, + "contract_templates": { ... }, + "actions": { ... } } ``` @@ -23,74 +21,89 @@ of metadata fields followed by the data sections. | Field | Required | Purpose | |-------|----------|---------| -| `manifest_version` | yes | Version of the tx-manifest format itself. Current: `"0.1.0"`. | +| `manifest_version` | yes | Version of the tx-manifest format itself. Current: `"0.2.0"`. | | `protocol` | yes | Kebab-case protocol identifier, e.g. `"simplicity-lending"`. | -| `description` | yes | Free-text summary of the whole protocol. | +| `description` | no | Free-text summary of the whole protocol. | | `chain` | no | `"bitcoin"`, `"liquid"`/`"elements"`, or `"cross-chain"`. Defaults to `"elements"`. | -| `simplicity_hl_version` | no | SimplicityHL compiler version the scripts require. | -| `source` | no | Relative path to the top-level `.simf` file. | -| `confidential_outputs` | no | File-level default for output blinding. See [Outputs & destinations](../recipes/03-outputs-and-destinations.md). | +| `simplicity_hl` | no | SimplicityHL toolchain settings. See below. | + +Two authoring keys may appear on any object at any depth and carry no protocol +meaning: `$comment`, for prose a tool must ignore, and `$schema`, an editor hint +pointing at the JSON Schema. Both are stripped before a manifest is interpreted. + +### `simplicity_hl` — how the covenants are compiled + + +```json +"simplicity_hl": { "debug_symbols": false } +``` + +`debug_symbols` decides whether the `.simf` programs are compiled with debug +information. It is not a cosmetic setting: `assert!` and `panic!` embed source +locations into `fail`-node commitments, so turning it on changes every program's +CMR, and therefore its tapleaf, its address, and every script hash derived from +it. Two tools that disagree about the flag derive *different addresses for the +same manifest*. Omit the block and it is `false`. + +There is deliberately no compiler-version field here. SimplicityHL pins its own +version from inside the source (`simc "=0.6.0";`), where the compiler can +enforce it across the entry file and every dependency. ## The data sections -A file carries up to five data sections. Two of them — the contract's -compile-time **fields** and its **methods** — can be written one of two ways: +A file carries up to three data sections, and most files won't carry all of +them. There are two ways to write a contract's compile-time values and the +transactions that operate on them: -- **Grouped into a top-level `classes` map** (the canonical model, used by the - [lending example](../walkthrough/lending-protocol.md)). Each entry is a - deployable contract type bundling its `fields` and `methods`. -- **Flattened** into a top-level `compile_params` block plus a top-level - `actions` map. Simpler, and used by the early recipes in this book. +- **Grouped into a top-level `contract_templates` map** (the canonical model, + used by the [lending example](../walkthrough/lending-protocol.md)). Each entry + is a deployable contract type bundling its `fields` and its `actions`. +- **Flattened** into a top-level `actions` map, where each action declares its + own `params`. Simpler, and used by the early recipes in this book. -The other sections — `utxo_types` and `lifecycle` — look the same either way. -Most files won't carry every section. +`utxo_types` looks the same either way. -### `classes` — typed contracts (fields + methods) +### `contract_templates` — typed contracts (fields + actions) -A `class` is a typed contract definition: one deployable contract type with its -compile-time `fields` and the `methods` (actions) that operate on it, all grouped -under a top-level `classes` map. Richer protocols use this form — the canonical -lending file is built entirely from classes. +A **contract template** is a typed contract definition: one deployable contract +type with its compile-time `fields` and the `actions` that operate on it, all +grouped under a top-level `contract_templates` map. Richer protocols use this +form — the canonical lending file is built entirely from templates. + ```json -"classes": { +"contract_templates": { "p2pk_contract": { "description": "Pay-to-public-key contract.", "fields": { "PUBKEY": { "type": "pubkey", "description": "Key that controls spending." } }, - "methods": { "Pay": { ... }, "Receive": { ... } } + "actions": { "Pay": { ... }, "Receive": { ... } } } } ``` -A file may also carry a top-level `actions` map *alongside* `classes`, for -utility actions that don't belong to a single instance (the lending file uses -this for its `Prepare` actions). Classes, fields, and methods are covered in full -in [Instance, state & constructors](../recipes/10-instance-state-constructors.md). +A file may also carry a top-level `actions` map *alongside* `contract_templates`, +for utility actions that don't belong to a single instance (the lending file uses +this for its `Prepare` actions). An action inside a template is identical to a +top-level one, except that its formulas may also reference `instance.*`. +Templates, fields, and instances are covered in full in +[Instance, state & constructors](../recipes/10-instance-state-constructors.md). -### Compile-time parameters — what's baked in at deploy time +### Compile-time values — what's baked in at deploy time -Whether they live in a class's `fields` or a top-level `compile_params` block, -compile-time values parameterize the covenant scripts: a pubkey, an asset ID, a +Compile-time values parameterize the covenant scripts: a pubkey, an asset ID, a loan amount, an expiry height. They are fixed for a deployment — change one and you get a different script hash, and therefore a different address. -In the canonical model they are the **fields** of a `class` (above), and their -values are stored in the instance file. Simpler single-type contracts — -including the early recipes in this book — may instead declare them in a -top-level `compile_params` block. Both forms are accepted by the tooling: +They are the **fields** of a contract template, and their values are stored per +deployment in the instance file, where formulas reach them as `instance.NAME`. +Runtime values that are prompted for, or set by a hook, are an action's `params` +instead, reached as `params.NAME`. -```json -"compile_params": { - "user_provided": { - "PUBKEY": { "type": "pubkey", "description": "Key that controls spending." } - } -} -``` - -Some params are **derived** rather than supplied — computed from other params or -from the outpoints of issuance inputs. See +Some params are computed rather than supplied — derived from other params or +from the outpoints of issuance inputs. A param carrying a `compute` is never +prompted for. See [Formulas & derived params](../recipes/07-formulas-and-derived-params.md). ### `utxo_types` — the on-chain states @@ -99,6 +112,7 @@ Each UTXO type is a named on-chain state with a known script (usually a Taproot address built from a Simplicity leaf). A wallet uses these definitions to recognise the protocol's outputs on-chain. + ```json "utxo_types": { "p2pk_output": { @@ -113,63 +127,54 @@ recognise the protocol's outputs on-chain. } ``` -We cover the `script` block in detail in -[Covenant UTXO types](../recipes/05-covenant-utxo-types.md). +The `script.compile_params` map wires the manifest's own names onto the `.simf` +program's `param::` names: the key is the name inside the SimplicityHL source, +the value is what to look up in the manifest. We cover the `script` block in +detail in [Covenant UTXO types](../recipes/05-covenant-utxo-types.md). ### `actions` — the valid transactions -Each action (or *method*) is a single transaction recipe: which UTXOs to consume -(`inputs`), what to create (`outputs`), what witnesses to provide (`witnesses`), -and what must be true before building (`validations`). +Each action is a single transaction recipe: which UTXOs to consume (`inputs`), +what to create (`outputs`), and — on each input — what witnesses satisfy its +script. + ```json "actions": { "Pay": { "description": "Pay a recipient by locking funds into a p2pk output keyed to their public key.", "params": { ... }, "inputs": [ ... ], - "outputs": [ ... ], - "validations": [ ... ] + "outputs": [ ... ] } } ``` -> **Classes vs. top-level actions.** In richer protocols, actions are grouped -> inside a `classes..methods` block — a *class* is a typed contract with -> fields and methods. For simple, single-type contracts, actions can live -> directly under top-level `actions`. Structurally a method and an action are -> identical. We start with top-level `actions` and introduce classes in +An action may also declare `allow_change`, which bounds whether the engine may +append a change output the manifest never wrote down. It defaults to `"none"` — +every output a transaction carries must appear in the manifest, the network fee +being the one exception. See +[Outputs & destinations](../recipes/03-outputs-and-destinations.md). + +> **Templates vs. top-level actions.** In richer protocols, actions are grouped +> inside a `contract_templates..actions` block — a template is a typed +> contract with fields and actions. For simple, single-type contracts, actions +> can live directly under top-level `actions`. We start with top-level `actions` +> and introduce templates in > [Instance, state & constructors](../recipes/10-instance-state-constructors.md). -### `lifecycle` — documentation of the state machine - -Purely descriptive: the named states, the transitions between them, and whether -each action needs one party (`unilateral`) or both (`cooperative`). Tools render -diagrams from it, but nothing on-chain depends on it. - -```json -"lifecycle": { - "states": ["paid", "received"], - "transitions": { - "Pay": { "to": "paid" }, - "Receive": { "from": "paid", "to": "received" } - } -} -``` - ## The execution model, in brief When you run an action, a tool like `tx-manifest-wallet` performs roughly these steps: -1. **Resolve parameters** — load compile params, auto-derive wallet keys, apply overrides. +1. **Resolve parameters** — load instance fields, prompt for the rest, auto-derive wallet keys. 2. **Resolve inputs** — find each input UTXO (from state file, wallet, or `provided_inputs`). 3. **Compute derived params** — compile `.simf` files to get covenant script hashes. -4. **Run validations** — abort if any rule is false. -5. **Construct outputs** — evaluate amount and asset formulas, resolve destinations. -6. **Build the PSET**, sign (computing Simplicity witnesses), and broadcast. -7. **Update the state file** — remove spent UTXOs, add new covenant outputs. +4. **Construct outputs** — evaluate amount and asset formulas, resolve destinations. +5. **Build the PSET**, sign (computing Simplicity witnesses), and broadcast. +6. **Update the state file** — remove spent UTXOs, add new covenant outputs. You don't need to memorise this yet — each recipe touches the parts it needs. The -full sequence is in [`Spec.md` §11](https://github.com/stringhandler/tx_manifest_spec/blob/main/Spec.md). +full sequence is in [`Spec.md`](https://github.com/stringhandler/tx_manifest_spec/blob/main/Spec.md). With the skeleton in hand, let's write our first contract. diff --git a/src/getting-started/setup.md b/src/getting-started/setup.md index c246557..693354e 100644 --- a/src/getting-started/setup.md +++ b/src/getting-started/setup.md @@ -127,9 +127,9 @@ The wallet derives keys on the BIP86 (taproot) paths the spec expects: | `m/86h/1h/0h/0/0` | `m/86h/0h/0h/0/0` | Wallet signing key | | `m/86h/1h/1h/0/0` | `m/86h/0h/1h/0/0` | Oracle key | -A `compile_params` entry with `source: { "type": "wallet_key" }` is auto-filled -from the first path; `oracle_key` from the second. (More on this in -[Parameters & validations](../recipes/02-params-and-validations.md).) +A param with `"compute": { "type": "wallet", "wallet": "key" }` is auto-filled +from the first path. (More on this in +[Parameters](../recipes/02-params-and-validations.md).) ## Fund and sync diff --git a/src/getting-started/what-is-a-manifest.md b/src/getting-started/what-is-a-manifest.md index 462cc6b..345f681 100644 --- a/src/getting-started/what-is-a-manifest.md +++ b/src/getting-started/what-is-a-manifest.md @@ -23,7 +23,7 @@ A live contract is described by **three companion files**: | File | Naming | What it holds | Lifetime | |------|--------|---------------|----------| -| **Manifest** | `txmanifest.json` | The protocol definition: classes, actions, inputs, outputs, witnesses. | Static — shared by every deployment. | +| **Manifest** | `txmanifest.json` | The protocol definition: contract templates, actions, inputs, outputs, witnesses. | Static — shared by every deployment. | | **Instance file** | `.instance.json` | The compile-time parameters for *one* deployment (this borrower's pubkey, this loan's amount). | Created when the contract is instantiated. | | **State file** | `.state.json` | The live on-chain UTXO set for this instance. | Updated after every broadcast. | diff --git a/src/recipes/00-splitting-utxos.md b/src/recipes/00-splitting-utxos.md index 390159d..5a5db4b 100644 --- a/src/recipes/00-splitting-utxos.md +++ b/src/recipes/00-splitting-utxos.md @@ -16,8 +16,7 @@ The full manifest is reproduced inline below — save it as `txmanifest.json`. ```json { - "manifest_version": "0.1.0", - "attestation_version": "1", + "manifest_version": "0.2.0", "protocol": "utxo-split", "description": "Split one wallet UTXO into four equal wallet UTXOs.", "chain": "liquid", @@ -49,14 +48,6 @@ The full manifest is reproduced inline below — save it as `txmanifest.json`. { "id": "split_2", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" }, { "id": "split_3", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" }, { "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true } - ], - - "validations": [ - { - "id": "amount_nonzero", - "rule": { "type": "arithmetic", "expr": "params.amount_each > 0" }, - "error": { "code": "INVALID_AMOUNT", "message": "amount_each must be greater than zero" } - } ] } } @@ -67,9 +58,9 @@ The full manifest is reproduced inline below — save it as `txmanifest.json`. **The whole envelope, and nothing else.** This file has the required top-level fields (`manifest_version`, `protocol`, `description`, `chain`) and a single -`actions` block. There are no `utxo_types` (no on-chain covenant states), no -`compile_params` (nothing is baked into a script), and no `classes`. A manifest -can be this small. +`actions` block. There are no `utxo_types` (no on-chain covenant states) and no +`contract_templates` (nothing is baked into a script). A manifest can be this +small. **One action parameter.** `amount_each` is an action `param` of type `u64` — you supply it each time you run `Split`. It is *not* a compile param: it doesn't change diff --git a/src/recipes/01-hello-world-p2pk.md b/src/recipes/01-hello-world-p2pk.md index d1359c4..a9ca00a 100644 --- a/src/recipes/01-hello-world-p2pk.md +++ b/src/recipes/01-hello-world-p2pk.md @@ -73,8 +73,7 @@ present but empty: ```json { - "manifest_version": "0.1.0", - "attestation_version": "1", + "manifest_version": "0.2.0", "protocol": "p2pk-simplicity", "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.", "chain": "liquid", @@ -98,6 +97,7 @@ We'll fill the two sections in order. The UTXO type names the on-chain state and points at the `.simf` program. Replace the empty `utxo_types` with: + ```json "utxo_types": { "p2pk_output": { @@ -106,8 +106,7 @@ the empty `utxo_types` with: "type": "simplicity", "source": "./p2pk.simf" }, - "asset": "lbtc", - "confidential": false + "asset": "lbtc" } }, ``` @@ -123,6 +122,7 @@ creates, and what must hold before it builds. The `Pay` action declares a `pubke parameter and feeds it into the covenant on the output's `destination`. Replace the empty `actions` with: + ```json "actions": { "Pay": { @@ -164,13 +164,6 @@ the empty `actions` with: "asset": "lbtc", "optional": true } - ], - "validations": [ - { - "id": "amount_nonzero", - "rule": { "type": "arithmetic", "expr": "params.amount_sat > 0" }, - "error": { "code": "INVALID_AMOUNT", "message": "Amount must be greater than zero" } - } ] } } @@ -247,6 +240,7 @@ lesson. After a successful `Pay`, the tool records the new covenant output in a **state file** next to your manifest, auto-named `txmanifest.state.json`: + ```json { "last_action": "Pay", @@ -276,15 +270,18 @@ A contract has up to two companion files: an **instance file** (compile-time field values) and a **state file** (live UTXOs). This lesson produced only the state file — there is **no `txmanifest.instance.json`**. -Why? Instance files exist to persist a `class`'s `fields`. This contract declares -no `classes` at all — and the recipient's key (the `pubkey` action parameter) is -supplied fresh at run time, never stored. With no class fields to record, there is +Why? Instance files exist to persist a contract template's `fields`. This +contract declares +no `contract_templates` at all — and the recipient's key (the `pubkey` action +parameter) is +supplied fresh at run time, never stored. With no template fields to record, +there is nothing for an instance file to hold. Instance files first appear once we -introduce classes and constructors in +introduce contract templates and constructors in [Instance, state & constructors](./10-instance-state-constructors.md). ## Try next You now have a covenant on-chain. The next recipe digs deeper into parameters — -the `pubkey` and `amount_sat` values you just supplied — and adds runtime -validation: [Parameters & validations](./02-params-and-validations.md). +the `pubkey` and `amount_sat` values you just supplied — and shows how a param +can fill itself in: [Parameters](./02-params-and-validations.md). diff --git a/src/recipes/01b-hello-world-receive.md b/src/recipes/01b-hello-world-receive.md index 16cf4ae..7f14149 100644 --- a/src/recipes/01b-hello-world-receive.md +++ b/src/recipes/01b-hello-world-receive.md @@ -26,6 +26,7 @@ Part 1's `Pay` action only *built* a covenant output; it locked funds into a Add this action alongside `Pay` in `txmanifest.json`: + ```json "Receive": { "description": "Spend a p2pk output back into your wallet. Requires a BIP340 signature from the pubkey the output was locked to.", @@ -157,6 +158,7 @@ and updates the state file. A successful `Receive` consumes the covenant UTXO, so the tool **removes** it from `txmanifest.state.json`. If that was the only entry, `utxos` is now empty: + ```json { "last_action": "Receive", @@ -171,6 +173,6 @@ spent it and removed that entry. ## Try next You've now built *and* spent a covenant — the full lifecycle of the simplest -contract. The next recipe looks more closely at the parameters and validation -rules that drive these actions: -[Parameters & validations](./02-params-and-validations.md). +contract. The next recipe looks more closely at the parameters that drive these +actions, and at which of them change the covenant's address: +[Parameters](./02-params-and-validations.md). diff --git a/src/recipes/02-params-and-validations.md b/src/recipes/02-params-and-validations.md index 94de62f..fea7652 100644 --- a/src/recipes/02-params-and-validations.md +++ b/src/recipes/02-params-and-validations.md @@ -1,141 +1,105 @@ -# Parameters & validations +# Parameters -> **Problem.** Reject obviously-broken transactions *before* building them, and -> understand when a value belongs in `compile_params` versus an action's `params`. +> **Problem.** Understand when a value belongs to a contract template's `fields` +> versus an action's `params`, and how a value gets filled in without prompting. -The `Pay` action from [recipe 1](./01-hello-world-p2pk.md) would happily let you -pay an output of zero satoshis. This recipe adds a validation rule to stop that, -and along the way pins down the two kinds of parameters a manifest deals with. +The `Pay` action from [recipe 1](./01-hello-world-p2pk.md) takes one value from +you each time it runs, and bakes another into the covenant address forever. This +recipe pins down the difference. ## Two kinds of parameters This trips up everyone at first, so it's worth being precise: -| | `compile_params` | action `params` | +| | template `fields` | action `params` | |---|---|---| | **When fixed** | At deploy time, once. | Per transaction, every time you run the action. | | **Baked into the script?** | Yes — they change the covenant's address. | No — they only affect this transaction. | | **Stored in** | the instance file | nowhere; supplied at run time | +| **Referenced as** | `instance.NAME` | `params.NAME` | | **Example** | `PUBKEY`, `LOAN_EXPIRATION_TIME` | `amount_sat`, `CURRENT_BLOCK_HEIGHT` | A useful test: *"if I changed this value, would the on-chain address change?"* If -yes, it's a compile param. If it only affects which inputs/outputs this particular -transaction picks, it's an action param. +yes, it belongs in a template's `fields`. If it only affects which inputs and +outputs this particular transaction picks, it's an action param. -### Auto-filled params with `source` +A single-type contract that never needs an instance file can skip +`contract_templates` altogether and declare everything as action `params` — which +is what the early recipes in this book do. -An action param (or a user-provided compile param) can declare a `source` so the -tool fills it in without prompting: +## Params that fill themselves in: `compute` +A param that declares a `compute` block is never prompted for. The simplest form +is a bare formula string; the structured forms cover what an expression cannot +say: + + ```json "params": { "BORROWER_PUB_KEY": { "type": "pubkey", "description": "Borrower's signing key.", - "source": { "type": "wallet_key" } - } -} -``` - -| `source.type` | Resolves to | Derivation path (testnet / mainnet) | -|---------------|-------------|--------------------------------------| -| `"wallet_key"` | 32-byte x-only pubkey | `m/86h/1h/0h/0/0` / `m/86h/0h/0h/0/0` | -| `"oracle_key"` | 32-byte x-only pubkey | `m/86h/1h/1h/0/0` / `m/86h/0h/1h/0/0` | - -If a param has no `source`, the tool prompts you for it interactively (or you -supply it via `--params`, below). In recipe 1, `PUBKEY` has no source, so `Pay` -prompts you for it. The lending protocol's `BORROWER_PUB_KEY` (above) uses -`wallet_key`, so it's filled from the wallet silently. - -## Recipe - -Add a `validations` array to the `Pay` action. Each rule is checked before the -PSET is built; if any rule's expression is false, the action aborts with the -rule's error message. - -```json -"Pay": { - "description": "Lock funds into a p2pk output that only PUBKEY's owner can spend.", - "params": { - "amount_sat": { "type": "u64", "description": "Amount in satoshis to lock." } + "compute": { "type": "wallet", "wallet": "key" } }, - "inputs": [ ... ], - "outputs": [ ... ], - - "validations": [ - { - "id": "amount_nonzero", - "description": "Must lock a positive amount.", - "rule": { "type": "arithmetic", "expr": "params.amount_sat > 0" }, - "error": { "code": "INVALID_AMOUNT", "message": "Amount must be greater than zero" } - } - ] -} -``` - -The other rule type, `utxo_exists`, guards an action that *spends* a covenant — -you'll add one when you build the spend action in a later lesson. It checks that a -UTXO of a given type exists before the action runs: - -```json -"validations": [ - { - "id": "p2pk_exists", - "description": "A p2pk output must exist before it can be spent.", - "rule": { "type": "utxo_exists", "utxo_type": "p2pk_output" }, - "error": { "code": "MISSING_UTXO", "message": "No p2pk UTXO found. Has Pay been run?" } + "PRINCIPAL_INTEREST_AMOUNT": { + "type": "u64", + "compute": "instance.PRINCIPAL_AMOUNT * instance.INTEREST_RATE / 10000" } -] +} ``` -## How it works - -A validation rule has four parts: - -| Field | Required | Purpose | -|-------|----------|---------| -| `id` | yes | Unique name for the rule (shown in errors and logs). | -| `description` | no | Human-readable intent. | -| `rule` | yes | The check itself — see the two `type`s below. | -| `error` | no | `{ "code", "message" }` surfaced when the rule fails. | - -There are two rule types: - -- **`arithmetic`** — the `expr` is a [formula](./07-formulas-and-derived-params.md) - that must evaluate to `true`. Use it for amount bounds, timelock checks, - relationships between params. `params.amount_sat > 0` is the simplest case; - `compile_params.LOAN_EXPIRATION_TIME < params.CURRENT_BLOCK_HEIGHT` is a real - one from the lending protocol. -- **`utxo_exists`** — names a `utxo_type` that must have at least one live entry - in the state file. Use it as a precondition: don't try to spend something that - was never created. - -**When validations run.** All params are resolved and all inputs are selected -*before* validations execute, so a rule can reference resolved input amounts and -assets — but it runs *before* the PSET is constructed, so a failing rule costs -nothing. Any failure aborts the whole action. - -> **Error codes.** The `error.code` strings can be collected into a top-level -> `errors` map (`{ "1": "Loan has not yet expired", ... }`) that documents every -> failure mode the protocol can produce. This is optional but recommended for -> protocols a wallet will surface to users. +| `compute.type` | Produces | +|---|---| +| *(bare string)* or `expr` | The value of a [formula](./07-formulas-and-derived-params.md) | +| `wallet` | A value from the executing wallet — see below | +| `script_hash` | `sha256(scriptPubKey)` of an `address` | +| `tapleaf` | A covenant script hash, by compiling a `.simf` | +| `simf_fn` | The return value of a named function in a `.simf` | +| `hook` | Nothing here — a hook sets this param later in the run | + +The `wallet` variant takes a second key naming which wallet-derived value it wants: + +| `compute.wallet` | Resolves to | +|---|---| +| `"key"` | The wallet's 32-byte x-only BIP340 pubkey | +| `"script_hash"` | `sha256(scriptPubKey)` of the wallet's index-0 explicit output | +| `"address"` | The explicit address matching `"script_hash"` — the two are a pair | + +Declaring `{ "type": "hook" }` looks pointless but is not: it is how a param that +a hook will set gets an identifier. Without the declaration, a hook writing to +`params.SOMETHING` would be inventing a name nothing checks. + +If a param has no `compute`, the tool prompts you for it interactively (or you +supply it via `--params`, below). In recipe 1, `PUBKEY` has no `compute`, so +`Pay` prompts you for it. + +## What about pre-flight validations? + +Earlier revisions of the format had an action-level `validations` block: a list +of rules checked before the PSET was built, each with an error code and message. +**Format 0.2.0 does not have it.** It was removed and deferred to a later +revision — of the eleven rules the reference manifests carried, only three were +ever enforced, and the other eight printed `[TODO]` and passed. Re-admitting the +block as written would have preserved exactly the parses-but-never-fires shape +that 0.2.0 set out to remove. + +So a manifest today gets its guarantees from three places instead: + +- **The covenant.** A rule that actually protects value belongs in the `.simf`, + where the chain enforces it and no wallet can skip it. +- **`allow_change`.** An action defaults to `"none"`, so a surplus that no + declared output absorbs is an error rather than a silent change output. +- **Parsing.** Unknown and misspelled fields are hard errors, and + `manifest_version` is checked before anything else runs. ## Run it -Validations are invisible on the happy path. To see one fire, run `Pay` and -enter `0` when prompted for `amount_sat`: - -```sh -txw run examples/p2pk/txmanifest.json Pay \ - --network testnet --wallet wallet.json -# → aborts with: INVALID_AMOUNT: Amount must be greater than zero -``` - ### Supplying params non-interactively Instead of typing params at the prompt, pass a flat JSON file of string→string values and reference it with `--params`: + ```json { "amount_sat": "50000", "PUBKEY": "<64-hex-char x-only pubkey>" } ``` diff --git a/src/recipes/03-outputs-and-destinations.md b/src/recipes/03-outputs-and-destinations.md index 49346dd..476bd0e 100644 --- a/src/recipes/03-outputs-and-destinations.md +++ b/src/recipes/03-outputs-and-destinations.md @@ -28,6 +28,7 @@ An output descriptor has these fields: ### Wallet and change + ```json { "id": "to_me", "destination": "wallet", "amount_sat": "...", "asset": "lbtc" } { "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true } @@ -40,6 +41,7 @@ can be zero. ### An address supplied at run time + ```json { "id": "recipient_output", "destination": "params.recipient_address", "amount_sat": "params.send_amount_sat", "asset": "lbtc" } ``` @@ -49,6 +51,7 @@ the way to pay an arbitrary recipient address that the user supplies at run time ### A covenant UTXO type + ```json { "id": "p2pk_out", "destination": { "utxo_type": "p2pk_output" }, "amount_sat": "params.amount_sat", "asset": "lbtc" } ``` @@ -60,8 +63,9 @@ protocol's on-chain states — exactly what `Pay` in ### A raw script hash from a compile param + ```json -{ "id": "relocked", "destination": { "script_hash": "compile_params.PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" }, "amount_sat": 1, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID" } +{ "id": "relocked", "destination": { "script_hash": "instance.PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" }, "amount_sat": 1, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID" } ``` When you already hold a 32-byte covenant script hash as a derived compile param, @@ -70,13 +74,14 @@ this to re-lock NFTs under a script-auth covenant. ### `OP_RETURN` + ```json { "id": "indexer_op_return", "destination": { "type": "op_return" }, "amount_sat": 0, "asset": "lbtc", - "data": "concat(compile_params.BORROWER_PUB_KEY, compile_params.PRINCIPAL_ASSET_ID)" + "data": "concat(instance.BORROWER_PUB_KEY, instance.PRINCIPAL_ASSET_ID)" } ``` @@ -91,12 +96,21 @@ uses: ## How confidentiality is decided -On Liquid, outputs can be **blinded** (amount and asset hidden). The tool resolves -blinding in this precedence order: +On Liquid, outputs can be **blinded** (amount and asset hidden). Blinding is +decided per output: -1. The per-output `confidential` field, if present. -2. The top-level `confidential_outputs` field, if present. -3. The chain default: `false` for Bitcoin, `true` for Liquid/Elements. +1. The output's own `confidential` field, if present. +2. Otherwise the chain default: `false` for Bitcoin, `true` for Liquid/Elements. + +Format 0.2.0 has no file-level or per-`utxo_type` default. Both existed once and +both were removed, because they answered per address, or per file, a question +that is per output: one covenant address can hold a blinded reissuance token +beside an explicit collateral UTXO, so only the output can say. + +An output may also carry a `blinding` block pinning the `asset_bf` and `value_bf` +scalars the builder would otherwise choose at random. That matters when a +covenant verifies its own UTXOs as Pedersen commitments and so has to know the +factors in advance. > **Covenants and `OP_RETURN` are always unblinded**, regardless of the settings > above. Simplicity covenants introspect explicit amounts and asset IDs with jets @@ -110,6 +124,7 @@ Covenants that introspect the transaction often require outputs in an exact orde ("collateral at output 0, principal at output 1"). Pin an output's position with `required_index`: + ```json { "id": "lending_collateral_out", "required_index": 0, "destination": { "utxo_type": "lending_collateral" }, ... } { "id": "principal_to_borrower", "required_index": 1, "destination": "params.borrower_address", ... } diff --git a/src/recipes/04-witnesses.md b/src/recipes/04-witnesses.md index 8bd291f..1822744 100644 --- a/src/recipes/04-witnesses.md +++ b/src/recipes/04-witnesses.md @@ -20,6 +20,7 @@ Witnesses sit on an **input** — specifically a covenant input (`utxo_source` i `utxo_type`, not `"wallet"`). Plain wallet inputs sign themselves the ordinary way and have no `witnesses` map. + ```json { "id": "p2pk_in", @@ -45,6 +46,7 @@ see [Not yet wired up](#not-yet-wired-up) below.) This is the one from Part 2. You don't write a signature by hand; the tool computes it while signing. + ```json "SIGNATURE": { "type": "Signature", @@ -60,7 +62,7 @@ computes it while signing. only `sig_type` defined. - **`source: { "type": "wallet", "key": ... }`** identifies the signing key. The `key` resolves to an x-only pubkey — from an action `param` (`params.pubkey`, - as here), a compile param / class field (`compile_params.BORROWER_PUB_KEY`, the + as here), a contract-template field (`instance.BORROWER_PUB_KEY`, the form the lending example uses), or a literal hex value. The tool searches your wallet's BIP86 derivation paths for the private key matching that pubkey and signs with it. @@ -74,6 +76,7 @@ a `simplicityhl` witness** holding the 64-byte signature as `0x…` hex — so a A fixed value, parsed against the witness's type. Use it for branch selectors, indices, and raw byte values. + ```json "PATH": { "type": "simplicityhl", diff --git a/src/recipes/04b-last-will.md b/src/recipes/04b-last-will.md index cc0c995..bf70b46 100644 --- a/src/recipes/04b-last-will.md +++ b/src/recipes/04b-last-will.md @@ -82,14 +82,15 @@ fee in output 1, with nothing else. ## The manifest A will is something you *deploy once* and then operate — exactly what a -[**class**](../getting-started/anatomy.md) models. We define a -`last_will_contract` class whose **fields** are the three keys, and whose -**methods** are the four actions. A **constructor** method (`Fund`) records the -keys in an instance file the first time you set the will up; the spend methods -read them back. +[**contract template**](../getting-started/anatomy.md) models. We define a +`last_will_contract` contract template whose **fields** are the three keys, and +whose **actions** are the four transactions. A **constructor** (`Fund`) records +the keys in an instance file the first time you set the will up; the spend +actions read them back. + ```json -"classes": { +"contract_templates": { "last_will_contract": { "fields": { "INHERITOR_PUB_KEY": { "type": "pubkey" }, @@ -97,14 +98,15 @@ read them back. "COLD_PUB_KEY": { "type": "pubkey" }, "INHERIT_BLOCKS": { "type": "u16" } }, - "methods": { "Fund": { ... }, "ColdBreak": { ... }, "Refresh": { ... }, "Inherit": { ... } } + "actions": { "Fund": { ... }, "ColdBreak": { ... }, "Refresh": { ... }, "Inherit": { ... } } } } ``` The `last_will` UTXO type (top-level, as before) wires those three fields into the -program — the field names double as the compile params the script consumes: +program — the field names double as the `param::` names the script consumes: + ```json "utxo_types": { "last_will": { @@ -127,11 +129,13 @@ program — the field names double as the compile params the script consumes: `Fund` does double duty — it locks the funds *and* writes the instance file. It takes the three keys as params (two auto-filled from your wallet) and an amount, -locks a wallet UTXO into the covenant, then `create_instance` records the keys: +locks a wallet UTXO into the covenant, then `create_instance` records the keys. +Carrying that block is what *makes* `Fund` a constructor — there is no separate +flag to set: + ```json "Fund": { - "is_constructor": true, "params": { "INHERITOR_PUB_KEY": { "type": "pubkey", @@ -185,7 +189,6 @@ locks a wallet UTXO into the covenant, then `create_instance` records the keys: } ], "create_instance": { - "class": "last_will_contract", "fields": { "INHERITOR_PUB_KEY": "$params.INHERITOR_PUB_KEY", "HOT_PUB_KEY": "$params.HOT_PUB_KEY", @@ -203,20 +206,22 @@ compile params, so `will_out`'s covenant address is computed from the keys you just supplied — *before* the instance exists. After broadcast, `create_instance` writes those same three keys into `txmanifest.instance.json`. -> **One instance per will.** Unlike Hello World — which had no `classes` and so +> **One instance per will.** Unlike Hello World — which had no +> `contract_templates` and so > [no instance file](./01-hello-world-p2pk.md#no-instance-file) — the keys here -> are a class's fields, persisted at construction. Every later spend reads them -> from the instance, so you only enter the keys once. This is the full -> class / instance model from +> are a template's fields, persisted at construction. Every later spend reads +> them from the instance, so you only enter the keys once. This is the full +> template / instance model from > [Instance, state & constructors](./10-instance-state-constructors.md). ### Each spend reads the instance -Every spend is a method whose input is the `last_will` UTXO (found in the state +Every spend is an action whose input is the `last_will` UTXO (found in the state file) with a `SPEND_PATH` selector and the matching `Signature`. The signature -keys reference `compile_params.*` — the fields loaded back from the instance file, +keys reference `instance.*` — the fields loaded back from the instance file, so you never re-enter them. **ColdBreak**: + ```json "ColdBreak": { "inputs": [ @@ -228,7 +233,7 @@ so you never re-enter them. **ColdBreak**: "COLD_SIG": { "type": "Signature", "sig_type": "sig_hash_all", - "source": { "type": "wallet", "key": "compile_params.COLD_PUB_KEY" } + "source": { "type": "wallet", "key": "instance.COLD_PUB_KEY" } } } }, @@ -247,6 +252,7 @@ and `INHERITOR_SIG`. **Refresh** is the one that's different — the covenant forces it to re-lock: + ```json "Refresh": { "inputs": [ @@ -255,7 +261,7 @@ and `INHERITOR_SIG`. "utxo_source": { "utxo_type": "last_will" }, "witnesses": { "SPEND_PATH": { "type": "simplicityhl", "value": "Right(Right(()))" }, - "HOT_SIG": { "type": "Signature", "sig_type": "sig_hash_all", "source": { "type": "wallet", "key": "compile_params.HOT_PUB_KEY" } } + "HOT_SIG": { "type": "Signature", "sig_type": "sig_hash_all", "source": { "type": "wallet", "key": "instance.HOT_PUB_KEY" } } } } ], @@ -306,6 +312,7 @@ You don't even pass a flag. The tool **auto-discovers** a file named filename stem) — so for `testnet` it loads `txmanifest.testnet.json` from `examples/last_will/`: + ```json { "INHERITOR_PUB_KEY": "…heir's wallet pubkey…", @@ -331,7 +338,7 @@ the book ships scripts that do it for you: `make_params.ps1` runs `info` on each wallet, pulls out the signing and oracle pubkeys, and writes the params file — `INHERITOR_PUB_KEY` from the heir wallet, `HOT_PUB_KEY` / `COLD_PUB_KEY` from the owner wallet. (`HOT_PUB_KEY` also -auto-fills from the wallet at run time, since it's a `wallet_key` source; the file +auto-fills from the wallet at run time, since it's a wallet `compute`; the file just makes every value explicit.) ## Run it diff --git a/src/recipes/07-formulas-and-derived-params.md b/src/recipes/07-formulas-and-derived-params.md index 7520812..c4a9f5a 100644 --- a/src/recipes/07-formulas-and-derived-params.md +++ b/src/recipes/07-formulas-and-derived-params.md @@ -7,14 +7,14 @@ > 🚧 **This recipe is a stub.** Outline of what it will cover: > -> - Where formulas appear: output/input `amount_sat`, validation `expr`, hook +> - Where formulas appear: output/input `amount_sat`, a param's `compute`, hook > `set` values, witness `expr`. > - Operators (`+ - * /`, comparisons, `&& || !`) and references -> (`compile_params.X`, `params.X`, `input_id.amount_sat`, `input_id.asset`, +> (`instance.X`, `params.X`, `input_id.amount_sat`, `input_id.asset`, > `input_id.present`). > - Functions: `pow(base, exp)`, `index_of(id)`, `concat(...)`. > - The special `fees` value used in change formulas. -> - **Derived params** (`"derived": true`): interest = `PRINCIPAL_AMOUNT * +> - **Computed params** (`"compute": "..."`): interest = `PRINCIPAL_AMOUNT * > PRINCIPAL_INTEREST_RATE / 10000`, and params derived from issuance outpoints. See [`Spec.md` §9](https://github.com/stringhandler/tx_manifest_spec/blob/main/Spec.md) diff --git a/src/recipes/08-issuance-and-nfts.md b/src/recipes/08-issuance-and-nfts.md index aa5f32c..2806e8a 100644 --- a/src/recipes/08-issuance-and-nfts.md +++ b/src/recipes/08-issuance-and-nfts.md @@ -24,6 +24,7 @@ shows all four issuances together. Add `issuance` to any wallet input to mint an asset as that input is spent: + ```json { "id": "nft_issuance_input", @@ -63,13 +64,14 @@ Once the build picks the input's UTXO, its outpoint — and therefore the new as ID — is fixed. An `on_resolved` hook on the input fires at that moment and lets you stash the ID into a compile param: + ```json { "id": "nft_issuance_input", "utxo_source": "wallet", "asset": "lbtc", "issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 }, - "on_resolved": { "set": { "compile_params.BORROWER_NFT_ASSET_ID": "asset" } } + "on_resolved": { "set": { "instance.BORROWER_NFT_ASSET_ID": "asset" } } } ``` @@ -77,7 +79,7 @@ Inside an input's own `on_resolved`, the bare word **`asset`** means *this input resolved asset ID — the freshly minted one. Elsewhere in the action you refer to it by the input's id, as **`nft_issuance_input.asset`** (the general [formula reference](./07-formulas-and-derived-params.md) form). From here on, -`compile_params.BORROWER_NFT_ASSET_ID` is a normal param: you can lock outputs to +`instance.BORROWER_NFT_ASSET_ID` is a normal param: you can lock outputs to it, feed it into a covenant's compile params, or write it into the instance file. > **Hooks recap.** `on_resolved` runs per-input as soon as that input's UTXO is diff --git a/src/recipes/09-hooks-and-tapleaf.md b/src/recipes/09-hooks-and-tapleaf.md index a107d99..d75699b 100644 --- a/src/recipes/09-hooks-and-tapleaf.md +++ b/src/recipes/09-hooks-and-tapleaf.md @@ -9,8 +9,8 @@ > > - Hook blocks: `on_resolved` (per input) and `on_pre_broadcast` (per action), > each running `set` assignments in declaration order. -> - Assignment targets: `compile_params.X`, `params.X`, `args.X`. -> - The **tapleaf compute spec** (`compute: "tapleaf"`): compiling a `.simf` to a +> - Assignment targets: `instance.X` and `params.X`. +> - The **tapleaf compute spec** (`"type": "tapleaf"`): compiling a `.simf` to a > covenant script hash, with `params` and `depends_on`. > - **Circular dependencies**: when two covenants each reference the other's hash, > seed with 32 zero bytes and iterate to convergence. diff --git a/src/recipes/10-instance-state-constructors.md b/src/recipes/10-instance-state-constructors.md index be9b6de..244c6fa 100644 --- a/src/recipes/10-instance-state-constructors.md +++ b/src/recipes/10-instance-state-constructors.md @@ -3,14 +3,17 @@ > 📝 **Draft.** This chapter has not been reviewed yet — content may be incomplete or change. > **Problem.** Deploy a contract once and then act on it repeatedly — persisting -> the compile params and tracking the live UTXO set across transactions. +> the template's field values and tracking the live UTXO set across +> transactions. > 🚧 **This recipe is a stub.** Outline of what it will cover: > > - The three-file model in practice: manifest / instance / state. -> - **Classes**: grouping methods under a typed contract with `fields`. -> - **Constructors** (`is_constructor: true`) and `create_instance`: writing the -> instance file with resolved field values. +> - **Contract templates**: grouping actions under a typed contract with +> `fields`, and the `instance.NAME` namespace they populate. +> - **Constructors**: an action carrying a `create_instance` block *is* a +> constructor — there is no separate flag — and writing the instance file +> (`{ "instance": { "template", "fields" } }`) with resolved field values. > - The **state file**: how covenant outputs are added and spent inputs removed > after each broadcast. > - **`provided_inputs`**: pre-filling a counterparty's UTXO inline (the diff --git a/src/walkthrough/lending-accept.md b/src/walkthrough/lending-accept.md index 1c50e45..cd35c01 100644 --- a/src/walkthrough/lending-accept.md +++ b/src/walkthrough/lending-accept.md @@ -15,8 +15,8 @@ weight. ## Two paths, selected by a witness `pre_lock` accepts two spending paths, and the manifest exposes each as its own -method: `SetupLending` takes the **accept** path, `CancelOffer` the **cancel** -path. A method picks its path with a `PATH` witness — `Left(())` or `Right(())` — +action: `SetupLending` takes the **accept** path, `CancelOffer` the **cancel** +path. An action picks its path with a `PATH` witness — `Left(())` or `Right(())` — the `simplicityhl` selector from [Multiple spending paths](../recipes/06-multiple-spending-paths.md). How each path is enforced on-chain is out of scope here; what's new for the manifest are two @@ -43,6 +43,7 @@ the principal is injected at output 1, the NFT outputs sit one slot below their inputs. Rather than hope the builder lands on that layout, every input and output declares an explicit `required_index`: + ```json "SetupLending": { "inputs": [ @@ -55,18 +56,18 @@ declares an explicit `required_index`: { "id": "second_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 2, "…": "…" }, { "id": "borrower_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 3, "…": "…" }, { "id": "lender_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 4, "…": "…" }, - { "id": "principal_in", "utxo_source": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", "required_index": 5, - "amount_sat": { "min_amount": "compile_params.PRINCIPAL_AMOUNT" } }, + { "id": "principal_in", "utxo_source": "wallet", "asset": "instance.PRINCIPAL_ASSET_ID", "required_index": 5, + "amount_sat": { "min_amount": "instance.PRINCIPAL_AMOUNT" } }, { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true, "required_index": 6 } ], "outputs": [ - { "id": "lending_collateral_out", "destination": { "utxo_type": "lending_collateral" }, "required_index": 0, "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" }, - { "id": "principal_to_borrower", "destination": { "utxo_type": "p2pk" }, "required_index": 1, "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "compile_params.PRINCIPAL_AMOUNT" }, + { "id": "lending_collateral_out", "destination": { "utxo_type": "lending_collateral" }, "required_index": 0, "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" }, + { "id": "principal_to_borrower", "destination": { "utxo_type": "p2pk" }, "required_index": 1, "asset": "instance.PRINCIPAL_ASSET_ID", "amount_sat": "instance.PRINCIPAL_AMOUNT" }, { "id": "first_params_relocked", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 2, "…": "…" }, { "id": "second_params_relocked", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 3, "…": "…" }, - { "id": "borrower_nft_released", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 4, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "lender_nft_released", "destination": "wallet", "required_index": 5, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "principal_change", "destination": "change", "asset": "compile_params.PRINCIPAL_ASSET_ID", "optional": true, "required_index": -2 }, + { "id": "borrower_nft_released", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 4, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "lender_nft_released", "destination": "wallet", "required_index": 5, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "principal_change", "destination": "change", "asset": "instance.PRINCIPAL_ASSET_ID", "optional": true, "required_index": -2 }, { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true, "required_index": -1 } ] } @@ -93,29 +94,32 @@ Three details that make this work: If no lender accepts, the borrower reclaims the collateral and destroys the offer. `CancelOffer` takes the cancel path (`PATH = Right(())`), which the covenant gates -with a borrower signature — so this method adds a `SIGNATURE` witness sourced from +with a borrower signature — so this action adds a `SIGNATURE` witness sourced from `BORROWER_PUB_KEY` — and routes every NFT to an `op_return` to burn it. Here the outputs line up one-to-one with the inputs (no principal is injected), so no `required_index` is needed; the collateral returns to the borrower's wallet: + ```json "CancelOffer": { "inputs": [ { "id": "pre_lock_in", "utxo_source": { "utxo_type": "pre_lock" }, "witnesses": { "PATH": { "type": "simplicityhl", "value": "Right(())" }, "SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all", - "source": { "type": "wallet", "key": "compile_params.BORROWER_PUB_KEY" } }, + "source": { "type": "wallet", "key": "instance.BORROWER_PUB_KEY" } }, "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "pre_lock_leaf" } } } }, - { "id": "first_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" }, - "…borrower & lender NFTs…", + { "id": "first_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" }, + { "id": "second_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" }, + { "id": "borrower_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" }, + { "id": "lender_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" }, { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" } ], "outputs": [ - { "id": "collateral_returned", "destination": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "pre_lock_in.amount_sat" }, - { "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" }, - { "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" }, - { "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "collateral_returned", "destination": "wallet", "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "pre_lock_in.amount_sat" }, + { "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" }, + { "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" }, + { "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true } ] } diff --git a/src/walkthrough/lending-issuance.md b/src/walkthrough/lending-issuance.md index c1eb143..d43fc0c 100644 --- a/src/walkthrough/lending-issuance.md +++ b/src/walkthrough/lending-issuance.md @@ -18,18 +18,19 @@ protocol, because it's where four cookbook ideas converge: - **Tapleaf compute** — deriving each covenant's script hash by compiling a `.simf`, where some hashes feed into others ([recipe 9](../recipes/09-hooks-and-tapleaf.md)). - **A constructor** — `create_instance` persists the whole deal to a file so every - later method can read it back ([recipe 10](../recipes/10-instance-state-constructors.md)). + later action can read it back ([recipe 10](../recipes/10-instance-state-constructors.md)). ## The constructor and its terms -`IssueUtilityNFTs` is the class's `is_constructor` method. Its `params` are the -loan terms the borrower chooses: +`IssueUtilityNFTs` is the template's constructor: it carries a `create_instance` +block, and that is what makes it one. Its `params` are the loan terms the +borrower chooses: + ```json "IssueUtilityNFTs": { - "is_constructor": true, "params": { - "BORROWER_PUB_KEY": { "type": "pubkey", "source": { "type": "wallet_key" } }, + "BORROWER_PUB_KEY": { "type": "pubkey", "compute": { "type": "wallet", "wallet": "key" } }, "COLLATERAL_ASSET_ID": { "type": "liquid.asset_id" }, "COLLATERAL_AMOUNT": { "type": "u64" }, "COLLATERAL_DECIMALS_MANTISSA":{ "type": "u8", "default": "8" }, @@ -44,7 +45,7 @@ loan terms the borrower chooses: ``` `BORROWER_PUB_KEY` auto-fills from the borrower's wallet signing key (the -`wallet_key` source from [Parameters](../recipes/02-params-and-validations.md)). +wallet `compute` from [Parameters](../recipes/02-params-and-validations.md)). The interest rate is in **basis points** (`u16`, so 10,000 = 100%); the expiry is a **block height** (CLTV). The two `DECIMALS_MANTISSA` values matter for the encoding below — they let a base-10 amount like "1 L-BTC" be stored compactly @@ -63,13 +64,14 @@ helper `Prepare` action splits one UTXO into four beforehand. Each issuance inpu declares an `issuance` block and captures the resulting asset ID with an `on_resolved` hook: + ```json { "id": "borrower_nft_issuance_input", "utxo_source": "wallet", "asset": "lbtc", "issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 }, - "on_resolved": { "set": { "compile_params.BORROWER_NFT_ASSET_ID": "asset" } } + "on_resolved": { "set": { "instance.BORROWER_NFT_ASSET_ID": "asset" } } } ``` @@ -78,17 +80,18 @@ single-unit bearer tokens. The **two Parameter NFTs are different**: their issue amount is not `1` but the *encoded loan terms* (next section), so the asset's very supply carries the offer: + ```json { "id": "first_params_issuance_input", "utxo_source": "wallet", "asset": "lbtc", - "issuance": { "kind": "new", "asset_amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED", "inflation_amount_sat": 0 }, - "on_resolved": { "set": { "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" } } + "issuance": { "kind": "new", "asset_amount_sat": "instance.FIRST_PARAMETERS_ENCODED", "inflation_amount_sat": 0 }, + "on_resolved": { "set": { "instance.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" } } } ``` -All four asset IDs land in `compile_params.*` via `on_resolved`, ready for the +All four asset IDs land in `instance.*` via `on_resolved`, ready for the covenant-hash computation. The matching `outputs` simply send each freshly minted NFT back to the borrower's wallet. See [Asset issuance & NFTs](../recipes/08-issuance-and-nfts.md) for the issuance @@ -101,12 +104,13 @@ the terms off-chain, the protocol packs them into the **amount fields** of the t Parameter NFTs, computed in an `on_pre_broadcast` hook before the transaction is built: + ```json "on_pre_broadcast": { "set": { - "compile_params.FIRST_PARAMETERS_ENCODED": + "instance.FIRST_PARAMETERS_ENCODED": "params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.COLLATERAL_DECIMALS_MANTISSA * 8796093022208 + params.PRINCIPAL_DECIMALS_MANTISSA * 140737488355328", - "compile_params.SECOND_PARAMETERS_ENCODED": + "instance.SECOND_PARAMETERS_ENCODED": "params.COLLATERAL_AMOUNT / pow(10, COLLATERAL_DECIMALS_MANTISSA) + params.PRINCIPAL_AMOUNT / pow(10, PRINCIPAL_DECIMALS_MANTISSA) * 33554432" } } @@ -163,16 +167,16 @@ address depends on the `lending` covenant's hash, which depends on the principal *after* the issuance inputs resolve. `create_instance` untangles this with a set of **tapleaf compute** fields, each compiling a `.simf` to its script hash: + ```json "create_instance": { - "class": "lending_contract", "fields": { "PRINCIPAL_OUTPUT_SCRIPT_HASH": { - "compute": "tapleaf", "simf": "./p2pk.simf", + "type": "tapleaf", "simf": "./p2pk.simf", "params": { "PUB_KEY": { "type": "pubkey", "value": "BORROWER_PUB_KEY" } } }, "LENDER_PRINCIPAL_COV_HASH": { - "compute": "tapleaf", "simf": "./asset_auth.simf", + "type": "tapleaf", "simf": "./asset_auth.simf", "params": { "ASSET_ID": { "type": "liquid.asset_id", "value": "LENDER_NFT_ASSET_ID" }, "ASSET_AMOUNT": { "type": "u64", "value": "1" }, @@ -180,11 +184,11 @@ of **tapleaf compute** fields, each compiling a `.simf` to its script hash: } }, "LENDING_COV_HASH": { - "compute": "tapleaf", "simf": "./lending.simf", + "type": "tapleaf", "simf": "./lending.simf", "params": { "…": "…", "LENDER_PRINCIPAL_COV_HASH": { "type": "bytes32", "value": "LENDER_PRINCIPAL_COV_HASH" } } }, "PRE_LOCK_COV_HASH": { - "compute": "tapleaf", "simf": "./pre_lock.simf", + "type": "tapleaf", "simf": "./pre_lock.simf", "params": { "…": "…", "LENDING_COV_HASH": { "type": "bytes32", "value": "LENDING_COV_HASH" } } }, "…": "…" @@ -227,6 +231,7 @@ the above) and its own `script_auth` wrapper. Most instance fields are either passthrough (`"$params.X"`) or tapleaf hashes. One is a plain arithmetic [derived param](../recipes/07-formulas-and-derived-params.md): + ```json "PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000" ``` @@ -242,9 +247,9 @@ After broadcast, `create_instance` writes `lending.instance.json` next to the manifest, holding every field above: the four asset IDs, the four covenant hashes, the packed parameter values, the interest amount, and the borrower's key. **This file is the deal.** Every later -method — `LockCollateral`, `SetupLending`, `RepayLoan`, and the rest — is run with +action — `LockCollateral`, `SetupLending`, `RepayLoan`, and the rest — is run with `--instance lending.instance.json` so the wallet rebuilds the exact same covenant -addresses without re-entering anything. This is the full class / instance model +addresses without re-entering anything. This is the full template / instance model from [Instance, state & constructors](../recipes/10-instance-state-constructors.md). ## Run it @@ -268,6 +273,7 @@ the manifest and the tool auto-discovers it — for `testnet` it looks for [faucet](https://liquidtestnet.com/faucet) can fund both the borrower and the lender: + ```json { "COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", @@ -287,7 +293,7 @@ That id is testnet L-BTC (the same asset the (`1000` basis points). A few values are worth understanding rather than copying blindly: -- **`BORROWER_PUB_KEY` is absent on purpose** — it's a `wallet_key` source, so it +- **`BORROWER_PUB_KEY` is absent on purpose** — it's a wallet `compute`, so it auto-fills from the borrower wallet. The file only carries the terms you choose. - **`*_DECIMALS_MANTISSA` is `0` here, not `8`.** Recall from [bit-packing](#bit-packing-the-loan-terms) that each amount is stored as @@ -323,12 +329,13 @@ txw get-balance --wallet borrower.json # four new single-asset balances ### Inspect the instance file Open `examples/lending/lending.instance.json` to see what the constructor -recorded — this is what every later method reads back: +recorded — this is what every later action reads back: + ```json { "instance": { - "class": "lending_contract", + "template": "lending_contract", "fields": { "BORROWER_NFT_ASSET_ID": "f94aff7f54bd4f4076a0aa07635264a32926966e119dc523ac86427d1f2239f7", "BORROWER_NFT_OUTPUT_SCRIPT_HASH": "c4b8e6299c4924f9375650c24457a3cb6c69c54cf66afcd0f8b667146ce55667", diff --git a/src/walkthrough/lending-offer.md b/src/walkthrough/lending-offer.md index d87659c..5642e99 100644 --- a/src/walkthrough/lending-offer.md +++ b/src/walkthrough/lending-offer.md @@ -10,7 +10,7 @@ After [issuing the NFTs](./lending-issuance.md) the borrower holds four tokens and an instance file, but the collateral is still loose in their wallet. This phase commits it — the protocol's first move of value into covenant addresses, and the -first use of the `op_return` destination and a pre-build `validations` check. +first use of the `op_return` destination. ## `LockCollateral` — publishing the offer @@ -19,32 +19,28 @@ and moves them into covenant addresses. The collateral goes into the `pre_lock` UTXO; each NFT goes into a `prelock_script_auth` UTXO (the `script_auth.simf` wrapper compiled to `PRE_LOCK_COV_HASH`): + ```json "LockCollateral": { "inputs": [ - { "id": "collateral_in", "utxo_source": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", - "amount_sat": { "min_amount": "compile_params.COLLATERAL_AMOUNT" } }, - { "id": "borrower_nft_in", "utxo_source": "wallet", "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "lender_nft_in", "utxo_source": "wallet", "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "first_params_in", "utxo_source": "wallet", "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" }, - { "id": "second_params_in", "utxo_source": "wallet", "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" }, + { "id": "collateral_in", "utxo_source": "wallet", "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { "min_amount": "instance.COLLATERAL_AMOUNT" } }, + { "id": "borrower_nft_in", "utxo_source": "wallet", "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "lender_nft_in", "utxo_source": "wallet", "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "first_params_in", "utxo_source": "wallet", "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" }, + { "id": "second_params_in", "utxo_source": "wallet", "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" }, { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" } ], "outputs": [ - { "id": "pre_lock_out", "destination": { "utxo_type": "pre_lock" }, "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" }, - { "id": "borrower_nft_locked","destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "lender_nft_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "first_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" }, - { "id": "second_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" }, + { "id": "pre_lock_out", "destination": { "utxo_type": "pre_lock" }, "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" }, + { "id": "borrower_nft_locked","destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "lender_nft_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "first_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" }, + { "id": "second_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" }, { "id": "indexer_op_return", "destination": { "type": "op_return" }, - "data": "concat(compile_params.BORROWER_PUB_KEY, compile_params.PRINCIPAL_ASSET_ID)" }, - { "id": "collateral_change", "destination": "change", "asset": "compile_params.COLLATERAL_ASSET_ID", "optional": true }, + "data": "concat(instance.BORROWER_PUB_KEY, instance.PRINCIPAL_ASSET_ID)" }, + { "id": "collateral_change", "destination": "change", "asset": "instance.COLLATERAL_ASSET_ID", "optional": true }, { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true } - ], - "validations": [ - { "id": "collateral_amount_matches", - "rule": { "type": "arithmetic", "expr": "collateral_in.amount_sat == compile_params.COLLATERAL_AMOUNT" }, - "error": { "code": "AMOUNT_MISMATCH", "message": "Collateral input amount does not match COLLATERAL_AMOUNT" } } ] } ``` @@ -58,10 +54,11 @@ Two things worth pausing on: markers. [Outputs & destinations](../recipes/03-outputs-and-destinations.md) introduced the `op_return` destination; here it's used for discovery rather than burning. -- **The collateral amount is checked twice.** The `validations` block asserts the - input matches `COLLATERAL_AMOUNT` *before building* (a fast, friendly error), and - the `pre_lock` covenant re-checks it *on-chain at spend time*. Validations are a - convenience; the covenant is the law. +- **The collateral amount is checked on-chain.** The `pre_lock` covenant + re-derives `COLLATERAL_AMOUNT` and rejects a spend that does not match, so a + mis-sized input costs a rejected broadcast rather than a lost deposit. Format + 0.2.0 has no pre-flight `validations` block to catch it earlier — the covenant + is the law, and now it is also the only check. After this broadcasts, the contract is in `offer_open`: collateral and NFTs sit in covenant UTXOs that only the two `pre_lock` paths can move. diff --git a/src/walkthrough/lending-protocol.md b/src/walkthrough/lending-protocol.md index 866dee2..bf6944c 100644 --- a/src/walkthrough/lending-protocol.md +++ b/src/walkthrough/lending-protocol.md @@ -14,8 +14,8 @@ time — [covenant UTXO types](../recipes/05-covenant-utxo-types.md), [asset issuance & NFTs](../recipes/08-issuance-and-nfts.md), [formulas & derived params](../recipes/07-formulas-and-derived-params.md), [hooks & tapleaf compute](../recipes/09-hooks-and-tapleaf.md), and the -[class / instance model](../recipes/10-instance-state-constructors.md) — shows up -here at once, wired into a single working protocol. +[template / instance model](../recipes/10-instance-state-constructors.md) — shows +up here at once, wired into a single working protocol. The full example lives in the repository at [`examples/lending/`](https://github.com/stringhandler/txmanifest-wallet/tree/main/examples/lending): @@ -68,45 +68,31 @@ in their amount field so the offer is self-describing on-chain: ## The lifecycle -The whole protocol is one `lending_contract` **class**, and the manifest closes -with an optional `lifecycle` block that names the state machine its methods walk -through: - -```json -"lifecycle": { - "states": ["nfts_issued", "offer_open", "loan_active", "repaid", "liquidated", "cancelled"], - "entry_actions": ["IssueUtilityNFTs"], - "transitions": { - "IssueUtilityNFTs": { "to": "nfts_issued" }, - "LockCollateral": { "from": "nfts_issued", "to": "offer_open" }, - "CancelOffer": { "from": "offer_open", "to": "cancelled", "unilateral": true }, - "SetupLending": { "from": "offer_open", "to": "loan_active" }, - "RepayLoan": { "from": "loan_active", "to": "repaid", "cooperative": true }, - "LiquidateAfterExpiry": { "from": "loan_active", "to": "liquidated", "unilateral": true }, - "ClaimPrincipalWithInterest": { "from": "repaid", "to": "settled" } - } -} -``` - -> **`lifecycle` is documentation-only.** The [spec](https://github.com/stringhandler/tx_manifest_spec/blob/main/Spec.md) -> lists it among the top-level fields as *"named states, transitions, execution -> paths"* and marks it purely informative — **nothing on-chain depends on it, and -> no tool is required to enforce it.** It exists so a reader (or a diagram -> renderer) can see the intended state machine at a glance without tracing every -> method's inputs and outputs. It may be dropped from a future revision; treat it -> as a map, not machinery. - -The block has three parts: - -- **`states`** — the named states an instance can be in. They're free-form labels; - the `from`/`to` fields below refer to them. (`settled` appears as a `to` target - without being listed — a reminder that this section is descriptive, not - validated.) -- **`entry_actions`** — the methods that *create* a fresh instance rather than - advancing an existing one. Here it's the constructor, `IssueUtilityNFTs`. -- **`transitions`** — one entry per method, each naming the state it moves *from* - and *to*, plus two optional flags described below. A transition with no `from` - (the constructor) is an entry point. +The whole protocol is one `lending_contract` **contract template**, and its +actions walk an instance through a sequence of states: + +| State | Reached by | Meaning | +|---|---|---| +| `nfts_issued` | `IssueUtilityNFTs` | The four NFTs exist; terms are encoded. | +| `offer_open` | `LockCollateral` | Collateral and NFTs sit behind `pre_lock`. | +| `cancelled` | `CancelOffer` | The borrower withdrew before a lender took it. | +| `loan_active` | `SetupLending` | A lender funded the offer. | +| `repaid` | `RepayLoan` | The borrower paid principal plus interest. | +| `liquidated` | `LiquidateAfterExpiry` | The deadline passed; the lender took the collateral. | +| `settled` | `ClaimPrincipalWithInterest` | The lender drew the repayment from the vault. | + +> **This table is documentation, not a manifest field.** Earlier revisions of the +> format had a top-level `lifecycle` block naming these states and the +> transitions between them. **Format 0.2.0 does not** — it was removed because +> nothing enforced it: no tool checked that an action's `from` state matched +> reality, and `settled` appeared as a transition target without ever being +> listed as a state, which no one noticed. A state machine nothing validates is a +> diagram, so it now lives where diagrams live. +> +> What actually constrains the order of these actions is the chain. Each action's +> inputs name UTXO types that only the previous action creates, so `SetupLending` +> cannot run before `LockCollateral` has produced a `pre_lock` output to spend. +> The sequence is enforced by the covenants, not by a table. Rendered, those transitions are the protocol's flow: @@ -140,18 +126,17 @@ Rendered, those transitions are the protocol's flow: settled ``` -The two optional flags annotate *who* a transition needs. `"unilateral": true` -marks the two escape hatches — `CancelOffer` and `LiquidateAfterExpiry` — that one -party can take without the other's cooperation; that's the whole point of a -trustless protocol, the exits don't depend on the counterparty playing along. -`"cooperative": true` marks `RepayLoan` as the happy path both sides want. The -flags don't *do* anything on-chain — the covenants are what actually enforce who -can spend — but they tell a reader at a glance which transitions are adversarial -and which are mutual. +Two of those arrows are **unilateral** escape hatches — `CancelOffer` and +`LiquidateAfterExpiry` — that one party can take without the other's cooperation. +That's the whole point of a trustless protocol: the exits don't depend on the +counterparty playing along. `RepayLoan` is the **cooperative** happy path both +sides want. Nothing in the manifest labels them as such; what makes an exit +unilateral is that its covenant path needs only one party's key or NFT, which you +can read off the action's witnesses. ## How this chapter is organised -The walkthrough follows the lifecycle across four pages, each building one phase +The walkthrough follows those states across four pages, each building one phase and pulling in the recipes that introduced its pieces: 1. **[Issuing the NFTs & encoding the terms](./lending-issuance.md)** — the @@ -160,7 +145,7 @@ and pulling in the recipes that introduced its pieces: interdependent covenant hashes that every later step relies on. 2. **[Opening the offer](./lending-offer.md)** — `LockCollateral` puts the collateral and NFTs on-chain behind the `pre_lock` covenant, with an `op_return` - discovery beacon and a pre-build `validations` check. + discovery beacon. 3. **[Accepting or cancelling the offer](./lending-accept.md)** — the two spending paths of `pre_lock`: the lender's `SetupLending` (with the `required_index` discipline a covenant demands) versus the borrower's `CancelOffer`. @@ -194,8 +179,8 @@ txw sync --wallet lender.json > through. Get oriented with `describe` and `validate` before building anything — `describe` -prints the classes, methods, and lifecycle; `validate` checks the manifest is -internally consistent: +prints the contract templates, their fields and actions; `validate` checks the +manifest is internally consistent: ```sh txw describe examples/lending/txmanifest.json diff --git a/src/walkthrough/lending-settlement.md b/src/walkthrough/lending-settlement.md index 624c21a..cc5b336 100644 --- a/src/walkthrough/lending-settlement.md +++ b/src/walkthrough/lending-settlement.md @@ -23,6 +23,7 @@ wallet so the borrower can actually use it. It's the [Hello World receive](../recipes/01b-hello-world-receive.md) spend, unchanged: one `p2pk` input, a Schnorr signature, one wallet output. + ```json "ClaimLoanFunds": { "inputs": [ @@ -34,7 +35,7 @@ wallet so the borrower can actually use it. It's the { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true } ], "outputs": [ - { "id": "principal_to_borrower", "destination": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "principal_in.amount_sat" }, + { "id": "principal_to_borrower", "destination": "wallet", "asset": "instance.PRINCIPAL_ASSET_ID", "amount_sat": "principal_in.amount_sat" }, { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true } ] } @@ -105,6 +106,7 @@ the Parameter and Borrower NFTs are **burned** (the loan is over), and the principal + interest goes to the vault — *not* directly to the lender. The manifest mirrors that layout: + ```json "RepayLoan": { "inputs": [ @@ -114,18 +116,18 @@ mirrors that layout: { "id": "first_params_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" }, { "id": "second_params_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" }, { "id": "borrower_nft_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" }, - { "id": "repayment_in", "utxo_source": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", - "amount_sat": { "min_amount": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" } }, + { "id": "repayment_in", "utxo_source": "wallet", "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { "min_amount": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT" } }, { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true } ], "outputs": [ - { "id": "collateral_returned", "destination": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" }, - { "id": "principal_interest_to_vault","destination": { "utxo_type": "lender_principal_vault" }, "asset": "compile_params.PRINCIPAL_ASSET_ID", - "amount_sat": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" }, - { "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" }, - { "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" }, - { "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, - { "id": "repayment_change", "destination": "change", "asset": "compile_params.PRINCIPAL_ASSET_ID", "optional": true }, + { "id": "collateral_returned", "destination": "wallet", "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" }, + { "id": "principal_interest_to_vault","destination": { "utxo_type": "lender_principal_vault" }, "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT" }, + { "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" }, + { "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" }, + { "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "repayment_change", "destination": "change", "asset": "instance.PRINCIPAL_ASSET_ID", "optional": true }, { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true } ] } @@ -162,16 +164,16 @@ is only valid once the chain reaches `LOAN_EXPIRATION_TIME`. Note the mapping is **identity** here (no payout injected) and the gating token is the **Lender NFT**, which the lender brings from their wallet — there's no signature, holding the NFT *is* the authorisation. The collateral lands in the lender's wallet; the NFTs burn. -The manifest also declares a friendly pre-build `validations` check so you get a -clean error instead of a rejected broadcast if you try too early: +Liquidating early is refused by the chain rather than by the wallet: the +covenant's `check_lock_height` is what enforces the deadline, so an early +attempt costs you a rejected broadcast rather than a clean pre-flight error. -```json -"validations": [ - { "id": "expiry_reached", - "rule": { "type": "arithmetic", "expr": "current_block_height >= compile_params.LOAN_EXPIRATION_TIME" }, - "error": { "code": "TIMELOCK_NOT_ELAPSED", "message": "Loan has not yet expired. Cannot liquidate before LOAN_EXPIRATION_TIME." } } -] -``` +> **Where did `validations` go?** The action-level `validations` block was +> removed in format 0.2.0 and deferred to a later revision. Of the eleven rules +> the reference manifests carried, only three were ever enforced — the rest +> printed `[TODO]` and passed — and re-admitting the block as written would have +> preserved that parses-but-never-fires shape. Until it returns, a check like +> this has to live in the covenant, where the chain enforces it. This is the **unilateral** escape hatch for the lender, the mirror image of the borrower's `CancelOffer`: state moves to `liquidated`. Compare the *relative* @@ -207,6 +209,7 @@ Lender NFT as an input and burn it as an output.* The NFT is a one-shot key. The lender opens the vault by co-spending and burning their NFT, sending the principal + interest wherever they like: + ```json "ClaimPrincipalWithInterest": { "params": { "lender_destination": { "type": "address" } }, @@ -216,12 +219,12 @@ principal + interest wherever they like: "INPUT_ASSET_INDEX": { "type": "formula", "expr": "index_of(lender_nft_in)" }, "OUTPUT_ASSET_INDEX": { "type": "formula", "expr": "index_of(lender_nft_burned)" }, "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lender_principal_vault_leaf" } } } }, - { "id": "lender_nft_in", "utxo_source": "wallet", "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "lender_nft_in", "utxo_source": "wallet", "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" } ], "outputs": [ - { "id": "principal_interest_out", "destination": "params.lender_destination", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "vault_in.amount_sat" }, - { "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, + { "id": "principal_interest_out", "destination": "params.lender_destination", "asset": "instance.PRINCIPAL_ASSET_ID", "amount_sat": "vault_in.amount_sat" }, + { "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }, { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true } ] } @@ -290,7 +293,7 @@ seen, working together, every concept the cookbook introduced one recipe at a ti [issuance & NFTs](../recipes/08-issuance-and-nfts.md), [formulas & derived params](../recipes/07-formulas-and-derived-params.md), [hooks & tapleaf compute](../recipes/09-hooks-and-tapleaf.md), and the -[class / instance model](../recipes/10-instance-state-constructors.md). +[template / instance model](../recipes/10-instance-state-constructors.md). For the precise rules behind anything here, the authoritative reference is [`Spec.md`](https://github.com/stringhandler/tx_manifest_spec/blob/main/Spec.md). From 99b115435b3ced6dbbf512ef179cc4d72dbfd25e Mon Sep 17 00:00:00 2001 From: stringhandler Date: Fri, 4 Sep 2026 16:47:41 +0200 Subject: [PATCH 4/4] review edits --- src/getting-started/anatomy.md | 77 +++++++++++++++++++++++++++++----- src/getting-started/setup.md | 24 ++++++----- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/getting-started/anatomy.md b/src/getting-started/anatomy.md index f95be77..af99dd6 100644 --- a/src/getting-started/anatomy.md +++ b/src/getting-started/anatomy.md @@ -33,21 +33,78 @@ pointing at the JSON Schema. Both are stripped before a manifest is interpreted. ### `simplicity_hl` — how the covenants are compiled +The whole block is optional, and so is every field in it. Written out with its +defaults, it says exactly what omitting it says: + ```json -"simplicity_hl": { "debug_symbols": false } +"simplicity_hl": { + "debug_symbols": false, + "unstable_features": [] +} ``` -`debug_symbols` decides whether the `.simf` programs are compiled with debug -information. It is not a cosmetic setting: `assert!` and `panic!` embed source -locations into `fail`-node commitments, so turning it on changes every program's -CMR, and therefore its tapleaf, its address, and every script hash derived from -it. Two tools that disagree about the flag derive *different addresses for the -same manifest*. Omit the block and it is `false`. +| Field | Type | Default | Effect | +|-------|------|---------|--------| +| `debug_symbols` | boolean | `false` | Compile the `.simf` programs with debug symbols. **Changes every covenant address.** | +| `unstable_features` | array of strings | `[]` | Allow gated SimplicityHL syntax. Never changes an address. | + +That is the entire block — there is no third field, and in particular no +compiler-version field (see below). + +#### `debug_symbols` — changes every address + +Not a cosmetic setting. `assert!` and `panic!` embed source locations into +`fail`-node commitments, so turning it on changes every program's CMR, and +therefore its tapleaf, its P2TR address, and every script hash derived from it. +Two tools that disagree about the flag derive *different addresses for the same +manifest* — funds sent by one are not spendable by the other's view of the +contract. + +`false` is the production value; debug symbols are a transitional SimplicityHL +feature. Set it `true` only to match a counterparty that compiles with them on +(the deployed simplicity-lending contracts do, which is why +`examples/lending_v3` sets it). + +#### `unstable_features` — changes nothing + +The manifest form of `simc -Z `, one entry per feature: + + +```json +"simplicity_hl": { "unstable_features": ["enums"] } +``` + +| Value | Unlocks | +|-------|---------| +| `"imports"` | Module syntax: `use` imports, `mod` modules, `as` aliases, `crate::` paths | +| `"enums"` | Enum syntax: `enum` declarations and `EnumName::Variant` match expressions | + +The compiler rejects gated syntax unless the feature is listed, so a program +using `enum` fails to compile until `"enums"` appears here. The reverse is +harmless: enabling a feature the programs don't use only lifts a restriction, so +unlike `debug_symbols` it never changes generated code, a CMR, or an address. + +It is manifest-wide rather than per-`utxo_type`, mirroring `simc`'s own +per-invocation `-Z` flag — the point of a gate is that a reader can see in one +place which unstable syntax a protocol depends on. Names are checked against the +compiler's own list at load time, so both a misspelling (`"enum"`) and a feature +that has since stabilized upstream are errors rather than silent no-ops. + +#### No compiler-version field + +Deliberately absent. SimplicityHL pins its own version from inside the source: + +```text +simc "=0.6.0"; +``` -There is deliberately no compiler-version field here. SimplicityHL pins its own -version from inside the source (`simc "=0.6.0";`), where the compiler can -enforce it across the entry file and every dependency. +The compiler enforces that fail-fast before lexing, across the entry file and +every reachable dependency — neither of which a manifest key could do. A range +does not pin the output either: two compiler versions can both satisfy one and +still produce different CMRs, hence different addresses, so anything deployed +should pin an exact version. Duplicating the requirement in the manifest would +only create a second place for the two to disagree. ## The data sections diff --git a/src/getting-started/setup.md b/src/getting-started/setup.md index 693354e..4496c5d 100644 --- a/src/getting-started/setup.md +++ b/src/getting-started/setup.md @@ -120,16 +120,20 @@ Inspect it — fingerprint, master xpub, oracle key, and a receive address: txw info --wallet wallet.json ``` -The wallet derives keys on the BIP86 (taproot) paths the spec expects: - -| Path (testnet) | Path (mainnet) | Role | -|----------------|----------------|------| -| `m/86h/1h/0h/0/0` | `m/86h/0h/0h/0/0` | Wallet signing key | -| `m/86h/1h/1h/0/0` | `m/86h/0h/1h/0/0` | Oracle key | - -A param with `"compute": { "type": "wallet", "wallet": "key" }` is auto-filled -from the first path. (More on this in -[Parameters](../recipes/02-params-and-validations.md).) +> **⚠️ Use this wallet for testing only.** `create-wallet` generates a fresh +> 12-word BIP39 seed phrase and writes it to `wallet.json` **in plaintext**. +> Anyone who can read that file — a backup, a synced folder, a shared machine, +> a screenshot — controls every coin it holds, and there is no passphrase or +> encryption to stop them. +> +> This is a deliberate simplification in an example CLI, not an oversight to be +> worked around: it keeps the recipes runnable without a key-management detour. +> A real wallet encrypts key material at rest and keeps it off disk while +> unlocked. +> +> So treat `wallet.json` as disposable and keep it on testnet. Don't reuse a +> seed you care about, don't commit the file, and don't put mainnet funds behind +> it — `--mainnet true` exists for completeness, not as a recommendation. ## Fund and sync