Skip to content
Open
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
75 changes: 75 additions & 0 deletions docker/bitcoin-cli
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Commands:
send [amount] [address] [-m N] Send to address and optionally mine N blocks (default: 1)
getInvoice <amount> 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 <node_id> [amount] Open channel from LND to node (default: 500000 sats)
payinvoice <invoice> [amount] Pay a Lightning invoice via LND
Expand Down Expand Up @@ -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
Expand Down
101 changes: 100 additions & 1 deletion test/specs/lnurl.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ function waitForEvent(lnurlServer: any, name: string): Promise<void> {
});
}

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<ReturnType<typeof initElectrum>> | undefined;
let lnurlServer: any;
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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);
Expand Down
73 changes: 73 additions & 0 deletions test/specs/send.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});