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/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/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..19b2a54 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.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,
}
}
}
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"),
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.