Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
65f462d
docs: add database field lifecycle documentation to dev fee spec
grunch Dec 24, 2025
f508669
feat: implement automated development fee payment system
grunch Dec 24, 2025
102cfed
fix: resolve dev fee payment failures with two critical fixes
grunch Dec 25, 2025
680d515
Fix linting errors
grunch Dec 25, 2025
eaf3319
Validate dev_fee amount before attempting payment.
grunch Dec 26, 2025
42253f1
Fix edge case on dev fund payment
grunch Dec 26, 2025
2655943
fix: improve dev fee payment reliability and coverage
grunch Dec 26, 2025
5c1cdb5
Fix linting errors
grunch Dec 26, 2025
1eb6d3a
fix: reset dev_fee when market price orders timeout
grunch Dec 27, 2025
723e0f1
docs: add troubleshooting entry for stale dev_fee in market orders
grunch Dec 27, 2025
c3d3ffb
Add function documentation
grunch Dec 27, 2025
a365eda
fix: prevent duplicate dev fee payments with optimistic locking
grunch Dec 27, 2025
fa0759f
Fix lint errors
grunch Dec 27, 2025
1d091c1
dev_fee should be reset unconditionally
grunch Dec 27, 2025
14b01ac
feat: add cleanup for stale PENDING dev fee markers
grunch Dec 27, 2025
f82630b
Fix lint errors
grunch Dec 27, 2025
2b1d2ea
Add phase 4 on documentation
grunch Dec 27, 2025
c803471
fix: correct timestamp binding in SQL queries
grunch Dec 27, 2025
8f9179d
fix: persist dev_fee to database when resetting orders to initial state
grunch Dec 30, 2025
2349669
refactor: unify dev_fee calculation at order take time
grunch Dec 30, 2025
1d9aa22
Fix linting error
grunch Dec 30, 2025
7e9b0a0
fix: calculate dev_fee before invoice validation in take_sell
grunch Dec 30, 2025
e5c6222
docs: update DEV_FEE.md to reflect Phase 3 completion and recent changes
grunch Dec 30, 2025
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
1,002 changes: 917 additions & 85 deletions docs/DEV_FEE.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions sqlx-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
},
"query": "\n UPDATE users SET last_rating = ?1, min_rating = ?2, max_rating = ?3, total_reviews = ?4, total_rating = ?5 WHERE pubkey = ?6\n "
},
"6c883d45be83424710f9d5e065c4cf4efc08928c85070a6a0095b24da00611e1": {
"1a9c409177b4ac73e9270fc702d2d2f38d35cd06432073e212d27c5aa146233b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 9
"Right": 10
}
},
"query": "\n UPDATE orders\n SET\n status = ?1,\n amount = ?2,\n fee = ?3,\n hash = ?4,\n preimage = ?5,\n buyer_invoice = ?6,\n taken_at = ?7,\n invoice_held_at = ?8\n WHERE id = ?9\n "
"query": "\n UPDATE orders\n SET\n status = ?1,\n amount = ?2,\n fee = ?3,\n dev_fee = ?4,\n hash = ?5,\n preimage = ?6,\n buyer_invoice = ?7,\n taken_at = ?8,\n invoice_held_at = ?9\n WHERE id = ?10\n "
},
"b047209f67b1c1fad80c1d812c0ad0fa4116d16175fa2569962cd13c0ca05ac3": {
"describe": {
Expand Down Expand Up @@ -50,4 +50,4 @@
},
"query": "\n UPDATE users SET last_trade_index = ?1 WHERE pubkey = ?2\n "
}
}
}
2 changes: 1 addition & 1 deletion src/app/cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ async fn cancel_order_by_taker(
reset_api_quotes(&mut order);

// Update order to initial state and save it to the database
update_order_to_initial_state(pool, order.id, order.amount, order.fee)
update_order_to_initial_state(pool, order.id, order.amount, order.fee, order.dev_fee)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;

Expand Down
129 changes: 128 additions & 1 deletion src/app/release.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::config;
use crate::config::constants::DEV_FEE_LIGHTNING_ADDRESS;
use crate::config::MOSTRO_DB_PASSWORD;
use crate::db::{self};
use crate::lightning::LndConnector;
Expand All @@ -23,7 +24,7 @@ use sqlx_crud::Crud;
use std::cmp::Ordering;
use std::str::FromStr;
use tokio::sync::mpsc::channel;
use tracing::info;
use tracing::{error, info};

/// Check if order has failed payment retries
pub async fn check_failure_retries(
Expand Down Expand Up @@ -560,6 +561,132 @@ async fn payment_success(
Ok(())
}

/// Send development fee payment via Lightning Network
///
/// Attempts to pay the configured development fee for a completed order.
/// Uses LNURL resolution to get payment invoice, then sends payment via LND.
///
/// # Timeouts
/// - LNURL resolution: 15 seconds
/// - send_payment call: 5 seconds
/// - Payment result wait: 25 seconds
/// - Total: 45 seconds maximum
///
/// # Returns
/// - `Ok(String)`: Payment hash on successful payment
/// - `Err(MostroError)`: Error if payment fails or times out
pub async fn send_dev_fee_payment(order: &Order) -> Result<String, MostroError> {
info!(
"Initiating dev fee payment for order {} - amount: {} sats to {}",
order.id, order.dev_fee, DEV_FEE_LIGHTNING_ADDRESS
);

if order.dev_fee <= 0 {
return Err(MostroInternalErr(ServiceError::WrongAmountError));
}

// Step 1: LNURL resolution (15s timeout)
let payment_request = tokio::time::timeout(
std::time::Duration::from_secs(15),
resolv_ln_address(DEV_FEE_LIGHTNING_ADDRESS, order.dev_fee as u64),
)
.await
.map_err(|_| {
error!(
"Dev fee LNURL resolution timeout for order {} ({} sats)",
order.id, order.dev_fee
);
MostroInternalErr(ServiceError::LnAddressParseError)
})?
.map_err(|e| {
error!(
"Dev fee LNURL resolution failed for order {} ({} sats): {:?}",
order.id, order.dev_fee, e
);
e
})?;

// Step 2: Create LND connector
let mut ln_client = LndConnector::new().await?;
let (tx, mut rx) = channel(100);

// Step 3: Send payment (5s timeout to prevent hanging)
tokio::time::timeout(
std::time::Duration::from_secs(5),
ln_client.send_payment(&payment_request, order.dev_fee, tx),
)
.await
.map_err(|_| {
error!(
"Dev fee send_payment timeout for order {} ({} sats)",
order.id, order.dev_fee
);
MostroInternalErr(ServiceError::LnPaymentError(
"send_payment timeout".to_string(),
))
})?
.map_err(|e| {
error!(
"Dev fee send_payment failed for order {} ({} sats): {:?}",
order.id, order.dev_fee, e
);
e
})?;

// Step 4: Wait for payment result (25s timeout)
// Loop to receive multiple status messages from LND until terminal status
let payment_result = tokio::time::timeout(std::time::Duration::from_secs(25), async {
while let Some(msg) = rx.recv().await {
if let Ok(status) = PaymentStatus::try_from(msg.payment.status) {
match status {
PaymentStatus::Succeeded => {
// Terminal status - payment succeeded
return Ok(msg.payment.payment_hash);
}
PaymentStatus::Failed => {
// Terminal status - payment failed
error!(
"Dev fee payment failed for order {} ({} sats) - failure_reason: {}",
order.id, order.dev_fee, msg.payment.failure_reason
);
return Err(MostroInternalErr(ServiceError::LnPaymentError(format!(
"payment failed: reason {}",
msg.payment.failure_reason
))));
}
_ => {
// Ignore intermediate statuses (Unknown, InFlight)
// Continue waiting for terminal status
}
}
}
}
// Channel closed without receiving terminal status
error!(
"Dev fee payment channel closed for order {} ({} sats)",
order.id, order.dev_fee
);
Err(MostroInternalErr(ServiceError::LnPaymentError(
"channel closed".to_string(),
)))
})
.await
.map_err(|_| {
error!(
"Dev fee payment result timeout for order {} ({} sats)",
order.id, order.dev_fee
);
MostroInternalErr(ServiceError::LnPaymentError("result timeout".to_string()))
})??; // Double ? to unwrap both timeout Result and inner Result

// Step 5: Log and return the successful payment hash
info!(
"Dev fee payment succeeded for order {} - amount: {} sats, hash: {}",
order.id, order.dev_fee, payment_result
);
Ok(payment_result)
}

/// Check if order is range type
/// Add parent range id and update max amount
/// publish a new replaceable kind nostr event with the status updated
Expand Down
12 changes: 10 additions & 2 deletions src/app/take_buy.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::util::{
get_fiat_amount_requested, get_market_amount_and_fee, get_order, show_hold_invoice,
get_dev_fee, get_fiat_amount_requested, get_market_amount_and_fee, get_order, show_hold_invoice,
};

use crate::config::MOSTRO_DB_PASSWORD;
Expand Down Expand Up @@ -53,10 +53,18 @@ pub async fn take_buy_action(
match get_market_amount_and_fee(order.fiat_amount, &order.fiat_code, order.premium).await {
Ok(amount_fees) => {
order.amount = amount_fees.0;
order.fee = amount_fees.1
order.fee = amount_fees.1;
// Calculate dev_fee now that we know the fee amount
let total_mostro_fee = order.fee * 2;
order.dev_fee = get_dev_fee(total_mostro_fee);
}
Err(_) => return Err(MostroInternalErr(ServiceError::WrongAmountError)),
};
} else {
// Calculate dev_fee for fixed price orders
// The fee is already calculated at order creation, we only calculate dev_fee here
let total_mostro_fee = order.fee * 2;
order.dev_fee = get_dev_fee(total_mostro_fee);
}

// Get seller and buyer public keys
Expand Down
42 changes: 26 additions & 16 deletions src/app/take_sell.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::config::MOSTRO_DB_PASSWORD;
use crate::db::{buyer_has_pending_order, update_user_trade_index};
use crate::util::{
get_fiat_amount_requested, get_market_amount_and_fee, get_order, set_waiting_invoice_status,
show_hold_invoice, update_order_event, validate_invoice,
get_dev_fee, get_fiat_amount_requested, get_market_amount_and_fee, get_order,
set_waiting_invoice_status, show_hold_invoice, update_order_event, validate_invoice,
};
use mostro_core::prelude::*;
use nostr::nips::nip59::UnwrappedGift;
Expand Down Expand Up @@ -67,16 +67,37 @@ pub async fn take_sell_action(
// Get seller pubkey
let seller_pubkey = order.get_seller_pubkey().map_err(MostroInternalErr)?;

// Validate invoice and get payment request if present
let payment_request = validate_invoice(&msg, &order).await?;

// Get amount request if user requested one for range order - fiat amount will be used below
// IMPORTANT: This must come BEFORE dev_fee calculation for market price orders
if let Some(am) = get_fiat_amount_requested(&order, &msg) {
order.fiat_amount = am;
} else {
return Err(MostroCantDo(CantDoReason::OutOfRangeSatsAmount));
}

// Calculate dev_fee BEFORE validate_invoice
// Invoice validation needs the correct dev_fee to verify buyer invoice amount
if order.has_no_amount() {
// Market price: calculate amount, fee, and dev_fee
match get_market_amount_and_fee(order.fiat_amount, &order.fiat_code, order.premium).await {
Ok(amount_fees) => {
order.amount = amount_fees.0;
order.fee = amount_fees.1;
let total_mostro_fee = order.fee * 2;
order.dev_fee = get_dev_fee(total_mostro_fee);
}
Err(_) => return Err(MostroInternalErr(ServiceError::WrongAmountError)),
};
} else {
// Fixed price: only calculate dev_fee (amount/fee already set at creation)
let total_mostro_fee = order.fee * 2;
order.dev_fee = get_dev_fee(total_mostro_fee);
}

// Validate invoice and get payment request if present
// NOW dev_fee is set correctly for proper validation
let payment_request = validate_invoice(&msg, &order).await?;

// Add buyer pubkey to order
order.buyer_pubkey = Some(event.rumor.pubkey.to_string());
// Add buyer identity pubkey to order
Expand All @@ -100,17 +121,6 @@ pub async fn take_sell_action(
// Timestamp take order time
order.set_timestamp_now();

// Check market price value in sats - if order was with market price then calculate it and send a DM to buyer
if order.has_no_amount() {
match get_market_amount_and_fee(order.fiat_amount, &order.fiat_code, order.premium).await {
Ok(amount_fees) => {
order.amount = amount_fees.0;
order.fee = amount_fees.1
}
Err(_) => return Err(MostroInternalErr(ServiceError::WrongAmountError)),
};
}

// Update trade index only after all checks are done
update_user_trade_index(pool, event.sender.to_string(), trade_index)
.await
Expand Down
41 changes: 31 additions & 10 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ pub async fn find_order_by_date(pool: &SqlitePool) -> Result<Vec<Order>, MostroE
WHERE expires_at < ?1 AND status == 'pending'
"#,
)
.bind(expire_time.to_string())
.bind(expire_time.as_u64() as i64)
.fetch_all(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
Expand All @@ -746,7 +746,7 @@ pub async fn find_order_by_seconds(pool: &SqlitePool) -> Result<Vec<Order>, Most
WHERE taken_at < ?1 AND ( status == 'waiting-buyer-invoice' OR status == 'waiting-payment' )
"#,
)
.bind(expire_time.to_string())
.bind(expire_time.as_u64() as i64)
.fetch_all(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
Expand Down Expand Up @@ -778,6 +778,7 @@ pub async fn update_order_to_initial_state(
order_id: Uuid,
amount: i64,
fee: i64,
dev_fee: i64,
) -> Result<bool, MostroError> {
let status = Status::Pending.to_string();
let hash: Option<String> = None;
Expand All @@ -791,16 +792,18 @@ pub async fn update_order_to_initial_state(
status = ?1,
amount = ?2,
fee = ?3,
hash = ?4,
preimage = ?5,
buyer_invoice = ?6,
taken_at = ?7,
invoice_held_at = ?8
WHERE id = ?9
dev_fee = ?4,
hash = ?5,
preimage = ?6,
buyer_invoice = ?7,
taken_at = ?8,
invoice_held_at = ?9
WHERE id = ?10
"#,
status,
amount,
fee,
dev_fee,
hash,
preimage,
buyer_invoice,
Expand Down Expand Up @@ -892,6 +895,24 @@ pub async fn find_failed_payment(pool: &SqlitePool) -> Result<Vec<Order>, Mostro
Ok(order)
}

pub async fn find_unpaid_dev_fees(pool: &SqlitePool) -> Result<Vec<Order>, MostroError> {
let orders = sqlx::query_as::<_, Order>(
r#"
SELECT *
FROM orders
WHERE (status = 'settled-hold-invoice' OR status = 'success')
AND dev_fee > 0
AND dev_fee_paid = 0
AND (dev_fee_payment_hash IS NULL OR dev_fee_payment_hash NOT LIKE 'PENDING-%')
"#,
)
.fetch_all(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;

Ok(orders)
}

pub async fn get_admin_password(pool: &SqlitePool) -> Result<Option<String>, MostroError> {
if let Some(user) = sqlx::query_as::<_, User>(
r#"
Expand Down Expand Up @@ -955,7 +976,7 @@ pub async fn add_new_user(pool: &SqlitePool, new_user: User) -> Result<String, M
let created_at: Timestamp = Timestamp::now();
let _result = sqlx::query(
"
INSERT INTO users (pubkey, is_admin,admin_password, is_solver, is_banned, category, last_trade_index, total_reviews, total_rating, last_rating, max_rating, min_rating, created_at)
INSERT INTO users (pubkey, is_admin,admin_password, is_solver, is_banned, category, last_trade_index, total_reviews, total_rating, last_rating, max_rating, min_rating, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
",
)
Expand All @@ -971,7 +992,7 @@ pub async fn add_new_user(pool: &SqlitePool, new_user: User) -> Result<String, M
.bind(new_user.last_rating)
.bind(new_user.max_rating)
.bind(new_user.min_rating)
.bind(created_at.to_string())
.bind(created_at.as_u64() as i64)
.execute(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
Expand Down
7 changes: 6 additions & 1 deletion src/lightning/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,12 @@ impl LndConnector {
// We need to set a max fee amount
// If the amount is small we use a different max routing fee
let max_fee = match amount.cmp(&1000) {
Ordering::Less | Ordering::Equal => amount as f64 * 0.01,
Ordering::Less | Ordering::Equal => {
// For small amounts, use 1% but ensure minimum of 10 sats
// to allow routing (otherwise tiny amounts like 30 sats would have 0 fee limit)
let calculated_fee = amount as f64 * 0.01;
calculated_fee.max(10.0)
}
Ordering::Greater => amount as f64 * mostro_settings.max_routing_fee,
};

Expand Down
Loading