diff --git a/docker/bitcoin-cli b/docker/bitcoin-cli index 6700969..5106f57 100755 --- a/docker/bitcoin-cli +++ b/docker/bitcoin-cli @@ -27,6 +27,7 @@ Commands: send [amount] [address] [-m N] Send to address and optionally mine N blocks (default: 1) getInvoice Get a new BIP21 URI with a bech32 address LND: + getlninvoice [sats] [--msat N] Create Lightning invoice (supports msat precision) getinfo Show LND node info (for connectivity debugging) openchannel [amount] Open channel from LND to node (default: 500000 sats) payinvoice [amount] Pay a Lightning invoice via LND @@ -198,6 +199,80 @@ if [[ "$command" = "getinfo" ]]; then exit fi +# Create a Lightning invoice (LND) +if [[ "$command" = "getlninvoice" ]]; then + shift + + sats="" + msat="" + memo="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --msat) + msat="${2:-}" + shift 2 + ;; + --sats) + sats="${2:-}" + shift 2 + ;; + -m|--memo) + memo="${2:-}" + shift 2 + ;; + -*) + echo "Unknown option $1" + echo "Usage: $CLI_NAME getlninvoice [sats] [--msat N] [--memo TEXT]" + exit 1 + ;; + *) + # Positional value is treated as sats if numeric. + if [[ "$1" =~ ^[0-9]+$ ]]; then + sats="$1" + shift + else + echo "Invalid amount '$1' (expected integer sats)" + exit 1 + fi + ;; + esac + done + + if [[ -n "$sats" && -n "$msat" ]]; then + echo "Use either sats or --msat, not both." + exit 1 + fi + + if [[ -n "$msat" ]]; then + echo "→ Creating Lightning invoice (${msat} msat)..." + result=$("${LNCLI_CMD[@]}" addinvoice --amt_msat "$msat" --memo "$memo" 2>&1) || true + elif [[ -n "$sats" ]]; then + echo "→ Creating Lightning invoice (${sats} sats)..." + result=$("${LNCLI_CMD[@]}" addinvoice --amt "$sats" --memo "$memo" 2>&1) || true + else + echo "→ Creating amountless Lightning invoice..." + result=$("${LNCLI_CMD[@]}" addinvoice --memo "$memo" 2>&1) || true + fi + + payment_request=$(echo "$result" | jq -r '.payment_request // empty' 2>/dev/null) || payment_request="" + if [ -z "$payment_request" ]; then + echo "${result:-LND command produced no output}" + exit 1 + fi + + echo "" + echo "$payment_request" + echo "" + + if command -v pbcopy &>/dev/null; then + echo "$payment_request" | pbcopy + echo "Invoice copied to clipboard." + fi + + exit +fi + # Open channel from LND to a node if [[ "$command" = "openchannel" ]]; then shift diff --git a/test/specs/lnurl.e2e.ts b/test/specs/lnurl.e2e.ts index d0f1304..fe2f309 100644 --- a/test/specs/lnurl.e2e.ts +++ b/test/specs/lnurl.e2e.ts @@ -54,6 +54,36 @@ function waitForEvent(lnurlServer: any, name: string): Promise { }); } +function spendingBalanceLabelSats(satsInteger: number): string { + return satsInteger.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); +} + +/** Balance in msats after pay (subtract) or withdraw (add) from a prior msat total. */ +function applyLnurlMsatDelta(balanceMsats: bigint, deltaMsats: number, direction: 'pay' | 'withdraw'): bigint { + const d = BigInt(deltaMsats); + return direction === 'pay' ? balanceMsats - d : balanceMsats + d; +} + +async function expectMoneyTextRoundedSats( + parentTestId: 'ReviewAmount-primary' | 'WithdrawAmount-primary', + msats: number, +) { + const money = await elementByIdWithin(parentTestId, 'MoneyText'); + const raw = await money.getText(); + const digits = raw.replace(/[^\d]/g, ''); + const displayed = Number(digits); + if (Number.isNaN(displayed)) { + throw new Error(`MoneyText is not numeric: raw=${JSON.stringify(raw)} msats=${msats}`); + } + const floorSats = Math.floor(msats / 1000); + const ceilSats = Math.ceil(msats / 1000); + if (displayed !== floorSats && displayed !== ceilSats) { + throw new Error( + `Unexpected MoneyText: raw=${JSON.stringify(raw)} displayed=${displayed} expected=${floorSats}|${ceilSats} msats=${msats}`, + ); + } +} + describe('@lnurl - LNURL', () => { let electrum: Awaited> | undefined; let lnurlServer: any; @@ -98,7 +128,7 @@ describe('@lnurl - LNURL', () => { }); ciIt( - '@lnurl_1 - Can process lnurl-channel, lnurl-pay, lnurl-withdraw, and lnurl-auth', + '@lnurl_1 - Can process lnurl-channel, lnurl-pay, lnurl-withdraw, lnurl-auth, and msat-precision pay/withdraw', async () => { await receiveOnchainFunds({ sats: 1000 }); @@ -309,6 +339,75 @@ describe('@lnurl - LNURL', () => { await swipeFullScreen('down'); await swipeFullScreen('down'); + // Fixed min==max LNURL amounts in msats (LND invoice uses value_msat). Each pair pays then withdraws the same amount so balance returns to 19 713 sats. + // 222538 — remainder 538 msats (regression: payment must not truncate msats). + // 222222 — remainder 222 msats (< 500). + // 500500 — remainder 500 msats exactly. + let balanceMsats = 19713000n; + + async function msatPayWithdraw(label: string, msats: number) { + const afterPay = applyLnurlMsatDelta(balanceMsats, msats, 'pay'); + const afterWithdraw = applyLnurlMsatDelta(afterPay, msats, 'withdraw'); + + const payReq = await lnurlServer.generateNewUrl('payRequest', { + minSendable: msats, + maxSendable: msats, + metadata: `[["text/plain","lnurl-msat-${label}"]]`, + commentAllowed: 0, + }); + console.log(`payRequest msat ${label}`, payReq); + + await enterAddressViaScanPrompt(payReq.encoded, { acceptCameraPermission: false }); + await sleep(2000); + await elementById('ReviewAmount-primary').waitForDisplayed({ timeout: 5000 }); + await elementById('CommentInput').waitForDisplayed({ reverse: true }); + await expectMoneyTextRoundedSats('ReviewAmount-primary', msats); + await dragOnElement('GRAB', 'right', 0.95); + await elementById('SendSuccess').waitForDisplayed(); + await tap('Close'); + balanceMsats = afterPay; + await expectTextWithin( + 'ActivitySpending', + spendingBalanceLabelSats(Number(balanceMsats / 1000n)), + ); + await elementById('ActivityShort-0').waitForDisplayed(); + await expectTextWithin('ActivityShort-0', '-'); + await expectTextWithin('ActivityShort-0', 'Sent'); + await sleep(1000); + await swipeFullScreen('down'); + await swipeFullScreen('down'); + + const wReq = await lnurlServer.generateNewUrl('withdrawRequest', { + minWithdrawable: msats, + maxWithdrawable: msats, + defaultDescription: `lnurl-withdraw-msat-${label}`, + }); + console.log(`withdrawRequest msat ${label}`, wReq); + + await enterAddressViaScanPrompt(wReq.encoded, { acceptCameraPermission: false }); + await sleep(2000); + await elementById('WithdrawAmount-primary').waitForDisplayed({ timeout: 5000 }); + await expectMoneyTextRoundedSats('WithdrawAmount-primary', msats); + await tap('WithdrawConfirmButton'); + await acknowledgeReceivedPayment(); + balanceMsats = afterWithdraw; + await expectTextWithin( + 'ActivitySpending', + spendingBalanceLabelSats(Number(balanceMsats / 1000n)), + ); + await elementById('ActivityShort-0').waitForDisplayed(); + await expectTextWithin('ActivityShort-0', '+'); + await expectTextWithin('ActivityShort-0', `lnurl-withdraw-msat-${label}`); + await expectTextWithin('ActivityShort-0', 'Received'); + await sleep(1000); + await swipeFullScreen('down'); + await swipeFullScreen('down'); + } + + await msatPayWithdraw('222538', 222_538); + await msatPayWithdraw('222222', 222_222); + await msatPayWithdraw('500500', 500_500); + // lnurl-auth const loginRequest1 = await lnurlServer.generateNewUrl('login'); console.log('loginRequest1', loginRequest1); diff --git a/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index fa9c9a5..209056f 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -545,4 +545,77 @@ describe('@send - Send', () => { await elementById('Activity-1').waitForDisplayed(); await elementById('Activity-2').waitForDisplayed(); }); + + ciIt('@send_3 - Can pay regular invoices with msat precision', async () => { + await receiveOnchainFunds(); + + // send funds to LND node and open a channel + const { lnd, lndNodeID } = await setupLND(rpc, lndConfig); + await electrum?.waitForSync(); + + // get LDK Node id + const ldkNodeId = await getLDKNodeID(); + + // connect to LND + await connectToLND(lndNodeID); + + // wait for peer to be connected + await waitForPeerConnection(lnd, ldkNodeId); + + // open a channel and wait until active + await openLNDAndSync(lnd, rpc, ldkNodeId); + await electrum?.waitForSync(); + await waitForActiveChannel(lnd, ldkNodeId); + + await waitForToast('SpendingBalanceReadyToast'); + + // Ensure spending balance by paying ourselves from LND. + let receive: string; + try { + receive = await getReceiveAddress('lightning'); + } catch { + await swipeFullScreen('down'); + await sleep(10_000); + await attemptRefreshOnHomeScreen(); + await sleep(10_000); + await attemptRefreshOnHomeScreen(); + await sleep(1000); + receive = await getReceiveAddress('lightning'); + } + if (!receive) throw new Error('No lightning invoice received'); + await swipeFullScreen('down'); + await lnd.sendPaymentSync({ paymentRequest: receive, amt: '10000' }); + await acknowledgeReceivedPayment(); + if (driver.isIOS) { + await dismissBackgroundPaymentsTimedSheet({ triggerTimedSheet: driver.isIOS }); + await dismissQuickPayIntro({ triggerTimedSheet: driver.isIOS }); + } else { + await dismissQuickPayIntro({ triggerTimedSheet: true }); + } + await expectTextWithin('ActivitySpending', '10 000'); + + async function payMsatInvoice(valueMsat: string, acceptCameraPermission: boolean) { + const { paymentRequest } = await lnd.addInvoice({ valueMsat }); + console.info({ valueMsat, paymentRequest }); + await sleep(1000); + await enterAddress(paymentRequest, { acceptCameraPermission }); + await elementById('ReviewAmount-primary').waitForDisplayed({ timeout: 15_000 }); + await dragOnElement('GRAB', 'right', 0.95); + await elementById('SendSuccess').waitForDisplayed(); + await tap('Close'); + await elementById('ActivityShort-0').waitForDisplayed(); + await expectTextWithin('ActivityShort-0', '-'); + await expectTextWithin('ActivityShort-0', 'Sent'); + await sleep(1000); + await swipeFullScreen('down'); + await swipeFullScreen('down'); + } + + // >500 msat remainder + await payMsatInvoice('222538', true); + // <500 msat remainder + await payMsatInvoice('222222', false); + // exactly 500 msat remainder + await payMsatInvoice('500500', false); + }); });