diff --git a/CHANGELOG.md b/CHANGELOG.md index 754ebc9..ab41bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,42 @@ released together. No changelog was kept before 0.2.0; for 0.1.x see the git history. +## [Unreleased] + +### Added + +- **Messages inside rangeproofs.** A confidential output may carry + `rangeproof_embed`: plain text (with `params.X` / `instance.X` references + resolved), raw bytes in the OP_RETURN `data` dialect, or a nostr event signed + at build time. Elements uses only the first 64 bytes of a rangeproof's message + field, so ≈3125 bytes per output are free — the proof is the same size either + way, so neither the transaction's weight nor its fee changes, and the message + is readable only by the holder of the output's blinding key. +- **Signed nostr events.** `rangeproof_embed.nostr` takes the unsigned event + fields (`kind`, `content`, `tags`, `created_at`); the engine fills in `pubkey`, + `id` and `sig` so a relay will accept what lands on chain. `sign_with` names + which wallet key signs — `"wallet"`, `"oracle"`, or a BIP32 path — so the + manifest never carries a secret and the nostr identity is the key's x-only + pubkey. The build report and the clear-signing preview both show the event's + content and its signing key before broadcast. +- **`read-messages` command.** Reads embedded messages back out of the wallet's + own confidential outputs from persisted state, rendering a payload as a nostr + event when it is one. Only wallet-owned outputs are readable: rewinding a + rangeproof needs the output's blinding key, which is the gate that makes the + message private. +- The frame is byte-compatible with `liquidrangeproof` / `liquid-nostr-bridge`, + so their reader and the bridge relay can read outputs written here. + +### Changed + +- The engine's own blinding pass now also runs when an output carries a + rangeproof message, not only when it pins blinding factors: Elements' + `blind_last` writes the 64-byte message itself and offers no way to extend it. + Rather than rewind and replace a finished proof, the builder signs the longer + message on the first pass — which means an embed can ride on an output paying + someone *else's* confidential address, since the receiver's blinding key is + never needed. + ## [0.2.0] - 2026-08-20 **Breaking:** a manifest that sets `utxo_type.confidential` no longer parses. diff --git a/Cargo.lock b/Cargo.lock index 83d1447..f64c05c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -422,6 +428,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bmp-monochrome" version = "1.1.0" @@ -463,6 +478,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.0" @@ -1625,9 +1649,22 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -1978,6 +2015,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "nostr" +version = "0.44.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + [[package]] name = "num" version = "0.4.3" @@ -2112,6 +2173,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2684,6 +2756,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ + "password-hash", "pbkdf2", "salsa20", "sha2 0.10.9", @@ -3353,6 +3426,7 @@ dependencies = [ "base64 0.22.1", "bip39", "console", + "crc32fast", "dialoguer", "dirs-next", "elements-miniscript", @@ -3361,6 +3435,7 @@ dependencies = [ "lwk_common", "lwk_signer", "lwk_wollet", + "nostr", "rand 0.8.7", "schemars", "serde", @@ -3498,6 +3573,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/README.md b/README.md index 76b15de..0886208 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ manifest-wallet/ │ ├── eval.rs # expression evaluator (amounts, formulas, references) │ ├── prepare.rs # UTXO pre-funding / splitting │ ├── pset_builder.rs # PSET construction +│ ├── rangeproof.rs # messages embedded in a confidential output's rangeproof +│ ├── nostr_embed.rs # signing a nostr event for such a message │ ├── validate.rs # static manifest schema checks │ ├── describe.rs # interactive manifest explorer │ ├── wallet.rs # key management & signing @@ -43,7 +45,8 @@ manifest-wallet/ ├── deadcat/ # binary prediction market with on-chain oracle resolution ├── deadcat_v2/ # …unblinded tokens — a documented dead end ├── deadcat_v3/ # …derivable blinding factors; the runnable fork - └── last_will/ # time-locked inheritance + ├── last_will/ # time-locked inheritance + └── rangeproof_message/ # messages (and signed nostr events) inside rangeproofs ``` ## How a manifest works @@ -63,6 +66,50 @@ See [`examples/p2pk/txmanifest.json`](examples/p2pk/txmanifest.json) for a minim example, or [`examples/lending/txmanifest.json`](examples/lending/txmanifest.json) for a full multi-action covenant protocol. +### Messages inside rangeproofs + +Elements signs every confidential output's value rangeproof over an author-supplied +message, uses only the first 64 bytes of it (asset id ‖ asset blinding factor), and +discards the rest. Those spare bytes are recovered verbatim by a rewind, are readable +only by the holder of the output's blinding key, and — because `min_bits = 52` fixes +the ring count — cost nothing: **≈3125 bytes per output, at no change in transaction +size or fee.** + +Any confidential output may carry one, via `rangeproof_embed`: + +```jsonc +// plain text; params.X / instance.X references are resolved first +"rangeproof_embed": "hello from inside a rangeproof" +"rangeproof_embed": { "message": "params.note" } + +// raw bytes, in the same dialect as an OP_RETURN `data` field +"rangeproof_embed": { "data": { "parts": [ { "type": "u64", "value": "params.seq" } ] } } + +// a nostr event, signed at build time with a key this wallet derives +"rangeproof_embed": { "nostr": { + "kind": 1, + "content": "params.content", + "tags": [["t", "liquid"]], + "sign_with": "wallet" // or "oracle", or a BIP32 path +} } +``` + +For the nostr form the engine fills in `pubkey`, `id` and `sig`, so what lands on chain +is a complete NIP-01 event a relay will accept. The manifest never carries a secret: +`sign_with` names a wallet key, and the resulting nostr identity is that key's x-only +pubkey — the one `info` prints. Signing at the authoring end is what makes a bridge that +republishes these events a transport rather than an authority. + +Read them back with `read-messages` (wallet-owned outputs only — the blinding key is the +gate). The frame is byte-compatible with +[`liquidrangeproof`](https://github.com/stringhandler/liquidrangeproof) and the +`liquid-nostr-bridge` relay, so outputs written here can be read by those tools. + +A message only exists on a *confidential* output. `validate` rejects one on a change, +OP_RETURN, burn, fee or `script_hash` destination, or on an output that sets +`"confidential": false`, rather than silently dropping it. See +[`examples/rangeproof_message/txmanifest.json`](examples/rangeproof_message/txmanifest.json). + ## Building ```sh @@ -128,6 +175,7 @@ cargo run -- run examples/p2pk/txmanifest.json Pay --wallet wallet.json | `info` | Show wallet fingerprint, xpub, oracle key, and a receive address. | | `sync` | Sync wallet state against an Esplora server and show balance. | | `get-balance` | Show last-synced balance (no network call). | +| `read-messages` | Read messages embedded in the rangeproofs of the wallet's confidential outputs. | | `split` | Split a wallet asset into N equal UTXOs. | | `config` | Show or update configuration (`default_network`, `default_esplora`). | diff --git a/examples/rangeproof_message/data/params.json b/examples/rangeproof_message/data/params.json new file mode 100644 index 0000000..e85ee15 --- /dev/null +++ b/examples/rangeproof_message/data/params.json @@ -0,0 +1,3 @@ +{ + "recipient": "tlq1qqdea9qzfk70hqw3ypnlvefmz2dwrhmu9f08dw0uu4qm58n8y0ptun9tsmmrr9ns5kve4crrd3ulnl78tkw256j9hseufz6aux" +} \ No newline at end of file diff --git a/examples/rangeproof_message/txmanifest.json b/examples/rangeproof_message/txmanifest.json new file mode 100644 index 0000000..f22c89a --- /dev/null +++ b/examples/rangeproof_message/txmanifest.json @@ -0,0 +1,145 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "manifest_version": "0.1.0", + "protocol": "rangeproof-message", + "description": "Carry a message inside a confidential output's value rangeproof. Elements uses only the first 64 bytes of a rangeproof's message field, so about 3125 bytes per output are free \u2014 at no cost in transaction size or fee, and readable only by the holder of the output's blinding key.", + "chain": "liquid", + "actions": { + "Post": { + "description": "Publish a signed nostr event inside a confidential output's rangeproof. The event is signed by a key this wallet derives, so authorship survives the trip: a bridge that reads the chain and republishes to nostr is a transport, not an authority \u2014 it cannot forge events, and it runs on a view key that cannot spend.", + "params": { + "recipient": { + "type": "string", + "description": "A confidential Liquid address to carry the event. Whoever holds this address's blinding key is the only party who can read the note \u2014 so send it where the intended reader is looking: one of your own addresses if you want `read-messages` to find it, or an address covered by the descriptor a nostr bridge watches. An unblinded address has no rangeproof and is rejected." + }, + "content": { + "type": "string", + "description": "The note's text. Roughly 3000 bytes fit once the event's JSON framing and signature are accounted for." + }, + "amount_sat": { + "type": "u64", + "description": "Satoshis to send to the recipient. The event rides along for free, so this only needs to clear the dust limit.", + "default": "1000" + }, + "sign_with": { + "type": "string", + "description": "Which wallet key signs the event: \"wallet\" (the general signing key), \"oracle\", or a BIP32 path. The resulting nostr identity is that key's x-only pubkey \u2014 the one `tx-manifest-wallet info` prints.", + "default": "wallet" + } + }, + "inputs": [ + { + "id": "funding_input", + "description": "Wallet UTXO providing the funds and the fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.amount_sat" + }, + "ui": { + "label": "input funding the carrier output and the fee", + "role": "funding" + } + } + ], + "outputs": [ + { + "id": "carrier_out", + "description": "The output whose rangeproof carries the event. It must be confidential \u2014 that is what gives it a rangeproof to write into \u2014 so the address must be a blinded one.", + "destination": "params.recipient", + "amount_sat": "params.amount_sat", + "asset": "lbtc", + "rangeproof_embed": { + "nostr": { + "kind": 1, + "content": "params.content", + "tags": [ + [ + "t", + "liquid" + ] + ], + "sign_with": "params.sign_with" + } + }, + "ui": { + "label": "the output carrying your nostr note", + "role": "carrier" + } + }, + { + "id": "change_out", + "description": "Change returned to the funding wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change back to your wallet", + "role": "change" + } + } + ], + "intent": "post a nostr note on chain to {params.recipient}: {params.content}" + }, + "Send": { + "description": "Pay a confidential address with a plain-text message in the rangeproof. Only the recipient can read it \u2014 the message is gated on the output's blinding key, which is theirs.", + "params": { + "recipient": { + "type": "string", + "description": "A confidential Liquid address. An unblinded address has no rangeproof and is rejected." + }, + "message": { + "type": "string", + "description": "The text to embed. Up to 3125 bytes." + }, + "amount_sat": { + "type": "u64", + "description": "Satoshis to send." + } + }, + "inputs": [ + { + "id": "funding_input", + "description": "Wallet UTXO providing the funds and the fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.amount_sat" + }, + "ui": { + "label": "input funding the payment and the fee", + "role": "funding" + } + } + ], + "outputs": [ + { + "id": "recipient_out", + "description": "The recipient's output, carrying the message.", + "destination": "params.recipient", + "amount_sat": "params.amount_sat", + "asset": "lbtc", + "rangeproof_embed": { + "message": "params.message" + }, + "ui": { + "label": "payment to the recipient, carrying your message", + "role": "payment" + } + }, + { + "id": "change_out", + "description": "Change returned to the funding wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change back to your wallet", + "role": "change" + } + } + ], + "intent": "send {params.amount_sat} sat with a private message: {params.message}" + } + } +} diff --git a/schema/txmanifest.schema.json b/schema/txmanifest.schema.json index e9f1bee..b127289 100644 --- a/schema/txmanifest.schema.json +++ b/schema/txmanifest.schema.json @@ -348,6 +348,56 @@ ], "type": "object" }, + "NostrEmbed": { + "additionalProperties": false, + "description": "The unsigned fields of a nostr event to embed, plus which wallet key signs it.\n\nThe engine fills in `pubkey`, `id` and `sig`, so what reaches the chain is a complete NIP-01 event that a relay will accept: authorship survives the trip, and a bridge that republishes it is only a transport — it cannot forge events on this author's behalf.\n\nThe signature is made by a key this wallet derives, so the manifest never carries a secret. `sign_with` names it: `\"wallet\"` (the general signing key, the default), `\"oracle\"`, or a literal BIP32 path. The resulting nostr identity is that key's x-only pubkey — the same one `tx-manifest-wallet info` prints.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "content": { + "description": "Event content. `params.X` / `instance.X` references are resolved first.", + "type": "string" + }, + "created_at": { + "description": "Unix seconds. Defaults to the moment the transaction is built. Pin it only when a reproducible event id matters, since it is part of what the id commits to." + }, + "kind": { + "description": "Event kind. Defaults to 1 (a text note).", + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sign_with": { + "description": "Which wallet key signs: `\"wallet\"` (default), `\"oracle\"`, or a BIP32 path such as `\"m/86h/1h/7h/0/0\"`. May be a reference resolving to one of those.", + "type": [ + "string", + "null" + ] + }, + "tags": { + "description": "Event tags, each an array of strings whose first element is the tag name — `[[\"t\", \"liquid\"], [\"e\", \"\"]]`. Every element is reference-resolved.", + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "content" + ], + "type": "object" + }, "Output": { "additionalProperties": false, "properties": { @@ -407,6 +457,17 @@ "null" ] }, + "rangeproof_embed": { + "anyOf": [ + { + "$ref": "#/definitions/RangeproofEmbed" + }, + { + "type": "null" + } + ], + "description": "A message to carry inside this output's value rangeproof, readable only by the holder of the output's blinding key. See [`RangeproofEmbed`]." + }, "required_index": { "description": "Required transaction output index; same semantics and same caveat as [`Input::required_index`] (Spec §7) — parsed, never enforced.", "format": "int64", @@ -744,6 +805,60 @@ ], "type": "object" }, + "RangeproofEmbed": { + "description": "A message to carry inside this confidential output's value rangeproof (about 3125 bytes, at no cost in transaction size or fee, readable only by the holder of the output's blinding key). A string is UTF-8 text with references resolved; an object carries exactly one of `message`, `data` (OP_RETURN-style raw bytes) or `nostr` (event fields, signed at build time).", + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "data": {} + }, + "required": [ + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "nostr": { + "$ref": "#/definitions/NostrEmbed" + } + }, + "required": [ + "nostr" + ], + "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.", diff --git a/txmanifest_lib/Cargo.toml b/txmanifest_lib/Cargo.toml index a9b7ca0..ed31f70 100644 --- a/txmanifest_lib/Cargo.toml +++ b/txmanifest_lib/Cargo.toml @@ -23,6 +23,10 @@ evalexpr = "11" rand = "0.8" ureq = "2" base64 = "0.22" +crc32fast = "1" +# Same major version the liquid-nostr-bridge relay verifies with, so a +# txmanifest-written event and a bridge-read one cannot disagree about NIP-01. +nostr = { version = "0.44", default-features = false, features = ["std"] } # Covenant dry-runs, address derivation and witness building only use upstream # (master) APIs. The `feat/compile-function-only` fork is a superset of master that # additionally provides the standalone compile_function / expression-eval APIs used diff --git a/txmanifest_lib/src/eval.rs b/txmanifest_lib/src/eval.rs index b48beb2..f962046 100644 --- a/txmanifest_lib/src/eval.rs +++ b/txmanifest_lib/src/eval.rs @@ -61,6 +61,17 @@ pub fn eval_destination_str(dest: &str, ctx: &ExecutionContext) -> Option String { + resolve_ref(text.trim(), ctx).unwrap_or_else(|| text.to_string()) +} + /// Evaluate an OP_RETURN `data` value into a raw byte payload. /// /// Two forms are accepted: diff --git a/txmanifest_lib/src/lib.rs b/txmanifest_lib/src/lib.rs index 9e1dbd7..d41586a 100644 --- a/txmanifest_lib/src/lib.rs +++ b/txmanifest_lib/src/lib.rs @@ -8,11 +8,13 @@ pub mod covenant; pub mod eval; pub mod instance; pub mod lifecycle; +pub mod nostr_embed; pub mod params; pub mod prepare; pub mod preview; pub mod prompt; pub mod pset_builder; +pub mod rangeproof; pub mod schema; pub mod state; pub mod validate; diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index 685463a..6afd7a8 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -222,6 +222,58 @@ fn is_change_output(output: &crate::manifest::Output) -> bool { /// asset from the set the action permits change in, and the build then rejects the /// leftover with "this action does not permit L-BTC change" for a manifest that declared /// exactly that. +/// Resolve an output's `rangeproof_embed` to the bytes that will ride in its value +/// rangeproof, plus a one-line note for the build report. +/// +/// The three forms differ only in how the bytes are produced: text is UTF-8 with +/// references resolved, `data` reuses the OP_RETURN byte evaluator, and `nostr` builds +/// and signs a NIP-01 event with a wallet key. Capacity is checked here rather than at +/// blinding time so an oversized message is reported next to the output that declared +/// it, not as a proof-signing failure several steps later. +fn resolve_rangeproof_embed( + spec: &crate::manifest::RangeproofEmbed, + ctx: &ExecutionContext, + type_hints: &std::collections::HashMap, + wallet: Option<&WalletFile>, +) -> Result<(Vec, String)> { + use crate::manifest::RangeproofEmbed; + + let (bytes, note) = match spec { + RangeproofEmbed::Message(text) => { + let resolved = eval::eval_text(text, ctx); + let n = resolved.len(); + (resolved.into_bytes(), format!("{n} byte message")) + } + RangeproofEmbed::Data(data) => { + let bytes = eval::eval_op_return_data(data, ctx, type_hints) + .context("rangeproof_embed.data")?; + let n = bytes.len(); + (bytes, format!("{n} bytes")) + } + RangeproofEmbed::Nostr(nostr) => { + let wallet = wallet.context( + "rangeproof_embed.nostr needs a wallet to sign with, and none is loaded", + )?; + let signed = crate::nostr_embed::build(nostr, ctx, wallet)?; + let note = format!( + "signed nostr event, {} bytes, author {} ({})", + signed.json.len(), + &signed.pubkey[..16], + signed.key_path, + ); + (signed.json, note) + } + }; + + anyhow::ensure!( + bytes.len() <= crate::rangeproof::MAX_PAYLOAD, + "rangeproof_embed is {} bytes but only {} fit in a rangeproof", + bytes.len(), + crate::rangeproof::MAX_PAYLOAD, + ); + Ok((bytes, note)) +} + fn skips_when_amount_absent(output: &crate::manifest::Output) -> bool { !is_change_output(output) && output.optional.unwrap_or(false) } @@ -1487,6 +1539,50 @@ pub fn run( } }; + // The rangeproof message, if the manifest declared one. Resolved here for + // the same reason as the pins above: a message that cannot be built (an + // oversized payload, an unknown signing key) is an error in every arm. + let (embed_bytes, embed_note) = match &output.rangeproof_embed { + None => (None, None), + Some(spec) => { + let unblindable = if is_change { + Some("change") + } else if is_op_return { + Some("OP_RETURN/burn") + } else if dest_type == Some("fee") { + Some("fee") + } else if matches!(&*output.destination, + serde_json::Value::Object(m) if m.contains_key("script_hash")) { + Some("script_hash") + } else { + None + }; + if let Some(kind) = unblindable { + println!( + " {} Output '{}' carries a rangeproof message, but a {} output \ + has no rangeproof to put it in. Only a confidential output to a \ + wallet or address destination can carry one.", + style("[error]").red(), output.id, kind, + ); + collect_outputs_ok = false; + break; + } + match resolve_rangeproof_embed( + spec, &ctx, &compile_param_type_hints, loaded_wallet.as_ref(), + ) { + Ok((bytes, note)) => (Some(bytes), Some(note)), + Err(e) => { + println!( + " {} Output '{}' rangeproof_embed: {e:#}", + style("[error]").red(), output.id + ); + collect_outputs_ok = false; + break; + } + } + } + }; + match &*output.destination { serde_json::Value::String(dest) if dest == "change" => { if pinned_blinding.is_some() { @@ -1537,6 +1633,7 @@ pub fn run( } pset_outputs.push(pset_builder::PsetOutputSpec { script_pubkey, amount, asset: asset_id, blinding_key: None, blinding: None, + rangeproof_embed: None, }); } serde_json::Value::Object(m) if m.contains_key("utxo_type") => { @@ -1638,6 +1735,7 @@ pub fn run( }); pset_outputs.push(pset_builder::PsetOutputSpec { script_pubkey, amount, asset: asset_id, blinding_key, blinding: pinned_blinding, + rangeproof_embed: embed_bytes, }); } serde_json::Value::String(dest) if dest == "wallet" => { @@ -1676,7 +1774,7 @@ pub fn run( ); pset_outputs.push(pset_builder::PsetOutputSpec { script_pubkey: addr.script_pubkey(), amount, asset: asset_id, blinding_key: bpk, - blinding: pinned_blinding, + blinding: pinned_blinding, rangeproof_embed: embed_bytes, }); } serde_json::Value::String(dest) => { @@ -1711,7 +1809,7 @@ pub fn run( ); pset_outputs.push(pset_builder::PsetOutputSpec { script_pubkey: addr.script_pubkey(), amount, asset: asset_id, blinding_key: bpk, - blinding: pinned_blinding, + blinding: pinned_blinding, rangeproof_embed: embed_bytes, }); } serde_json::Value::Object(m) if m.contains_key("script_hash") => { @@ -1757,6 +1855,7 @@ pub fn run( } pset_outputs.push(pset_builder::PsetOutputSpec { script_pubkey, amount, asset: asset_id, blinding_key: None, blinding: None, + rangeproof_embed: None, }); } other => { @@ -1764,6 +1863,9 @@ pub fn run( continue; } } + if let Some(note) = embed_note { + println!(" {} rangeproof message: {note}", style("↳").dim()); + } // Record this output's amount formula so it can be re-evaluated once // the `fee` keyword is resolved (each iteration pushes at most one output). if pset_outputs.len() > push_start { diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index 4d36768..e059d46 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -775,6 +775,9 @@ pub struct Output { /// Pin this confidential output's blinding factors instead of letting the builder /// pick them. See [`BlindingFactors`]. pub blinding: Option, + /// A message to carry inside this output's value rangeproof, readable only by the + /// holder of the output's blinding key. See [`RangeproofEmbed`]. + pub rangeproof_embed: Option, /// Clear-signing UI hint for this output (net-effect credit line). pub ui: Option, } @@ -968,6 +971,157 @@ impl JsonSchema for OutputDestination { /// /// The alternative is assembling `SchemaObject`s field by field, which for a `anyOf` of /// object shapes is several times the code and reads nothing like the schema it produces. +/// A message carried inside a confidential output's value rangeproof. +/// +/// Elements uses only the first 64 bytes of a rangeproof's message field and leaves the +/// rest — about 3125 usable bytes — unused. Writing there costs nothing: the proof is +/// the same size either way, so the transaction's weight and fee do not change, and the +/// output is indistinguishable from an ordinary one to anyone not holding its blinding +/// key. See [`crate::rangeproof`] for the frame layout and the measured properties. +/// +/// The field only means something on a **confidential** output; on an explicit one there +/// is no rangeproof to carry it, and the engine errors rather than dropping the message. +/// +/// Three forms: +/// +/// | form | meaning | +/// |---|---| +/// | `"some text"` or `{"message": "some text"}` | UTF-8 text; `params.X` / `instance.X` references are resolved first | +/// | `{"data": …}` | raw bytes, using the same evaluator as an OP_RETURN `data` field (a `concat(…)` expression or a typed `{"parts": […]}` layout) | +/// | `{"nostr": {…}}` | the unsigned fields of a nostr event, signed at build time with a wallet key — see [`NostrEmbed`] | +/// +/// A payload of more than 3125 bytes is refused at build time rather than truncated. +#[derive(Debug, Clone)] +pub enum RangeproofEmbed { + /// UTF-8 text, with references resolved. + Message(String), + /// Raw bytes, in the OP_RETURN `data` dialect. + Data(serde_json::Value), + /// A nostr event, signed by the wallet as it is built. + Nostr(NostrEmbed), +} + +/// The unsigned fields of a nostr event to embed, plus which wallet key signs it. +/// +/// The engine fills in `pubkey`, `id` and `sig`, so what reaches the chain is a complete +/// NIP-01 event that a relay will accept: authorship survives the trip, and a bridge that +/// republishes it is only a transport — it cannot forge events on this author's behalf. +/// +/// The signature is made by a key this wallet derives, so the manifest never carries a +/// secret. `sign_with` names it: `"wallet"` (the general signing key, the default), +/// `"oracle"`, or a literal BIP32 path. The resulting nostr identity is that key's +/// x-only pubkey — the same one `tx-manifest-wallet info` prints. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NostrEmbed { + /// Event kind. Defaults to 1 (a text note). + pub kind: Option, + /// Event content. `params.X` / `instance.X` references are resolved first. + pub content: String, + /// Event tags, each an array of strings whose first element is the tag name — + /// `[["t", "liquid"], ["e", ""]]`. Every element is reference-resolved. + pub tags: Option>>, + /// Unix seconds. Defaults to the moment the transaction is built. Pin it only when + /// a reproducible event id matters, since it is part of what the id commits to. + pub created_at: Option, + /// Which wallet key signs: `"wallet"` (default), `"oracle"`, or a BIP32 path such + /// as `"m/86h/1h/7h/0/0"`. May be a reference resolving to one of those. + pub sign_with: Option, +} + +/// Object keys `rangeproof_embed` accepts. Kept next to the parser so the schema, the +/// parser and `validate` cannot drift apart. +const EMBED_FORMS: [&str; 3] = ["message", "data", "nostr"]; + +impl<'de> Deserialize<'de> for RangeproofEmbed { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(Self::Message(s)), + serde_json::Value::Object(map) => { + let present: Vec<&str> = EMBED_FORMS + .iter() + .copied() + .filter(|k| map.contains_key(*k)) + .collect(); + match present.as_slice() { + [_] if map.len() > 1 => Err(serde::de::Error::custom(format!( + "rangeproof_embed has unknown key(s) alongside '{}'; expected exactly \ + one of: {}", + present[0], + EMBED_FORMS.join(", ") + ))), + ["message"] => match &map["message"] { + serde_json::Value::String(s) => Ok(Self::Message(s.clone())), + other => Err(serde::de::Error::custom(format!( + "rangeproof_embed.message must be a string, got {other}" + ))), + }, + ["data"] => Ok(Self::Data(map["data"].clone())), + ["nostr"] => serde_json::from_value(map["nostr"].clone()) + .map(Self::Nostr) + .map_err(|e| { + serde::de::Error::custom(format!("rangeproof_embed.nostr: {e}")) + }), + [] => Err(serde::de::Error::custom(format!( + "rangeproof_embed object must carry exactly one of: {}", + EMBED_FORMS.join(", ") + ))), + many => Err(serde::de::Error::custom(format!( + "rangeproof_embed carries {} at once ({}); they are alternative \ + spellings of the same payload, so exactly one is expected", + many.len(), + many.join(" and ") + ))), + } + } + other => Err(serde::de::Error::custom(format!( + "rangeproof_embed must be a string or an object, got {other}" + ))), + } + } +} + +impl JsonSchema for RangeproofEmbed { + fn schema_name() -> String { + "RangeproofEmbed".to_string() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + let nostr = gen.subschema_for::(); + subschema(serde_json::json!({ + "description": + "A message to carry inside this confidential output's value rangeproof \ + (about 3125 bytes, at no cost in transaction size or fee, readable only \ + by the holder of the output's blinding key). A string is UTF-8 text with \ + references resolved; an object carries exactly one of `message`, `data` \ + (OP_RETURN-style raw bytes) or `nostr` (event fields, signed at build \ + time).", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "data": {} }, + "required": ["data"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "nostr": nostr }, + "required": ["nostr"] + } + ] + })) + } +} + fn subschema(value: serde_json::Value) -> schemars::schema::Schema { serde_json::from_value(value).expect("hand-written subschema is a valid JSON Schema") } @@ -1515,6 +1669,79 @@ mod tests { ) } + /// A manifest with one output carrying `extra` inside it. + fn output_manifest(extra: &str) -> String { + format!( + r#"{{ + "manifest_version": "1", + "protocol": "test", + "actions": {{ "A": {{ "outputs": [ + {{ "id": "out0", "destination": "wallet", "amount_sat": 1000{extra} }} + ] }} }} + }}"# + ) + } + + fn parse_embed(json: &str) -> RangeproofEmbed { + let m = Manifest::from_json_str(&output_manifest(&format!( + r#", "rangeproof_embed": {json}"# + ))) + .expect("manifest with a rangeproof_embed should parse"); + m.actions["A"].outputs.as_ref().unwrap()[0] + .rangeproof_embed + .clone() + .expect("the embed reaches the model") + } + + #[test] + fn rangeproof_embed_accepts_all_three_forms() { + assert!(matches!(parse_embed(r#""a bare string""#), RangeproofEmbed::Message(m) if m == "a bare string")); + assert!(matches!(parse_embed(r#"{"message": "spelled out"}"#), RangeproofEmbed::Message(m) if m == "spelled out")); + assert!(matches!(parse_embed(r#"{"data": "concat(params.a, params.b)"}"#), RangeproofEmbed::Data(_))); + + let RangeproofEmbed::Nostr(n) = parse_embed( + r#"{"nostr": {"kind": 1, "content": "hi", "tags": [["t", "liquid"]], "sign_with": "oracle"}}"#, + ) else { + panic!("expected the nostr form"); + }; + assert_eq!(n.kind, Some(1)); + assert_eq!(n.content, "hi"); + assert_eq!(n.tags.unwrap(), vec![vec!["t".to_string(), "liquid".to_string()]]); + assert_eq!(n.sign_with.as_deref(), Some("oracle")); + } + + #[test] + fn rangeproof_embed_defaults_the_optional_nostr_fields() { + let RangeproofEmbed::Nostr(n) = parse_embed(r#"{"nostr": {"content": "minimal"}}"#) else { + panic!("expected the nostr form"); + }; + assert_eq!((n.kind, n.tags, n.created_at, n.sign_with), (None, None, None, None)); + } + + /// The forms are alternative spellings of one payload, so more than one at a time is + /// an author error rather than a merge — and a typo'd key must not be ignored. + #[test] + fn rangeproof_embed_rejects_ambiguous_and_misspelled_shapes() { + for (json, expect) in [ + (r#"{"message": "a", "data": "b"}"#, "exactly one"), + (r#"{}"#, "exactly one"), + (r#"{"mesage": "typo"}"#, "exactly one"), + (r#"{"message": 42}"#, "must be a string"), + (r#"42"#, "must be a string or an object"), + (r#"{"nostr": {"content": "hi", "kynd": 1}}"#, "kynd"), + (r#"{"nostr": {"kind": 1}}"#, "content"), + ] { + let err = Manifest::from_json_str(&output_manifest(&format!( + r#", "rangeproof_embed": {json}"# + ))) + .expect_err(&format!("{json} must not parse")); + assert!( + err.to_string().contains(expect), + "error for {json} should mention '{expect}', got: {err}" + ); + } + } + #[test] fn baseline_manifest_parses() { Manifest::from_json_str(&manifest_json("")).expect("baseline manifest should parse"); diff --git a/txmanifest_lib/src/nostr_embed.rs b/txmanifest_lib/src/nostr_embed.rs new file mode 100644 index 0000000..3f59c45 --- /dev/null +++ b/txmanifest_lib/src/nostr_embed.rs @@ -0,0 +1,261 @@ +//! Signing a nostr event for a rangeproof embed. +//! +//! A manifest declares the *unsigned* fields — kind, content, tags — and names a wallet +//! key. The engine fills in `pubkey`, `id` and `sig`, so what lands on chain is a +//! complete NIP-01 event that a relay will accept. +//! +//! Signing here, at the authoring end, is what makes a bridge that republishes these +//! events a transport rather than an authority: the secret key never leaves the wallet, +//! and the bridge cannot forge an event on this author's behalf. It also runs on a view +//! key that cannot spend, so reading the chain and writing to nostr stay separate powers. +//! +//! The `nostr` crate does the framing and the id, at the same major version the +//! `liquid-nostr-bridge` relay verifies with — a writer and a reader that disagree about +//! NIP-01 serialization fail silently, so they are held to one implementation. + +use anyhow::{Context, Result}; +use nostr::prelude::*; + +use crate::context::ExecutionContext; +use crate::eval; +use crate::manifest::NostrEmbed; +use crate::wallet::WalletFile; + +/// What was signed, for the build report. +#[derive(Debug)] +pub struct SignedEvent { + /// The event as JSON — the bytes that go into the rangeproof. + pub json: Vec, + /// The author's x-only pubkey (nostr identity), hex. + pub pubkey: String, + /// The event id, hex. + pub id: String, + /// The wallet derivation path the signature came from. + pub key_path: String, +} + +/// Build and sign the event an output's `rangeproof_embed.nostr` describes. +pub fn build( + spec: &NostrEmbed, + ctx: &ExecutionContext, + wallet: &WalletFile, +) -> Result { + let key_spec = spec.sign_with.as_deref().unwrap_or("wallet"); + let key_path = crate::wallet::resolve_key_path(wallet, &eval::eval_text(key_spec, ctx))?; + let sk = crate::wallet::derive_secret_key(wallet, &key_path)?; + let keys = Keys::new( + SecretKey::from_slice(&sk.secret_bytes()) + .map_err(|e| anyhow::anyhow!("Derived key at '{key_path}' is not a valid nostr key: {e}"))?, + ); + + let content = eval::eval_text(&spec.content, ctx); + let mut builder = EventBuilder::new(Kind::from(spec.kind.unwrap_or(1)), &content); + + for (i, tag) in spec.tags.as_deref().unwrap_or_default().iter().enumerate() { + let resolved: Vec = tag.iter().map(|v| eval::eval_text(v, ctx)).collect(); + builder = builder.tag( + Tag::parse(resolved) + .with_context(|| format!("nostr tag {i} ({tag:?}) is not a valid NIP-01 tag"))?, + ); + } + + if let Some(at) = &spec.created_at { + let secs = eval::eval_amount(at, ctx).context("nostr created_at")?; + builder = builder.custom_created_at(Timestamp::from_secs(secs)); + } + + let event = builder + .sign_with_keys(&keys) + .context("could not sign the nostr event")?; + + let json = event.as_json().into_bytes(); + anyhow::ensure!( + json.len() <= crate::rangeproof::MAX_PAYLOAD, + "the signed nostr event is {} bytes but only {} fit in a rangeproof; \ + the content is {} bytes and the signing overhead is {}", + json.len(), + crate::rangeproof::MAX_PAYLOAD, + content.len(), + json.len().saturating_sub(content.len()), + ); + + Ok(SignedEvent { + json, + pubkey: event.pubkey.to_hex(), + id: event.id.to_hex(), + key_path, + }) +} + +/// If a payload is a signed nostr event, render it for a human; otherwise `None`. +/// +/// The event's own JSON is the payload — the exact bytes that were embedded — so it is +/// what gets shown, pretty-printed. A prose summary would have to leave fields out, and +/// the one field it is most tempting to omit is `sig`, whose absence is indistinguishable +/// from a payload that never carried one. Printing the record verbatim means a reader can +/// see the signature, hand the compact form straight to a relay, and check both against +/// what the chain actually holds. +pub fn describe(payload: &[u8]) -> Option { + let json = std::str::from_utf8(payload).ok()?; + let event = Event::from_json(json).ok()?; + + // Re-serialize rather than reflow the input: this is the canonical NIP-01 form, and a + // mismatch with the payload would itself be worth seeing. + let compact = event.as_json(); + let pretty = serde_json::from_str::(&compact) + .ok() + .and_then(|v| serde_json::to_string_pretty(&v).ok()) + .unwrap_or_else(|| compact.clone()); + + let verdict = match event.verify() { + Ok(()) => "signature verifies".to_string(), + Err(e) => format!("SIGNATURE DOES NOT VERIFY ({e}) — a relay will reject this"), + }; + + Some(format!( + "nostr event, {} bytes, {verdict}\n{pretty}", + payload.len(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + fn wallet() -> WalletFile { + WalletFile { network: "testnet".to_string(), mnemonic: MNEMONIC.to_string() } + } + + fn spec(content: &str) -> NostrEmbed { + NostrEmbed { + kind: None, + content: content.to_string(), + tags: None, + created_at: None, + sign_with: None, + } + } + + #[test] + fn signs_with_the_wallet_key_by_default() { + let w = wallet(); + let ctx = ExecutionContext::new(); + let signed = build(&spec("hello from a rangeproof"), &ctx, &w).unwrap(); + + // The nostr identity is the key the wallet already publishes, not a new one. + let (expected, path) = crate::wallet::wallet_signing_pubkey(&w).unwrap(); + assert_eq!(signed.pubkey, expected); + assert_eq!(signed.key_path, path); + + // And a relay will take it. + let event = Event::from_json(String::from_utf8(signed.json).unwrap()).unwrap(); + event.verify().expect("a relay rejects anything that does not verify"); + assert_eq!(event.content, "hello from a rangeproof"); + assert_eq!(event.kind.as_u16(), 1); + } + + #[test] + fn honours_kind_tags_and_created_at() { + let w = wallet(); + let ctx = ExecutionContext::new(); + let mut s = spec("tagged"); + s.kind = Some(30023); + s.tags = Some(vec![ + vec!["t".to_string(), "liquid".to_string()], + vec!["d".to_string(), "an-identifier".to_string()], + ]); + s.created_at = Some(serde_json::json!(1_700_000_000u64)); + + let signed = build(&s, &ctx, &w).unwrap(); + let event = Event::from_json(String::from_utf8(signed.json).unwrap()).unwrap(); + event.verify().unwrap(); + assert_eq!(event.kind.as_u16(), 30023); + assert_eq!(event.created_at.as_secs(), 1_700_000_000); + let tags: Vec> = event.tags.iter().map(|t| t.clone().to_vec()).collect(); + assert!(tags.contains(&vec!["t".to_string(), "liquid".to_string()]), "tags: {tags:?}"); + + // A pinned created_at is the only way the id is reproducible, so check that it is. + let again = build(&s, &ctx, &w).unwrap(); + assert_eq!(again.id, signed.id); + } + + #[test] + fn sign_with_selects_a_different_identity() { + let w = wallet(); + let ctx = ExecutionContext::new(); + let mut s = spec("as the oracle"); + s.sign_with = Some("oracle".to_string()); + + let signed = build(&s, &ctx, &w).unwrap(); + let (oracle_pub, _) = ( + crate::wallet::derive_schnorr_pubkey(&w, crate::wallet::oracle_key_path(&w)).unwrap(), + (), + ); + assert_eq!(signed.pubkey, oracle_pub); + assert_ne!(signed.pubkey, crate::wallet::wallet_signing_pubkey(&w).unwrap().0); + + // An explicit path works too, and reaches the same key as the alias. + s.sign_with = Some(crate::wallet::oracle_key_path(&w).to_string()); + assert_eq!(build(&s, &ctx, &w).unwrap().pubkey, oracle_pub); + } + + #[test] + fn content_resolves_references() { + let w = wallet(); + let mut ctx = ExecutionContext::new(); + ctx.set_param("headline", "resolved from a param"); + let mut s = spec("params.headline"); + s.tags = Some(vec![vec!["t".to_string(), "params.headline".to_string()]]); + + let signed = build(&s, &ctx, &w).unwrap(); + let event = Event::from_json(String::from_utf8(signed.json).unwrap()).unwrap(); + assert_eq!(event.content, "resolved from a param"); + let tags: Vec> = event.tags.iter().map(|t| t.clone().to_vec()).collect(); + assert_eq!(tags[0][1], "resolved from a param"); + } + + #[test] + fn refuses_content_that_cannot_fit() { + let w = wallet(); + let ctx = ExecutionContext::new(); + let err = build(&spec(&"x".repeat(crate::rangeproof::MAX_PAYLOAD)), &ctx, &w) + .unwrap_err() + .to_string(); + assert!(err.contains("fit in a rangeproof"), "unhelpful message: {err}"); + } + + #[test] + fn rejects_an_unknown_key_name() { + let w = wallet(); + let ctx = ExecutionContext::new(); + let mut s = spec("who signs this?"); + s.sign_with = Some("treasurer".to_string()); + let err = build(&s, &ctx, &w).unwrap_err().to_string(); + assert!(err.contains("Unknown key 'treasurer'"), "unhelpful message: {err}"); + } + + #[test] + fn describes_only_what_is_actually_an_event() { + let w = wallet(); + let ctx = ExecutionContext::new(); + let signed = build(&spec("readable"), &ctx, &w).unwrap(); + let rendered = describe(&signed.json).expect("a signed event describes as one"); + assert!(rendered.contains("signature verifies"), "{rendered}"); + assert!(rendered.contains("readable"), "{rendered}"); + + // Every NIP-01 field is shown, `sig` above all: a summary that omits it looks + // exactly like a payload that never carried one, which is what prompted this. + for field in ["\"id\"", "\"pubkey\"", "\"created_at\"", "\"kind\"", "\"tags\"", "\"content\"", "\"sig\""] { + assert!(rendered.contains(field), "{field} must be visible in:\n{rendered}"); + } + assert!(rendered.contains(&signed.pubkey), "{rendered}"); + assert!(rendered.contains(&signed.id), "{rendered}"); + + assert!(describe(b"just a plain message").is_none()); + } +} + + diff --git a/txmanifest_lib/src/preview.rs b/txmanifest_lib/src/preview.rs index bdfcc58..3fc42d4 100644 --- a/txmanifest_lib/src/preview.rs +++ b/txmanifest_lib/src/preview.rs @@ -469,7 +469,7 @@ fn build_net_effect(action: &Action, ctx: &ExecutionContext, fee_sat: Option String { label } +/// A one-line description of the message an output's rangeproof will carry, for the +/// detailed-effect screen. +/// +/// Statically resolved from the manifest and the context, so it shows what will be +/// embedded without needing the wallet the signature comes from — a nostr event's id and +/// signature are only known once it is built, and neither is what a signer is reading for. +fn rangeproof_note(output: &Output, ctx: &ExecutionContext) -> Option { + use crate::manifest::RangeproofEmbed; + + Some(match output.rangeproof_embed.as_ref()? { + RangeproofEmbed::Message(text) => { + let resolved = eval::eval_text(text, ctx); + format!("rangeproof message ({} bytes): {}", resolved.len(), quoted(&resolved)) + } + RangeproofEmbed::Data(_) => "rangeproof message (raw bytes)".to_string(), + RangeproofEmbed::Nostr(nostr) => format!( + "signed nostr event (kind {}) as your \"{}\" key: {}", + nostr.kind.unwrap_or(1), + eval::eval_text(nostr.sign_with.as_deref().unwrap_or("wallet"), ctx), + quoted(&eval::eval_text(&nostr.content, ctx)), + ), + }) +} + +/// A quoted, single-line, length-capped rendering of author text. +fn quoted(text: &str) -> String { + let one_line: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let trimmed = one_line.trim(); + if trimmed.chars().count() > 60 { + let head: String = trimmed.chars().take(60).collect(); + format!("\"{head}…\"") + } else { + format!("\"{trimmed}\"") + } +} + fn short(s: &str) -> String { if s.len() > 16 { format!("{}…{}", &s[..8], &s[s.len() - 4..]) @@ -704,6 +751,44 @@ mod tests { .expect("CreateOffer method") } + /// A rangeproof message is content the signer is publishing, so it has to be legible + /// on the detailed-effect screen — resolved, not shown as the reference it was written + /// as, and capped so a 3000-byte note cannot push the amounts off screen. + #[test] + fn a_rangeproof_message_reads_on_the_detailed_screen() { + let src = include_str!("../../examples/rangeproof_message/txmanifest.json"); + let manifest = Manifest::from_json_str(src).expect("parse the example manifest"); + let mut ctx = ExecutionContext::new(); + ctx.set_param("content", "hello from inside a rangeproof"); + ctx.set_param("sign_with", "oracle"); + ctx.set_param("amount_sat", "1000"); + + let post = manifest.actions.get("Post").expect("Post action"); + let carrier = post.outputs.as_ref().unwrap().iter().find(|o| o.id == "carrier_out").unwrap(); + let note = rangeproof_note(carrier, &ctx).expect("the carrier output carries a note"); + assert!(note.contains("signed nostr event (kind 1)"), "{note}"); + assert!(note.contains("your \"oracle\" key"), "the signing key must be resolved: {note}"); + assert!(note.contains("hello from inside a rangeproof"), "{note}"); + + // The change output alongside it has nothing to say. + let change = post.outputs.as_ref().unwrap().iter().find(|o| o.id == "change_out").unwrap(); + assert!(rangeproof_note(change, &ctx).is_none()); + + // A plain message renders as its resolved text and its byte count. + let send = manifest.actions.get("Send").expect("Send action"); + let out = send.outputs.as_ref().unwrap().iter().find(|o| o.id == "recipient_out").unwrap(); + ctx.set_param("message", "meet me at the usual place"); + let note = rangeproof_note(out, &ctx).unwrap(); + assert_eq!(note, "rangeproof message (26 bytes): \"meet me at the usual place\""); + + // Long text is truncated, and newlines never break the line layout. + ctx.set_param("message", format!("first line\nsecond line{}", "!".repeat(200))); + let note = rangeproof_note(out, &ctx).unwrap(); + assert!(note.ends_with("…\""), "{note}"); + assert!(!note.contains('\n'), "{note}"); + assert!(note.len() < 120, "{note}"); + } + /// The role is the machine-readable half of the hint and was declared-but-never-read until /// the run flow and `describe` started rendering it. Pin the accessor to the detailed form. #[test] diff --git a/txmanifest_lib/src/pset_builder.rs b/txmanifest_lib/src/pset_builder.rs index 392ee4e..ab6ffe4 100644 --- a/txmanifest_lib/src/pset_builder.rs +++ b/txmanifest_lib/src/pset_builder.rs @@ -7,7 +7,7 @@ use lwk_wollet::{ confidential::{Asset, AssetBlindingFactor, Nonce, Value, ValueBlindingFactor}, hashes::{sha256, Hash as _}, pset::{Input, Output, PartiallySignedTransaction}, - secp256k1_zkp::{RangeProof, SecretKey, SurjectionProof, Tweak}, + secp256k1_zkp::{Generator, RangeProof, SecretKey, SurjectionProof, Tweak}, AssetId, ContractHash, OutPoint, Script, Sequence, Txid, TxOut, TxOutWitness, BlindAssetProofs, BlindValueProofs, RangeProofMessage, SurjectionInput, TxOutSecrets, }, @@ -79,6 +79,9 @@ pub struct PsetOutputSpec { /// Blinding factors the manifest pinned for this output. `None` (the usual case) /// leaves both to the blinder. See [`PinnedBlinding`]. pub blinding: Option, + /// Bytes to carry in this output's value rangeproof, already resolved and framed by + /// the caller. Only meaningful on a confidential output. See [`crate::rangeproof`]. + pub rangeproof_embed: Option>, } /// Blinding factors chosen by the manifest rather than by the blinder. @@ -177,11 +180,19 @@ pub fn build_pset(wollet: &Wollet, network: ElementsNetwork, req: &BuildPsetRequ .enumerate() .filter_map(|(i, o)| o.blinding.map(|b| (i, b))) .collect(); - if pins.is_empty() { + let embeds: HashMap> = req + .outputs + .iter() + .enumerate() + .filter_map(|(i, o)| o.rangeproof_embed.clone().map(|e| (i, e))) + .collect(); + // `blind_last` writes the 64-byte Elements message and offers no way to extend + // it, so an embed — like a pinned factor — needs the engine's own blinding pass. + if pins.is_empty() && embeds.is_empty() { pset.blind_last(&mut rng, &secp, &inp_txout_sec) .map_err(|e| anyhow::anyhow!("PSET blinding failed: {e}"))?; } else { - blind_with_pinned_factors(&mut pset, &secp, &mut rng, &inp_txout_sec, &pins) + blind_with_pinned_factors(&mut pset, &secp, &mut rng, &inp_txout_sec, &pins, &embeds) .context("PSET blinding failed")?; } } @@ -213,6 +224,7 @@ fn blind_with_pinned_factors( rng: &mut (impl rand::RngCore + rand::CryptoRng), inp_txout_sec: &HashMap, pins: &HashMap, + embeds: &HashMap>, ) -> Result<()> { for (i, inp) in pset.inputs().iter().enumerate() { if inp.has_issuance() && inp.blinded_issuance.unwrap_or(1) == 1 { @@ -246,6 +258,15 @@ fn blind_with_pinned_factors( ); } } + for i in embeds.keys() { + if !to_blind.contains(i) { + anyhow::bail!( + "Output {i} carries a rangeproof message but is not being blinded — there is \ + no rangeproof to put it in. Set \"confidential\": true on that output, or \ + drop its `rangeproof_embed`." + ); + } + } // A surjection proof is a ring signature over the difference between the output's // asset generator and that of an input carrying the same asset. Give an output the @@ -302,7 +323,8 @@ fn blind_with_pinned_factors( let pin = pins.get(&i).copied().unwrap_or_default(); let abf = pin.asset_bf.unwrap_or_else(|| AssetBlindingFactor::new(rng)); let vbf = pin.value_bf.unwrap_or_else(|| ValueBlindingFactor::new(rng)); - let value = blind_one_output(pset, i, secp, rng, &surject_inputs, abf, vbf)?; + let value = + blind_one_output(pset, i, secp, rng, &surject_inputs, abf, vbf, embeds.get(&i))?; out_secrets.push((value, abf, vbf)); } @@ -332,7 +354,7 @@ fn blind_with_pinned_factors( .ok_or_else(|| anyhow::anyhow!("Output {free} has no explicit amount to blind"))?; let free_vbf = ValueBlindingFactor::last(secp, free_value, free_abf, &inp_secrets, &out_secrets); - blind_one_output(pset, free, secp, rng, &surject_inputs, free_abf, free_vbf)?; + blind_one_output(pset, free, secp, rng, &surject_inputs, free_abf, free_vbf, embeds.get(&free))?; // Nothing was left for another blinder to finish, so no scalar is carried. Ok(()) @@ -340,6 +362,13 @@ fn blind_with_pinned_factors( /// Blind one PSET output with the given factors, writing back the commitments and all /// four proofs. Returns the output's explicit amount, which the balance solver needs. +/// +/// `embed`, when present, is carried in the value rangeproof's message after the 64 +/// bytes Elements reserves. That costs nothing — the proof is the same length either way +/// (`rangeproof::message_length_does_not_change_proof_size`), so the fee estimated on the +/// builder's first pass still holds — and it is invisible to anyone without the output's +/// blinding key. +#[allow(clippy::too_many_arguments)] fn blind_one_output( pset: &mut PartiallySignedTransaction, idx: usize, @@ -348,6 +377,7 @@ fn blind_one_output( surject_inputs: &[SurjectionInput], abf: AssetBlindingFactor, vbf: ValueBlindingFactor, + embed: Option<&Vec>, ) -> Result { let out = &pset.outputs()[idx]; let asset_id = out @@ -365,16 +395,44 @@ fn blind_one_output( let (asset_comm, surjection_proof) = Asset::Explicit(asset_id) .blind(rng, secp, abf, surject_inputs) .map_err(|e| anyhow::anyhow!("Output {idx} asset blinding failed: {e}"))?; - let (value_comm, nonce, rangeproof) = Value::Explicit(value) - .blind( - secp, - vbf, - blinding_pk, - SecretKey::new(rng), - &script_pubkey, - &RangeProofMessage { asset: asset_id, bf: abf }, - ) - .map_err(|e| anyhow::anyhow!("Output {idx} value blinding failed: {e}"))?; + let rp_message = RangeProofMessage { asset: asset_id, bf: abf }; + let (value_comm, nonce, rangeproof) = match embed { + // No message to carry: the one-call path, identical to what `blind_last` does. + None => Value::Explicit(value) + .blind(secp, vbf, blinding_pk, SecretKey::new(rng), &script_pubkey, &rp_message) + .map_err(|e| anyhow::anyhow!("Output {idx} value blinding failed: {e}"))?, + // Carrying one: the same steps `Value::blind` takes, opened up so the rangeproof + // can be signed over a longer message. The engine is the blinder, so it holds the + // ephemeral secret and can do this on the first pass — no rewinding a finished + // proof, and no need for the *receiver's* blinding key, which is what lets an + // embed ride on an output paying someone else's confidential address. + Some(payload) => { + let asset_gen = Generator::new_blinded(secp, asset_id.into_tag(), abf.into_inner()); + let value_comm = Value::new_confidential(secp, value, asset_gen, vbf); + let commitment = value_comm + .commitment() + .ok_or_else(|| anyhow::anyhow!("Output {idx} value commitment missing"))?; + let (nonce, shared_secret) = + Nonce::with_ephemeral_sk(secp, SecretKey::new(rng), &blinding_pk); + let message = crate::rangeproof::build_message(rp_message.to_bytes(), Some(payload)) + .with_context(|| format!("Output {idx} rangeproof message"))?; + let rangeproof = RangeProof::new( + secp, + TxOut::RANGEPROOF_MIN_VALUE, + commitment, + value, + vbf.into_inner(), + &message, + script_pubkey.as_bytes(), + shared_secret, + TxOut::RANGEPROOF_EXP_SHIFT, + TxOut::RANGEPROOF_MIN_PRIV_BITS, + asset_gen, + ) + .map_err(|e| anyhow::anyhow!("Output {idx} value blinding failed: {e}"))?; + (value_comm, nonce, rangeproof) + } + }; let asset_gen = asset_comm .commitment() @@ -1036,7 +1094,7 @@ mod pinned_blinding_tests { 0usize, PinnedBlinding { asset_bf: Some(one_abf), value_bf: Some(one_vbf) }, )]); - blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins) + blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins, &HashMap::new()) .expect("pinned blinding"); let tx = pset.extract_tx().expect("extract_tx"); @@ -1050,6 +1108,148 @@ mod pinned_blinding_tests { assert_eq!(opened.asset, asset); } + /// The whole rangeproof-embed claim, end to end at the PSET level: the transaction + /// still verifies the way a node verifies it, the payload comes back to the holder of + /// the output's blinding key, the *other* confidential output is untouched, and the + /// transaction is exactly the same size as one without a message — which is what makes + /// the fee the builder estimated on its first pass still correct on its second. + #[test] + fn an_embedded_message_reaches_the_chain_for_free() { + fn build( + embeds: &HashMap>, + ) -> (lwk_wollet::elements::Transaction, TxOut, SecretKey, SecretKey) { + let secp = EC.clone(); + // A fixed rng would be better still, but the surjection proof draws from it; + // the size assertion below holds regardless because the proof length does not + // depend on the message. See `rangeproof::message_length_does_not_change_proof_size`. + let mut rng = rand::thread_rng(); + let asset = test_asset(11); + + let prev = TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(1000), + nonce: Nonce::Null, + script_pubkey: test_spk(1), + witness: TxOutWitness::default(), + }; + let mut pset = PartiallySignedTransaction::new_v2(); + let mut input = Input::from_prevout(OutPoint::new(Txid::from_byte_array([3u8; 32]), 0)); + input.witness_utxo = Some(prev.clone()); + input.asset = Some(asset); + input.amount = Some(1000); + pset.add_input(input); + + let mut secrets = HashMap::new(); + secrets.insert(0usize, TxOutSecrets { + value: 1000, + value_bf: ValueBlindingFactor::zero(), + asset, + asset_bf: AssetBlindingFactor::zero(), + }); + + // Deterministic receiver keys so both builds address the same outputs. + let carrier_sk = SecretKey::from_slice(&[0x21u8; 32]).unwrap(); + let plain_sk = SecretKey::from_slice(&[0x22u8; 32]).unwrap(); + pset.add_output(confidential_output( + test_spk(2), 600, asset, btc_pubkey(PublicKey::from_secret_key(&secp, &carrier_sk)), 0, + )); + pset.add_output(confidential_output( + test_spk(3), 300, asset, btc_pubkey(PublicKey::from_secret_key(&secp, &plain_sk)), 0, + )); + pset.add_output(Output::new_explicit(Script::default(), 100, asset, None)); + + blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &HashMap::new(), embeds) + .expect("blinding with an embed"); + let tx = pset.extract_tx().expect("extract_tx"); + (tx, prev, carrier_sk, plain_sk) + } + + let secp = EC.clone(); + let payload = b"a message nobody but the receiver can read".to_vec(); + let embeds = HashMap::from([(0usize, payload.clone())]); + let (tx, prev, carrier_sk, plain_sk) = build(&embeds); + + // A node accepts it. + tx.verify_tx_amt_proofs(&secp, std::slice::from_ref(&prev)) + .expect("rangeproofs, surjection proofs and the commitment balance must all check"); + + // The receiver reads the message… + assert_eq!( + crate::rangeproof::extract_message(&secp, &tx.output[0], &carrier_sk).unwrap(), + Some(payload), + ); + // …and still unblinds the output as any wallet would. + let opened = tx.output[0].unblind(&secp, carrier_sk).expect("unblind the carrier"); + assert_eq!(opened.value, 600); + assert_eq!(opened.asset, test_asset(11)); + + // The output that was not asked to carry anything carries nothing — an embed must + // not leak onto its neighbours, and a scan must not false-positive on them. + assert_eq!( + crate::rangeproof::extract_message(&secp, &tx.output[1], &plain_sk).unwrap(), + None, + ); + + // Nobody else can read it: without the blinding key the rewind fails outright. + assert!(crate::rangeproof::extract_message(&secp, &tx.output[0], &plain_sk).is_err()); + + // And it was free. + let (bare, _, _, _) = build(&HashMap::new()); + assert_eq!( + tx.weight(), bare.weight(), + "an embedded message must not change the transaction's weight, or the fee is wrong", + ); + } + + /// An embed on an output nobody is blinding has nowhere to go. Failing loudly beats + /// building a transaction that silently drops the message the manifest declared. + #[test] + fn an_embed_on_an_explicit_output_is_rejected() { + let secp = EC.clone(); + let mut rng = rand::thread_rng(); + let asset = test_asset(13); + + let prev = TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(1000), + nonce: Nonce::Null, + script_pubkey: test_spk(1), + witness: TxOutWitness::default(), + }; + let mut pset = PartiallySignedTransaction::new_v2(); + let mut input = Input::from_prevout(OutPoint::new(Txid::from_byte_array([4u8; 32]), 0)); + input.witness_utxo = Some(prev); + input.asset = Some(asset); + input.amount = Some(1000); + pset.add_input(input); + + let mut secrets = HashMap::new(); + secrets.insert(0usize, TxOutSecrets { + value: 1000, + value_bf: ValueBlindingFactor::zero(), + asset, + asset_bf: AssetBlindingFactor::zero(), + }); + + let sk = SecretKey::new(&mut rng); + pset.add_output(confidential_output( + test_spk(2), 600, asset, btc_pubkey(PublicKey::from_secret_key(&secp, &sk)), 0, + )); + // Output 1 is explicit, so it has no rangeproof at all. + pset.add_output(Output::new_explicit(test_spk(3), 300, asset, None)); + pset.add_output(Output::new_explicit(Script::default(), 100, asset, None)); + + let embeds = HashMap::from([(1usize, b"nowhere to put this".to_vec())]); + let err = blind_with_pinned_factors( + &mut pset, &secp, &mut rng, &secrets, &HashMap::new(), &embeds, + ) + .expect_err("an explicit output cannot carry a rangeproof message"); + assert!( + err.to_string().contains("not being blinded"), + "error must say why, got: {err}" + ); + } + /// Pinning every confidential output leaves the balance residue nowhere to go. That is /// unsatisfiable rather than merely unusual, so it must fail loudly at build time — not /// produce a transaction a node rejects. @@ -1093,7 +1293,7 @@ mod pinned_blinding_tests { 0usize, PinnedBlinding { asset_bf: Some(one_abf), value_bf: Some(one_vbf) }, )]); - let err = blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins) + let err = blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins, &HashMap::new()) .expect_err("no output left free to balance"); assert!( err.to_string().contains("value blinding factor"), @@ -1175,7 +1375,7 @@ mod covenant_input_tests { 0usize, PinnedBlinding { asset_bf: Some(abf), value_bf: Some(vbf) }, )]); - blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins).unwrap(); + blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins, &HashMap::new()).unwrap(); let created = pset.extract_tx().unwrap().output[0].clone(); // --- transaction B: spend it, with only the factors to go on --- @@ -1338,7 +1538,7 @@ mod abf_reuse_tests { value_bf: Some(ValueBlindingFactor::from_slice(&scalar(1)).unwrap()), }, )]); - let err = blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins) + let err = blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins, &HashMap::new()) .expect_err("reusing the input's abf must be refused"); let msg = err.to_string(); assert!(msg.contains("surjection"), "must explain the surjection proof: {msg}"); @@ -1390,7 +1590,7 @@ mod abf_reuse_tests { value_bf: Some(ValueBlindingFactor::from_slice(&scalar(2)).unwrap()), }, )]); - blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins) + blind_with_pinned_factors(&mut pset, &secp, &mut rng, &secrets, &pins, &HashMap::new()) .expect("advancing the factor must build"); let tx = pset.extract_tx().unwrap(); diff --git a/txmanifest_lib/src/rangeproof.rs b/txmanifest_lib/src/rangeproof.rs new file mode 100644 index 0000000..cd434f5 --- /dev/null +++ b/txmanifest_lib/src/rangeproof.rs @@ -0,0 +1,427 @@ +//! Messages carried inside a confidential output's value rangeproof. +//! +//! Elements signs every confidential output's rangeproof over an author-supplied +//! message and uses only the first 64 bytes of it (asset id ‖ asset blinding factor). +//! The rest is recovered verbatim by a rewind, is readable only by the holder of the +//! output's blinding key, and — because `min_bits = 52` forces the ring count — costs +//! nothing in proof size. That unused tail is what this module writes into. +//! +//! The frame written after the Elements prefix is byte-compatible with `lrp-core` +//! (the `liquidrangeproof` / `liquid-nostr-bridge` proof of concept), so an output +//! built here reads back with those tools and with the bridge relay: +//! +//! ```text +//! magic "LRPM" (4B) | version u8 | len u16 LE | payload (len B) | crc32 u32 LE +//! ``` +//! +//! Unlike that proof of concept, nothing here rewinds and replaces an already-built +//! proof. The engine is the blinder, so it knows the ephemeral secret and can sign the +//! extended message on the first pass — which also means an embed can ride on an +//! output paying *someone else's* confidential address, not only our own. + +use anyhow::{bail, ensure, Context, Result}; +use lwk_wollet::elements::{ + confidential::{Asset, Nonce, Value}, + secp256k1_zkp::{ + Generator, PedersenCommitment, RangeProof, Secp256k1, SecretKey, Verification, + }, + TxOut, +}; + +/// Frame magic. Shared with `lrp-core`; changing it forks the format. +pub const MAGIC: &[u8; 4] = b"LRPM"; + +/// Frame version. +pub const VERSION: u8 = 1; + +/// Bytes of frame overhead around the payload: magic + version + len + crc32. +pub const FRAME_OVERHEAD: usize = 4 + 1 + 2 + 4; + +/// Bytes Elements reserves at the front of the message: 32-byte asset id followed by +/// the 32-byte asset blinding factor. +pub const PREFIX_LEN: usize = 64; + +/// Total rangeproof message capacity, in bytes. +/// +/// `secp256k1_rangeproof_sign` rejects any message longer than `128 * (rings - 1)`. +/// With `RANGEPROOF_MIN_PRIV_BITS = 52` — what Elements always signs with, and what +/// [`crate::pset_builder`] therefore has to match — the mantissa is forced to 52, +/// giving `rings = (52 + 1) >> 1 = 26` and a cap of `128 * 25`. Asserted by +/// `capacity_is_exactly_as_derived` rather than taken on trust from the C source. +pub const MESSAGE_CAPACITY: usize = 3200; + +/// Bytes available to a manifest's payload, after the Elements prefix and the framing. +pub const MAX_PAYLOAD: usize = MESSAGE_CAPACITY - PREFIX_LEN - FRAME_OVERHEAD; + +/// Wrap `payload` in a frame. +pub fn encode_frame(payload: &[u8]) -> Result> { + ensure!( + payload.len() <= MAX_PAYLOAD, + "rangeproof payload is {} bytes but only {MAX_PAYLOAD} fit \ + ({MESSAGE_CAPACITY} byte message capacity, less {PREFIX_LEN} reserved by \ + Elements and {FRAME_OVERHEAD} of framing)", + payload.len() + ); + + let mut out = Vec::with_capacity(FRAME_OVERHEAD + payload.len()); + out.extend_from_slice(MAGIC); + out.push(VERSION); + out.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + out.extend_from_slice(payload); + out.extend_from_slice(&crc32fast::hash(payload).to_le_bytes()); + Ok(out) +} + +/// Recover a payload from the start of `buf`, which may carry arbitrary trailing +/// padding — a rewind always returns the full capacity, zero-filled. +/// +/// `Ok(None)` means `buf` carries no frame at all (the common case: an ordinary +/// output, whose message tail is all zeros). `Err` means a frame is present but +/// malformed, which is worth reporting rather than silently skipping. +pub fn decode_frame(buf: &[u8]) -> Result>> { + if buf.len() < FRAME_OVERHEAD || &buf[..4] != MAGIC { + return Ok(None); + } + + let version = buf[4]; + ensure!( + version == VERSION, + "unsupported rangeproof frame version {version} (this build understands {VERSION})" + ); + + let len = u16::from_le_bytes([buf[5], buf[6]]) as usize; + let end = FRAME_OVERHEAD + len; + ensure!( + end <= buf.len(), + "rangeproof frame claims a {len}-byte payload but only {} bytes remain", + buf.len().saturating_sub(FRAME_OVERHEAD - 4) + ); + + let payload = &buf[7..7 + len]; + let expected = u32::from_le_bytes([buf[end - 4], buf[end - 3], buf[end - 2], buf[end - 1]]); + let actual = crc32fast::hash(payload); + ensure!( + actual == expected, + "rangeproof frame checksum mismatch: expected {expected:08x}, computed {actual:08x}" + ); + + Ok(Some(payload.to_vec())) +} + +/// Build the full rangeproof message for an output: the 64 bytes Elements requires, +/// followed by the framed payload when the manifest asked for one. +/// +/// Keeping the prefix byte-identical to what `RangeProofMessage::to_bytes` produces is +/// what lets an ordinary wallet unblind the output as usual — the embed is invisible to +/// anything not looking for it. +pub fn build_message(prefix: [u8; PREFIX_LEN], payload: Option<&[u8]>) -> Result> { + let mut message = Vec::with_capacity(MESSAGE_CAPACITY); + message.extend_from_slice(&prefix); + if let Some(payload) = payload { + message.extend_from_slice(&encode_frame(payload)?); + } + Ok(message) +} + +/// Pull the confidential commitments out of a `TxOut`, erroring clearly when the +/// output is not a blinded one (a fee or OP_RETURN leg, say). +fn confidential_parts(txout: &TxOut) -> Result<(PedersenCommitment, Generator, &RangeProof)> { + let value_commitment = match txout.value { + Value::Confidential(c) => c, + _ => bail!("output value is not confidential, so it carries no rangeproof message"), + }; + let asset_generator = match txout.asset { + Asset::Confidential(g) => g, + _ => bail!("output asset is not confidential"), + }; + let proof = txout + .witness + .rangeproof + .as_deref() + .context("output has no rangeproof")?; + Ok((value_commitment, asset_generator, proof)) +} + +/// The ECDH shared secret between the sender's ephemeral key (recorded in the output's +/// nonce) and a blinding key. This is what both blinding and rewinding key off. +fn shared_secret(nonce: &Nonce, blinding_sk: &SecretKey) -> Result { + nonce + .shared_secret(blinding_sk) + .context("output has no ECDH nonce, so no shared secret can be derived") +} + +/// Rewind an output's rangeproof and return the message tail that follows the Elements +/// prefix, padding included. +pub fn rewind_tail( + secp: &Secp256k1, + txout: &TxOut, + blinding_sk: &SecretKey, +) -> Result> { + let (value_commitment, asset_generator, proof) = confidential_parts(txout)?; + let secret = shared_secret(&txout.nonce, blinding_sk)?; + + let (opening, _range) = proof + .rewind( + secp, + value_commitment, + secret, + txout.script_pubkey.as_bytes(), + asset_generator, + ) + .map_err(|e| anyhow::anyhow!("rangeproof rewind failed: {e}"))?; + + ensure!( + opening.message.len() >= PREFIX_LEN, + "rewound message is {} bytes, too short to hold the Elements prefix", + opening.message.len() + ); + Ok(opening.message[PREFIX_LEN..].to_vec()) +} + +/// Read an embedded message out of a confidential output, given its blinding key. +/// +/// `Ok(None)` means the output rewound cleanly but carries no message. +pub fn extract_message( + secp: &Secp256k1, + txout: &TxOut, + blinding_sk: &SecretKey, +) -> Result>> { + decode_frame(&rewind_tail(secp, txout, blinding_sk)?) +} + +/// Render a payload for a terminal: as a signed nostr event when it is one, as text +/// when it is valid UTF-8, and as hex otherwise. +pub fn describe_payload(payload: &[u8]) -> String { + if let Some(rendered) = crate::nostr_embed::describe(payload) { + return rendered; + } + match std::str::from_utf8(payload) { + Ok(text) if text.chars().all(|c| !c.is_control() || c.is_whitespace()) => text.to_string(), + _ => format!("{} bytes (binary): {}", payload.len(), hex_of(payload)), + } +} + +fn hex_of(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let shown = &bytes[..bytes.len().min(64)]; + let mut s = shown.iter().fold(String::new(), |mut acc, b| { + let _ = write!(acc, "{b:02x}"); + acc + }); + if bytes.len() > shown.len() { + s.push('…'); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + use lwk_wollet::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; + use lwk_wollet::elements::secp256k1_zkp::{Tag, Tweak}; + use lwk_wollet::elements::Script; + use lwk_wollet::elements::AssetId; + + fn round_trip(payload: &[u8]) { + let frame = encode_frame(payload).unwrap(); + assert_eq!(frame.len(), FRAME_OVERHEAD + payload.len()); + assert_eq!(decode_frame(&frame).unwrap().as_deref(), Some(payload)); + } + + #[test] + fn frames_round_trip() { + round_trip(b""); + round_trip(b"hello liquid"); + round_trip(&[0u8; 1024]); + round_trip(&vec![0x5au8; MAX_PAYLOAD]); + } + + #[test] + fn tolerates_trailing_padding() { + // Exactly what a rewind returns: the frame followed by zero padding. + let mut buf = encode_frame(b"padded").unwrap(); + buf.resize(MESSAGE_CAPACITY - PREFIX_LEN, 0); + assert_eq!(decode_frame(&buf).unwrap().as_deref(), Some(&b"padded"[..])); + } + + #[test] + fn an_ordinary_output_is_not_a_frame() { + assert!(decode_frame(&[0u8; MESSAGE_CAPACITY - PREFIX_LEN]).unwrap().is_none()); + assert!(decode_frame(&[]).unwrap().is_none()); + assert!(decode_frame(b"LRP").unwrap().is_none()); + assert!(decode_frame(b"NOPE and then some padding").unwrap().is_none()); + } + + #[test] + fn rejects_corruption_and_bad_headers() { + let mut frame = encode_frame(b"tamper with me").unwrap(); + frame[8] ^= 0xff; + assert!(decode_frame(&frame).is_err()); + + let mut frame = encode_frame(b"short").unwrap(); + frame[5] = 0xff; + assert!(decode_frame(&frame).is_err()); + + let mut frame = encode_frame(b"from the future").unwrap(); + frame[4] = 99; + assert!(decode_frame(&frame).is_err()); + } + + #[test] + fn refuses_a_payload_that_cannot_fit() { + let err = encode_frame(&vec![0u8; MAX_PAYLOAD + 1]).unwrap_err().to_string(); + assert!(err.contains("only 3125 fit"), "unhelpful message: {err}"); + } + + /// The frame layout is the contract with `lrp-core`; pin the bytes so a refactor + /// here cannot silently fork it. + #[test] + fn frame_layout_matches_lrp_core() { + assert_eq!( + encode_frame(b"hi").unwrap(), + b"LRPM\x01\x02\x00hi\xac\x2a\x93\xd8".to_vec(), + ); + assert_eq!(MAX_PAYLOAD, 3125); + } + + // -- Proof-level facts, measured rather than asserted from the C source. ---- + + struct Fixture { + secp: Secp256k1, + commitment: PedersenCommitment, + generator: Generator, + value: u64, + vbf: Tweak, + nonce: SecretKey, + spk: Script, + } + + fn fixture(value: u64) -> Fixture { + let secp = Secp256k1::new(); + let generator = + Generator::new_blinded(&secp, Tag::from([7u8; 32]), Tweak::from_inner([3u8; 32]).unwrap()); + let vbf = Tweak::from_inner([5u8; 32]).unwrap(); + let commitment = PedersenCommitment::new(&secp, value, vbf, generator); + Fixture { + secp, + commitment, + generator, + value, + vbf, + nonce: SecretKey::from_slice(&[9u8; 32]).unwrap(), + spk: Script::from(vec![0x00, 0x14, 0xab]), + } + } + + fn sign(f: &Fixture, message: &[u8]) -> Result { + RangeProof::new( + &f.secp, + TxOut::RANGEPROOF_MIN_VALUE, + f.commitment, + f.value, + f.vbf, + message, + f.spk.as_bytes(), + f.nonce, + TxOut::RANGEPROOF_EXP_SHIFT, + TxOut::RANGEPROOF_MIN_PRIV_BITS, + f.generator, + ) + } + + #[test] + fn capacity_is_exactly_as_derived() { + let f = fixture(100_000); + assert!(sign(&f, &vec![0xab; MESSAGE_CAPACITY]).is_ok()); + assert!(sign(&f, &vec![0xab; MESSAGE_CAPACITY + 1]).is_err()); + } + + /// The whole premise: an embed must not change the transaction's size, or the fee + /// the builder estimated on the first pass would be wrong on the second. + #[test] + fn message_length_does_not_change_proof_size() { + let f = fixture(100_000); + let bare = sign(&f, &[0u8; PREFIX_LEN]).unwrap(); + let full = sign(&f, &vec![0xcd; MESSAGE_CAPACITY]).unwrap(); + assert_eq!(bare.serialize().len(), full.serialize().len()); + } + + /// Capacity must not depend on the amount, or a dust-sized output would silently + /// truncate a message that fits elsewhere. + #[test] + fn capacity_is_independent_of_value() { + for value in [1u64, 1_000, 21_000_000 * 100_000_000] { + let f = fixture(value); + assert!( + sign(&f, &vec![0xab; MESSAGE_CAPACITY]).is_ok(), + "value {value} did not accept a full-capacity message" + ); + } + } + + /// End to end through the public API: blind an output the way `pset_builder` does, + /// then read the payload back with only the receiver's blinding key. + #[test] + fn round_trips_through_a_blinded_output() { + let secp = Secp256k1::new(); + let asset = AssetId::from_slice(&[0x11u8; 32]).unwrap(); + let abf = AssetBlindingFactor::from_slice(&[0x22u8; 32]).unwrap(); + let vbf = ValueBlindingFactor::from_slice(&[0x33u8; 32]).unwrap(); + let value = 100_000u64; + let spk = Script::from(vec![0x00, 0x14, 0xcd]); + + let blinding_sk = SecretKey::from_slice(&[0x44u8; 32]).unwrap(); + let blinding_pk = blinding_sk.public_key(&secp); + let ephemeral_sk = SecretKey::from_slice(&[0x55u8; 32]).unwrap(); + + let payload = b"the quick brown fox jumps over the lazy dog"; + let mut prefix = [0u8; PREFIX_LEN]; + prefix[..32].copy_from_slice(asset.into_tag().as_ref()); + prefix[32..].copy_from_slice(abf.into_inner().as_ref()); + let message = build_message(prefix, Some(payload)).unwrap(); + + let (nonce, secret) = Nonce::with_ephemeral_sk(&secp, ephemeral_sk, &blinding_pk); + let asset_gen = Generator::new_blinded(&secp, asset.into_tag(), abf.into_inner()); + let value_comm = Value::new_confidential(&secp, value, asset_gen, vbf); + let proof = RangeProof::new( + &secp, + TxOut::RANGEPROOF_MIN_VALUE, + value_comm.commitment().unwrap(), + value, + vbf.into_inner(), + &message, + spk.as_bytes(), + secret, + TxOut::RANGEPROOF_EXP_SHIFT, + TxOut::RANGEPROOF_MIN_PRIV_BITS, + asset_gen, + ) + .unwrap(); + + let txout = TxOut { + asset: Asset::Confidential(asset_gen), + value: value_comm, + nonce, + script_pubkey: spk, + witness: lwk_wollet::elements::TxOutWitness { + surjection_proof: None, + rangeproof: Some(Box::new(proof)), + }, + }; + + // The receiver reads the message… + assert_eq!( + extract_message(&secp, &txout, &blinding_sk).unwrap().as_deref(), + Some(&payload[..]) + ); + // …and ordinary unblinding is untouched, which is what keeps the embed invisible. + let secrets = txout.unblind(&secp, blinding_sk).unwrap(); + assert_eq!(secrets.value, value); + assert_eq!(secrets.asset, asset); + assert_eq!(secrets.asset_bf, abf); + + // Anyone else sees an ordinary confidential output and nothing more. + let stranger = SecretKey::from_slice(&[0x66u8; 32]).unwrap(); + assert!(extract_message(&secp, &txout, &stranger).is_err()); + } +} diff --git a/txmanifest_lib/src/validate.rs b/txmanifest_lib/src/validate.rs index a4c4ba1..dcf1c5c 100644 --- a/txmanifest_lib/src/validate.rs +++ b/txmanifest_lib/src/validate.rs @@ -437,7 +437,10 @@ fn check_action( let requires_amount = check_destination(report, utxo_types, referenced, &oloc, &output.destination.0); let optional = output.optional.unwrap_or(false); if requires_amount && output.amount_sat.is_none() && !optional { - report.error(oloc, "missing amount_sat (required for this destination)"); + report.error(oloc.clone(), "missing amount_sat (required for this destination)"); + } + if let Some(embed) = &output.rangeproof_embed { + check_rangeproof_embed(report, &oloc, output, embed); } } } @@ -849,6 +852,100 @@ fn check_utxo_site( /// Validate an output `destination` and return whether it requires an explicit /// `amount_sat` (covenant, wallet, address, and script_hash destinations do; /// change, op_return/burn, fee, and conditional destinations do not). +/// Static checks on an output's `rangeproof_embed`. +/// +/// A rangeproof message only exists on a confidential output, and the destinations the +/// engine never blinds cannot carry one at all. Catching that here means the author +/// hears about it from `validate`, not from a build that got as far as blinding. +fn check_rangeproof_embed( + report: &mut Report, + loc: &str, + output: &crate::manifest::Output, + embed: &crate::manifest::RangeproofEmbed, +) { + use crate::manifest::RangeproofEmbed; + + let loc = format!("{loc}.rangeproof_embed"); + + // Destinations that are never blinded, so there is no rangeproof to write into. + let unblindable = match &*output.destination { + serde_json::Value::String(d) if d == "change" => Some("change"), + serde_json::Value::Object(m) => match m.get("type").and_then(|v| v.as_str()) { + Some("op_return") => Some("an OP_RETURN"), + Some("burn") => Some("a burn"), + Some("fee") => Some("the fee"), + _ if m.contains_key("script_hash") => Some("a script_hash"), + _ => None, + }, + _ => None, + }; + if let Some(kind) = unblindable { + report.error( + loc.clone(), + format!("{kind} output is never blinded, so it has no rangeproof to carry a message"), + ); + } else if output.confidential == Some(false) { + report.error( + loc.clone(), + "output sets \"confidential\": false, so it has no rangeproof to carry a message", + ); + } + + match embed { + // Only a literal can be measured here; a reference is whatever it resolves to at + // build time, and the engine checks the resolved length before blinding. + RangeproofEmbed::Message(text) if !is_reference(text) => { + if text.len() > crate::rangeproof::MAX_PAYLOAD { + report.error( + loc, + format!( + "message is {} bytes; a rangeproof holds {}", + text.len(), + crate::rangeproof::MAX_PAYLOAD + ), + ); + } + } + RangeproofEmbed::Nostr(nostr) => { + if let Some(spec) = &nostr.sign_with { + if !matches!(spec.trim(), "wallet" | "oracle") + && !spec.trim().starts_with("m/") + && !is_reference(spec) + { + report.error( + format!("{loc}.nostr.sign_with"), + format!( + "unknown signing key '{spec}'; expected \"wallet\", \"oracle\", a \ + BIP32 path starting with \"m/\", or a reference resolving to one" + ), + ); + } + } + if nostr.content.len() > crate::rangeproof::MAX_PAYLOAD && !is_reference(&nostr.content) { + report.error( + format!("{loc}.nostr.content"), + format!( + "content is {} bytes; a rangeproof holds {} including the event's \ + JSON framing and signature", + nostr.content.len(), + crate::rangeproof::MAX_PAYLOAD + ), + ); + } + } + _ => {} + } +} + +/// True when a string is a whole-string reference the engine will resolve at build time, +/// rather than a literal whose length is already known. +fn is_reference(s: &str) -> bool { + let s = s.trim(); + ["params.", "instance.", "compile_params.", "inputs."] + .iter() + .any(|p| s.starts_with(p)) +} + fn check_destination( report: &mut Report, utxo_types: &std::collections::BTreeMap, @@ -948,6 +1045,76 @@ mod tests { assert!(msg.contains("declares no `params`"), "{msg}"); } + /// A rangeproof message only exists on a confidential output. Every destination the + /// engine never blinds must be told so here, not several steps into a build. + #[test] + fn a_rangeproof_embed_needs_something_to_embed_into() { + let validate_output = |output: &str| { + let manifest = Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "1", "protocol": "t", + "actions": {{ "A": {{ "outputs": [ {output} ] }} }} }}"# + )) + .expect("manifest should parse"); + let report = validate(&manifest); + report.issues.iter().map(|i| i.message.clone()).collect::>().join("\n") + }; + + for (output, expect) in [ + (r#"{"id":"o","destination":"change","asset":"lbtc","rangeproof_embed":"x"}"#, "change output is never blinded"), + (r#"{"id":"o","destination":{"type":"op_return"},"asset":"lbtc","rangeproof_embed":"x"}"#, "an OP_RETURN output is never blinded"), + (r#"{"id":"o","destination":{"type":"burn"},"asset":"lbtc","rangeproof_embed":"x"}"#, "a burn output is never blinded"), + (r#"{"id":"o","destination":{"type":"fee"},"asset":"lbtc","rangeproof_embed":"x"}"#, "the fee output is never blinded"), + (r#"{"id":"o","destination":{"script_hash":"params.h"},"amount_sat":"1","rangeproof_embed":"x"}"#, "a script_hash output is never blinded"), + (r#"{"id":"o","destination":"wallet","amount_sat":"1","confidential":false,"rangeproof_embed":"x"}"#, "\"confidential\": false"), + ] { + let msg = validate_output(output); + assert!(msg.contains(expect), "expected '{expect}' for {output}, got: {msg}"); + } + + // The one that should be clean: a confidential wallet output. + let msg = validate_output( + r#"{"id":"o","destination":"wallet","amount_sat":"1","rangeproof_embed":"x"}"#, + ); + assert!(msg.is_empty(), "a confidential wallet output may carry a message: {msg}"); + } + + /// Capacity and key names are checkable without a wallet when they are literals — + /// and must not be second-guessed when they are references, whose value is only + /// known at build time. + #[test] + fn a_rangeproof_embed_is_measured_only_when_it_is_a_literal() { + let validate_embed = |embed: &str| { + let manifest = Manifest::from_json_str(&format!( + r#"{{ "manifest_version": "1", "protocol": "t", + "actions": {{ "A": {{ "outputs": [ {{ "id": "o", + "destination": "wallet", "amount_sat": "1", + "rangeproof_embed": {embed} }} ] }} }} }}"# + )) + .expect("manifest should parse"); + let report = validate(&manifest); + report.issues.iter().map(|i| i.message.clone()).collect::>().join("\n") + }; + + let too_long = "x".repeat(crate::rangeproof::MAX_PAYLOAD + 1); + assert!(validate_embed(&format!("\"{too_long}\"")).contains("a rangeproof holds 3125")); + assert!(validate_embed(&format!("\"{}\"", "x".repeat(crate::rangeproof::MAX_PAYLOAD))).is_empty()); + + // A reference could resolve to anything; the engine checks the resolved length. + assert!(validate_embed(r#""params.body""#).is_empty()); + assert!(validate_embed(r#"{"nostr":{"content":"hi","sign_with":"params.author"}}"#).is_empty()); + + for key in ["wallet", "oracle", "m/86h/1h/7h/0/0"] { + assert!( + validate_embed(&format!(r#"{{"nostr":{{"content":"hi","sign_with":"{key}"}}}}"#)).is_empty(), + "'{key}' is a valid signing key" + ); + } + assert!( + validate_embed(r#"{"nostr":{"content":"hi","sign_with":"treasurer"}}"#) + .contains("unknown signing key 'treasurer'") + ); + } + /// Build a manifest with a single action whose one wallet input carries the /// given `witnesses` JSON, then validate it. fn validate_with_input_witnesses(witnesses: Value) -> Report { diff --git a/txmanifest_lib/src/wallet.rs b/txmanifest_lib/src/wallet.rs index 4a78ab5..fe7ecfe 100644 --- a/txmanifest_lib/src/wallet.rs +++ b/txmanifest_lib/src/wallet.rs @@ -199,23 +199,127 @@ pub fn explicit_utxos(wallet: &WalletFile, data_dir: &Path) -> Result, + /// `Ok(None)` — the output rewound cleanly and carries no message (the common case). + /// `Err` — a frame is there but malformed, which is worth showing rather than hiding. + pub message: Result>>, +} + +/// Read rangeproof messages out of the wallet's own confidential outputs. +/// +/// Works off persisted state, so it needs a prior `sync` and no network call. Only +/// wallet-owned outputs are visible: rewinding a rangeproof needs the output's blinding +/// key, which is exactly the gate that makes an embedded message private in the first +/// place. `only` narrows the scan to a single outpoint. +pub fn scan_rangeproof_messages( + wallet: &WalletFile, + data_dir: &Path, + only: Option<(lwk_wollet::elements::Txid, u32)>, +) -> Result> { + let network = elements_network(wallet); + let desc = descriptor(wallet)?; + let wollet = lwk_wollet::Wollet::with_fs_persist(network, desc, data_dir) + .map_err(|e| anyhow::anyhow!("Failed to open wallet: {e}"))?; + let descriptor = wollet.descriptor(); + let txs = wollet + .transactions() + .map_err(|e| anyhow::anyhow!("Failed to read wallet transactions: {e}"))?; + + let mut found = Vec::new(); + for wtx in txs { + if only.is_some_and(|(txid, _)| txid != wtx.txid) { + continue; + } + for (vout, owned) in wtx.outputs.iter().enumerate() { + let Some(owned) = owned else { continue }; + if only.is_some_and(|(_, v)| v != vout as u32) { + continue; + } + let txout = &wtx.tx.output[vout]; + // An explicit output has no rangeproof at all; skip rather than report an + // error on every fee and OP_RETURN leg in the wallet's history. + if !txout.value.is_confidential() { + continue; + } + let Some(blinding_sk) = + lwk_common::derive_blinding_key(descriptor, &txout.script_pubkey) + else { + continue; + }; + found.push(ScannedOutput { + txid: wtx.txid, + vout: vout as u32, + value: owned.unblinded.value, + asset: owned.unblinded.asset, + height: wtx.height, + message: crate::rangeproof::extract_message( + &lwk_wollet::EC, + txout, + &blinding_sk, + ), + }); + } + } + found.sort_by_key(|o| (std::cmp::Reverse(o.height.unwrap_or(u32::MAX)), o.vout)); + Ok(found) +} + pub struct SyncResult { pub tip: u32, pub utxos: Vec, pub explicit_utxos: Vec, } -/// Sign a 32-byte hash with BIP340 Schnorr using the wallet key that matches `pubkey_hex`. +/// The wallet's general signing key path for this network. +pub fn wallet_key_path(wallet: &WalletFile) -> &'static str { + if wallet.is_mainnet() { WALLET_KEY_PATH_MAINNET } else { WALLET_KEY_PATH_TESTNET } +} + +/// The wallet's oracle key path for this network. +pub fn oracle_key_path(wallet: &WalletFile) -> &'static str { + if wallet.is_mainnet() { ORACLE_PATH_MAINNET } else { ORACLE_PATH_TESTNET } +} + +/// Resolve a key specification to a BIP32 derivation path. /// -/// Tries the wallet signing key path and oracle key path. Errors if neither matches. -pub fn sign_schnorr_for_pubkey( +/// Accepts the two named keys this wallet publishes (`"wallet"`, `"oracle"`) or a +/// literal path (`"m/86h/1h/7h/0/0"`). Aliases exist so a manifest does not have to +/// hard-code a path that differs between mainnet and testnet. +pub fn resolve_key_path(wallet: &WalletFile, spec: &str) -> Result { + let spec = spec.trim(); + match spec { + "wallet" => Ok(wallet_key_path(wallet).to_string()), + "oracle" => Ok(oracle_key_path(wallet).to_string()), + _ if spec.starts_with("m/") || spec.starts_with("M/") => { + DerivationPath::from_str(spec) + .map_err(|e| anyhow::anyhow!("Invalid derivation path '{spec}': {e}"))?; + Ok(spec.to_string()) + } + _ => anyhow::bail!( + "Unknown key '{spec}'. Expected \"wallet\", \"oracle\", or a BIP32 path \ + starting with \"m/\" (e.g. \"{}\").", + wallet_key_path(wallet) + ), + } +} + +/// Derive the secp256k1 secret key at `path` from `wallet`. +/// +/// The counterpart to [`derive_schnorr_pubkey`]: what that returns as an x-only pubkey, +/// this returns as the key that produces it. +pub fn derive_secret_key( wallet: &WalletFile, - pubkey_hex: &str, - hash: &[u8; 32], -) -> Result<[u8; 64]> { - use elements_miniscript::bitcoin::secp256k1::{Keypair, Message, Secp256k1}; + path_str: &str, +) -> Result { + use elements_miniscript::bitcoin::secp256k1::Secp256k1; - let path_str = find_path_for_pubkey(wallet, pubkey_hex)?; let secp = Secp256k1::new(); let mnemonic: bip39::Mnemonic = wallet.mnemonic.parse() .map_err(|e| anyhow::anyhow!("Failed to parse mnemonic: {e}"))?; @@ -231,7 +335,23 @@ pub fn sign_schnorr_for_pubkey( .map_err(|e| anyhow::anyhow!("Invalid derivation path '{path_str}': {e}"))?; let child = root.derive_priv(&secp, &path) .with_context(|| format!("Key derivation failed at '{path_str}'"))?; - let keypair = Keypair::from_secret_key(&secp, &child.private_key); + Ok(child.private_key) +} + +/// Sign a 32-byte hash with BIP340 Schnorr using the wallet key that matches `pubkey_hex`. +/// +/// Tries the wallet signing key path and oracle key path. Errors if neither matches. +pub fn sign_schnorr_for_pubkey( + wallet: &WalletFile, + pubkey_hex: &str, + hash: &[u8; 32], +) -> Result<[u8; 64]> { + use elements_miniscript::bitcoin::secp256k1::{Keypair, Message, Secp256k1}; + + let path_str = find_path_for_pubkey(wallet, pubkey_hex)?; + let secp = Secp256k1::new(); + let sk = derive_secret_key(wallet, path_str)?; + let keypair = Keypair::from_secret_key(&secp, &sk); let msg = Message::from_digest(*hash); let sig = secp.sign_schnorr(&msg, &keypair); Ok(sig.serialize()) diff --git a/txmanifest_wallet/src/main.rs b/txmanifest_wallet/src/main.rs index e8b3362..200f2e6 100644 --- a/txmanifest_wallet/src/main.rs +++ b/txmanifest_wallet/src/main.rs @@ -194,6 +194,30 @@ enum Commands { data_dir: Option, }, + /// Read messages embedded in the rangeproofs of the wallet's confidential outputs + /// (no network call; run sync first). + /// + /// With no arguments it scans every wallet output; `--txid`/`--vout` narrow it to one. + /// Only wallet-owned outputs are readable — a rangeproof message is gated on the + /// output's blinding key, which is what keeps it private. + ReadMessages { + /// Only look at this transaction + #[arg(long)] + txid: Option, + /// Only look at this output index (requires --txid) + #[arg(long, requires = "txid")] + vout: Option, + /// Also list outputs that carry no message + #[arg(long)] + all: bool, + /// Wallet file to load (default: wallet.json) + #[arg(long, default_value = "wallet.json")] + wallet: PathBuf, + /// Directory where wallet state is persisted + #[arg(long)] + data_dir: Option, + }, + /// Split a wallet asset into N equal-sized UTXOs and broadcast the transaction. /// Useful for pre-funding multiple action inputs. Split { @@ -417,6 +441,86 @@ fn cmd_get_balance(wallet_path: &Path, data_dir: Option<&std::path::Path>) -> Re Ok(()) } +fn cmd_read_messages( + txid: Option<&str>, + vout: Option, + show_all: bool, + wallet_path: &Path, + data_dir: Option<&std::path::Path>, +) -> Result<()> { + use console::style; + use std::str::FromStr as _; + use tx_manifest_lib::rangeproof; + + let w = wallet::load_wallet(wallet_path)?; + let data_dir = data_dir.map(|p| p.to_path_buf()).unwrap_or_else(wallet::default_data_dir); + + // `--vout` without `--txid` is refused by clap; `--txid` alone means the whole tx. + let only = match txid { + None => None, + Some(t) => { + let txid = lwk_wollet::elements::Txid::from_str(t.trim()) + .map_err(|e| anyhow::anyhow!("Invalid txid '{t}': {e}"))?; + Some((txid, vout.unwrap_or(u32::MAX))) + } + }; + + println!(); + println!("{}", style("Rangeproof messages (last synced state)").bold().cyan()); + println!(" Network : {}", style(&w.network).cyan()); + println!(" Capacity : {} bytes per confidential output", style(rangeproof::MAX_PAYLOAD).cyan()); + println!(); + + // A whole-transaction scan passes a sentinel vout, so filter here rather than there. + let outputs: Vec<_> = wallet::scan_rangeproof_messages( + &w, &data_dir, + only.filter(|(_, v)| *v != u32::MAX), + )? + .into_iter() + .filter(|o| only.is_none_or(|(t, _)| t == o.txid)) + .collect(); + + if outputs.is_empty() { + println!(" No confidential wallet outputs found. Run `sync` first."); + return Ok(()); + } + + let mut carrying = 0usize; + for out in &outputs { + let label = format!("{}:{}", out.txid, out.vout); + match &out.message { + Ok(Some(payload)) => { + carrying += 1; + println!( + " {} {} — {} sat", + style("●").green(), style(&label).bold(), style(out.value).yellow(), + ); + for line in rangeproof::describe_payload(payload).lines() { + println!(" {line}"); + } + println!(); + } + Ok(None) if show_all => { + println!(" {} {} — {} sat — no message", style("·").dim(), label, out.value); + } + Ok(None) => {} + Err(e) => { + carrying += 1; + println!( + " {} {} — {} sat — malformed message: {e:#}", + style("✗").red(), style(&label).bold(), out.value, + ); + } + } + } + + println!( + " {} of {} confidential output(s) carry a message", + style(carrying).bold(), outputs.len(), + ); + Ok(()) +} + fn print_balance(utxos: &[lwk_wollet::WalletTxOut], explicit: &[lwk_wollet::ExternalUtxo]) { use console::style; use std::collections::BTreeMap; @@ -728,6 +832,8 @@ fn main() -> Result<()> { Commands::Info { wallet } => cmd_info(&wallet), Commands::Sync { wallet, esplora, data_dir } => cmd_sync(&wallet, esplora.as_deref(), data_dir.as_deref()), Commands::GetBalance { wallet, data_dir } => cmd_get_balance(&wallet, data_dir.as_deref()), + Commands::ReadMessages { txid, vout, all, wallet, data_dir } => + cmd_read_messages(txid.as_deref(), vout, all, &wallet, data_dir.as_deref()), Commands::Split { count, asset, amount_each, wallet, esplora, data_dir } => cmd_split(count, &asset, amount_each, &wallet, esplora.as_deref(), data_dir.as_deref()), }