Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion ark-client-sample/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ lightning-invoice actor amount:
pay-invoice actor invoice:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic pay-invoice {{ invoice }}

# Create a chain swap (ARK <-> BTC) via Boltz
# e.g. just chain-swap alice btc-to-ark 100000

# e.g. just chain-swap alice ark-to-btc 100000 --address tb1q... --fee-rate 1.5
chain-swap actor direction amount *args:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic chain-swap {{ direction }} {{ amount }} {{ args }}

# Claim a chain swap after the server has locked funds
# e.g. just claim-chain-swap alice <swap_id> --address tb1q... --fee-rate 1.5
claim-chain-swap actor swap_id *args:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic claim-chain-swap {{ swap_id }} {{ args }}

# Check the status of a Boltz swap
swap-status actor swap_id:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic swap-status {{ swap_id }}

# Refund a chain swap (reclaim locked funds after expiry)
# e.g. just refund-chain-swap alice <swap_id>
# e.g. just refund-chain-swap alice <swap_id> --address tb1q... --fee-rate 1.5
refund-chain-swap actor swap_id *args:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic refund-chain-swap {{ swap_id }} {{ args }}

# Refund a submarine swap collaboratively
refund-swap-collab actor swap_id:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic refund-swap {{ swap_id }}
Expand Down Expand Up @@ -103,7 +125,6 @@ settle-recoverable actor:
estimate-fees actor address amount:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic estimate-fees {{ address }} {{ amount }}


