From 15b0386a34e229bd0a5a5d944dcf546a68a4c573 Mon Sep 17 00:00:00 2001 From: bitwalt Date: Wed, 29 Apr 2026 17:28:11 +0100 Subject: [PATCH] fix: support paying RGB Lightning invoices with open asset amount Lightning invoices specifying an asset_id but leaving asset_amount null (open-amount RGB invoices) were misclassified as plain BTC invoices in the Withdraw modal: the asset section was hidden, the amount input never appeared, and sendPayment was invoked without asset_id / asset_amount. The Withdraw flow now keys the asset section in LightningInvoiceDetails off asset_id alone (showing "Not specified by invoice" when the amount is null), the form reveals the amount input for this case, validation uses the asset's precision-based minimum, and handleConfirmedSubmit forwards asset_id plus the raw asset_amount to the node so the payment can settle. ConfirmationModal renders the user-entered amount. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Withdraw/components/ConfirmationModal.tsx | 44 +++++----- .../components/LightningInvoiceDetails.tsx | 17 ++-- .../Withdraw/components/WithdrawForm.tsx | 9 +- .../Layout/Modal/Withdraw/index.tsx | 85 +++++++++++++++++-- src/i18n/locales/de.json | 4 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/it.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/zh.json | 4 +- 11 files changed, 139 insertions(+), 44 deletions(-) diff --git a/src/components/Layout/Modal/Withdraw/components/ConfirmationModal.tsx b/src/components/Layout/Modal/Withdraw/components/ConfirmationModal.tsx index 58be461c..ffb3a0ee 100644 --- a/src/components/Layout/Modal/Withdraw/components/ConfirmationModal.tsx +++ b/src/components/Layout/Modal/Withdraw/components/ConfirmationModal.tsx @@ -28,9 +28,7 @@ const ConfirmationModal: React.FC = ({ const decodedInvoice = pendingData?.decodedInvoice const isLightningPayment = pendingData?.network === 'lightning' const isRgbLightningPayment = - isLightningPayment && - decodedInvoice?.asset_amount && - decodedInvoice?.asset_id + isLightningPayment && Boolean(decodedInvoice?.asset_id) const hasRegularBtcAmount = isLightningPayment && decodedInvoice?.amt_msat && !decodedInvoice?.asset_id @@ -306,24 +304,28 @@ const ConfirmationModal: React.FC = ({ )} {/* Asset Amount (if RGB Lightning) */} - {isLightningPayment && - decodedInvoice?.asset_amount && - decodedInvoice?.asset_id && ( -
- - {t('withdrawModal.confirmation.labels.assetAmount')} - - - {getAssetAmount( - decodedInvoice.asset_amount, - decodedInvoice.asset_id - )}{' '} - {availableAssets.find( - (a: AssetOption) => a.value === decodedInvoice?.asset_id - )?.label || t('withdrawModal.main.labels.unknownAsset')} - -
- )} + {isRgbLightningPayment && decodedInvoice?.asset_id && ( +
+ + {t('withdrawModal.confirmation.labels.assetAmount')} + + + {decodedInvoice.asset_amount + ? `${getAssetAmount(decodedInvoice.asset_amount, decodedInvoice.asset_id)} ${ + availableAssets.find( + (a: AssetOption) => + a.value === decodedInvoice?.asset_id + )?.label || t('withdrawModal.main.labels.unknownAsset') + }` + : `${formatNumberWithCommas(String(pendingData?.amount ?? '0'))} ${ + availableAssets.find( + (a: AssetOption) => + a.value === decodedInvoice?.asset_id + )?.label || t('withdrawModal.main.labels.unknownAsset') + }`} + +
+ )} {/* Fee (for BTC only) */} {pendingData?.asset_id === BTC_ASSET_ID && diff --git a/src/components/Layout/Modal/Withdraw/components/LightningInvoiceDetails.tsx b/src/components/Layout/Modal/Withdraw/components/LightningInvoiceDetails.tsx index a9aa3c70..8a07e7fa 100644 --- a/src/components/Layout/Modal/Withdraw/components/LightningInvoiceDetails.tsx +++ b/src/components/Layout/Modal/Withdraw/components/LightningInvoiceDetails.tsx @@ -16,7 +16,8 @@ const LightningInvoiceDetails: React.FC = ({ if (!decodedInvoice) return null - const hasAsset = decodedInvoice.asset_id && decodedInvoice.asset_amount + const hasAsset = Boolean(decodedInvoice.asset_id) + const hasAssetAmount = Boolean(decodedInvoice.asset_amount) const assetInfo = hasAsset ? assets.data?.nia.find( @@ -27,7 +28,7 @@ const LightningInvoiceDetails: React.FC = ({ assetInfo?.ticker || t('withdrawModal.main.labels.unknownAsset') const formattedAssetAmount = - hasAsset && decodedInvoice.asset_amount + hasAssetAmount && decodedInvoice.asset_amount ? formatAssetAmountWithPrecision( decodedInvoice.asset_amount, ticker, @@ -70,8 +71,8 @@ const LightningInvoiceDetails: React.FC = ({ (decodedInvoice.amt_msat ?? 0) > maxLightningCapacity const isAssetBalanceExceeded = - hasAsset && assetInfo - ? decodedInvoice.asset_amount! > + hasAssetAmount && assetInfo + ? (decodedInvoice.asset_amount ?? 0) > (assetInfo.balance.offchain_outbound || 0) : false @@ -100,7 +101,13 @@ const LightningInvoiceDetails: React.FC = ({ {t('withdrawModal.details.lightning.assetAmountLabel')} - {formattedAssetAmount} {ticker} + {hasAssetAmount ? ( + <> + {formattedAssetAmount} {ticker} + + ) : ( + t('withdrawModal.details.lightning.assetAmountNotSpecified') + )} {assetInfo && ( diff --git a/src/components/Layout/Modal/Withdraw/components/WithdrawForm.tsx b/src/components/Layout/Modal/Withdraw/components/WithdrawForm.tsx index 3ab50e20..8dc4b864 100644 --- a/src/components/Layout/Modal/Withdraw/components/WithdrawForm.tsx +++ b/src/components/Layout/Modal/Withdraw/components/WithdrawForm.tsx @@ -576,7 +576,9 @@ const WithdrawForm: React.FC = ({ addressType === 'lightning-address' || (addressType === 'lightning' && decodedInvoice && - (!decodedInvoice.amt_msat || decodedInvoice.amt_msat === 0))) && ( + (!decodedInvoice.amt_msat || + decodedInvoice.amt_msat === 0 || + (decodedInvoice.asset_id && !decodedInvoice.asset_amount)))) && ( <> {/* Asset Selector - Only for rgb invoices without asset_id specified or lightning-address */} {((addressType === 'rgb' && !decodedRgbInvoice?.asset_id) || @@ -724,7 +726,10 @@ const WithdrawForm: React.FC = ({ addressType === 'rgb' || (addressType === 'lightning' && decodedInvoice && - (!decodedInvoice.amt_msat || decodedInvoice.amt_msat === 0))) && ( + (!decodedInvoice.amt_msat || + decodedInvoice.amt_msat === 0 || + (decodedInvoice.asset_id && + !decodedInvoice.asset_amount)))) && (
{ setValue('network', 'lightning') // Validate invoice amount against balance and capacity - if (decoded.asset_id && decoded.asset_amount) { - // Invoice specifies an RGB asset + if (decoded.asset_id) { + // Invoice specifies an RGB asset (asset_amount may be null for open-amount invoices) setValue('asset_id', decoded.asset_id) // Set asset_id for potential display/validation + // Reset amount so the user enters it for open-amount asset invoices + if (!decoded.asset_amount) { + setValue('amount', '') + } await fetchAssetBalance(decoded.asset_id, 'offchain_outbound') // Get max local asset amount for this asset from channels const maxAssetAmount = maxAssetCapacities[decoded.asset_id] || 0 - // Check asset amount against channel capacity - if (decoded.asset_amount > maxAssetAmount) { + // Check asset amount against channel capacity (only when invoice fixes the amount) + if (decoded.asset_amount && decoded.asset_amount > maxAssetAmount) { setValidationMessage({ message: t('withdrawModal.main.errors.invoiceAssetCapacity', { asset: decoded.asset_id.substring(0, 8), @@ -483,6 +487,22 @@ export const WithdrawModalContent: React.FC = () => { }) } } + + // Inform the user when they need to enter the asset amount themselves + if (!decoded.asset_amount) { + const assetInfo = (assets.data?.nia || []).find( + (a: any) => a.asset_id === decoded.asset_id + ) + const ticker = + assetInfo?.ticker || + t('withdrawModal.main.labels.assetFallback') + setValidationMessage({ + message: t('withdrawModal.main.info.assetAmountRequired', { + ticker, + }), + type: 'info', + }) + } } // If this is a regular BTC invoice with an amount else if ((decoded.amt_msat || 0) > 0) { @@ -724,8 +744,16 @@ export const WithdrawModalContent: React.FC = () => { // For lightning payments, need to use a limit based on invoice or capacity if (addressType === 'lightning') { + // RGB asset payment over Lightning: min is one base unit of the asset + if (decodedInvoice?.asset_id && assetId !== BTC_ASSET_ID) { + const assetInfo = (assets.data?.nia || []).find( + (a: any) => a.asset_id === assetId + ) + const precision = assetInfo?.precision ?? 0 + return precision > 0 ? 1 / Math.pow(10, precision) : 1 + } if (decodedInvoice?.amt_msat) { - // If invoice has an amount, use that as the min + // BTC Lightning invoice with amount: use that as the min return msatToSat(decodedInvoice.amt_msat) } // Return a reasonable min for lightning (1 sat) @@ -734,7 +762,13 @@ export const WithdrawModalContent: React.FC = () => { // For other assets or address types, use 1 as minimum return 1 - }, [addressType, assetId, decodedInvoice, maxLightningCapacity]) // Add missing dependency + }, [ + addressType, + assetId, + decodedInvoice, + maxLightningCapacity, + assets.data?.nia, + ]) // Add missing dependency const getMinAmountMessage = useCallback(() => { if (assetId === BTC_ASSET_ID) { @@ -858,9 +892,24 @@ export const WithdrawModalContent: React.FC = () => { formattedData.decodedInvoice = decodedInvoice if (decodedInvoice.asset_id && decodedInvoice.asset_amount) { - // RGB Lightning Invoice with asset + // RGB Lightning Invoice with fixed asset amount formattedData.asset_id = decodedInvoice.asset_id formattedData.amount = decodedInvoice.asset_amount + } else if (decodedInvoice.asset_id && !decodedInvoice.asset_amount) { + // RGB Lightning Invoice without a fixed asset amount — + // user provides the asset amount via the form (display units) + formattedData.asset_id = decodedInvoice.asset_id + const cleanAmount = String(formattedData.amount ?? '').replace( + /,/g, + '' + ) + const amountValue = Number(cleanAmount) + if (!cleanAmount || isNaN(amountValue) || amountValue <= 0) { + toast.error(t('withdrawModal.main.errors.zeroAmountRequired'), { + autoClose: 5000, + }) + return + } } else if ((decodedInvoice?.amt_msat || 0) > 0) { // Standard BTC Lightning invoice - convert using helpers const amountSats = msatToSat(decodedInvoice.amt_msat || 0) @@ -952,13 +1001,31 @@ export const WithdrawModalContent: React.FC = () => { invoice: pendingData.address, } - // If zero-amount invoice, add the amount from user input + // RGB Lightning invoice without a fixed asset amount: pass + // asset_id + asset_amount (in raw base units) to the node. if ( + pendingData.decodedInvoice?.asset_id && + !pendingData.decodedInvoice.asset_amount + ) { + const assetInfo = (assets.data?.nia || []).find( + (a: any) => a.asset_id === pendingData.decodedInvoice!.asset_id + ) + const ticker = + assetInfo?.ticker || t('withdrawModal.main.labels.unknownAsset') + const rawAssetAmount = parseAssetAmountWithPrecision( + String(pendingData.amount ?? ''), + ticker, + bitcoinUnit, + assets.data?.nia + ) + paymentParams.asset_id = pendingData.decodedInvoice.asset_id + paymentParams.asset_amount = Math.floor(rawAssetAmount) + } else if ( pendingData.decodedInvoice && (!pendingData.decodedInvoice.amt_msat || pendingData.decodedInvoice.amt_msat === 0) ) { - // Convert user-entered amount to msat + // Zero-amount BTC invoice: convert user-entered amount to msat const userAmount = Number(pendingData.amount) if (bitcoinUnit === 'SAT') { paymentParams.amt_msat = userAmount * 1000 diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index cda59da5..2c2d0c4d 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1846,7 +1846,8 @@ "noRgbAssets": "Warnung: Keine RGB-Assets zum Senden verfügbar." }, "info": { - "zeroAmountInvoice": "Dies ist eine Nullbetrags-Rechnung. Bitte geben Sie unten den Betrag ein, den Sie zahlen möchten." + "zeroAmountInvoice": "Dies ist eine Nullbetrags-Rechnung. Bitte geben Sie unten den Betrag ein, den Sie zahlen möchten.", + "assetAmountRequired": "Diese Rechnung gibt den {{ticker}}-Betrag nicht an. Bitte geben Sie ein, wie viel Sie senden möchten." } }, "details": { @@ -1854,6 +1855,7 @@ "title": "Lightning-Rechnungsdetails", "assetLabel": "Asset:", "assetAmountLabel": "Asset-Betrag:", + "assetAmountNotSpecified": "Nicht in der Rechnung angegeben", "lightningBalanceLabel": "Ihr Lightning-Guthaben:", "btcAmountLabel": "BTC-Betrag:", "amountLabel": "Betrag:", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8d1a983c..019fdf73 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2459,7 +2459,8 @@ "noRgbAssets": "Warning: No RGB assets available to send." }, "info": { - "zeroAmountInvoice": "This is a zero-amount invoice. Please enter the amount you want to pay below." + "zeroAmountInvoice": "This is a zero-amount invoice. Please enter the amount you want to pay below.", + "assetAmountRequired": "This invoice does not specify the {{ticker}} amount. Please enter how much you want to send." } }, "details": { @@ -2467,6 +2468,7 @@ "title": "Lightning Invoice Details", "assetLabel": "Asset:", "assetAmountLabel": "Asset Amount:", + "assetAmountNotSpecified": "Not specified by invoice", "lightningBalanceLabel": "Your Lightning Balance:", "btcAmountLabel": "BTC Amount:", "amountLabel": "Amount:", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 322db249..f5296e9c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2507,7 +2507,8 @@ "noRgbAssets": "Advertencia: No hay assets RGB disponibles para enviar." }, "info": { - "zeroAmountInvoice": "Esta es una factura de importe cero. Introduce abajo la cantidad que deseas pagar." + "zeroAmountInvoice": "Esta es una factura de importe cero. Introduce abajo la cantidad que deseas pagar.", + "assetAmountRequired": "Esta factura no especifica la cantidad de {{ticker}}. Introduce cuánto deseas enviar." } }, "details": { @@ -2515,6 +2516,7 @@ "title": "Detalles de la Factura Lightning", "assetLabel": "Activo:", "assetAmountLabel": "Cantidad del Asset:", + "assetAmountNotSpecified": "No especificado por la factura", "lightningBalanceLabel": "Tu Saldo Lightning:", "btcAmountLabel": "Cantidad BTC:", "amountLabel": "Cantidad:", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2ba05a18..9842f76e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1852,7 +1852,8 @@ "noRgbAssets": "Avertissement : Aucun actif RGB disponible à envoyer." }, "info": { - "zeroAmountInvoice": "Il s'agit d'une facture à montant zéro. Veuillez saisir ci-dessous le montant que vous souhaitez payer." + "zeroAmountInvoice": "Il s'agit d'une facture à montant zéro. Veuillez saisir ci-dessous le montant que vous souhaitez payer.", + "assetAmountRequired": "Cette facture ne précise pas le montant en {{ticker}}. Veuillez saisir le montant que vous souhaitez envoyer." } }, "details": { @@ -1860,6 +1861,7 @@ "title": "Détails de la facture Lightning", "assetLabel": "Actif :", "assetAmountLabel": "Montant de l'actif :", + "assetAmountNotSpecified": "Non précisé par la facture", "lightningBalanceLabel": "Votre solde Lightning :", "btcAmountLabel": "Montant BTC :", "amountLabel": "Montant :", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 5fc0977a..031aa2dd 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2501,7 +2501,8 @@ "noRgbAssets": "Avviso: Nessun asset RGB disponibile da inviare." }, "info": { - "zeroAmountInvoice": "Questa è una fattura a importo zero. Inserisci qui sotto l'importo che vuoi pagare." + "zeroAmountInvoice": "Questa è una fattura a importo zero. Inserisci qui sotto l'importo che vuoi pagare.", + "assetAmountRequired": "Questa fattura non specifica l'importo in {{ticker}}. Inserisci quanto vuoi inviare." } }, "details": { @@ -2509,6 +2510,7 @@ "title": "Dettagli Fattura Lightning", "assetLabel": "Attivo:", "assetAmountLabel": "Importo Asset:", + "assetAmountNotSpecified": "Non specificato dalla fattura", "lightningBalanceLabel": "Tuo Saldo Lightning:", "btcAmountLabel": "Importo BTC:", "amountLabel": "Importo:", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cda72f8b..ba1c4dda 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1852,7 +1852,8 @@ "noRgbAssets": "警告: 送信可能なRGBアセットがありません。" }, "info": { - "zeroAmountInvoice": "これはゼロ金額インボイスです。下に支払いたい金額を入力してください。" + "zeroAmountInvoice": "これはゼロ金額インボイスです。下に支払いたい金額を入力してください。", + "assetAmountRequired": "このインボイスには{{ticker}}の金額が指定されていません。送信する金額を入力してください。" } }, "details": { @@ -1860,6 +1861,7 @@ "title": "Lightningインボイス詳細", "assetLabel": "アセット:", "assetAmountLabel": "アセット量:", + "assetAmountNotSpecified": "インボイスに未指定", "lightningBalanceLabel": "あなたのLightning残高:", "btcAmountLabel": "BTC量:", "amountLabel": "金額:", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 862004c7..4e44e65e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2498,7 +2498,8 @@ "noRgbAssets": "警告:没有可用于发送的 RGB 资产。" }, "info": { - "zeroAmountInvoice": "这是一张零金额发票。请在下方输入你要支付的金额。" + "zeroAmountInvoice": "这是一张零金额发票。请在下方输入你要支付的金额。", + "assetAmountRequired": "该发票未指定 {{ticker}} 金额。请输入您要发送的数量。" } }, "details": { @@ -2506,6 +2507,7 @@ "title": "Lightning 发票详情", "assetLabel": "资产:", "assetAmountLabel": "资产金额:", + "assetAmountNotSpecified": "发票未指定", "lightningBalanceLabel": "您的 Lightning 余额:", "btcAmountLabel": "BTC 金额:", "amountLabel": "金额:",