Test fees (#698)

* feat: Add Docker container setup for Nutshell mint in test-nutshell recipe

* test: Add wait mechanism for Nutshell docker container startup

* test: Modify Nutshell wallet tests to run sequentially

* fix: mintd set input fee pkk

* feat: fee tests

* fix: melt returning fee in change

* fix: fee tests

* fix: fee tests
This commit is contained in:
thesimplekid
2025-04-03 00:30:50 +01:00
committed by GitHub
parent f0766d0ae4
commit 7fbe55ea02
12 changed files with 330 additions and 99 deletions

View File

@@ -7,11 +7,6 @@ jobs:
name: Nutshell Mint Integration Tests
runs-on: ubuntu-latest
steps:
- name: Pull and start mint
run: |
docker run -d -p 3338:3338 --name nutshell -e MINT_LIGHTNING_BACKEND=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:latest poetry run mint
- name: Check running containers
run: docker ps
- name: checkout
uses: actions/checkout@v4
- name: Install Nix
@@ -21,7 +16,7 @@ jobs:
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Test Nutshell
run: nix develop -i -L .#stable --command just test-nutshell
run: nix develop -i -L .#integration --command just test-nutshell
- name: Show logs if tests fail
if: failure()
run: docker logs nutshell

View File

@@ -207,7 +207,7 @@ impl MintPayment for FakeWallet {
payment_proof: Some("".to_string()),
payment_lookup_id: payment_hash,
status: payment_status,
total_spent: melt_quote.amount,
total_spent: melt_quote.amount + 1.into(),
unit: melt_quote.unit,
})
}

View File

@@ -2,11 +2,14 @@ use std::env;
use std::sync::Arc;
use anyhow::{anyhow, bail, Result};
use cashu::Bolt11Invoice;
use cdk::amount::{Amount, SplitTarget};
use cdk::nuts::{MintQuoteState, NotificationPayload, State};
use cdk::wallet::WalletSubscription;
use cdk::Wallet;
use init_regtest::get_mint_url;
use cdk_fake_wallet::create_fake_invoice;
use init_regtest::{get_lnd_dir, get_mint_url, LND_RPC_ADDR};
use ln_regtest_rs::ln_client::{LightningClient, LndClient};
use tokio::time::{sleep, timeout, Duration};
pub mod init_auth_mint;
@@ -145,3 +148,71 @@ pub fn get_second_mint_url_from_env() -> String {
Err(_) => get_mint_url("1"),
}
}
// This is the ln wallet we use to send/receive ln payements as the wallet
pub async fn init_lnd_client() -> LndClient {
let lnd_dir = get_lnd_dir("one");
let cert_file = lnd_dir.join("tls.cert");
let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon");
LndClient::new(
format!("https://{}", LND_RPC_ADDR),
cert_file,
macaroon_file,
)
.await
.unwrap()
}
/// Pays a Bolt11Invoice if it's on the regtest network, otherwise returns Ok
///
/// This is useful for tests that need to pay invoices in regtest mode but
/// should be skipped in other environments.
pub async fn pay_if_regtest(invoice: &Bolt11Invoice) -> Result<()> {
// Check if the invoice is for the regtest network
if invoice.network() == bitcoin::Network::Regtest {
println!("Regtest invoice");
let lnd_client = init_lnd_client().await;
lnd_client.pay_invoice(invoice.to_string()).await?;
Ok(())
} else {
// Not a regtest invoice, just return Ok
Ok(())
}
}
/// Determines if we're running in regtest mode based on environment variable
///
/// Checks the CDK_TEST_REGTEST environment variable:
/// - If set to "1", "true", or "yes" (case insensitive), returns true
/// - Otherwise returns false
pub fn is_regtest_env() -> bool {
match env::var("CDK_TEST_REGTEST") {
Ok(val) => {
let val = val.to_lowercase();
val == "1" || val == "true" || val == "yes"
}
Err(_) => false,
}
}
/// Creates a real invoice if in regtest mode, otherwise returns a fake invoice
///
/// Uses the is_regtest_env() function to determine whether to
/// create a real regtest invoice or a fake one for testing.
pub async fn create_invoice_for_env(amount_sat: Option<u64>) -> Result<String> {
if is_regtest_env() {
// In regtest mode, create a real invoice
let lnd_client = init_lnd_client().await;
lnd_client
.create_invoice(amount_sat)
.await
.map_err(|e| anyhow!("Failed to create regtest invoice: {}", e))
} else {
// Not in regtest mode, create a fake invoice
let fake_invoice = create_fake_invoice(
amount_sat.expect("Amount must be defined") * 1_000,
"".to_string(),
);
Ok(fake_invoice.to_string())
}
}

