fix: display units for invoice expiry and CLTV expiry (#4223)

The PaymentRequest view rendered the decoded expiry as raw seconds
(e.g. 3600) and cltv_expiry as a bare number. Display the expiry as a
humanized duration via the Invoice model's existing formatter, and
append the blocks unit to CLTV expiry on both the PaymentRequest and
Invoice views.

The model's originalTimeUntilExpiryInSeconds only worked when a bolt11
string was available to decode, but pay_req is built from a backend
decodepayreq response that carries no bolt11 string. Fall back to the
raw expiry/expires_at fields in that case.
This commit is contained in:
Evan Kaloudis
2026-07-13 16:15:23 -04:00
parent eb935a7a50
commit ee7561ebe6
4 changed files with 74 additions and 6 deletions
+56
View File
@@ -0,0 +1,56 @@
jest.mock('../stores/Stores', () => ({}));
import Invoice from './Invoice';
// decodes with timestamp 1700074718 and expiry 3600 (see Bolt11Utils.test.ts)
const paymentRequest =
'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0';
describe('Invoice.originalTimeUntilExpiryInSeconds', () => {
it('derives expiry from a decodable payment request', () => {
const invoice = new Invoice({ payment_request: paymentRequest });
expect(invoice.originalTimeUntilExpiryInSeconds).toBe(3600);
});
it('falls back to the expiry field when no payment request string is present (decodepayreq response)', () => {
const invoice = new Invoice({
destination: '02758997f184be06f4350b136db0bed6f8',
timestamp: '1700074718',
expiry: '3600',
cltv_expiry: '80'
});
expect(invoice.originalTimeUntilExpiryInSeconds).toBe(3600);
});
it('uses expires_at with the model timestamp when no payment request string is present', () => {
const invoice = new Invoice({
timestamp: '1700074718',
expires_at: 1700074718 + 600
});
expect(invoice.originalTimeUntilExpiryInSeconds).toBe(600);
});
it('returns undefined when no expiry information is available', () => {
const invoice = new Invoice({
destination: '02758997f184be06f4350b136db0bed6f8'
});
expect(invoice.originalTimeUntilExpiryInSeconds).toBeUndefined();
});
});
describe('Invoice.determineFormattedOriginalTimeUntilExpiry', () => {
it('humanizes the fallback expiry seconds', () => {
const invoice = new Invoice({
timestamp: '1700074718',
expiry: '3600'
});
invoice.determineFormattedOriginalTimeUntilExpiry('en');
expect(invoice.formattedOriginalTimeUntilExpiry).toBe('1 hour');
});
it('humanizes the expiry of a decodable payment request', () => {
const invoice = new Invoice({ payment_request: paymentRequest });
invoice.determineFormattedOriginalTimeUntilExpiry('en');
expect(invoice.formattedOriginalTimeUntilExpiry).toBe('1 hour');
});
});
+8 -3
View File
@@ -387,12 +387,17 @@ export default class Invoice extends BaseModel {
| number
| undefined {
const decoded = this.decodedPaymentRequest;
if (!decoded) return undefined;
const timestamp = decoded ? decoded.timestamp : Number(this.timestamp);
if (this.expires_at != null) {
// expiry is missing in payment request in Core Lightning
return this.expires_at - decoded.timestamp;
return !isNaN(timestamp) ? this.expires_at - timestamp : undefined;
}
return decoded.expiry;
if (decoded) return decoded.expiry;
// no bolt11 string to decode (e.g. pay_req built from a backend
// decodepayreq response) — use the reported expiry seconds
return this.expiry != null && this.expiry !== ''
? Number(this.expiry)
: undefined;
}
public determineFormattedOriginalTimeUntilExpiry(
+3 -1
View File
@@ -418,7 +418,9 @@ export default class InvoiceView extends React.Component<
keyValue={localeString(
'views.Invoice.cltvExpiry'
)}
value={cltv_expiry}
value={`${cltv_expiry} ${localeString(
'general.blocks'
)}`}
/>
)}
+7 -2
View File
@@ -518,6 +518,9 @@ export default class PaymentRequest extends React.Component<
const isZaplockerValid = isPmtHashSigValid && isRelaysSigValid;
const locale = SettingsStore.settings.locale;
if (pay_req) pay_req.determineFormattedOriginalTimeUntilExpiry(locale);
// variables cannot be destructured traditionally here
// due to how we clear the pay_req from the store upon
// navigating back
@@ -525,7 +528,7 @@ export default class PaymentRequest extends React.Component<
pay_req && pay_req.getRequestAmount
? pay_req.getRequestAmount
: undefined;
const expiry = pay_req && pay_req.expiry;
const expiry = pay_req && pay_req.formattedOriginalTimeUntilExpiry;
const cltv_expiry = pay_req && pay_req.cltv_expiry;
const destination = pay_req && pay_req.destination;
const payment_hash = pay_req && pay_req.payment_hash;
@@ -971,7 +974,9 @@ export default class PaymentRequest extends React.Component<
keyValue={localeString(
'views.PaymentRequest.cltvExpiry'
)}
value={cltv_expiry}
value={`${cltv_expiry} ${localeString(
'general.blocks'
)}`}
/>
)}