# List pending (submitted but not finalized) offchain transactions for a given actor
list-pending-txs actor:
cargo run -p ark-client-sample -- --config ./{{ actor }}/ark.config.toml --mnemonic ./{{ actor }}/ark.mnemonic list-pending-txs
Expand Down
214 changes: 214 additions & 0 deletions ark-client-sample/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use ark_bdk_wallet::Wallet;
use ark_client::lightning_invoice::Bolt11Invoice;
use ark_client::Bip32KeyProvider;
use ark_client::Blockchain;
use ark_client::ChainSwapAmount;
use ark_client::ChainSwapDirection;
use ark_client::Error;
use ark_client::KeyProvider;
use ark_client::OfflineClient;
Expand Down Expand Up @@ -135,6 +137,46 @@ enum Commands {
/// A BOLT11 invoice.
invoice: String,
},
/// Create a chain swap (ARK <-> BTC) via Boltz.
ChainSwap {
/// Direction: "ark-to-btc" or "btc-to-ark".
direction: String,
/// Amount in sats.
amount: u64,
/// Target BTC address (required for ark-to-btc).
#[arg(long)]
address: Option<String>,
/// Fee rate in sat/vB for the on-chain claim (default: 1.0).
#[arg(long, default_value = "1.0")]
fee_rate: f64,
},
/// Claim a chain swap after the server has locked funds.
ClaimChainSwap {
/// The Boltz swap ID.
swap_id: String,
/// Target BTC address (required for ark-to-btc claims).
#[arg(long)]
address: Option<String>,
/// Fee rate in sat/vB for the on-chain claim (default: 1.0).
#[arg(long, default_value = "1.0")]
fee_rate: f64,
},
/// Check the status of a Boltz swap.
SwapStatus {
/// The Boltz swap ID.
swap_id: String,
},
/// Refund a chain swap (reclaim locked funds after expiry).
RefundChainSwap {
/// The Boltz swap ID.
swap_id: String,
/// Target BTC address (required for btc-to-ark refunds).
#[arg(long)]
address: Option<String>,
/// Fee rate in sat/vB for the on-chain refund (default: 1.0).
#[arg(long, default_value = "1.0")]
fee_rate: f64,
},
/// Attempt to refund a past swap collaboratively.
RefundSwap { swap_id: String },
/// Attempt to refund a past swap without the receiver's signature.
Expand Down Expand Up @@ -674,6 +716,178 @@ async fn run_command<K: KeyProvider>(

tracing::info!(swap_id, "Payment made");
}
Commands::ChainSwap {
direction,
amount,
address,
fee_rate,
} => {
let direction = match direction.as_str() {
"ark-to-btc" => ChainSwapDirection::ArkToBtc,
"btc-to-ark" => ChainSwapDirection::BtcToArk,
other => {
bail!("invalid direction '{other}', expected 'ark-to-btc' or 'btc-to-ark'")
}
};

let amount = ChainSwapAmount::UserLock(Amount::from_sat(*amount));

let result = client
.create_chain_swap(direction.clone(), amount)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(
swap_id = result.swap_id,
user_lockup_address = %result.user_lockup_address,
user_lockup_amount = %result.user_lockup_amount,
server_lockup_amount = %result.server_lockup_amount,
bip21 = result.bip21.as_deref().unwrap_or("n/a"),
"Chain swap created — fund the user_lockup_address to proceed"
);

match direction {
ChainSwapDirection::BtcToArk => {
// BtcToArk: user funds BTC on-chain, then claims Ark VHTLC
tracing::info!(swap_id = result.swap_id, "Waiting for server lockup...");

client
.wait_for_chain_swap_server_lockup(&result.swap_id)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(
swap_id = result.swap_id,
"Server locked ARK VHTLC, claiming..."
);

let txid = client
.claim_chain_swap(&result.swap_id)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(swap_id = result.swap_id, %txid, "Chain swap claimed (ARK)");
}
ChainSwapDirection::ArkToBtc => {
// ArkToBtc: fund Ark VHTLC, wait for server BTC lockup, claim BTC
let destination: Address = address
.as_deref()
.ok_or_else(|| anyhow!("--address is required for ark-to-btc"))?
.parse::<Address<NetworkUnchecked>>()
.map_err(|e| anyhow!("invalid BTC address: {e}"))?
.assume_checked();

let lockup_address = ArkAddress::decode(&result.user_lockup_address)
.map_err(|e| anyhow!("failed to parse ARK lockup address: {e}"))?;

tracing::info!(swap_id = result.swap_id, "Funding ARK VHTLC...");

let fund_txid = client
.send_vtxo(lockup_address, result.user_lockup_amount)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(
swap_id = result.swap_id,
%fund_txid,
"Funded ARK VHTLC, waiting for server BTC lockup..."
);

client
.wait_for_chain_swap_server_lockup(&result.swap_id)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(
swap_id = result.swap_id,
"Server locked BTC, claiming on-chain..."
);

let txid = client
.claim_chain_swap_btc(&result.swap_id, destination, *fee_rate)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(swap_id = result.swap_id, %txid, "Chain swap claimed (BTC)");
}
}
}
Commands::ClaimChainSwap {
swap_id,
address,
fee_rate,
} => {
// Try Ark VHTLC claim first; if it fails (wrong direction), try BTC claim.
match client.claim_chain_swap(swap_id).await {
Ok(txid) => {
tracing::info!(%txid, swap_id, "Chain swap claimed (ARK VHTLC)");
}
Err(_) => {
let destination: Address = address
.as_deref()
.ok_or_else(|| anyhow!("--address is required for ark-to-btc claims"))?
.parse::<Address<NetworkUnchecked>>()
.map_err(|e| anyhow!("invalid BTC address: {e}"))?
.assume_checked();

let txid = client
.claim_chain_swap_btc(swap_id, destination, *fee_rate)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(%txid, swap_id, "Chain swap claimed (on-chain BTC)");
}
Comment on lines +821 to +839
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't use a blanket Err as the direction switch.

claim_chain_swap() and refund_chain_swap() can fail for ordinary reasons too. Falling back on every error hides the real failure and can turn a BtcToArk problem into a misleading BTC-address error. Only take the BTC path when the swap direction is known, or when the Ark call failed with the explicit wrong-side condition.

Also applies to: 861-889

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ark-client-sample/src/main.rs` around lines 821 - 839, The current code
treats any Err from client.claim_chain_swap(swap_id) as meaning "wrong chain"
and always falls back to on-chain BTC via claim_chain_swap_btc, which hides real
failures; change the logic in the claim_flow (the match around
client.claim_chain_swap and the analogous refund_flow around
client.refund_chain_swap) to only take the BTC path when you can
deterministically detect the swap direction or when the error is the explicit
"wrong side" variant from the client API (e.g., inspect the Err value for a
WrongSwapDirection/WrongSide/SwapNotOnArk variant or query the swap metadata
before deciding), otherwise propagate or log other errors. In short: replace
Err(_) branches with explicit error matching (or a pre-check of swap direction
via client.get_swap or similar) and only call claim_chain_swap_btc or the BTC
refund when the swap is known to be BTC-side; rethrow/return other errors
unchanged.

}
}
Commands::SwapStatus { swap_id } => {
let info = client
.get_swap_status(swap_id.as_str())
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(
swap_id,
swap_type = %info.swap_type,
status = ?info.status,
"Swap status"
);
}
Commands::RefundChainSwap {
swap_id,
address,
fee_rate,
} => {
// Try the Ark VHTLC refund first (ArkToBtc direction).
match client.refund_chain_swap(swap_id).await {
Ok(txid) => {
tracing::info!(%txid, swap_id, "Chain swap refunded (ARK VHTLC)");
}
Err(ark_err) => {
// If no --address provided, it's an Ark refund that failed — report the error.
let Some(addr_str) = address.as_deref() else {
return Err(anyhow!(ark_err).context(
"Ark VHTLC refund failed (pass --address for on-chain BTC refund)",
));
};

tracing::debug!(
"Ark VHTLC refund failed ({ark_err}), trying on-chain BTC refund"
);

let destination: Address = addr_str
.parse::<Address<NetworkUnchecked>>()
.map_err(|e| anyhow!("invalid BTC address: {e}"))?
.assume_checked();

let txid = client
.refund_chain_swap_btc(swap_id, destination, *fee_rate)
.await
.map_err(|e| anyhow!(e))?;

tracing::info!(%txid, swap_id, "Chain swap refunded (on-chain BTC)");
}
}
}
Commands::RefundSwap { swap_id } => {
let txid = client
.refund_vhtlc(swap_id.as_str())
Expand Down
11 changes: 11 additions & 0 deletions ark-client/migrations/002_chain_swaps.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Add chain swaps table for ARK <-> BTC chain swaps via Boltz

CREATE TABLE chain_swaps (
id TEXT PRIMARY KEY,
data TEXT NOT NULL, -- JSON serialized ChainSwapData
created_at INTEGER NOT NULL, -- Unix timestamp
updated_at INTEGER NOT NULL -- Unix timestamp
);

CREATE INDEX idx_chain_swaps_created_at ON chain_swaps(created_at);
CREATE INDEX idx_chain_swaps_updated_at ON chain_swaps(updated_at);
Loading
Loading