From a2c47b9b68a1af687c5de9bc40fa30ed8ed5cd66 Mon Sep 17 00:00:00 2001 From: "Jonathan P. Navarrete" Date: Mon, 17 Aug 2026 21:34:02 -0400 Subject: [PATCH 1/3] feat: add support for signing Schnorr digests with P2TR addresses - Introduced `sign_schnorr_digest` method in the `Signer` trait to allow signing of application-provided 32-byte BIP-340 digests. - Implemented the method in `KeystoreSigner`, ensuring it validates the P2TR address and derives the appropriate key. - Added `message_signing` capability to `SignerCaps` to indicate support for digest signing. - Created `SchnorrDigestSignature` struct to encapsulate the result of the signing operation, including the address, output key, and signature. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/labcoat-cli/src/contract.rs | 157 +++ crates/labcoat-cli/src/docs.rs | 75 ++ crates/labcoat-cli/src/main.rs | 71 + crates/labcoat-cli/src/mcp.rs | 125 ++ crates/labcoat-core/Cargo.toml | 1 + crates/labcoat-core/src/atomic_exchange.rs | 1380 +++++++++++++++++--- crates/labcoat-core/src/signer.rs | 79 ++ 9 files changed, 1721 insertions(+), 169 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef9e56f..bb09a3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2869,6 +2869,7 @@ dependencies = [ "flate2", "hcl-rs", "hex", + "ordinals", "prost", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 3d08065..b8d9433 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ alkanes-support = { git = "https://github.com/kungfuflex/alkanes-rs", rev = "714 # native-deps is REQUIRED: upstream cfg-gates its whole HTTP client on it # (without it every RPC method returns "not available in WASM environment"). alkanes-cli-common = { git = "https://github.com/kungfuflex/alkanes-rs", rev = "714843c416e2ab57352a33f05b8461cf3f540f5a", default-features = false, features = ["std", "native-deps"] } +ordinals = { git = "https://github.com/kungfuflex/alkanes-rs", rev = "714843c416e2ab57352a33f05b8461cf3f540f5a" } # NOTE on transitive git deps: alkanes-rs declares metashrew by tag and emasm # by a moving branch ref. Cargo forbids [patch]-ing a git source with itself at a rev, diff --git a/crates/labcoat-cli/src/contract.rs b/crates/labcoat-cli/src/contract.rs index ac065fc..5455151 100644 --- a/crates/labcoat-cli/src/contract.rs +++ b/crates/labcoat-cli/src/contract.rs @@ -37,6 +37,15 @@ pub enum WalletCmd { #[arg(long = "out")] output: Option, }, + /// Sign a 32-byte application digest with the tweaked key controlling an + /// owned P2TR address. + SignDigest { + #[arg(long)] + address: String, + /// Exactly 32 bytes as lowercase or uppercase hexadecimal. + #[arg(long)] + digest: String, + }, } #[derive(Subcommand)] @@ -558,6 +567,31 @@ pub async fn wallet(ctx: &Ctx, cmd: WalletCmd, json: bool) -> (&'static str, Cmd .await; ("wallet-sign-psbt", to_envelope(res)) } + WalletCmd::SignDigest { address, digest } => { + let res = async { + use labcoat_core::signer::Signer; + let bytes = hex::decode(&digest).map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + "--digest must be exactly 32 bytes of hexadecimal", + "pass a 64-character digest without a 0x prefix", + ) + })?; + let digest: [u8; 32] = bytes.try_into().map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + "--digest must be exactly 32 bytes", + "pass a 64-character hexadecimal digest", + ) + })?; + let provider = + labcoat_core::system::connect(&ctx.config, ctx.passphrase(), true).await?; + let signer = labcoat_core::signer::KeystoreSigner::from_provider(&provider)?; + signer.sign_schnorr_digest(&address, digest).await + } + .await; + ("wallet-sign-digest", to_envelope(res)) + } } } @@ -1036,6 +1070,129 @@ pub async fn exchange( ("exchange", to_envelope(res)) } +fn exchange_asset( + id: (u128, u128), + label: &str, +) -> Result { + Ok(labcoat_core::atomic_exchange::AlkaneId { + block: u64::try_from(id.0).map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + format!("{label} block {} does not fit u64", id.0), + "use a valid on-chain Alkane ID", + ) + })?, + tx: u64::try_from(id.1).map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + format!("{label} tx {} does not fit u64", id.1), + "use a valid on-chain Alkane ID", + ) + })?, + }) +} + +#[allow(clippy::too_many_arguments)] +pub async fn exchange_plan( + ctx: &Ctx, + offered: &str, + offered_amount: u64, + payment: &str, + payment_amount: u64, + seller_address: &str, + buyer_address: &str, + plan_out: &str, + psbt_out: &str, +) -> (&'static str, CmdResult) { + let res = async { + let offered = exchange_asset(resolve(&ctx.config, offered)?, "offered asset")?; + let payment = exchange_asset(resolve(&ctx.config, payment)?, "payment asset")?; + let mut provider = ctx.wallet_provider().await?; + let plan = labcoat_core::atomic_exchange::build_exchange_plan( + &mut provider, + &ctx.config, + labcoat_core::atomic_exchange::AtomicExchangeRequest { + offered, + offered_amount, + payment, + payment_amount, + seller_address: seller_address.to_string(), + buyer_address: buyer_address.to_string(), + }, + ) + .await?; + let plan_json = serde_json::to_string_pretty(&plan).expect("serializable exchange plan"); + std::fs::write(plan_out, format!("{plan_json}\n")).map_err(|e| { + labcoat_core::LabcoatError::new( + "TOOLKIT_ERROR", + format!("cannot write {plan_out}: {e}"), + "check the plan output path", + ) + })?; + std::fs::write(psbt_out, format!("{}\n", plan.psbt)).map_err(|e| { + labcoat_core::LabcoatError::new( + "TOOLKIT_ERROR", + format!("cannot write {psbt_out}: {e}"), + "check the PSBT output path", + ) + })?; + Ok(serde_json::json!({ "plan": plan, "planOut": plan_out, "psbtOut": psbt_out })) + } + .await; + ("exchange-plan", to_envelope(res)) +} + +pub async fn exchange_settle( + ctx: &Ctx, + plan_path: &str, + psbt_path: &str, + seller_wallet_file: &str, + broadcast: bool, +) -> (&'static str, CmdResult) { + let res = async { + let plan_raw = std::fs::read_to_string(plan_path).map_err(|e| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + format!("cannot read {plan_path}: {e}"), + "pass the ExchangePlanV1 JSON file", + ) + })?; + let plan: labcoat_core::atomic_exchange::ExchangePlanV1 = serde_json::from_str(&plan_raw) + .map_err(|e| { + labcoat_core::LabcoatError::new( + "EXCHANGE_PLAN_INVALID", + format!("cannot decode exchange plan: {e}"), + "recreate the plan with `labcoat exchange-plan`", + ) + })?; + let psbt_raw = std::fs::read_to_string(psbt_path).map_err(|e| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + format!("cannot read {psbt_path}: {e}"), + "pass the buyer-signed PSBT file", + ) + })?; + let psbt = labcoat_core::signer::decode_psbt(psbt_raw.trim())?; + let mut seller_config = ctx.config.clone(); + seller_config.wallet_file = PathBuf::from(seller_wallet_file); + let connected = + labcoat_core::system::connect_signing(&seller_config, &ctx.signer_spec()?).await?; + let mut provider = connected.provider; + let outcome = labcoat_core::atomic_exchange::settle_exchange( + &mut provider, + connected.signer.as_ref(), + &seller_config, + &plan, + psbt, + broadcast, + ) + .await?; + Ok(outcome) + } + .await; + ("exchange-settle", to_envelope(res)) +} + pub async fn simulate( ctx: &Ctx, contract: &str, diff --git a/crates/labcoat-cli/src/docs.rs b/crates/labcoat-cli/src/docs.rs index 4e99885..4076b08 100644 --- a/crates/labcoat-cli/src/docs.rs +++ b/crates/labcoat-cli/src/docs.rs @@ -25,6 +25,81 @@ const ERROR_CODES: &[(&str, &str, &str)] = &[ "the keystore could not be unlocked", "set `LABCOAT_WALLET_PASSPHRASE`", ), + ( + "WALLET_ERROR", + "wallet metadata, ownership, or signing failed", + "inspect the wallet, PSBT prevouts, and expected derivation path", + ), + ( + "SIGNER_UNSUPPORTED", + "the selected signer lacks a required capability", + "use the keystore signer or a compatible PSBT signer", + ), + ( + "SIGNER_TIMEOUT", + "an external signer did not return a PSBT in time", + "sign the request file or raise `LABCOAT_PSBT_TIMEOUT_SECS`", + ), + ( + "SIGNER_MISMATCH", + "external signer output does not match the requested transaction", + "sign the exact PSBT without changing inputs or outputs", + ), + ( + "EXCHANGE_PLAN_INVALID", + "exchange terms or fixed output layout are invalid", + "rebuild the exchange plan from current wallet state", + ), + ( + "EXCHANGE_PLAN_MISMATCH", + "the supplied PSBT differs from its content-addressed plan", + "use the PSBT emitted by `labcoat exchange-plan`", + ), + ( + "EXCHANGE_INPUT_OWNERSHIP", + "an exchange input is unsafe, ambiguous, or owned by the wrong party", + "use clean P2TR inputs containing only the participant's required asset", + ), + ( + "EXCHANGE_ASSET_UNSAFE", + "an exchange input or output contains an unrelated or misrouted Alkane", + "use single-asset owner inputs and rebuild the exchange plan", + ), + ( + "EXCHANGE_SELLER_DEBIT", + "the transaction would consume seller bitcoin value", + "rebuild with buyer-funded outputs and fees", + ), + ( + "EXCHANGE_SIGNATURE_MISSING", + "a required buyer or seller signature is absent", + "sign the PSBT with the expected participant wallet", + ), + ( + "EXCHANGE_SIGNATURE_INVALID", + "an exchange input signature failed verification", + "discard the PSBT and recreate the plan", + ), + ( + "EXCHANGE_SIGHASH_UNSUPPORTED", + "an exchange signature is not Taproot SIGHASH_DEFAULT", + "sign the complete unchanged transaction with SIGHASH_DEFAULT", + ), + ( + "EXCHANGE_NETWORK_MISMATCH", + "the live chain instance differs from the exchange plan", + "discard stale plans after a network reset", + ), + ( + "EXCHANGE_TIP_STALE", + "the observed planning tip is no longer in the active chain", + "rebuild the plan after the reorganization", + ), + ( + "EXCHANGE_INPUT_SPENT", + "a planned input has already been spent", + "rebuild the plan with current UTXOs", + ), ( "RPC_UNREACHABLE", "the configured Qubitcoin endpoint cannot be reached", diff --git a/crates/labcoat-cli/src/main.rs b/crates/labcoat-cli/src/main.rs index 49b1786..249b399 100644 --- a/crates/labcoat-cli/src/main.rs +++ b/crates/labcoat-cli/src/main.rs @@ -220,6 +220,32 @@ enum Commands { #[arg(long)] seller_wallet_file: String, }, + /// Build an owner-partitioned exchange plan and unsigned PSBT. + ExchangePlan { + offered: String, + offered_amount: u64, + payment: String, + payment_amount: u64, + #[arg(long)] + seller_address: String, + #[arg(long)] + buyer_address: String, + #[arg(long)] + plan_out: String, + #[arg(long)] + psbt_out: String, + }, + /// Validate a buyer-signed exchange PSBT, sign as seller, and optionally broadcast. + ExchangeSettle { + #[arg(long)] + plan: String, + #[arg(long)] + psbt: String, + #[arg(long)] + seller_wallet_file: String, + #[arg(long)] + broadcast: bool, + }, /// Reconcile the deployment manifest against the chain and show pending actions Plan { /// Manifest path (default alkanes.hcl) @@ -514,6 +540,51 @@ async fn run(cli: Cli) -> i32 { progress.finish(); output::finish_contract(json, cmd_name, res, output_options) } + Commands::ExchangePlan { + offered, + offered_amount, + payment, + payment_amount, + seller_address, + buyer_address, + plan_out, + psbt_out, + } => { + let progress = output::Progress::new("Building exchange plan…", !json); + let (cmd_name, res) = contract::exchange_plan( + &ctx, + &offered, + offered_amount, + &payment, + payment_amount, + &seller_address, + &buyer_address, + &plan_out, + &psbt_out, + ) + .await; + progress.finish(); + output::finish_contract(json, cmd_name, res, output_options) + } + Commands::ExchangeSettle { + plan, + psbt, + seller_wallet_file, + broadcast, + } => { + let progress = output::Progress::new( + if broadcast { + "Validating, signing, and broadcasting exchange…" + } else { + "Validating and signing exchange…" + }, + !json, + ); + let (cmd_name, res) = + contract::exchange_settle(&ctx, &plan, &psbt, &seller_wallet_file, broadcast).await; + progress.finish(); + output::finish_contract(json, cmd_name, res, output_options) + } Commands::Plan { manifest } => { let progress = output::Progress::new("Planning against the manifest…", !json); let (cmd_name, res) = contract::plan(&ctx, manifest.as_deref()).await; diff --git a/crates/labcoat-cli/src/mcp.rs b/crates/labcoat-cli/src/mcp.rs index 6deae3d..5f26848 100644 --- a/crates/labcoat-cli/src/mcp.rs +++ b/crates/labcoat-cli/src/mcp.rs @@ -58,6 +58,10 @@ pub(crate) fn tools() -> Vec { json!({"package": {"type": "string", "description": "exact Cargo contract package name"}, "wasm": {"type": "string", "description": "explicit path to raw .wasm; skips compilation"}, "name": {"type": "string", "description": "optional name for wasm deployments"}, "args": arg_array.clone(), "reserve": {"type": "string", "description": "reserved number N for a [3,N] deploy target (default: next free id via [1,0])"}, "inputs": {"type": "string", "description": "comma-separated extra inputs: alkanes block:tx:amount (0 = all) or bitcoin B:sats"}, "to": {"type": "string", "description": "recipient address for protostone outputs (default: wallet primary address)"}, "pointer": {"type": "string", "description": "protostone pointer target vN or pN (default v0)"}, "refund": {"type": "string", "description": "protostone refund target (default: pointer)"}, "edicts": {"type": "array", "items": {"type": "string"}, "description": "edicts block:tx:amount:target appended to the protostone"}}), &[]), tool("call", "Execute a state-changing contract call and wait for its trace.", json!({"contract": {"type": "string", "description": "labcoat.lock name or block:tx id"}, "opcode": {"type": "string", "description": "exact ABI method name or decimal opcode"}, "args": arg_array.clone(), "inputs": {"type": "string", "description": "comma-separated extra inputs: alkanes block:tx:amount (0 = all) or bitcoin B:sats"}, "to": {"type": "string", "description": "recipient address for protostone outputs (default: wallet primary address)"}, "pointer": {"type": "string", "description": "protostone pointer target vN or pN (default v0)"}, "refund": {"type": "string", "description": "protostone refund target (default: pointer)"}, "edicts": {"type": "array", "items": {"type": "string"}, "description": "edicts block:tx:amount:target appended to the protostone"}}), &["contract", "opcode"]), + tool("exchange_plan", "Build an owner-partitioned atomic exchange plan and return its base64 PSBT.", + json!({"offered": {"type": "string"}, "offeredAmount": {"type": "integer", "minimum": 1}, "payment": {"type": "string"}, "paymentAmount": {"type": "integer", "minimum": 1}, "sellerAddress": {"type": "string"}, "buyerAddress": {"type": "string"}}), &["offered", "offeredAmount", "payment", "paymentAmount", "sellerAddress", "buyerAddress"]), + tool("exchange_settle", "Validate a buyer-signed PSBT, sign seller inputs, and optionally broadcast. broadcast must be true to transact.", + json!({"plan": {"type": "object"}, "psbt": {"type": "string", "description": "base64 or hex buyer-signed PSBT"}, "sellerWalletFile": {"type": "string"}, "broadcast": {"type": "boolean"}}), &["plan", "psbt", "sellerWalletFile", "broadcast"]), tool("simulate", "Simulate a deployed contract against live indexed chain state (no transaction).", json!({"contract": {"type": "string"}, "opcode": {"type": "string", "description": "exact ABI method name or decimal opcode"}, "args": arg_array}), &["contract", "opcode"]), tool("trace", "Decoded protostone traces for a transaction.", @@ -354,6 +358,127 @@ async fn dispatch(ctx: &Ctx, name: &str, args: &Value) -> Result { + let offered = args + .get("offered") + .and_then(Value::as_str) + .unwrap_or_default(); + let payment = args + .get("payment") + .and_then(Value::as_str) + .unwrap_or_default(); + let offered_amount = args + .get("offeredAmount") + .and_then(Value::as_u64) + .unwrap_or(0); + let payment_amount = args + .get("paymentAmount") + .and_then(Value::as_u64) + .unwrap_or(0); + let seller_address = args + .get("sellerAddress") + .and_then(Value::as_str) + .unwrap_or_default(); + let buyer_address = args + .get("buyerAddress") + .and_then(Value::as_str) + .unwrap_or_default(); + let res = async { + let offered = contract::resolve(&ctx.config, offered)?; + let payment = contract::resolve(&ctx.config, payment)?; + let mut provider = ctx.wallet_provider().await?; + labcoat_core::atomic_exchange::build_exchange_plan( + &mut provider, + &ctx.config, + labcoat_core::atomic_exchange::AtomicExchangeRequest { + offered: labcoat_core::atomic_exchange::AlkaneId { + block: u64::try_from(offered.0).map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + "offered block does not fit u64", + "use a valid Alkane ID", + ) + })?, + tx: u64::try_from(offered.1).map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + "offered tx does not fit u64", + "use a valid Alkane ID", + ) + })?, + }, + offered_amount, + payment: labcoat_core::atomic_exchange::AlkaneId { + block: u64::try_from(payment.0).map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + "payment block does not fit u64", + "use a valid Alkane ID", + ) + })?, + tx: u64::try_from(payment.1).map_err(|_| { + labcoat_core::LabcoatError::new( + "CONFIG_INVALID", + "payment tx does not fit u64", + "use a valid Alkane ID", + ) + })?, + }, + payment_amount, + seller_address: seller_address.to_string(), + buyer_address: buyer_address.to_string(), + }, + ) + .await + } + .await; + res.map(|plan| serde_json::to_value(plan).unwrap()) + .map_err(|e| (format!("[{}] {}", e.code, e.message), e.hint.to_string())) + } + "exchange_settle" => { + if args.get("broadcast").and_then(Value::as_bool) != Some(true) { + return Err(( + "[CONFIG_INVALID] exchange_settle requires broadcast: true".into(), + "use exchange_plan for read-only inspection".into(), + )); + } + let plan: labcoat_core::atomic_exchange::ExchangePlanV1 = serde_json::from_value( + args.get("plan").cloned().unwrap_or(Value::Null), + ) + .map_err(|e| { + ( + format!("[EXCHANGE_PLAN_INVALID] {e}"), + "pass the complete ExchangePlanV1 object".into(), + ) + })?; + let psbt = labcoat_core::signer::decode_psbt( + args.get("psbt").and_then(Value::as_str).unwrap_or_default(), + ) + .map_err(|e| (format!("[{}] {}", e.code, e.message), e.hint.to_string()))?; + let seller_wallet_file = args + .get("sellerWalletFile") + .and_then(Value::as_str) + .unwrap_or_default(); + let res = async { + let mut config = ctx.config.clone(); + config.wallet_file = std::path::PathBuf::from(seller_wallet_file); + let connected = + labcoat_core::system::connect_signing(&config, &ctx.signer_spec()?).await?; + let mut provider = connected.provider; + labcoat_core::atomic_exchange::settle_exchange( + &mut provider, + connected.signer.as_ref(), + &config, + &plan, + psbt, + true, + ) + .await + } + .await; + res.map(|outcome| serde_json::to_value(outcome).unwrap()) + .map_err(|e| (format!("[{}] {}", e.code, e.message), e.hint.to_string())) + } "trace" => { let txid = args .get("txid") diff --git a/crates/labcoat-core/Cargo.toml b/crates/labcoat-core/Cargo.toml index b02cd68..c519771 100644 --- a/crates/labcoat-core/Cargo.toml +++ b/crates/labcoat-core/Cargo.toml @@ -28,6 +28,7 @@ sha2 = "0.10" # Pinned alkanes-rs main commit (see TOOLCHAIN.md). alkanes-support.workspace = true alkanes-cli-common.workspace = true +ordinals.workspace = true hcl-rs = "0.19.8" [dev-dependencies] diff --git a/crates/labcoat-core/src/atomic_exchange.rs b/crates/labcoat-core/src/atomic_exchange.rs index 25231e7..47991f2 100644 --- a/crates/labcoat-core/src/atomic_exchange.rs +++ b/crates/labcoat-core/src/atomic_exchange.rs @@ -1,40 +1,123 @@ -//! Build and settle one atomic two-wallet Alkane exchange. +//! Plan and settle one atomic two-wallet Alkane exchange. //! -//! This is the local/native signer path used by integration tests and trusted -//! developer workflows. Both wallets sign the same PSBT with `SIGHASH_ALL`, so -//! neither token delivery nor payment can occur independently. Production RFQ -//! systems should exchange the PSBT between separate signer processes instead -//! of loading both keystores into one coordinator. +//! V1 deliberately uses seller-last `SIGHASH_DEFAULT` signing. The buyer may +//! receive an unsigned/partially-signed PSBT, but the seller never releases a +//! fully signed transaction before broadcasting it. use crate::error::{LabcoatError, Result}; -use crate::signer::{KeystoreSigner, Signer}; +use crate::signer::{decode_psbt, encode_psbt, KeystoreSigner, Signer}; use crate::system::ToolkitConfig; use alkanes_cli_common::alkanes::execute::EnhancedAlkanesExecutor; pub use alkanes_cli_common::alkanes::types::AlkaneId; use alkanes_cli_common::alkanes::types::{ EnhancedExecuteParams, ExecutionState, InputRequirement, OrdinalsStrategy, OutputTarget, - ProtostoneEdict, ProtostoneSpec, UtxoDataSource, + PrefetchedAlkane, PrefetchedUtxo, ProtostoneEdict, ProtostoneSpec, UtxoDataSource, }; use alkanes_cli_common::provider::ConcreteProvider; -use alkanes_cli_common::traits::{BitcoinRpcProvider, WalletProvider}; -use bitcoin::consensus::encode::serialize_hex; -use bitcoin::Witness; -use serde::Serialize; +use alkanes_cli_common::traits::{AlkanesProvider, BitcoinRpcProvider, WalletProvider}; +use bitcoin::consensus::encode::{serialize, serialize_hex}; +use bitcoin::hashes::Hash; +use bitcoin::psbt::Psbt; +use bitcoin::sighash::{Prevouts, SighashCache}; +use bitcoin::{Address, OutPoint, ScriptBuf, TapSighashType, TxOut, Witness}; +use ordinals::{Artifact, Runestone}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::str::FromStr; +const DUST_LIMIT: u64 = 546; +const INDEXER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const POST_BROADCAST_SYNC_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +const PLAN_TAG: &[u8] = b"Labcoat/ExchangePlan/v1"; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AtomicExchangeRequest { - /// Asset delivered by the seller to the buyer. pub offered: AlkaneId, pub offered_amount: u64, - /// Asset delivered by the buyer to the seller. pub payment: AlkaneId, pub payment_amount: u64, pub seller_address: String, pub buyer_address: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExchangeOwner { + Buyer, + Seller, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanAsset { + pub block: u64, + pub tx: u64, + pub amount: u64, +} + +impl PlanAsset { + fn id(&self) -> AlkaneId { + AlkaneId { + block: self.block, + tx: self.tx, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangePlanInput { + pub outpoint: String, + pub owner: ExchangeOwner, + pub value: u64, + pub script_pubkey: String, + pub assets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExchangeOutputRole { + BuyerAssets, + SellerSettlement, + BuyerBitcoinChange, + Runestone, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangePlanOutput { + pub index: u32, + pub role: ExchangeOutputRole, + pub value: u64, + pub script_pubkey: String, + pub assets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObservedTip { + pub height: u64, + pub block_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangePlanV1 { + pub version: u8, + pub chain_id: String, + pub observed_tip: ObservedTip, + pub request: AtomicExchangeRequest, + pub inputs: Vec, + pub outputs: Vec, + pub fee: u64, + pub fee_rate: f32, + pub unsigned_txid: String, + pub psbt: String, + pub plan_digest: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AtomicExchangeOutcome { @@ -47,94 +130,50 @@ pub struct AtomicExchangeOutcome { pub status: &'static str, } +#[derive(Clone)] +struct Candidate { + outpoint: OutPoint, + output: TxOut, + owner: ExchangeOwner, + assets: Vec, + confirmations: u32, + frozen: bool, + has_inscriptions: bool, + has_runes: bool, + is_coinbase: bool, + block_height: Option, +} + impl AtomicExchangeRequest { pub fn validate(&self) -> Result<()> { if self.offered_amount == 0 || self.payment_amount == 0 { - return Err(LabcoatError::new( - "CONFIG_INVALID", - "atomic exchange amounts must be greater than zero", - "set both offered and payment amounts", + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "exchange amounts must be greater than zero", )); } if self.offered == self.payment { - return Err(LabcoatError::new( - "CONFIG_INVALID", - "atomic exchange assets must be different", - "choose distinct offered and payment Alkane IDs", + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "exchange assets must be different", )); } if self.seller_address == self.buyer_address { - return Err(LabcoatError::new( - "CONFIG_INVALID", + return Err(exchange_error( + "EXCHANGE_INPUT_OWNERSHIP", "seller and buyer addresses must be different", - "use isolated wallets for both sides of the exchange", )); } Ok(()) } +} - fn params(&self, config: &ToolkitConfig) -> EnhancedExecuteParams { - let trade = ProtostoneSpec { - cellpack: None, - edicts: vec![ - ProtostoneEdict { - alkane_id: self.offered.clone(), - amount: self.offered_amount, - target: OutputTarget::Output(0), - }, - ProtostoneEdict { - alkane_id: self.payment.clone(), - amount: self.payment_amount, - target: OutputTarget::Output(1), - }, - ], - bitcoin_transfer: None, - // Any excess from a selected token UTXO returns to the buyer. This - // matters when the buyer's payment UTXO exceeds the quoted premium. - pointer: Some(OutputTarget::Output(0)), - refund: Some(OutputTarget::Output(0)), - }; - - EnhancedExecuteParams { - fee_rate: config.fee_rate, - to_addresses: vec![self.buyer_address.clone(), self.seller_address.clone()], - // Buyer first makes the buyer the normal source of BTC fees; token - // requirements still force selection of the seller's offered UTXO. - from_addresses: Some(vec![ - self.buyer_address.clone(), - self.seller_address.clone(), - ]), - change_address: Some(self.buyer_address.clone()), - alkanes_change_address: Some(self.buyer_address.clone()), - input_requirements: vec![ - InputRequirement::Alkanes { - block: self.offered.block, - tx: self.offered.tx, - amount: self.offered_amount, - }, - InputRequirement::Alkanes { - block: self.payment.block, - tx: self.payment.tx, - amount: self.payment_amount, - }, - ], - protostones: vec![trade], - envelope_data: None, - raw_output: true, - trace_enabled: false, - mine_enabled: false, - auto_confirm: true, - ordinals_strategy: OrdinalsStrategy::Exclude, - mempool_indexer: false, - split_transactions: false, - known_pending_tx_hexes: Vec::new(), - prefetched_utxos: Vec::new(), - excluded_utxos: Vec::new(), - skip_diesel_mint: true, - max_indexed_height: None, - utxo_source: UtxoDataSource::default(), - } - } +fn exchange_error(code: &'static str, message: impl Into) -> LabcoatError { + LabcoatError::new( + code, + message, + "rebuild the exchange plan from current wallet and chain state", + ) } pub async fn primary_address(provider: &ConcreteProvider) -> Result { @@ -144,96 +183,1092 @@ pub async fn primary_address(provider: &ConcreteProvider) -> Result { .map_err(|error| LabcoatError::classify(error.into())) } -/// Build one PSBT, sign only the inputs owned by each participant, and -/// broadcast only after every input has a signature. -pub async fn run( - buyer: &mut ConcreteProvider, - seller: &ConcreteProvider, +fn tagged_hash(tag: &[u8], payload: &[u8]) -> [u8; 32] { + let tag_hash = Sha256::digest(tag); + let mut hasher = Sha256::new(); + hasher.update(tag_hash); + hasher.update(tag_hash); + hasher.update(payload); + hasher.finalize().into() +} + +fn plan_digest( + chain_id: &str, + tip: &ObservedTip, + request: &AtomicExchangeRequest, + psbt: &Psbt, + inputs: &[ExchangePlanInput], + outputs: &[ExchangePlanOutput], +) -> String { + let mut bytes = Vec::new(); + bytes.extend_from_slice(chain_id.as_bytes()); + bytes.extend_from_slice(&tip.height.to_le_bytes()); + bytes.extend_from_slice(tip.block_hash.as_bytes()); + bytes.extend_from_slice(&request.offered.block.to_le_bytes()); + bytes.extend_from_slice(&request.offered.tx.to_le_bytes()); + bytes.extend_from_slice(&request.offered_amount.to_le_bytes()); + bytes.extend_from_slice(&request.payment.block.to_le_bytes()); + bytes.extend_from_slice(&request.payment.tx.to_le_bytes()); + bytes.extend_from_slice(&request.payment_amount.to_le_bytes()); + bytes.extend_from_slice(request.seller_address.as_bytes()); + bytes.push(0); + bytes.extend_from_slice(request.buyer_address.as_bytes()); + bytes.extend_from_slice(&serialize(&psbt.unsigned_tx)); + for input in &psbt.inputs { + if let Some(prevout) = &input.witness_utxo { + bytes.extend_from_slice(&serialize(prevout)); + } + } + for input in inputs { + bytes.extend_from_slice(&(input.outpoint.len() as u32).to_le_bytes()); + bytes.extend_from_slice(input.outpoint.as_bytes()); + bytes.push(match input.owner { + ExchangeOwner::Buyer => 0, + ExchangeOwner::Seller => 1, + }); + bytes.extend_from_slice(&input.value.to_le_bytes()); + bytes.extend_from_slice(&(input.script_pubkey.len() as u32).to_le_bytes()); + bytes.extend_from_slice(input.script_pubkey.as_bytes()); + bytes.extend_from_slice(&(input.assets.len() as u32).to_le_bytes()); + for asset in &input.assets { + bytes.extend_from_slice(&asset.block.to_le_bytes()); + bytes.extend_from_slice(&asset.tx.to_le_bytes()); + bytes.extend_from_slice(&asset.amount.to_le_bytes()); + } + } + for output in outputs { + bytes.extend_from_slice(&output.index.to_le_bytes()); + bytes.push(match output.role { + ExchangeOutputRole::BuyerAssets => 0, + ExchangeOutputRole::SellerSettlement => 1, + ExchangeOutputRole::BuyerBitcoinChange => 2, + ExchangeOutputRole::Runestone => 3, + }); + bytes.extend_from_slice(&output.value.to_le_bytes()); + bytes.extend_from_slice(&(output.script_pubkey.len() as u32).to_le_bytes()); + bytes.extend_from_slice(output.script_pubkey.as_bytes()); + bytes.extend_from_slice(&(output.assets.len() as u32).to_le_bytes()); + for asset in &output.assets { + bytes.extend_from_slice(&asset.block.to_le_bytes()); + bytes.extend_from_slice(&asset.tx.to_le_bytes()); + bytes.extend_from_slice(&asset.amount.to_le_bytes()); + } + } + hex::encode(tagged_hash(PLAN_TAG, &bytes)) +} + +async fn candidates_for( + provider: &ConcreteProvider, + address: &str, + owner: ExchangeOwner, +) -> Result> { + let utxos = WalletProvider::get_utxos(provider, true, Some(vec![address.to_string()])) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + let holdings = AlkanesProvider::protorunes_by_address(provider, address, None, 1) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + let mut balances: BTreeMap> = BTreeMap::new(); + let mut indexed_outputs: BTreeMap = BTreeMap::new(); + for entry in holdings.balances { + let mut assets = Vec::new(); + for (id, amount) in entry.balance_sheet.cached.balances { + if amount == 0 { + continue; + } + assets.push(PlanAsset { + block: u64::try_from(id.block).map_err(|_| { + exchange_error("EXCHANGE_PLAN_INVALID", "Alkane block does not fit u64") + })?, + tx: u64::try_from(id.tx).map_err(|_| { + exchange_error("EXCHANGE_PLAN_INVALID", "Alkane tx does not fit u64") + })?, + amount: u64::try_from(amount).map_err(|_| { + exchange_error("EXCHANGE_PLAN_INVALID", "Alkane balance does not fit u64") + })?, + }); + } + assets.sort_by_key(|asset| (asset.block, asset.tx)); + balances.insert(entry.outpoint, assets); + indexed_outputs.insert(entry.outpoint, entry.output); + } + let mut result = Vec::new(); + for (outpoint, info) in utxos { + let output = indexed_outputs.remove(&outpoint).or_else(|| { + info.script_pubkey.clone().map(|script_pubkey| TxOut { + value: bitcoin::Amount::from_sat(info.amount), + script_pubkey, + }) + }); + let Some(output) = output else { continue }; + result.push(Candidate { + outpoint, + output, + owner, + assets: balances.remove(&outpoint).unwrap_or_default(), + confirmations: info.confirmations, + frozen: info.frozen, + has_inscriptions: info.has_inscriptions, + has_runes: info.has_runes, + is_coinbase: info.is_coinbase, + block_height: info.block_height, + }); + } + Ok(result) +} + +/// Discover Bitcoin-only fee inputs across the connected buyer wallet. Token +/// inputs remain selected from the buyer's explicit quote address; this wider +/// scan is only for UTXOs upstream already considers when funding fees. +async fn buyer_clean_candidates(provider: &ConcreteProvider) -> Result> { + let utxos = WalletProvider::get_utxos(provider, true, None) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + let addresses: BTreeSet = utxos + .into_iter() + .map(|(_, info)| info.address) + .filter(|address| !address.is_empty()) + .collect(); + let mut candidates = Vec::new(); + for address in addresses { + candidates.extend(candidates_for(provider, &address, ExchangeOwner::Buyer).await?); + } + candidates.retain(|candidate| candidate.assets.is_empty()); + Ok(candidates) +} + +fn eligible(candidate: &Candidate, max_indexed_height: u64) -> bool { + !candidate.frozen + && !candidate.has_inscriptions + && !candidate.has_runes + && !(candidate.is_coinbase && candidate.confirmations < 100) + && candidate + .block_height + .is_none_or(|height| height <= max_indexed_height) + && candidate.output.script_pubkey.is_p2tr() +} + +fn only_asset(candidate: &Candidate, wanted: &AlkaneId) -> Option { + (candidate.assets.len() == 1 && candidate.assets[0].id() == *wanted) + .then_some(candidate.assets[0].amount) +} + +fn select_token_inputs( + mut candidates: Vec, + wanted: &AlkaneId, + amount: u64, + max_indexed_height: u64, +) -> Result<(Vec, u64)> { + candidates.retain(|candidate| { + eligible(candidate, max_indexed_height) && only_asset(candidate, wanted).is_some() + }); + candidates.sort_by(|a, b| { + only_asset(b, wanted) + .cmp(&only_asset(a, wanted)) + .then_with(|| a.outpoint.cmp(&b.outpoint)) + }); + let mut selected = Vec::new(); + let mut total = 0u64; + for candidate in candidates { + total = total + .checked_add(only_asset(&candidate, wanted).unwrap()) + .ok_or_else(|| exchange_error("EXCHANGE_PLAN_INVALID", "asset selection overflow"))?; + selected.push(candidate); + if total >= amount { + break; + } + } + if total < amount { + return Err(LabcoatError::new( + "INSUFFICIENT_FUNDS", + format!("owner has {total} spendable units but exchange requires {amount}"), + "fund the participant wallet with a clean single-asset UTXO", + )); + } + Ok((selected, total)) +} + +fn as_prefetched(candidate: &Candidate) -> PrefetchedUtxo { + PrefetchedUtxo { + outpoint: candidate.outpoint.to_string(), + value: candidate.output.value.to_sat(), + script_pubkey_hex: hex::encode(candidate.output.script_pubkey.as_bytes()), + alkanes: Some( + candidate + .assets + .iter() + .map(|asset| PrefetchedAlkane { + block: asset.block as u128, + tx: asset.tx as u128, + amount: asset.amount.to_string(), + }) + .collect(), + ), + } +} + +fn owner_for_script(script: &ScriptBuf, seller: &ScriptBuf) -> Result { + if script == seller { + Ok(ExchangeOwner::Seller) + } else if script.is_p2tr() { + // Buyer fee inputs may use an internal wallet change script. Their + // ownership is proven by the required valid buyer signature. + Ok(ExchangeOwner::Buyer) + } else { + Err(exchange_error( + "EXCHANGE_INPUT_OWNERSHIP", + "exchange input is not a supported P2TR owner script", + )) + } +} + +fn expected_output_assets( + request: &AtomicExchangeRequest, + offered_total: u64, + payment_total: u64, +) -> (Vec, Vec) { + let mut buyer = vec![PlanAsset { + block: request.offered.block, + tx: request.offered.tx, + amount: request.offered_amount, + }]; + if payment_total > request.payment_amount { + buyer.push(PlanAsset { + block: request.payment.block, + tx: request.payment.tx, + amount: payment_total - request.payment_amount, + }); + } + let mut seller = vec![PlanAsset { + block: request.payment.block, + tx: request.payment.tx, + amount: request.payment_amount, + }]; + if offered_total > request.offered_amount { + seller.push(PlanAsset { + block: request.offered.block, + tx: request.offered.tx, + amount: offered_total - request.offered_amount, + }); + } + (buyer, seller) +} + +fn validate_runestone_edicts( + psbt: &Psbt, + request: &AtomicExchangeRequest, + offered_total: u64, + payment_total: u64, +) -> Result<()> { + let runestone = match Runestone::decipher(&psbt.unsigned_tx) { + Some(Artifact::Runestone(runestone)) => runestone, + _ => { + return Err(exchange_error( + "EXCHANGE_ASSET_UNSAFE", + "exchange transaction does not contain a valid Runestone", + )) + } + }; + let protostones = alkanes_cli_common::Protostone::from_runestone(&runestone) + .map_err(|e| exchange_error("EXCHANGE_ASSET_UNSAFE", e.to_string()))?; + if protostones.len() != 1 { + return Err(exchange_error( + "EXCHANGE_ASSET_UNSAFE", + "exchange must contain exactly one Alkane protostone", + )); + } + let protostone = &protostones[0]; + if protostone.protocol_tag != 1 + || protostone.pointer != Some(0) + || protostone.refund != Some(0) + || protostone.burn.is_some() + || protostone.from.is_some() + || !protostone.message.is_empty() + { + return Err(exchange_error( + "EXCHANGE_ASSET_UNSAFE", + "exchange protostone routing fields differ from the fixed contract", + )); + } + let mut expected = vec![ + ( + request.offered.block as u128, + request.offered.tx as u128, + request.offered_amount as u128, + 0u128, + ), + ( + request.payment.block as u128, + request.payment.tx as u128, + request.payment_amount as u128, + 1u128, + ), + ]; + if offered_total > request.offered_amount { + expected.push(( + request.offered.block as u128, + request.offered.tx as u128, + (offered_total - request.offered_amount) as u128, + 1, + )); + } + if payment_total > request.payment_amount { + expected.push(( + request.payment.block as u128, + request.payment.tx as u128, + (payment_total - request.payment_amount) as u128, + 0, + )); + } + expected.sort_unstable(); + let mut actual: Vec<_> = protostone + .edicts + .iter() + .map(|edict| (edict.id.block, edict.id.tx, edict.amount, edict.output)) + .collect(); + actual.sort_unstable(); + if actual != expected { + return Err(exchange_error( + "EXCHANGE_ASSET_UNSAFE", + "Runestone edicts do not exactly deliver the quote and owner surpluses", + )); + } + Ok(()) +} + +/// Construct, but do not sign, a content-addressed exchange plan. +pub async fn build_exchange_plan( + provider: &mut ConcreteProvider, config: &ToolkitConfig, request: AtomicExchangeRequest, -) -> Result { +) -> Result { request.validate()?; - let params = request.params(config); - - let state = { - let mut executor = EnhancedAlkanesExecutor::new(buyer); - executor - .execute(params) + let max_indexed_height = crate::sync::wait_for_indexer(provider, INDEXER_TIMEOUT).await?; + let chain_id = BitcoinRpcProvider::get_block_hash(provider, 1) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + let tip_height = BitcoinRpcProvider::get_block_count(provider) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + let tip = ObservedTip { + height: tip_height, + block_hash: BitcoinRpcProvider::get_block_hash(provider, tip_height) .await - .map_err(|error| LabcoatError::classify(error.into()))? + .map_err(|e| LabcoatError::classify(e.into()))?, + }; + let seller_address = Address::from_str(&request.seller_address) + .map_err(|e| { + exchange_error( + "EXCHANGE_PLAN_INVALID", + format!("invalid seller address: {e}"), + ) + })? + .require_network(provider.get_network()) + .map_err(|e| exchange_error("EXCHANGE_PLAN_INVALID", e.to_string()))?; + let buyer_address = Address::from_str(&request.buyer_address) + .map_err(|e| { + exchange_error( + "EXCHANGE_PLAN_INVALID", + format!("invalid buyer address: {e}"), + ) + })? + .require_network(provider.get_network()) + .map_err(|e| exchange_error("EXCHANGE_PLAN_INVALID", e.to_string()))?; + if !seller_address.script_pubkey().is_p2tr() || !buyer_address.script_pubkey().is_p2tr() { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "exchange participants must use P2TR addresses", + )); + } + + let seller_pool = + candidates_for(provider, &request.seller_address, ExchangeOwner::Seller).await?; + let buyer_pool = candidates_for(provider, &request.buyer_address, ExchangeOwner::Buyer).await?; + let buyer_wallet_clean = buyer_clean_candidates(provider).await?; + let (seller_selected, offered_total) = select_token_inputs( + seller_pool.clone(), + &request.offered, + request.offered_amount, + max_indexed_height, + )?; + let (buyer_payment, payment_total) = select_token_inputs( + buyer_pool.clone(), + &request.payment, + request.payment_amount, + max_indexed_height, + )?; + let selected_token_outpoints: BTreeSet = seller_selected + .iter() + .chain(&buyer_payment) + .map(|c| c.outpoint) + .collect(); + let mut buyer_clean: Vec = buyer_pool + .iter() + .filter(|candidate| { + eligible(candidate, max_indexed_height) + && candidate.assets.is_empty() + && !selected_token_outpoints.contains(&candidate.outpoint) + }) + .cloned() + .collect(); + for candidate in buyer_wallet_clean.iter().filter(|candidate| { + eligible(candidate, max_indexed_height) + && !selected_token_outpoints.contains(&candidate.outpoint) + }) { + if !buyer_clean + .iter() + .any(|existing| existing.outpoint == candidate.outpoint) + { + buyer_clean.push(candidate.clone()); + } + } + let mut allowed: Vec = seller_selected + .iter() + .chain(&buyer_payment) + .cloned() + .collect(); + allowed.extend(buyer_clean); + let allowed_set: BTreeSet = allowed.iter().map(|c| c.outpoint).collect(); + let excluded_utxos: Vec = seller_pool + .iter() + .chain(&buyer_pool) + .chain(&buyer_wallet_clean) + .filter(|candidate| !allowed_set.contains(&candidate.outpoint)) + .map(|candidate| candidate.outpoint.to_string()) + .collect(); + let seller_input_sats = seller_selected + .iter() + .try_fold(0u64, |sum, c| sum.checked_add(c.output.value.to_sat())) + .ok_or_else(|| exchange_error("EXCHANGE_PLAN_INVALID", "seller input value overflow"))?; + let seller_output_sats = seller_input_sats.max(DUST_LIMIT); + let mut edicts = vec![ + ProtostoneEdict { + alkane_id: request.offered.clone(), + amount: request.offered_amount, + target: OutputTarget::Output(0), + }, + ProtostoneEdict { + alkane_id: request.payment.clone(), + amount: request.payment_amount, + target: OutputTarget::Output(1), + }, + ]; + if offered_total > request.offered_amount { + edicts.push(ProtostoneEdict { + alkane_id: request.offered.clone(), + amount: offered_total - request.offered_amount, + target: OutputTarget::Output(1), + }); + } + if payment_total > request.payment_amount { + edicts.push(ProtostoneEdict { + alkane_id: request.payment.clone(), + amount: payment_total - request.payment_amount, + target: OutputTarget::Output(0), + }); + } + let params = EnhancedExecuteParams { + fee_rate: config.fee_rate, + to_addresses: vec![ + request.buyer_address.clone(), + request.seller_address.clone(), + ], + from_addresses: Some(vec![ + request.buyer_address.clone(), + request.seller_address.clone(), + ]), + change_address: Some(request.buyer_address.clone()), + alkanes_change_address: Some(request.buyer_address.clone()), + input_requirements: vec![ + InputRequirement::Alkanes { + block: request.offered.block, + tx: request.offered.tx, + amount: request.offered_amount, + }, + InputRequirement::Alkanes { + block: request.payment.block, + tx: request.payment.tx, + amount: request.payment_amount, + }, + InputRequirement::BitcoinOutput { + amount: seller_output_sats, + target: OutputTarget::Output(1), + }, + ], + protostones: vec![ProtostoneSpec { + cellpack: None, + edicts, + bitcoin_transfer: None, + pointer: Some(OutputTarget::Output(0)), + refund: Some(OutputTarget::Output(0)), + }], + envelope_data: None, + raw_output: true, + trace_enabled: false, + mine_enabled: false, + auto_confirm: true, + ordinals_strategy: OrdinalsStrategy::Exclude, + mempool_indexer: false, + split_transactions: false, + known_pending_tx_hexes: Vec::new(), + prefetched_utxos: allowed.iter().map(as_prefetched).collect(), + excluded_utxos, + skip_diesel_mint: true, + max_indexed_height: Some(max_indexed_height), + utxo_source: UtxoDataSource::default(), }; + let state = EnhancedAlkanesExecutor::new(provider) + .execute(params) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; let ready = match state { ExecutionState::ReadyToSign(ready) if ready.split_psbt.is_none() => ready, - ExecutionState::ReadyToSign(_) => { - return Err(LabcoatError::new( - "TOOLKIT_ERROR", - "atomic exchange unexpectedly requires a split transaction", - "use clean, non-inscribed exchange inputs", - )); + _ => { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "exchange did not produce one signable PSBT", + )) } - other => { - return Err(LabcoatError::new( - "TOOLKIT_ERROR", - format!("atomic exchange produced unexpected execution state: {other:?}"), - "retry with a simple non-envelope exchange", + }; + let psbt = ready.psbt; + let actual: BTreeSet = psbt + .unsigned_tx + .input + .iter() + .map(|input| input.previous_output) + .collect(); + if !selected_token_outpoints.is_subset(&actual) { + return Err(exchange_error( + "EXCHANGE_INPUT_OWNERSHIP", + format!( + "upstream omitted owner-selected asset inputs (actual: {}; required assets: {})", + actual + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + selected_token_outpoints + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ), + )); + } + let mut by_outpoint: BTreeMap = + allowed.into_iter().map(|c| (c.outpoint, c)).collect(); + // Upstream may add a Bitcoin-only input from an internal buyer change + // script after satisfying the caller's prefetched asset whitelist. Admit + // only standard P2TR inputs that upstream represented as asset-free. A + // seller-script input here would increase seller debit and is rejected. + for (txin, psbt_input) in psbt.unsigned_tx.input.iter().zip(&psbt.inputs) { + if by_outpoint.contains_key(&txin.previous_output) { + continue; + } + let output = psbt_input.witness_utxo.clone().ok_or_else(|| { + exchange_error( + "EXCHANGE_INPUT_OWNERSHIP", + "upstream-added Bitcoin input lacks witness metadata", + ) + })?; + let owner = owner_for_script(&output.script_pubkey, &seller_address.script_pubkey())?; + if owner != ExchangeOwner::Buyer { + return Err(exchange_error( + "EXCHANGE_SELLER_DEBIT", + "upstream selected an unplanned seller Bitcoin input", )); } + by_outpoint.insert( + txin.previous_output, + Candidate { + outpoint: txin.previous_output, + output, + owner, + assets: Vec::new(), + confirmations: 0, + frozen: false, + has_inscriptions: false, + has_runes: false, + is_coinbase: false, + block_height: None, + }, + ); + } + let mut inputs = Vec::new(); + for txin in &psbt.unsigned_tx.input { + let candidate = by_outpoint.get(&txin.previous_output).ok_or_else(|| { + exchange_error( + "EXCHANGE_INPUT_OWNERSHIP", + "selected input has no owner metadata", + ) + })?; + inputs.push(ExchangePlanInput { + outpoint: candidate.outpoint.to_string(), + owner: candidate.owner, + value: candidate.output.value.to_sat(), + script_pubkey: hex::encode(candidate.output.script_pubkey.as_bytes()), + assets: candidate.assets.clone(), + }); + } + let (buyer_assets, seller_assets) = + expected_output_assets(&request, offered_total, payment_total); + let mut outputs = Vec::new(); + for (index, output) in psbt.unsigned_tx.output.iter().enumerate() { + let role = if index == 0 { + ExchangeOutputRole::BuyerAssets + } else if index == 1 { + ExchangeOutputRole::SellerSettlement + } else if output.script_pubkey.is_op_return() { + ExchangeOutputRole::Runestone + } else { + ExchangeOutputRole::BuyerBitcoinChange + }; + let assets = match role { + ExchangeOutputRole::BuyerAssets => buyer_assets.clone(), + ExchangeOutputRole::SellerSettlement => seller_assets.clone(), + _ => Vec::new(), + }; + outputs.push(ExchangePlanOutput { + index: index as u32, + role, + value: output.value.to_sat(), + script_pubkey: hex::encode(output.script_pubkey.as_bytes()), + assets, + }); + } + let unsigned_txid = psbt.unsigned_tx.compute_txid().to_string(); + let digest = plan_digest(&chain_id, &tip, &request, &psbt, &inputs, &outputs); + let plan = ExchangePlanV1 { + version: 1, + chain_id, + observed_tip: tip, + request, + inputs, + outputs, + fee: ready.fee, + fee_rate: config.fee_rate.unwrap_or(1.0), + unsigned_txid, + psbt: encode_psbt(&psbt).trim().to_string(), + plan_digest: digest, }; + validate_exchange_plan(&plan, &psbt)?; + Ok(plan) +} - let mut psbt = ready.psbt; - let seller_signer = KeystoreSigner::from_provider(seller)?; - let buyer_signer = KeystoreSigner::from_provider(buyer)?; - let seller_signed = seller_signer.sign_psbt(&mut psbt).await?; - let buyer_signed = buyer_signer.sign_psbt(&mut psbt).await?; - if seller_signed == 0 || buyer_signed == 0 { - return Err(LabcoatError::new( - "WALLET_ERROR", - format!( - "both participants must contribute inputs (seller signed {seller_signed}, buyer signed {buyer_signed})" - ), - "verify the wallet addresses and token balances", +pub fn validate_exchange_plan(plan: &ExchangePlanV1, psbt: &Psbt) -> Result<()> { + if plan.version != 1 || psbt.unsigned_tx.compute_txid().to_string() != plan.unsigned_txid { + return Err(exchange_error( + "EXCHANGE_PLAN_MISMATCH", + "PSBT transaction does not match the exchange plan", )); } + if plan_digest( + &plan.chain_id, + &plan.observed_tip, + &plan.request, + psbt, + &plan.inputs, + &plan.outputs, + ) != plan.plan_digest + { + return Err(exchange_error( + "EXCHANGE_PLAN_MISMATCH", + "exchange plan digest is invalid", + )); + } + if psbt.inputs.len() != plan.inputs.len() || psbt.unsigned_tx.output.len() != plan.outputs.len() + { + return Err(exchange_error( + "EXCHANGE_PLAN_MISMATCH", + "PSBT input/output count differs from the plan", + )); + } + let buyer = Address::from_str(&plan.request.buyer_address) + .map_err(|e| exchange_error("EXCHANGE_PLAN_INVALID", e.to_string()))? + .assume_checked() + .script_pubkey(); + let seller = Address::from_str(&plan.request.seller_address) + .map_err(|e| exchange_error("EXCHANGE_PLAN_INVALID", e.to_string()))? + .assume_checked() + .script_pubkey(); + for (index, (txin, input)) in psbt.unsigned_tx.input.iter().zip(&psbt.inputs).enumerate() { + let prevout = input.witness_utxo.as_ref().ok_or_else(|| { + exchange_error( + "EXCHANGE_PLAN_MISMATCH", + format!("input {index} lacks witness UTXO"), + ) + })?; + if !prevout.script_pubkey.is_p2tr() + || txin.previous_output.to_string() != plan.inputs[index].outpoint + || prevout.value.to_sat() != plan.inputs[index].value + || hex::encode(prevout.script_pubkey.as_bytes()) != plan.inputs[index].script_pubkey + { + return Err(exchange_error( + "EXCHANGE_PLAN_MISMATCH", + format!("input {index} differs from plan metadata"), + )); + } + if owner_for_script(&prevout.script_pubkey, &seller)? != plan.inputs[index].owner { + return Err(exchange_error( + "EXCHANGE_INPUT_OWNERSHIP", + format!("input {index} owner differs from plan"), + )); + } + if let Some(signature) = &input.tap_key_sig { + if signature.sighash_type != TapSighashType::Default { + return Err(exchange_error( + "EXCHANGE_SIGHASH_UNSUPPORTED", + "exchange signatures must use SIGHASH_DEFAULT", + )); + } + } + } + for (index, output) in psbt.unsigned_tx.output.iter().enumerate() { + let expected = &plan.outputs[index]; + if expected.index != index as u32 + || expected.value != output.value.to_sat() + || expected.script_pubkey != hex::encode(output.script_pubkey.as_bytes()) + { + return Err(exchange_error( + "EXCHANGE_PLAN_MISMATCH", + format!("output {index} differs from the plan"), + )); + } + } + if !(3..=4).contains(&plan.outputs.len()) { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "exchange must contain buyer assets, seller settlement, optional buyer change, and Runestone outputs", + )); + } + if plan.outputs[0].role != ExchangeOutputRole::BuyerAssets + || plan.outputs[0].value != DUST_LIMIT + || psbt.unsigned_tx.output[0].script_pubkey != buyer + { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "v0 must be the 546-sat buyer P2TR asset output", + )); + } + let final_index = plan.outputs.len() - 1; + if plan.outputs[final_index].role != ExchangeOutputRole::Runestone + || psbt.unsigned_tx.output[final_index].value.to_sat() != 0 + || !psbt.unsigned_tx.output[final_index] + .script_pubkey + .is_op_return() + || !plan.outputs[final_index].assets.is_empty() + { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "the final output must be the zero-sat Runestone OP_RETURN", + )); + } + if final_index == 3 + && (plan.outputs[2].role != ExchangeOutputRole::BuyerBitcoinChange + || plan.outputs[2].value < DUST_LIMIT + || psbt.unsigned_tx.output[2].script_pubkey != buyer + || !plan.outputs[2].assets.is_empty()) + { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "v2 must be asset-free buyer P2TR change above dust", + )); + } + let seller_inputs: u64 = plan + .inputs + .iter() + .filter(|i| i.owner == ExchangeOwner::Seller) + .map(|i| i.value) + .sum(); + let seller_output = plan.outputs.get(1).ok_or_else(|| { + exchange_error("EXCHANGE_PLAN_INVALID", "missing seller settlement output") + })?; + if seller_output.role != ExchangeOutputRole::SellerSettlement + || seller_output.value != seller_inputs.max(DUST_LIMIT) + || psbt.unsigned_tx.output[1].script_pubkey != seller + { + return Err(exchange_error( + "EXCHANGE_SELLER_DEBIT", + "seller settlement output does not return all seller input sats", + )); + } + let mut offered_total = 0u64; + let mut payment_total = 0u64; + for input in &plan.inputs { + for asset in &input.assets { + if asset.id() == plan.request.offered && input.owner == ExchangeOwner::Seller { + offered_total = offered_total.checked_add(asset.amount).ok_or_else(|| { + exchange_error("EXCHANGE_PLAN_INVALID", "offered asset total overflow") + })?; + } else if asset.id() == plan.request.payment && input.owner == ExchangeOwner::Buyer { + payment_total = payment_total.checked_add(asset.amount).ok_or_else(|| { + exchange_error("EXCHANGE_PLAN_INVALID", "payment asset total overflow") + })?; + } else { + return Err(exchange_error( + "EXCHANGE_ASSET_UNSAFE", + "an input carries an unrelated or wrong-owner Alkane", + )); + } + } + } + if offered_total < plan.request.offered_amount || payment_total < plan.request.payment_amount { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "selected inputs do not fund the quoted asset amounts", + )); + } + let (buyer_assets, seller_assets) = + expected_output_assets(&plan.request, offered_total, payment_total); + if plan.outputs[0].assets != buyer_assets || plan.outputs[1].assets != seller_assets { + return Err(exchange_error( + "EXCHANGE_ASSET_UNSAFE", + "output asset allocation does not match quoted delivery and owner surplus", + )); + } + validate_runestone_edicts(psbt, &plan.request, offered_total, payment_total)?; + let input_value = plan.inputs.iter().try_fold(0u64, |sum, input| { + sum.checked_add(input.value) + .ok_or_else(|| exchange_error("EXCHANGE_PLAN_INVALID", "input value overflow")) + })?; + let output_value = plan.outputs.iter().try_fold(0u64, |sum, output| { + sum.checked_add(output.value) + .ok_or_else(|| exchange_error("EXCHANGE_PLAN_INVALID", "output value overflow")) + })?; + if input_value.checked_sub(output_value) != Some(plan.fee) { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "plan fee does not equal input value minus output value", + )); + } + Ok(()) +} - let mut transaction = psbt - .clone() - .extract_tx() - .map_err(|error| LabcoatError::classify(error.into()))?; +fn output_key(script: &ScriptBuf) -> Result { + let bytes = script.as_bytes(); + if !script.is_p2tr() || bytes.len() != 34 { + return Err(exchange_error( + "EXCHANGE_PLAN_INVALID", + "input is not a standard P2TR output", + )); + } + bitcoin::secp256k1::XOnlyPublicKey::from_slice(&bytes[2..34]).map_err(|e| { + exchange_error( + "EXCHANGE_PLAN_INVALID", + format!("invalid P2TR output key: {e}"), + ) + }) +} + +fn verify_signatures(psbt: &Psbt, require_all: bool) -> Result<()> { + let secp = bitcoin::secp256k1::Secp256k1::verification_only(); + let prevouts: Vec = psbt + .inputs + .iter() + .enumerate() + .map(|(i, input)| { + input.witness_utxo.clone().ok_or_else(|| { + exchange_error( + "EXCHANGE_PLAN_MISMATCH", + format!("input {i} lacks witness UTXO"), + ) + }) + }) + .collect::>()?; for (index, input) in psbt.inputs.iter().enumerate() { - let signature = input.tap_key_sig.as_ref().ok_or_else(|| { - LabcoatError::new( - "WALLET_ERROR", - format!("no participant signed transaction input {index}"), - "every input must belong to either the seller or buyer wallet", + let Some(signature) = &input.tap_key_sig else { + if require_all { + return Err(exchange_error( + "EXCHANGE_SIGNATURE_MISSING", + format!("input {index} is unsigned"), + )); + } + continue; + }; + if signature.sighash_type != TapSighashType::Default { + return Err(exchange_error( + "EXCHANGE_SIGHASH_UNSUPPORTED", + format!("input {index} uses a non-default sighash"), + )); + } + let sighash = SighashCache::new(&psbt.unsigned_tx) + .taproot_key_spend_signature_hash( + index, + &Prevouts::All(&prevouts), + TapSighashType::Default, + ) + .map_err(|e| exchange_error("EXCHANGE_SIGNATURE_INVALID", e.to_string()))?; + let message = bitcoin::secp256k1::Message::from_digest(sighash.to_byte_array()); + secp.verify_schnorr( + &signature.signature, + &message, + &output_key(&prevouts[index].script_pubkey)?, + ) + .map_err(|_| { + exchange_error( + "EXCHANGE_SIGNATURE_INVALID", + format!("input {index} signature is invalid"), ) })?; - transaction.input[index].witness = Witness::p2tr_key_spend(signature); } + Ok(()) +} - let txid = buyer - .broadcast_transaction(serialize_hex(&transaction)) +/// Validate a buyer-signed plan, sign seller inputs, finalize, and optionally broadcast. +pub async fn settle_exchange( + provider: &mut ConcreteProvider, + signer: &dyn Signer, + config: &ToolkitConfig, + plan: &ExchangePlanV1, + mut psbt: Psbt, + broadcast: bool, +) -> Result { + validate_exchange_plan(plan, &psbt)?; + let chain_id = BitcoinRpcProvider::get_block_hash(provider, 1) .await - .map_err(|error| LabcoatError::classify(error.into()))?; - - if config.network.uses_regtest() { - buyer - .generate_to_address(1, &crate::execute::regtest_mining_address()) + .map_err(|e| LabcoatError::classify(e.into()))?; + if chain_id != plan.chain_id { + return Err(exchange_error( + "EXCHANGE_NETWORK_MISMATCH", + "live chain identity differs from the plan", + )); + } + let recorded_hash = BitcoinRpcProvider::get_block_hash(provider, plan.observed_tip.height) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + if recorded_hash != plan.observed_tip.block_hash { + return Err(exchange_error( + "EXCHANGE_TIP_STALE", + "the plan's observed chain tip is no longer active", + )); + } + // Segwit witness data does not affect txid. If a previous attempt + // broadcast successfully but crashed before its quote ledger was updated, + // the unsigned plan txid is enough to recover idempotently. + let mut transaction_seen = broadcast + && BitcoinRpcProvider::get_raw_transaction(provider, &plan.unsigned_txid, None) .await - .map_err(|error| LabcoatError::classify(error.into()))?; - crate::sync::wait_for_indexer(buyer, POST_BROADCAST_SYNC_TIMEOUT).await?; + .is_ok(); + if broadcast && !transaction_seen { + for vout in 0..plan.outputs.len() as u32 { + if BitcoinRpcProvider::get_tx_out(provider, &plan.unsigned_txid, vout, true) + .await + .is_ok_and(|output| !output.is_null()) + { + transaction_seen = true; + break; + } + } + } + if transaction_seen { + return Ok(AtomicExchangeOutcome { + txid: plan.unsigned_txid.clone(), + fee: plan.fee, + offered_asset: plan.request.offered.to_string(), + offered_amount: plan.request.offered_amount, + payment_asset: plan.request.payment.to_string(), + payment_amount: plan.request.payment_amount, + status: "success", + }); + } + for input in &plan.inputs { + let outpoint = OutPoint::from_str(&input.outpoint) + .map_err(|e| exchange_error("EXCHANGE_PLAN_INVALID", e.to_string()))?; + let live = BitcoinRpcProvider::get_tx_out( + provider, + &outpoint.txid.to_string(), + outpoint.vout, + true, + ) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + if live.is_null() { + return Err(exchange_error( + "EXCHANGE_INPUT_SPENT", + format!("input {} is already spent", input.outpoint), + )); + } + } + for (index, metadata) in plan.inputs.iter().enumerate() { + if metadata.owner == ExchangeOwner::Buyer && psbt.inputs[index].tap_key_sig.is_none() { + return Err(exchange_error( + "EXCHANGE_SIGNATURE_MISSING", + format!("buyer input {index} is unsigned"), + )); + } + } + verify_signatures(&psbt, false)?; + let seller_expected = plan + .inputs + .iter() + .filter(|input| input.owner == ExchangeOwner::Seller) + .count(); + let seller_signed = signer.sign_psbt(&mut psbt).await?; + if seller_signed != seller_expected { + return Err(exchange_error( + "EXCHANGE_SIGNATURE_MISSING", + format!("seller signed {seller_signed} of {seller_expected} expected inputs"), + )); + } + verify_signatures(&psbt, true)?; + for input in &mut psbt.inputs { + let signature = input.tap_key_sig.clone().ok_or_else(|| { + exchange_error( + "EXCHANGE_SIGNATURE_MISSING", + "all exchange inputs must be signed", + ) + })?; + input.final_script_witness = Some(Witness::p2tr_key_spend(&signature)); + } + let transaction = psbt + .clone() + .extract_tx() + .map_err(|e| LabcoatError::classify(e.into()))?; + let txid = transaction.compute_txid().to_string(); + if broadcast { + provider + .broadcast_transaction(serialize_hex(&transaction)) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + if config.network.uses_regtest() { + provider + .generate_to_address(1, &crate::execute::regtest_mining_address()) + .await + .map_err(|e| LabcoatError::classify(e.into()))?; + crate::sync::wait_for_indexer(provider, POST_BROADCAST_SYNC_TIMEOUT).await?; + } } - Ok(AtomicExchangeOutcome { txid, - fee: ready.fee, - offered_asset: request.offered.to_string(), - offered_amount: request.offered_amount, - payment_asset: request.payment.to_string(), - payment_amount: request.payment_amount, - status: "success", + fee: plan.fee, + offered_asset: plan.request.offered.to_string(), + offered_amount: plan.request.offered_amount, + payment_asset: plan.request.payment.to_string(), + payment_amount: plan.request.payment_amount, + status: if broadcast { "success" } else { "ready" }, }) } +/// Compatibility coordinator for trusted regtest workflows. +pub async fn run( + buyer: &mut ConcreteProvider, + seller: &ConcreteProvider, + config: &ToolkitConfig, + request: AtomicExchangeRequest, +) -> Result { + let plan = build_exchange_plan(buyer, config, request).await?; + let mut psbt = decode_psbt(&plan.psbt)?; + let buyer_signer = KeystoreSigner::from_provider(buyer)?; + if buyer_signer.sign_psbt(&mut psbt).await? == 0 { + return Err(exchange_error( + "EXCHANGE_SIGNATURE_MISSING", + "buyer signed no exchange inputs", + )); + } + let seller_signer = KeystoreSigner::from_provider(seller)?; + settle_exchange(buyer, &seller_signer, config, &plan, psbt, true).await +} + #[cfg(test)] mod tests { use super::*; @@ -249,35 +1284,42 @@ mod tests { } } - #[test] - fn routes_both_assets_in_one_protostone() { - let params = request().params(&ToolkitConfig::default()); - assert_eq!(params.to_addresses, ["buyer", "seller"]); - assert_eq!(params.protostones.len(), 1); - assert_eq!(params.protostones[0].edicts.len(), 2); - assert_eq!( - params.protostones[0].edicts[0].target, - OutputTarget::Output(0) - ); - assert_eq!( - params.protostones[0].edicts[1].target, - OutputTarget::Output(1) - ); - assert_eq!(params.protostones[0].pointer, Some(OutputTarget::Output(0))); - } - #[test] fn rejects_zero_same_asset_and_same_wallet_trades() { let mut invalid = request(); invalid.payment_amount = 0; assert!(invalid.validate().is_err()); - let mut invalid = request(); invalid.payment = invalid.offered.clone(); assert!(invalid.validate().is_err()); - let mut invalid = request(); invalid.buyer_address = invalid.seller_address.clone(); assert!(invalid.validate().is_err()); } + + #[test] + fn output_asset_change_is_partitioned_by_owner() { + let (buyer, seller) = expected_output_assets(&request(), 125, 700); + assert_eq!( + buyer[1], + PlanAsset { + block: 2, + tx: 2, + amount: 200 + } + ); + assert_eq!( + seller[1], + PlanAsset { + block: 4, + tx: 1, + amount: 25 + } + ); + } + + #[test] + fn plan_hash_is_domain_separated() { + assert_ne!(tagged_hash(PLAN_TAG, b"x"), tagged_hash(b"other", b"x")); + } } diff --git a/crates/labcoat-core/src/signer.rs b/crates/labcoat-core/src/signer.rs index 089aa63..a8f7f5f 100644 --- a/crates/labcoat-core/src/signer.rs +++ b/crates/labcoat-core/src/signer.rs @@ -27,6 +27,7 @@ use bitcoin::key::{TapTweak, UntweakedKeypair}; use bitcoin::psbt::Psbt; use bitcoin::sighash::{Prevouts, SighashCache}; use bitcoin::{Address, TapSighashType}; +use serde::Serialize; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -42,6 +43,17 @@ pub struct SignerCaps { /// Can produce taproot script-path signatures (needed for envelope /// reveal transactions). External PSBT tools generally cannot. pub script_path: bool, + /// Can sign an application-provided 32-byte BIP-340 digest with the + /// tweaked key controlling an owned P2TR address. + pub message_signing: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SchnorrDigestSignature { + pub address: String, + pub output_key: String, + pub signature: String, } /// Which signing backend a command should use. This replaces the bare @@ -114,6 +126,19 @@ pub trait Signer: Send + Sync { async fn addresses(&self) -> Result>; /// Sign owned inputs in place; returns how many inputs were signed. async fn sign_psbt(&self, psbt: &mut Psbt) -> Result; + /// Sign a pre-hashed application message. Backends that only exchange + /// PSBTs intentionally reject this optional capability. + async fn sign_schnorr_digest( + &self, + _address: &str, + _digest: [u8; 32], + ) -> Result { + Err(LabcoatError::new( + "SIGNER_UNSUPPORTED", + "this signer cannot sign application message digests", + "use the keystore signer for quote-message signing", + )) + } fn capabilities(&self) -> SignerCaps; } @@ -262,10 +287,63 @@ impl Signer for KeystoreSigner { Ok(signed) } + async fn sign_schnorr_digest( + &self, + address: &str, + digest: [u8; 32], + ) -> Result { + let checked = Address::from_str(address) + .map_err(|error| { + LabcoatError::new( + "CONFIG_INVALID", + format!("invalid P2TR address: {error}"), + "pass an address owned by this wallet", + ) + })? + .require_network(self.network) + .map_err(|error| { + LabcoatError::new( + "CONFIG_INVALID", + format!("address is for the wrong network: {error}"), + "pass an address for the configured network", + ) + })?; + if !checked.script_pubkey().is_p2tr() { + return Err(LabcoatError::new( + "SIGNER_UNSUPPORTED", + "digest signing requires a P2TR address", + "use a BIP-86 P2TR receive address", + )); + } + let path = self.find_p2tr_path(address)?.ok_or_else(|| { + LabcoatError::new( + "SIGNER_MISMATCH", + "the requested address is not owned by this keystore", + "use `labcoat wallet addresses` to choose an owned P2TR address", + ) + })?; + let secp = bitcoin::secp256k1::Secp256k1::new(); + let derived = self + .root + .derive_priv(&secp, &path) + .map_err(|error| LabcoatError::classify(error.into()))?; + let untweaked = UntweakedKeypair::from(derived.to_keypair(&secp)); + let tweaked = untweaked.tap_tweak(&secp, None); + let message = bitcoin::secp256k1::Message::from_digest(digest); + let signature = secp.sign_schnorr_no_aux_rand(&message, &tweaked.to_keypair()); + let bytes = checked.script_pubkey().into_bytes(); + Ok(SchnorrDigestSignature { + address: address.to_string(), + output_key: hex::encode(&bytes[2..34]), + signature: signature.to_string(), + }) + } + fn capabilities(&self) -> SignerCaps { SignerCaps { unattended_ok: true, script_path: false, + message_signing: true, } } } @@ -395,6 +473,7 @@ impl Signer for PsbtFileSigner { SignerCaps { unattended_ok: false, script_path: false, + message_signing: false, } } } From 1a1bd4dac99e0cb38d734fbcd40418232ba48960 Mon Sep 17 00:00:00 2001 From: "Jonathan P. Navarrete" Date: Mon, 17 Aug 2026 21:41:25 -0400 Subject: [PATCH 2/3] refactor: simplify eligibility check and update ToolkitConfig initialization --- crates/labcoat-core/src/atomic_exchange.rs | 10 +++++----- crates/labcoat-core/src/system.rs | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/labcoat-core/src/atomic_exchange.rs b/crates/labcoat-core/src/atomic_exchange.rs index 47991f2..19b2a54 100644 --- a/crates/labcoat-core/src/atomic_exchange.rs +++ b/crates/labcoat-core/src/atomic_exchange.rs @@ -338,10 +338,10 @@ async fn buyer_clean_candidates(provider: &ConcreteProvider) -> Result bool { - !candidate.frozen - && !candidate.has_inscriptions - && !candidate.has_runes - && !(candidate.is_coinbase && candidate.confirmations < 100) + !(candidate.frozen + || candidate.has_inscriptions + || candidate.has_runes + || candidate.is_coinbase && candidate.confirmations < 100) && candidate .block_height .is_none_or(|height| height <= max_indexed_height) @@ -1212,7 +1212,7 @@ pub async fn settle_exchange( } verify_signatures(&psbt, true)?; for input in &mut psbt.inputs { - let signature = input.tap_key_sig.clone().ok_or_else(|| { + let signature = input.tap_key_sig.ok_or_else(|| { exchange_error( "EXCHANGE_SIGNATURE_MISSING", "all exchange inputs must be signed", diff --git a/crates/labcoat-core/src/system.rs b/crates/labcoat-core/src/system.rs index b4e8d05..c621cc5 100644 --- a/crates/labcoat-core/src/system.rs +++ b/crates/labcoat-core/src/system.rs @@ -391,8 +391,10 @@ mod tests { #[test] fn external_signers_need_no_passphrase_on_public_networks() { - let mut config = ToolkitConfig::default(); - config.network = NetworkTarget::Mainnet; + let config = ToolkitConfig { + network: NetworkTarget::Mainnet, + ..ToolkitConfig::default() + }; assert!(config .require_signer_policy(&SignerSpec::PsbtFile { dir: PathBuf::from("psbts"), From 78aa25ba38fd305a44c7a35d0b301ea5271f73f1 Mon Sep 17 00:00:00 2001 From: "Jonathan P. Navarrete" Date: Mon, 17 Aug 2026 21:47:42 -0400 Subject: [PATCH 3/3] feat: enhance CLI documentation with new wallet commands and options for atomic exchanges --- .../src/content/docs/docs/reference/cli.md | 96 ++++- apps/web/src/generated/cli-reference.json | 328 +++++++++++++++++- skills/SKILL.md | 4 +- 3 files changed, 425 insertions(+), 3 deletions(-) diff --git a/apps/web/src/content/docs/docs/reference/cli.md b/apps/web/src/content/docs/docs/reference/cli.md index a276b47..c6e4279 100644 --- a/apps/web/src/content/docs/docs/reference/cli.md +++ b/apps/web/src/content/docs/docs/reference/cli.md @@ -220,6 +220,7 @@ init [OPTIONS] Arguments and options: - `mnemonic_stdin` (optional): Read the mnemonic from stdin (one line) Values: `true`, `false`. +- `show_mnemonic` (optional): Include a freshly generated mnemonic in machine-readable output (--json / MCP). Interactive terminal output always shows it — that is the one chance to write it down Values: `true`, `false`. #### `labcoat wallet addresses` @@ -241,6 +242,32 @@ Show spendable UTXOs utxos ``` +#### `labcoat wallet sign-psbt` + +Sign a PSBT file with this wallet's keys (the offline half of the psbt-file signer workflow) + +```text +sign-psbt [OPTIONS] --in +``` + +Arguments and options: + +- `input` (required): Unsigned PSBT file (base64 or hex) +- `output` (optional): Output path (defaults to `.signed.psbt`) + +#### `labcoat wallet sign-digest` + +Sign a 32-byte application digest with the tweaked key controlling an owned P2TR address + +```text +sign-digest --address
--digest +``` + +Arguments and options: + +- `address` (required) +- `digest` (required): Exactly 32 bytes as lowercase or uppercase hexadecimal + ### `labcoat build` Build Cargo contract packages into build/.{wasm,wasm.gz,abi.json} @@ -330,6 +357,56 @@ Arguments and options: - `edicts` (optional): Edict `block:tx:amount:target` appended to the protostone (repeatable) - `dry_run` (optional): Validate inputs and show what would happen without broadcasting Values: `true`, `false`. +### `labcoat exchange` + +Atomically exchange one wallet's Alkane asset for another wallet's asset + +```text +exchange --seller-wallet-file +``` + +Arguments and options: + +- `offered` (required): Asset sold by the seller: labcoat.lock name or block:tx id +- `offered_amount` (required): Complete offered quantity delivered to the buyer +- `payment` (required): Asset paid by the buyer: labcoat.lock name or block:tx id +- `payment_amount` (required): Complete payment quantity delivered to the seller +- `seller_wallet_file` (required): Seller keystore; --wallet-file is the buyer keystore + +### `labcoat exchange-plan` + +Build an owner-partitioned exchange plan and unsigned PSBT + +```text +exchange-plan --seller-address --buyer-address --plan-out --psbt-out +``` + +Arguments and options: + +- `offered` (required) +- `offered_amount` (required) +- `payment` (required) +- `payment_amount` (required) +- `seller_address` (required) +- `buyer_address` (required) +- `plan_out` (required) +- `psbt_out` (required) + +### `labcoat exchange-settle` + +Validate a buyer-signed exchange PSBT, sign as seller, and optionally broadcast + +```text +exchange-settle [OPTIONS] --plan --psbt --seller-wallet-file +``` + +Arguments and options: + +- `plan` (required) +- `psbt` (required) +- `seller_wallet_file` (required) +- `broadcast` (optional): Values: `true`, `false`. + ### `labcoat plan` Reconcile the deployment manifest against the chain and show pending actions @@ -459,7 +536,7 @@ doctor | `network_fund` | Send BTC from the Labcoat Network faucet wallet to an address. | | `network_reset` | Stop services and wipe all Labcoat Network chain data. | | `network_logs` | Recent Labcoat Network service logs. | -| `wallet_init` | Create or load the project wallet keystore. Optional mnemonic (else generated). | +| `wallet_init` | Create or load the project wallet keystore. Optional mnemonic (else generated). Generated mnemonics are redacted from the response unless showMnemonic is true. | | `wallet_addresses` | Wallet receive addresses per script type. | | `wallet_utxos` | Spendable wallet UTXOs. | | `build` | Build Cargo contract packages and extract their Wasm-exported ABIs. | @@ -468,6 +545,8 @@ doctor | `abi_verify` | Compare a deployed ABI with a locally built contract package. | | `deploy` | Build and deploy an exact Cargo contract package, or deploy an explicit raw Wasm. Provide exactly one of package or wasm. | | `call` | Execute a state-changing contract call and wait for its trace. | +| `exchange_plan` | Build an owner-partitioned atomic exchange plan and return its base64 PSBT. | +| `exchange_settle` | Validate a buyer-signed PSBT, sign seller inputs, and optionally broadcast. broadcast must be true to transact. | | `simulate` | Simulate a deployed contract against live indexed chain state (no transaction). | | `trace` | Decoded protostone traces for a transaction. | | `balance` | Alkanes token balances held by an address. | @@ -482,6 +561,21 @@ doctor | `CONFIG_INVALID` | configuration is invalid | run `labcoat doctor` | | `WALLET_MISSING` | the project wallet does not exist | run `labcoat wallet init` | | `WALLET_LOCKED` | the keystore could not be unlocked | set `LABCOAT_WALLET_PASSPHRASE` | +| `WALLET_ERROR` | wallet metadata, ownership, or signing failed | inspect the wallet, PSBT prevouts, and expected derivation path | +| `SIGNER_UNSUPPORTED` | the selected signer lacks a required capability | use the keystore signer or a compatible PSBT signer | +| `SIGNER_TIMEOUT` | an external signer did not return a PSBT in time | sign the request file or raise `LABCOAT_PSBT_TIMEOUT_SECS` | +| `SIGNER_MISMATCH` | external signer output does not match the requested transaction | sign the exact PSBT without changing inputs or outputs | +| `EXCHANGE_PLAN_INVALID` | exchange terms or fixed output layout are invalid | rebuild the exchange plan from current wallet state | +| `EXCHANGE_PLAN_MISMATCH` | the supplied PSBT differs from its content-addressed plan | use the PSBT emitted by `labcoat exchange-plan` | +| `EXCHANGE_INPUT_OWNERSHIP` | an exchange input is unsafe, ambiguous, or owned by the wrong party | use clean P2TR inputs containing only the participant's required asset | +| `EXCHANGE_ASSET_UNSAFE` | an exchange input or output contains an unrelated or misrouted Alkane | use single-asset owner inputs and rebuild the exchange plan | +| `EXCHANGE_SELLER_DEBIT` | the transaction would consume seller bitcoin value | rebuild with buyer-funded outputs and fees | +| `EXCHANGE_SIGNATURE_MISSING` | a required buyer or seller signature is absent | sign the PSBT with the expected participant wallet | +| `EXCHANGE_SIGNATURE_INVALID` | an exchange input signature failed verification | discard the PSBT and recreate the plan | +| `EXCHANGE_SIGHASH_UNSUPPORTED` | an exchange signature is not Taproot SIGHASH_DEFAULT | sign the complete unchanged transaction with SIGHASH_DEFAULT | +| `EXCHANGE_NETWORK_MISMATCH` | the live chain instance differs from the exchange plan | discard stale plans after a network reset | +| `EXCHANGE_TIP_STALE` | the observed planning tip is no longer in the active chain | rebuild the plan after the reorganization | +| `EXCHANGE_INPUT_SPENT` | a planned input has already been spent | rebuild the plan with current UTXOs | | `RPC_UNREACHABLE` | the configured Qubitcoin endpoint cannot be reached | run `labcoat status` | | `INDEXER_LAG` | indexed height did not catch chain height | inspect `qubitcoind` logs | | `INSUFFICIENT_FUNDS` | spendable BTC cannot cover the operation | fund the wallet and mine a block | diff --git a/apps/web/src/generated/cli-reference.json b/apps/web/src/generated/cli-reference.json index a3abf53..26fa4ce 100644 --- a/apps/web/src/generated/cli-reference.json +++ b/apps/web/src/generated/cli-reference.json @@ -265,6 +265,15 @@ "false" ], "required": false + }, + { + "description": "Include a freshly generated mnemonic in machine-readable output (--json / MCP). Interactive terminal output always shows it — that is the one chance to write it down", + "id": "show_mnemonic", + "possibleValues": [ + "true", + "false" + ], + "required": false } ], "description": "Create (or load) the project wallet. Mnemonic is read from LABCOAT_MNEMONIC or — with --mnemonic-stdin — from stdin; never argv", @@ -295,6 +304,48 @@ "path": "labcoat wallet utxos", "subcommands": [], "usage": "utxos" + }, + { + "arguments": [ + { + "description": "Unsigned PSBT file (base64 or hex)", + "id": "input", + "possibleValues": [], + "required": true + }, + { + "description": "Output path (defaults to `.signed.psbt`)", + "id": "output", + "possibleValues": [], + "required": false + } + ], + "description": "Sign a PSBT file with this wallet's keys (the offline half of the psbt-file signer workflow)", + "name": "sign-psbt", + "path": "labcoat wallet sign-psbt", + "subcommands": [], + "usage": "sign-psbt [OPTIONS] --in " + }, + { + "arguments": [ + { + "description": "", + "id": "address", + "possibleValues": [], + "required": true + }, + { + "description": "Exactly 32 bytes as lowercase or uppercase hexadecimal", + "id": "digest", + "possibleValues": [], + "required": true + } + ], + "description": "Sign a 32-byte application digest with the tweaked key controlling an owned P2TR address", + "name": "sign-digest", + "path": "labcoat wallet sign-digest", + "subcommands": [], + "usage": "sign-digest --address
--digest " } ], "usage": "wallet " @@ -515,6 +566,138 @@ "subcommands": [], "usage": "call [OPTIONS] [ARGS]..." }, + { + "arguments": [ + { + "description": "Asset sold by the seller: labcoat.lock name or block:tx id", + "id": "offered", + "possibleValues": [], + "required": true + }, + { + "description": "Complete offered quantity delivered to the buyer", + "id": "offered_amount", + "possibleValues": [], + "required": true + }, + { + "description": "Asset paid by the buyer: labcoat.lock name or block:tx id", + "id": "payment", + "possibleValues": [], + "required": true + }, + { + "description": "Complete payment quantity delivered to the seller", + "id": "payment_amount", + "possibleValues": [], + "required": true + }, + { + "description": "Seller keystore; --wallet-file is the buyer keystore", + "id": "seller_wallet_file", + "possibleValues": [], + "required": true + } + ], + "description": "Atomically exchange one wallet's Alkane asset for another wallet's asset", + "name": "exchange", + "path": "labcoat exchange", + "subcommands": [], + "usage": "exchange --seller-wallet-file " + }, + { + "arguments": [ + { + "description": "", + "id": "offered", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "offered_amount", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "payment", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "payment_amount", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "seller_address", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "buyer_address", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "plan_out", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "psbt_out", + "possibleValues": [], + "required": true + } + ], + "description": "Build an owner-partitioned exchange plan and unsigned PSBT", + "name": "exchange-plan", + "path": "labcoat exchange-plan", + "subcommands": [], + "usage": "exchange-plan --seller-address --buyer-address --plan-out --psbt-out " + }, + { + "arguments": [ + { + "description": "", + "id": "plan", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "psbt", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "seller_wallet_file", + "possibleValues": [], + "required": true + }, + { + "description": "", + "id": "broadcast", + "possibleValues": [ + "true", + "false" + ], + "required": false + } + ], + "description": "Validate a buyer-signed exchange PSBT, sign as seller, and optionally broadcast", + "name": "exchange-settle", + "path": "labcoat exchange-settle", + "subcommands": [], + "usage": "exchange-settle [OPTIONS] --plan --psbt --seller-wallet-file " + }, { "arguments": [ { @@ -723,6 +906,81 @@ "meaning": "the keystore could not be unlocked", "recovery": "set `LABCOAT_WALLET_PASSPHRASE`" }, + { + "code": "WALLET_ERROR", + "meaning": "wallet metadata, ownership, or signing failed", + "recovery": "inspect the wallet, PSBT prevouts, and expected derivation path" + }, + { + "code": "SIGNER_UNSUPPORTED", + "meaning": "the selected signer lacks a required capability", + "recovery": "use the keystore signer or a compatible PSBT signer" + }, + { + "code": "SIGNER_TIMEOUT", + "meaning": "an external signer did not return a PSBT in time", + "recovery": "sign the request file or raise `LABCOAT_PSBT_TIMEOUT_SECS`" + }, + { + "code": "SIGNER_MISMATCH", + "meaning": "external signer output does not match the requested transaction", + "recovery": "sign the exact PSBT without changing inputs or outputs" + }, + { + "code": "EXCHANGE_PLAN_INVALID", + "meaning": "exchange terms or fixed output layout are invalid", + "recovery": "rebuild the exchange plan from current wallet state" + }, + { + "code": "EXCHANGE_PLAN_MISMATCH", + "meaning": "the supplied PSBT differs from its content-addressed plan", + "recovery": "use the PSBT emitted by `labcoat exchange-plan`" + }, + { + "code": "EXCHANGE_INPUT_OWNERSHIP", + "meaning": "an exchange input is unsafe, ambiguous, or owned by the wrong party", + "recovery": "use clean P2TR inputs containing only the participant's required asset" + }, + { + "code": "EXCHANGE_ASSET_UNSAFE", + "meaning": "an exchange input or output contains an unrelated or misrouted Alkane", + "recovery": "use single-asset owner inputs and rebuild the exchange plan" + }, + { + "code": "EXCHANGE_SELLER_DEBIT", + "meaning": "the transaction would consume seller bitcoin value", + "recovery": "rebuild with buyer-funded outputs and fees" + }, + { + "code": "EXCHANGE_SIGNATURE_MISSING", + "meaning": "a required buyer or seller signature is absent", + "recovery": "sign the PSBT with the expected participant wallet" + }, + { + "code": "EXCHANGE_SIGNATURE_INVALID", + "meaning": "an exchange input signature failed verification", + "recovery": "discard the PSBT and recreate the plan" + }, + { + "code": "EXCHANGE_SIGHASH_UNSUPPORTED", + "meaning": "an exchange signature is not Taproot SIGHASH_DEFAULT", + "recovery": "sign the complete unchanged transaction with SIGHASH_DEFAULT" + }, + { + "code": "EXCHANGE_NETWORK_MISMATCH", + "meaning": "the live chain instance differs from the exchange plan", + "recovery": "discard stale plans after a network reset" + }, + { + "code": "EXCHANGE_TIP_STALE", + "meaning": "the observed planning tip is no longer in the active chain", + "recovery": "rebuild the plan after the reorganization" + }, + { + "code": "EXCHANGE_INPUT_SPENT", + "meaning": "a planned input has already been spent", + "recovery": "rebuild the plan with current UTXOs" + }, { "code": "RPC_UNREACHABLE", "meaning": "the configured Qubitcoin endpoint cannot be reached", @@ -907,11 +1165,14 @@ "name": "network_logs" }, { - "description": "Create or load the project wallet keystore. Optional mnemonic (else generated).", + "description": "Create or load the project wallet keystore. Optional mnemonic (else generated). Generated mnemonics are redacted from the response unless showMnemonic is true.", "inputSchema": { "properties": { "mnemonic": { "type": "string" + }, + "showMnemonic": { + "type": "boolean" } }, "required": [], @@ -1114,6 +1375,71 @@ }, "name": "call" }, + { + "description": "Build an owner-partitioned atomic exchange plan and return its base64 PSBT.", + "inputSchema": { + "properties": { + "buyerAddress": { + "type": "string" + }, + "offered": { + "type": "string" + }, + "offeredAmount": { + "minimum": 1, + "type": "integer" + }, + "payment": { + "type": "string" + }, + "paymentAmount": { + "minimum": 1, + "type": "integer" + }, + "sellerAddress": { + "type": "string" + } + }, + "required": [ + "offered", + "offeredAmount", + "payment", + "paymentAmount", + "sellerAddress", + "buyerAddress" + ], + "type": "object" + }, + "name": "exchange_plan" + }, + { + "description": "Validate a buyer-signed PSBT, sign seller inputs, and optionally broadcast. broadcast must be true to transact.", + "inputSchema": { + "properties": { + "broadcast": { + "type": "boolean" + }, + "plan": { + "type": "object" + }, + "psbt": { + "description": "base64 or hex buyer-signed PSBT", + "type": "string" + }, + "sellerWalletFile": { + "type": "string" + } + }, + "required": [ + "plan", + "psbt", + "sellerWalletFile", + "broadcast" + ], + "type": "object" + }, + "name": "exchange_settle" + }, { "description": "Simulate a deployed contract against live indexed chain state (no transaction).", "inputSchema": { diff --git a/skills/SKILL.md b/skills/SKILL.md index e918a68..141c7fa 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -133,7 +133,7 @@ fallback. - `network_fund` — Send BTC from the Labcoat Network faucet wallet to an address. - `network_reset` — Stop services and wipe all Labcoat Network chain data. - `network_logs` — Recent Labcoat Network service logs. -- `wallet_init` — Create or load the project wallet keystore. Optional mnemonic (else generated). +- `wallet_init` — Create or load the project wallet keystore. Optional mnemonic (else generated). Generated mnemonics are redacted from the response unless showMnemonic is true. - `wallet_addresses` — Wallet receive addresses per script type. - `wallet_utxos` — Spendable wallet UTXOs. - `build` — Build Cargo contract packages and extract their Wasm-exported ABIs. @@ -142,6 +142,8 @@ fallback. - `abi_verify` — Compare a deployed ABI with a locally built contract package. - `deploy` — Build and deploy an exact Cargo contract package, or deploy an explicit raw Wasm. Provide exactly one of package or wasm. - `call` — Execute a state-changing contract call and wait for its trace. +- `exchange_plan` — Build an owner-partitioned atomic exchange plan and return its base64 PSBT. +- `exchange_settle` — Validate a buyer-signed PSBT, sign seller inputs, and optionally broadcast. broadcast must be true to transact. - `simulate` — Simulate a deployed contract against live indexed chain state (no transaction). - `trace` — Decoded protostone traces for a transaction. - `balance` — Alkanes token balances held by an address.