View File

@@ -15,93 +15,24 @@ use std::sync::Arc;
use std::time::Duration;
use std::{char, env};
use anyhow::{anyhow, bail, Result};
use anyhow::{bail, Result};
use bip39::Mnemonic;
use cashu::{MeltBolt11Request, PreMintSecrets};
use cdk::amount::{Amount, SplitTarget};
use cdk::nuts::nut00::ProofsMethods;
use cdk::nuts::{CurrencyUnit, MeltQuoteState, NotificationPayload, State};
use cdk::wallet::{HttpClient, MintConnector, Wallet};
use cdk_fake_wallet::create_fake_invoice;
use cdk_integration_tests::init_regtest::{get_lnd_dir, LND_RPC_ADDR};
use cdk_integration_tests::{get_mint_url_from_env, wait_for_mint_to_be_paid};
use cdk_integration_tests::{
create_invoice_for_env, get_mint_url_from_env, pay_if_regtest, wait_for_mint_to_be_paid,
};
use cdk_sqlite::wallet::memory;
use futures::{SinkExt, StreamExt};
use lightning_invoice::Bolt11Invoice;
use ln_regtest_rs::ln_client::{LightningClient, LndClient};
use serde_json::json;
use tokio::time::timeout;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::protocol::Message;
// This is the ln wallet we use to send/receive ln payements as the wallet
async fn init_lnd_client() -> LndClient {
let lnd_dir = get_lnd_dir("one");
let cert_file = lnd_dir.join("tls.cert");
let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon");
LndClient::new(
format!("https://{}", LND_RPC_ADDR),
cert_file,
macaroon_file,
)
.await
.unwrap()
}
/// Pays a Bolt11Invoice if it's on the regtest network, otherwise returns Ok
///
/// This is useful for tests that need to pay invoices in regtest mode but
/// should be skipped in other environments.
async fn pay_if_regtest(invoice: &Bolt11Invoice) -> Result<()> {
// Check if the invoice is for the regtest network
if invoice.network() == bitcoin::Network::Regtest {
println!("Regtest invoice");
let lnd_client = init_lnd_client().await;
lnd_client.pay_invoice(invoice.to_string()).await?;
Ok(())
} else {
// Not a regtest invoice, just return Ok
Ok(())
}
}
/// Determines if we're running in regtest mode based on environment variable
///
/// Checks the CDK_TEST_REGTEST environment variable:
/// - If set to "1", "true", or "yes" (case insensitive), returns true
/// - Otherwise returns false
fn is_regtest_env() -> bool {
match env::var("CDK_TEST_REGTEST") {
Ok(val) => {
let val = val.to_lowercase();
val == "1" || val == "true" || val == "yes"
}
Err(_) => false,
}
}
/// Creates a real invoice if in regtest mode, otherwise returns a fake invoice
///
/// Uses the is_regtest_env() function to determine whether to
/// create a real regtest invoice or a fake one for testing.
async fn create_invoice_for_env(amount_sat: Option<u64>) -> Result<String> {
if is_regtest_env() {
// In regtest mode, create a real invoice
let lnd_client = init_lnd_client().await;
lnd_client
.create_invoice(amount_sat)
.await
.map_err(|e| anyhow!("Failed to create regtest invoice: {}", e))
} else {
// Not in regtest mode, create a fake invoice
let fake_invoice = create_fake_invoice(
amount_sat.expect("Amount must be defined") * 1_000,
"".to_string(),
);
Ok(fake_invoice.to_string())
}
}
async fn get_notification<T: StreamExt<Item = Result<Message, E>> + Unpin, E: Debug>(
reader: &mut T,
timeout_to_wait: Duration,
@@ -263,7 +194,7 @@ async fn test_happy_mint_melt_round_trip() -> Result<()> {
///
/// This ensures the basic minting flow works correctly from quote to token issuance.
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_happy_mint_melt() -> Result<()> {
async fn test_happy_mint() -> Result<()> {
let wallet = Wallet::new(
&get_mint_url_from_env(),
CurrencyUnit::Sat,
@@ -330,7 +261,7 @@ async fn test_restore() -> Result<()> {
.mint(&mint_quote.id, SplitTarget::default(), None)
.await?;
assert!(wallet.total_balance().await? == 100.into());
assert_eq!(wallet.total_balance().await?, 100.into());
let wallet_2 = Wallet::new(
&get_mint_url_from_env(),
@@ -340,18 +271,23 @@ async fn test_restore() -> Result<()> {
None,
)?;
assert!(wallet_2.total_balance().await? == 0.into());
assert_eq!(wallet_2.total_balance().await?, 0.into());
let restored = wallet_2.restore().await?;
let proofs = wallet_2.get_unspent_proofs().await?;
let expected_fee = wallet.get_proofs_fee(&proofs).await?;
wallet_2
.swap(None, SplitTarget::default(), proofs, None, false)
.await?;
assert!(restored == 100.into());
assert_eq!(restored, 100.into());
assert_eq!(wallet_2.total_balance().await?, 100.into());
// Since we have to do a swap we expect to restore amount - fee
assert_eq!(
wallet_2.total_balance().await?,
Amount::from(100) - expected_fee
);
let proofs = wallet.get_unspent_proofs().await?;

View File

@@ -634,6 +634,50 @@ async fn test_mint_enforce_fee() {
let _ = mint_bob.process_swap_request(swap_request).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_mint_change_with_fee_melt() {
setup_tracing();
let mint_bob = create_and_start_test_mint()
.await
.expect("Failed to create test mint");
mint_bob
.rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
.await
.unwrap();
let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
.await
.expect("Failed to create test wallet");
// Alice gets 100 sats
fund_wallet(
wallet_alice.clone(),
100,
Some(SplitTarget::Value(Amount::ONE)),
)
.await
.expect("Failed to fund wallet");
let proofs = wallet_alice
.get_unspent_proofs()
.await
.expect("Could not get proofs");
let fake_invoice = create_fake_invoice(1000, "".to_string());
let melt_quote = wallet_alice
.melt_quote(fake_invoice.to_string(), None)
.await
.unwrap();
let w = wallet_alice
.melt_proofs(&melt_quote.id, proofs)
.await
.unwrap();
assert_eq!(w.change.unwrap().total_amount().unwrap(), 97.into());
}
/// Tests concurrent double-spending attempts by trying to use the same proofs
/// in 3 swap transactions simultaneously using tokio tasks
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]

View File

@@ -112,7 +112,7 @@ async fn get_wallet_balance(base_url: &str) -> u64 {
}
/// Test the Nutshell wallet's ability to mint tokens from a Lightning invoice
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[tokio::test]
async fn test_nutshell_wallet_mint() {
// Get the wallet URL from environment variable
let base_url = std::env::var("WALLET_URL").expect("Wallet url is not set");
@@ -137,7 +137,7 @@ async fn test_nutshell_wallet_mint() {
}
/// Test the Nutshell wallet's ability to mint tokens from a Lightning invoice
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[tokio::test]
async fn test_nutshell_wallet_swap() {
// Get the wallet URL from environment variable
let base_url = std::env::var("WALLET_URL").expect("Wallet url is not set");
@@ -194,11 +194,12 @@ async fn test_nutshell_wallet_swap() {
let token_received = balance - initial_balance;
assert_eq!(token_received, send_amount);
let fee = 1;
assert_eq!(token_received, send_amount - fee);
}
/// Test the Nutshell wallet's ability to melt tokens to pay a Lightning invoice
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[tokio::test]
async fn test_nutshell_wallet_melt() {
// Get the wallet URL from environment variable
let base_url = std::env::var("WALLET_URL").expect("Wallet url is not set");

View File

@@ -0,0 +1,125 @@
use std::str::FromStr;
use std::sync::Arc;
use anyhow::Result;
use bip39::Mnemonic;
use cashu::{Bolt11Invoice, ProofsMethods};
use cdk::amount::{Amount, SplitTarget};
use cdk::nuts::CurrencyUnit;
use cdk::wallet::{SendKind, SendOptions, Wallet};
use cdk_integration_tests::{
create_invoice_for_env, get_mint_url_from_env, pay_if_regtest, wait_for_mint_to_be_paid,
};
use cdk_sqlite::wallet::memory;
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_swap() -> Result<()> {
let seed = Mnemonic::generate(12)?.to_seed_normalized("");
let wallet = Wallet::new(
&get_mint_url_from_env(),
CurrencyUnit::Sat,
Arc::new(memory::empty().await?),
&seed,
None,
)?;
let mint_quote = wallet.mint_quote(100.into(), None).await?;
let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
pay_if_regtest(&invoice).await?;
let _mint_amount = wallet
.mint(&mint_quote.id, SplitTarget::default(), None)
.await?;
let proofs: Vec<Amount> = wallet
.get_unspent_proofs()
.await?
.iter()
.map(|p| p.amount)
.collect();
println!("{:?}", proofs);
let send = wallet
.prepare_send(
4.into(),
SendOptions {
send_kind: SendKind::OfflineExact,
..Default::default()
},
)
.await?;
let proofs = send.proofs();
let fee = wallet.get_proofs_fee(&proofs).await?;
assert_eq!(fee, 1.into());
let send = wallet.send(send, None).await?;
let rec_amount = wallet
.receive(&send.to_string(), SplitTarget::default(), &[], &[])
.await?;
assert_eq!(rec_amount, 3.into());
let wallet_balance = wallet.total_balance().await?;
assert_eq!(wallet_balance, 99.into());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_fake_melt_change_in_quote() -> Result<()> {
let wallet = Wallet::new(
&get_mint_url_from_env(),
CurrencyUnit::Sat,
Arc::new(memory::empty().await?),
&Mnemonic::generate(12)?.to_seed_normalized(""),
None,
)?;
let mint_quote = wallet.mint_quote(100.into(), None).await?;
let bolt11 = Bolt11Invoice::from_str(&mint_quote.request)?;
pay_if_regtest(&bolt11).await?;
wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
let _mint_amount = wallet
.mint(&mint_quote.id, SplitTarget::default(), None)
.await?;
let invoice_amount = 9;
let invoice = create_invoice_for_env(Some(invoice_amount)).await?;
let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
let proofs = wallet.get_unspent_proofs().await?;
let proofs_total = proofs.total_amount().unwrap();
let fee = wallet.get_proofs_fee(&proofs).await?;
let melt = wallet.melt_proofs(&melt_quote.id, proofs.clone()).await?;
let change = melt.change.unwrap().total_amount().unwrap();
let idk = proofs.total_amount()? - Amount::from(invoice_amount) - change;
println!("{}", idk);
println!("{}", fee);
println!("{}", proofs_total);
println!("{}", change);
let ln_fee = 1;
assert_eq!(
wallet.total_balance().await?,
Amount::from(100 - invoice_amount - u64::from(fee) - ln_fee)
);
Ok(())
}

View File

@@ -207,6 +207,10 @@ async fn main() -> anyhow::Result<()> {
)
.await?;
if let Some(input_fee) = settings.info.input_fee_ppk {
mint_builder = mint_builder.set_unit_fee(&CurrencyUnit::Sat, input_fee)?;
}
let nut17_supported = SupportedMethods::new(PaymentMethod::Bolt11, CurrencyUnit::Sat);
mint_builder = mint_builder.add_supported_websockets(nut17_supported);
@@ -226,6 +230,9 @@ async fn main() -> anyhow::Result<()> {
Arc::new(lnbits),
)
.await?;
if let Some(input_fee) = settings.info.input_fee_ppk {
mint_builder = mint_builder.set_unit_fee(&CurrencyUnit::Sat, input_fee)?;
}
let nut17_supported = SupportedMethods::new(PaymentMethod::Bolt11, CurrencyUnit::Sat);
@@ -246,6 +253,9 @@ async fn main() -> anyhow::Result<()> {
Arc::new(lnd),
)
.await?;
if let Some(input_fee) = settings.info.input_fee_ppk {
mint_builder = mint_builder.set_unit_fee(&CurrencyUnit::Sat, input_fee)?;
}
let nut17_supported = SupportedMethods::new(PaymentMethod::Bolt11, CurrencyUnit::Sat);
@@ -272,6 +282,9 @@ async fn main() -> anyhow::Result<()> {
fake.clone(),
)
.await?;
if let Some(input_fee) = settings.info.input_fee_ppk {
mint_builder = mint_builder.set_unit_fee(&unit, input_fee)?;
}
let nut17_supported = SupportedMethods::new(PaymentMethod::Bolt11, unit);
@@ -308,6 +321,9 @@ async fn main() -> anyhow::Result<()> {
Arc::new(processor),
)
.await?;
if let Some(input_fee) = settings.info.input_fee_ppk {
mint_builder = mint_builder.set_unit_fee(&unit, input_fee)?;
}
let nut17_supported = SupportedMethods::new(PaymentMethod::Bolt11, unit);
mint_builder = mint_builder.add_supported_websockets(nut17_supported);

View File

@@ -292,6 +292,20 @@ impl MintBuilder {
self
}
/// Sets the input fee ppk for a given unit
///
/// The unit **MUST** already have been added with a ln backend
pub fn set_unit_fee(mut self, unit: &CurrencyUnit, input_fee_ppk: u64) -> Result<Self, Error> {
let (input_fee, _max_order) = self
.supported_units
.get_mut(unit)
.ok_or(Error::UnsupportedUnit)?;
*input_fee = input_fee_ppk;
Ok(self)
}
/// Build mint
pub async fn build(&self) -> anyhow::Result<Mint> {
let localstore = self

View File

@@ -669,7 +669,10 @@ impl Mint {
return Err(Error::BlindedMessageAlreadySigned);
}
let change_target = melt_request.proofs_amount()? - total_spent;
let fee = self.get_proofs_fee(melt_request.inputs()).await?;
let change_target = melt_request.proofs_amount()? - total_spent - fee;
let mut amounts = change_target.split();
let mut change_sigs = Vec::with_capacity(amounts.len());

View File

@@ -66,12 +66,34 @@ test-all db="memory":
./misc/fake_itests.sh "{{db}}"
test-nutshell:
#!/usr/bin/env bash
export CDK_TEST_MINT_URL=http://127.0.0.1:3338
export LN_BACKEND=FAKEWALLET
cargo test -p cdk-integration-tests --test happy_path_mint_wallet
unset CDK_TEST_MINT_URL
unset LN_BACKEND
#!/usr/bin/env bash
docker run -d -p 3338:3338 --name nutshell -e MINT_LIGHTNING_BACKEND=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY -e MINT_INPUT_FEE_PPK=100 cashubtc/nutshell:latest poetry run mint
# Wait for the Nutshell service to be ready
echo "Waiting for Nutshell to start..."
max_attempts=30
attempt=0
while ! curl -s http://127.0.0.1:3338/v1/info > /dev/null; do
attempt=$((attempt+1))
if [ $attempt -ge $max_attempts ]; then
echo "Nutshell failed to start after $max_attempts attempts"
docker stop nutshell
docker rm nutshell
exit 1
fi
echo "Waiting for Nutshell to start (attempt $attempt/$max_attempts)..."
sleep 1
done
echo "Nutshell is ready!"
export CDK_TEST_MINT_URL=http://127.0.0.1:3338
export LN_BACKEND=FAKEWALLET
cargo test -p cdk-integration-tests --test happy_path_mint_wallet
cargo test -p cdk-integration-tests --test test_fees
unset CDK_TEST_MINT_URL
unset LN_BACKEND
docker stop nutshell
docker rm nutshell
# run `cargo clippy` on everything

View File

@@ -33,6 +33,7 @@ cleanup() {
unset CDK_MINTD_LN_BACKEND CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS CDK_MINTD_MNEMONIC
unset CDK_MINTD_FAKE_WALLET_FEE_PERCENT CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN CDK_MINTD_DATABASE
unset TEST_STATUS
unset CDK_MINTD_INPUT_FEE_PPK
echo "Cleanup complete."
}
@@ -55,6 +56,7 @@ export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt lugg
export CDK_MINTD_FAKE_WALLET_FEE_PERCENT="0"
export CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN="1"
export CDK_MINTD_DATABASE="redb"
export CDK_MINTD_INPUT_FEE_PPK="100"
echo "Starting fake mintd"
@@ -150,10 +152,12 @@ fi
# Export URLs as environment variables
export MINT_URL=${MINT_URL}
export WALLET_URL=${WALLET_URL}
export CDK_TEST_MINT_URL=${MINT_URL}
# Run the integration test
echo "Running integration test..."
cargo test -p cdk-integration-tests --tests nutshell_wallet
cargo test -p cdk-integration-tests --test nutshell_wallet
cargo test -p cdk-integration-tests --test test_fees
TEST_STATUS=$?
# Exit with the test status