From f4c857c3e7818abc2f43dff8f3b7a604b7b9e485 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 30 Mar 2025 13:08:00 +0100 Subject: [PATCH] Nutshell wallet (#695) * chore: Add nutshell wallet integration test script feat: Add MINT_URL configuration for docker container in nutshell wallet integration test script chore: Make nutshell_wallet_itest.sh executable fix: Update MINT_URL to use host.docker.internal for Docker container access feat: Add Docker container startup for Cashu daemon in wallet integration test script chore: Update Docker image to use Docker Hub repository feat: Add cleanup trap to stop Docker container and unset variables feat: Add initial test stub for nutshell wallet mint functionality test: Add Cashu wallet mint integration test feat: Add just command for nutshell wallet integration test refactor: Organize imports and improve code formatting in nutshell wallet test fix: Update Cashu Docker image and test URL for correct container access fix: Update Docker container name and image for Cashu wallet test script fix: Handle Docker container name conflict in nutshell wallet integration test fix: Update Docker image to cashubtc/nutshell:latest in wallet integration test script feat: Add support for running separate Nutshell mint and wallet containers feat: Update Nutshell mint and wallet container configurations for integration testing fix: Update wallet port and container configuration in integration test script chore: Export MINT_URL and WALLET_URL as environment variables fix: Update invoice creation and state checking in nutshell wallet test fix: Update MINT_URL to use host.docker.internal for container access fix: Update nutshell wallet mint test to handle invoice payment state refactor: Improve Nutshell wallet test with better error handling and robustness refactor: Improve code formatting and logging in nutshell wallet test refactor: Replace anyhow with expect for error handling in Nutshell wallet test refactor: Simplify error handling in Nutshell wallet mint test refactor: Replace `?` with `expect()` in Nutshell wallet test refactor: Simplify nutshell wallet test by removing unused code and improving error handling refactor: Extract minting and balance helper functions in nutshell wallet test feat: Add test for Nutshell wallet token swap functionality fix: Trim quotes from token in nutshell wallet swap test refactor: Remove debug print statements and improve code readability refactor: Improve test_nutshell_wallet_melt with payment state checking and balance verification refactor: Update Nutshell wallet integration test script configuration feat: Extract common mint startup function into shared script refactor: Simplify nutshell wallet integration test script and improve startup process feat: Add mintd process termination and test status capture in integration test script refactor: Capitalize env vars and ensure comprehensive cleanup in trap feat: nutshell wallet test * ci: Add test step for Nutshell wallet integration tests * ci: Split Nutshell integration tests into separate jobs * feat: nutshell wallet test * ci: Add Docker setup and increase timeout for Nutshell wallet integration tests * chore: Improve Nutshell wallet integration test script robustness * fix: Remove -i flag from Nix develop and improve Docker accessibility check * fix: payment processor * fix: wallet tests * feat: Add integration shell with Docker and Rust stable for testing * ci: Simplify Nutshell wallet integration test workflow and script * fix: Improve mintd endpoint detection and error handling in integration test script * fix: wallet tests --- .github/workflows/nutshell_itest.yml | 26 +- .../tests/nutshell_wallet.rs | 251 ++++++++++++++++++ flake.nix | 22 +- justfile | 4 + misc/nutshell_wallet_itest.sh | 161 +++++++++++ 5 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 crates/cdk-integration-tests/tests/nutshell_wallet.rs create mode 100755 misc/nutshell_wallet_itest.sh diff --git a/.github/workflows/nutshell_itest.yml b/.github/workflows/nutshell_itest.yml index 0f0e3630..7a8ecb1c 100644 --- a/.github/workflows/nutshell_itest.yml +++ b/.github/workflows/nutshell_itest.yml @@ -3,7 +3,8 @@ name: Nutshell integration on: [push, pull_request] jobs: - integration-tests: + nutshell-integration-tests: + name: Nutshell Mint Integration Tests runs-on: ubuntu-latest steps: - name: Pull and start mint @@ -19,8 +20,29 @@ jobs: uses: DeterminateSystems/magic-nix-cache-action@v6 - name: Rust Cache uses: Swatinem/rust-cache@v2 - - name: Test + - name: Test Nutshell run: nix develop -i -L .#stable --command just test-nutshell - name: Show logs if tests fail if: failure() run: docker logs nutshell + + nutshell-wallet-integration-tests: + name: Nutshell Wallet Integration Tests + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + - name: Pull Nutshell Docker image + run: docker pull cashubtc/nutshell:latest + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v11 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@v6 + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + - name: Test Nutshell Wallet + run: | + nix develop -i -L .#integration --command just nutshell-wallet-itest + - name: Show Docker logs if tests fail + if: failure() + run: docker logs nutshell-wallet || true diff --git a/crates/cdk-integration-tests/tests/nutshell_wallet.rs b/crates/cdk-integration-tests/tests/nutshell_wallet.rs new file mode 100644 index 00000000..eabfe01e --- /dev/null +++ b/crates/cdk-integration-tests/tests/nutshell_wallet.rs @@ -0,0 +1,251 @@ +use std::time::Duration; + +use cdk_fake_wallet::create_fake_invoice; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::time::sleep; + +/// Response from the invoice creation endpoint +#[derive(Debug, Serialize, Deserialize)] +struct InvoiceResponse { + payment_request: String, + checking_id: Option, +} + +/// Maximum number of attempts to check invoice payment status +const MAX_PAYMENT_CHECK_ATTEMPTS: u8 = 20; +/// Delay between payment status checks in milliseconds +const PAYMENT_CHECK_DELAY_MS: u64 = 500; +/// Default test amount in satoshis +const DEFAULT_TEST_AMOUNT: u64 = 10000; + +/// Helper function to mint tokens via Lightning invoice +async fn mint_tokens(base_url: &str, amount: u64) -> String { + let client = Client::new(); + + // Create an invoice for the specified amount + let invoice_url = format!("{}/lightning/create_invoice?amount={}", base_url, amount); + + let invoice_response = client + .post(&invoice_url) + .send() + .await + .expect("Failed to send invoice creation request") + .json::() + .await + .expect("Failed to parse invoice response"); + + println!("Created invoice: {}", invoice_response.payment_request); + + invoice_response.payment_request +} + +/// Helper function to wait for payment confirmation +async fn wait_for_payment_confirmation(base_url: &str, payment_request: &str) { + let client = Client::new(); + let check_url = format!( + "{}/lightning/invoice_state?payment_request={}", + base_url, payment_request + ); + + let mut payment_confirmed = false; + + for attempt in 1..=MAX_PAYMENT_CHECK_ATTEMPTS { + println!( + "Checking invoice state (attempt {}/{})...", + attempt, MAX_PAYMENT_CHECK_ATTEMPTS + ); + + let response = client + .get(&check_url) + .send() + .await + .expect("Failed to send payment check request"); + + if response.status().is_success() { + let state: Value = response + .json() + .await + .expect("Failed to parse payment state response"); + println!("Payment state: {:?}", state); + + if let Some(result) = state.get("result") { + if result == 1 { + payment_confirmed = true; + break; + } + } + } else { + println!("Failed to check payment state: {}", response.status()); + } + + sleep(Duration::from_millis(PAYMENT_CHECK_DELAY_MS)).await; + } + + if !payment_confirmed { + panic!("Payment not confirmed after maximum attempts"); + } +} + +/// Helper function to get the current wallet balance +async fn get_wallet_balance(base_url: &str) -> u64 { + let client = Client::new(); + let balance_url = format!("{}/balance", base_url); + + let balance_response = client + .get(&balance_url) + .send() + .await + .expect("Failed to send balance request"); + + let balance: Value = balance_response + .json() + .await + .expect("Failed to parse balance response"); + + println!("Wallet balance: {:?}", balance); + + balance["balance"] + .as_u64() + .expect("Could not parse balance as u64") +} + +/// Test the Nutshell wallet's ability to mint tokens from a Lightning invoice +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +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"); + + // Step 1: Create an invoice and mint tokens + let amount = DEFAULT_TEST_AMOUNT; + let payment_request = mint_tokens(&base_url, amount).await; + + // Step 2: Wait for the invoice to be paid + wait_for_payment_confirmation(&base_url, &payment_request).await; + + // Step 3: Check the wallet balance + let available_balance = get_wallet_balance(&base_url).await; + + // Verify the balance is at least the amount we minted + assert!( + available_balance >= amount, + "Balance should be at least {} but was {}", + amount, + available_balance + ); +} + +/// Test the Nutshell wallet's ability to mint tokens from a Lightning invoice +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +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"); + + // Step 1: Create an invoice and mint tokens + let amount = DEFAULT_TEST_AMOUNT; + let payment_request = mint_tokens(&base_url, amount).await; + + // Step 2: Wait for the invoice to be paid + wait_for_payment_confirmation(&base_url, &payment_request).await; + + let send_amount = 100; + let send_url = format!("{}/send?amount={}", base_url, send_amount); + let client = Client::new(); + + let response: Value = client + .post(&send_url) + .send() + .await + .expect("Failed to send payment check request") + .json() + .await + .expect("Valid json"); + + // Extract the token and remove the surrounding quotes + let token_with_quotes = response + .get("token") + .expect("Missing token") + .as_str() + .expect("Token is not a string"); + let token = token_with_quotes.trim_matches('"'); + + let receive_url = format!("{}/receive?token={}", base_url, token); + + let response: Value = client + .post(&receive_url) + .send() + .await + .expect("Failed to receive request") + .json() + .await + .expect("Valid json"); + + let balance = response + .get("balance") + .expect("Bal in response") + .as_u64() + .expect("Valid num"); + let initial_balance = response + .get("initial_balance") + .expect("Bal in response") + .as_u64() + .expect("Valid num"); + + let token_received = balance - initial_balance; + + assert_eq!(token_received, send_amount); +} + +/// Test the Nutshell wallet's ability to melt tokens to pay a Lightning invoice +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +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"); + + // Step 1: Create an invoice and mint tokens + let amount = DEFAULT_TEST_AMOUNT; + let payment_request = mint_tokens(&base_url, amount).await; + + // Step 2: Wait for the invoice to be paid + wait_for_payment_confirmation(&base_url, &payment_request).await; + + // Get initial balance + let initial_balance = get_wallet_balance(&base_url).await; + println!("Initial balance: {}", initial_balance); + + // Step 3: Create a fake invoice to pay + let payment_amount = 1000; // 1000 sats + let fake_invoice = create_fake_invoice(payment_amount, "Test payment".to_string()); + let pay_url = format!("{}/lightning/pay_invoice?bolt11={}", base_url, fake_invoice); + let client = Client::new(); + + // Step 4: Pay the invoice + let _response: Value = client + .post(&pay_url) + .send() + .await + .expect("Failed to send pay request") + .json() + .await + .expect("Failed to parse pay response"); + + let final_balance = get_wallet_balance(&base_url).await; + println!("Final balance: {}", final_balance); + + assert!( + initial_balance > final_balance, + "Balance should decrease after payment" + ); + + let balance_difference = initial_balance - final_balance; + println!("Balance decreased by: {}", balance_difference); + + // The balance difference should be at least the payment amount + assert!( + balance_difference >= (payment_amount / 1000), + "Balance should decrease by at least the payment amount ({}) but decreased by {}", + payment_amount, + balance_difference + ); +} diff --git a/flake.nix b/flake.nix index c23a936d..a171ef3d 100644 --- a/flake.nix +++ b/flake.nix @@ -265,9 +265,29 @@ inherit nativeBuildInputs; } // envVars); + # Shell with Docker for integration tests + integration = pkgs.mkShell ({ + shellHook = '' + ${_shellHook} + # Ensure Docker is available + if ! command -v docker &> /dev/null; then + echo "Docker is not installed or not in PATH" + echo "Please install Docker to run integration tests" + exit 1 + fi + echo "Docker is available at $(which docker)" + echo "Docker version: $(docker --version)" + ''; + buildInputs = buildInputs ++ [ + stable_toolchain + pkgs.docker-client + ]; + inherit nativeBuildInputs; + } // envVars); + in { - inherit msrv stable nightly; + inherit msrv stable nightly integration; default = stable; }; } diff --git a/justfile b/justfile index 4fd254ab..9fdfb895 100644 --- a/justfile +++ b/justfile @@ -109,6 +109,10 @@ fake-auth-mint-itest db openid_discovery: #!/usr/bin/env bash ./misc/fake_auth_itests.sh "{{db}}" "{{openid_discovery}}" +nutshell-wallet-itest: + #!/usr/bin/env bash + ./misc/nutshell_wallet_itest.sh + run-examples: cargo r --example p2pk cargo r --example mint-token diff --git a/misc/nutshell_wallet_itest.sh b/misc/nutshell_wallet_itest.sh new file mode 100755 index 00000000..b015ba4d --- /dev/null +++ b/misc/nutshell_wallet_itest.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash + +set -e + +# Configuration +MINT_PORT=8085 +WALLET_PORT=4448 +MINT_CONTAINER_NAME="nutshell-mint" +WALLET_CONTAINER_NAME="nutshell-wallet" +# Use host.docker.internal for the mint URL so containers can access it +MINT_URL="http://0.0.0.0:${MINT_PORT}" +WALLET_URL="http://localhost:${WALLET_PORT}" +CDK_MINTD_PID="" + +# Function to clean up resources +cleanup() { + echo "Cleaning up resources..." + + if docker ps -a | grep -q ${WALLET_CONTAINER_NAME}; then + echo "Stopping and removing Docker container '${WALLET_CONTAINER_NAME}'..." + docker stop ${WALLET_CONTAINER_NAME} >/dev/null 2>&1 + docker rm ${WALLET_CONTAINER_NAME} >/dev/null 2>&1 + fi + + if [ -n "$CDK_MINTD_PID" ]; then + echo "Stopping mintd process (PID: $CDK_MINTD_PID)..." + kill -TERM $CDK_MINTD_PID >/dev/null 2>&1 || true + fi + + # Unset variables + unset MINT_URL WALLET_URL MINT_PORT WALLET_PORT MINT_CONTAINER_NAME WALLET_CONTAINER_NAME + unset CDK_MINTD_PID CDK_MINTD_URL CDK_MINTD_WORK_DIR CDK_MINTD_LISTEN_HOST CDK_MINTD_LISTEN_PORT + 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 + echo "Cleanup complete." +} + +# Set up trap to call cleanup function on script exit +trap cleanup EXIT INT TERM + + + +# Create a temporary directory for mintd +CDK_ITESTS=$(mktemp -d) +echo "Created temporary directory for mintd: $CDK_ITESTS" + +export CDK_MINTD_URL="$MINT_URL" +export CDK_MINTD_WORK_DIR="$CDK_ITESTS" +export CDK_MINTD_LISTEN_HOST="127.0.0.1" +export CDK_MINTD_LISTEN_PORT="8085" +export CDK_MINTD_LN_BACKEND="fakewallet" +export CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS="sat,usd" +export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal" +export CDK_MINTD_FAKE_WALLET_FEE_PERCENT="0" +export CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN="1" +export CDK_MINTD_DATABASE="redb" + + +echo "Starting fake mintd" +cargo run --bin cdk-mintd --features "redb" & +CDK_MINTD_PID=$! + +# Wait for the mint to be ready +echo "Waiting for mintd to start..." +TIMEOUT=300 +START_TIME=$(date +%s) + +# Try different URLs since the endpoint might vary +URLS=("http://localhost:${MINT_PORT}/v1/info" "http://127.0.0.1:${MINT_PORT}/v1/info" "http://0.0.0.0:${MINT_PORT}/v1/info") + +# Loop until one of the endpoints returns a 200 OK status or timeout is reached +while true; do + # Get the current time + CURRENT_TIME=$(date +%s) + + # Calculate the elapsed time + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + + # Check if the elapsed time exceeds the timeout + if [ $ELAPSED_TIME -ge $TIMEOUT ]; then + echo "Timeout of $TIMEOUT seconds reached. Exiting..." + exit 1 + fi + + # Try each URL + for URL in "${URLS[@]}"; do + # Make a request to the endpoint and capture the HTTP status code + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" "$URL" || echo "000") + + # Check if the HTTP status is 200 OK + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Received 200 OK from $URL" + MINT_URL=$(echo "$URL" | sed 's|/v1/info||') + echo "Setting MINT_URL to $MINT_URL" + export MINT_URL + break 2 # Break out of both loops + else + echo "Waiting for 200 OK response from $URL, current status: $HTTP_STATUS" + fi + done + + # Wait before retrying + sleep 5 +done + + + + +# Check if Docker is available and accessible +if docker info > /dev/null 2>&1; then + echo "Docker is available, starting Nutshell wallet container" + # Use the MINT_URL which is already set to host.docker.internal + docker run -d --name ${WALLET_CONTAINER_NAME} \ + --network=host \ + -p ${WALLET_PORT}:${WALLET_PORT} \ + -e MINT_URL=${MINT_URL} \ + cashubtc/nutshell:latest \ + poetry run cashu -d +else + echo "Docker is not accessible, skipping Nutshell wallet container setup" + # Set a flag to indicate we're not using Docker + export NO_DOCKER=true +fi + +# Wait for the mint to be ready +echo "Waiting for Nutshell Mint to start..." +sleep 5 + +# Check if the Mint API is responding (use localhost for local curl check) +echo "Checking if Nutshell Mint API is available..." +if curl -s "http://localhost:${MINT_PORT}/v1/info" > /dev/null; then + echo "Nutshell Mint is running and accessible at ${MINT_URL}" +else + echo "Warning: Nutshell Mint API is not responding. It might not be ready yet." +fi + +# Only check wallet if Docker is available +if [ -z "$NO_DOCKER" ]; then + # Check if the Wallet API is responding + echo "Checking if Nutshell Wallet API is available..." + if curl -s "${WALLET_URL}/info" > /dev/null; then + echo "Nutshell Wallet is running in container '${WALLET_CONTAINER_NAME}'" + echo "You can access it at ${WALLET_URL}" + else + echo "Warning: Nutshell Wallet API is not responding. The container might not be ready yet." + fi +fi + +# Export URLs as environment variables +export MINT_URL=${MINT_URL} +export WALLET_URL=${WALLET_URL} + +# Run the integration test +echo "Running integration test..." +cargo test -p cdk-integration-tests --tests nutshell_wallet +TEST_STATUS=$? + +# Exit with the test status +echo "Integration test completed with status: $TEST_STATUS" +exit $TEST_STATUS