diff --git a/api/logics.py b/api/logics.py index a3a069cd..d406661f 100644 --- a/api/logics.py +++ b/api/logics.py @@ -295,6 +295,7 @@ class Logics(): concept = LNPayment.Concepts.PAYBUYER, type = LNPayment.Types.NORM, sender = User.objects.get(username=ESCROW_USERNAME), + order_paid = order, # In case this user has other buyer_invoices, update the one related to this order. receiver= user, # if there is a LNPayment matching these above, it updates that one with defaults below. defaults={ @@ -320,6 +321,10 @@ class Logics(): order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.CHA]) else: order.status = Order.Status.WFE + + # If the order status is 'Failed Routing'. Retry payment. + if order.status == Order.Status.FAI: + follow_send_payment(order.buyer_invoice) order.save() return True, None @@ -766,10 +771,7 @@ class Logics(): if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash): is_payed, context = follow_send_payment(order.buyer_invoice) ##### !!! KEY LINE - PAYS THE BUYER INVOICE !!! if is_payed: - order.status = Order.Status.SUC - order.buyer_invoice.status = LNPayment.Status.SUCCED - order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.SUC]) - # RETURN THE BONDS + # RETURN THE BONDS // Probably best also do it even if payment failed cls.return_bond(order.taker_bond) cls.return_bond(order.maker_bond) order.save() diff --git a/api/management/commands/follow_invoices.py b/api/management/commands/follow_invoices.py index 878445e7..8c691d38 100644 --- a/api/management/commands/follow_invoices.py +++ b/api/management/commands/follow_invoices.py @@ -1,10 +1,12 @@ from django.core.management.base import BaseCommand, CommandError from api.lightning.node import LNNode +from api.tasks import follow_send_payment from api.models import LNPayment, Order from api.logics import Logics from django.utils import timezone +from datetime import timedelta from decouple import config from base64 import b64decode import time @@ -12,25 +14,36 @@ import time MACAROON = b64decode(config('LND_MACAROON_BASE64')) class Command(BaseCommand): - ''' - Background: SubscribeInvoices stub iterator would be great to use here. - However, it only sends updates when the invoice is OPEN (new) or SETTLED. - We are very interested on the other two states (CANCELLED and ACCEPTED). - Therefore, this thread (follow_invoices) will iterate over all LNpayment - objects and do InvoiceLookupV2 every X seconds to update their state 'live' - ''' help = 'Follows all active hold invoices' rest = 5 # seconds between consecutive checks for invoice updates - # def add_arguments(self, parser): - # parser.add_argument('debug', nargs='+', type=boolean) + def handle(self, *args, **options): + ''' Infinite loop to check invoices and retry payments. + ever mind database locked error, keep going, print out''' + + while True: + time.sleep(self.rest) - def follow_invoices(self, *args, **options): + try: + self.follow_hold_invoices() + self.retry_payments() + except Exception as e: + if 'database is locked' in str(e): + self.stdout.write('database is locked') + + self.stdout.write(str(e)) + + def follow_hold_invoices(self): ''' Follows and updates LNpayment objects - until settled or canceled''' - - # TODO handle 'database is locked' + until settled or canceled + + Background: SubscribeInvoices stub iterator would be great to use here. + However, it only sends updates when the invoice is OPEN (new) or SETTLED. + We are very interested on the other two states (CANCELLED and ACCEPTED). + Therefore, this thread (follow_invoices) will iterate over all LNpayment + objects and do InvoiceLookupV2 every X seconds to update their state 'live' + ''' lnd_state_to_lnpayment_status = { 0: LNPayment.Status.INVGEN, # OPEN @@ -41,64 +54,76 @@ class Command(BaseCommand): stub = LNNode.invoicesstub - while True: - time.sleep(self.rest) + # time it for debugging + t0 = time.time() + queryset = LNPayment.objects.filter(type=LNPayment.Types.HOLD, status__in=[LNPayment.Status.INVGEN, LNPayment.Status.LOCKED]) - # time it for debugging - t0 = time.time() - queryset = LNPayment.objects.filter(type=LNPayment.Types.HOLD, status__in=[LNPayment.Status.INVGEN, LNPayment.Status.LOCKED]) + debug = {} + debug['num_active_invoices'] = len(queryset) + debug['invoices'] = [] + at_least_one_changed = False - debug = {} - debug['num_active_invoices'] = len(queryset) - debug['invoices'] = [] - at_least_one_changed = False - - for idx, hold_lnpayment in enumerate(queryset): - old_status = LNPayment.Status(hold_lnpayment.status).label - - try: - request = LNNode.invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(hold_lnpayment.payment_hash)) - response = stub.LookupInvoiceV2(request, metadata=[('macaroon', MACAROON.hex())]) - hold_lnpayment.status = lnd_state_to_lnpayment_status[response.state] - - except Exception as e: - # If it fails at finding the invoice: it has been canceled. - # In RoboSats DB we make a distinction between cancelled and returned (LND does not) - if 'unable to locate invoice' in str(e): - self.stdout.write(str(e)) - hold_lnpayment.status = LNPayment.Status.CANCEL - # LND restarted. - if 'wallet locked, unlock it' in str(e): - self.stdout.write(str(timezone.now())+':: Wallet Locked') - # Other write to logs - else: - self.stdout.write(str(e)) - - new_status = LNPayment.Status(hold_lnpayment.status).label - - # Only save the hold_payments that change (otherwise this function does not scale) - changed = not old_status==new_status - if changed: - # self.handle_status_change(hold_lnpayment, old_status) - hold_lnpayment.save() - self.update_order_status(hold_lnpayment) - - # Report for debugging - new_status = LNPayment.Status(hold_lnpayment.status).label - debug['invoices'].append({idx:{ - 'payment_hash': str(hold_lnpayment.payment_hash), - 'old_status': old_status, - 'new_status': new_status, - }}) - - at_least_one_changed = at_least_one_changed or changed + for idx, hold_lnpayment in enumerate(queryset): + old_status = LNPayment.Status(hold_lnpayment.status).label - debug['time']=time.time()-t0 + try: + request = LNNode.invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(hold_lnpayment.payment_hash)) + response = stub.LookupInvoiceV2(request, metadata=[('macaroon', MACAROON.hex())]) + hold_lnpayment.status = lnd_state_to_lnpayment_status[response.state] - if at_least_one_changed: - self.stdout.write(str(timezone.now())) - self.stdout.write(str(debug)) + except Exception as e: + # If it fails at finding the invoice: it has been canceled. + # In RoboSats DB we make a distinction between cancelled and returned (LND does not) + if 'unable to locate invoice' in str(e): + self.stdout.write(str(e)) + hold_lnpayment.status = LNPayment.Status.CANCEL + # LND restarted. + if 'wallet locked, unlock it' in str(e): + self.stdout.write(str(timezone.now())+':: Wallet Locked') + # Other write to logs + else: + self.stdout.write(str(e)) + + new_status = LNPayment.Status(hold_lnpayment.status).label + # Only save the hold_payments that change (otherwise this function does not scale) + changed = not old_status==new_status + if changed: + # self.handle_status_change(hold_lnpayment, old_status) + self.update_order_status(hold_lnpayment) + hold_lnpayment.save() + + # Report for debugging + new_status = LNPayment.Status(hold_lnpayment.status).label + debug['invoices'].append({idx:{ + 'payment_hash': str(hold_lnpayment.payment_hash), + 'old_status': old_status, + 'new_status': new_status, + }}) + + at_least_one_changed = at_least_one_changed or changed + + debug['time']=time.time()-t0 + + if at_least_one_changed: + self.stdout.write(str(timezone.now())) + self.stdout.write(str(debug)) + + def retry_payments(self): + ''' Checks if any payment is due for retry, and tries to pay it''' + + queryset = LNPayment.objects.filter(type=LNPayment.Types.NORM, + status__in=[LNPayment.Status.VALIDI, LNPayment.Status.FAILRO], + routing_attempts__lt=4, + last_routing_time__lt=(timezone.now()-timedelta(minutes=int(config('RETRY_TIME'))))) + for lnpayment in queryset: + success, _ = follow_send_payment(lnpayment) + + # If already 3 attempts and last failed. Make it expire (ask for a new invoice) an reset attempts. + if not success and lnpayment.routing_attempts == 3: + lnpayment.status = LNPayment.Status.EXPIRE + lnpayment.routing_attempts = 0 + lnpayment.save() def update_order_status(self, lnpayment): ''' Background process following LND hold invoices @@ -123,21 +148,27 @@ class Command(BaseCommand): elif hasattr(lnpayment, 'order_escrow' ): Logics.trade_escrow_received(lnpayment.order_escrow) return + except Exception as e: self.stdout.write(str(e)) - # TODO If a lnpayment goes from LOCKED to INVGED. Totally weird - # halt the order - if lnpayment.status == LNPayment.Status.LOCKED: - pass + # If the LNPayment goes to CANCEL from INVGEN, the invoice had expired + # If it goes to CANCEL from LOCKED the bond was relased. Order had expired in both cases. + # Testing needed for end of time trades! + if lnpayment.status == LNPayment.Status.CANCEL : + if hasattr(lnpayment, 'order_made' ): + Logics.order_expires(lnpayment.order_made) + return - def handle(self, *args, **options): - ''' Never mind database locked error, keep going, print them out''' - - try: - self.follow_invoices() - except Exception as e: - if 'database is locked' in str(e): - self.stdout.write('database is locked') - - self.stdout.write(str(e)) \ No newline at end of file + elif hasattr(lnpayment, 'order_taken' ): + Logics.order_expires(lnpayment.order_taken) + return + + elif hasattr(lnpayment, 'order_escrow' ): + Logics.order_expires(lnpayment.order_escrow) + return + + # TODO If a lnpayment goes from LOCKED to INVGEN. Totally weird + # halt the order + if lnpayment.status == LNPayment.Status.INVGEN: + pass \ No newline at end of file diff --git a/api/tasks.py b/api/tasks.py index 08bad68a..6602faaa 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -38,6 +38,8 @@ def follow_send_payment(lnpayment): from decouple import config from base64 import b64decode + from django.utils import timezone + from datetime import timedelta from api.lightning.node import LNNode from api.models import LNPayment, Order @@ -51,36 +53,54 @@ def follow_send_payment(lnpayment): timeout_seconds=60) # time out payment in 60 seconds order = lnpayment.order_paid - for response in LNNode.routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]): - if response.status == 0 : # Status 0 'UNKNOWN' - # Not sure when this status happens - pass + try: + for response in LNNode.routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]): + if response.status == 0 : # Status 0 'UNKNOWN' + # Not sure when this status happens + pass - if response.status == 1 : # Status 1 'IN_FLIGHT' - print('IN_FLIGHT') - lnpayment.status = LNPayment.Status.FLIGHT - lnpayment.save() - order.status = Order.Status.PAY - order.save() + if response.status == 1 : # Status 1 'IN_FLIGHT' + print('IN_FLIGHT') + lnpayment.status = LNPayment.Status.FLIGHT + lnpayment.save() + order.status = Order.Status.PAY + order.save() - if response.status == 3 : # Status 3 'FAILED' - print('FAILED') - lnpayment.status = LNPayment.Status.FAILRO + if response.status == 3 : # Status 3 'FAILED' + print('FAILED') + lnpayment.status = LNPayment.Status.FAILRO + lnpayment.last_routing_time = timezone.now() + lnpayment.routing_attempts += 1 + lnpayment.save() + order.status = Order.Status.FAI + order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.FAI]) + order.save() + context = {'routing_failed': LNNode.payment_failure_context[response.failure_reason]} + print(context) + # Call a retry in 5 mins here? + return False, context + + if response.status == 2 : # Status 2 'SUCCEEDED' + print('SUCCEEDED') + lnpayment.status = LNPayment.Status.SUCCED + lnpayment.save() + order.status = Order.Status.SUC + order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.SUC]) + order.save() + return True, None + + except Exception as e: + if "invoice expired" in str(e): + print('INVOICE EXPIRED') + lnpayment.status = LNPayment.Status.EXPIRE + lnpayment.last_routing_time = timezone.now() lnpayment.save() order.status = Order.Status.FAI + order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.FAI]) order.save() - context = LNNode.payment_failure_context[response.failure_reason] - # Call a retry here? + context = {'routing_failed':'The payout invoice has expired'} return False, context - if response.status == 2 : # Status 2 'SUCCEEDED' - print('SUCCEEDED') - lnpayment.status = LNPayment.Status.SUCCED - lnpayment.save() - order.status = Order.Status.SUC - order.save() - return True, None - @shared_task(name="cache_external_market_prices", ignore_result=True) def cache_market(): diff --git a/api/views.py b/api/views.py index 15601fa8..b8e496bc 100644 --- a/api/views.py +++ b/api/views.py @@ -236,6 +236,11 @@ class OrderView(viewsets.ViewSet): elif order.status == Order.Status.FAI: data['retries'] = order.buyer_invoice.routing_attempts data['next_retry_time'] = order.buyer_invoice.last_routing_time + timedelta(minutes=RETRY_TIME) + + if order.buyer_invoice.status == LNPayment.Status.EXPIRE: + data['invoice_expired'] = True + # Add invoice amount once again if invoice was expired. + data['invoice_amount'] = int(order.last_satoshis * (1-FEE)) return Response(data, status.HTTP_200_OK) diff --git a/frontend/src/components/OrderPage.js b/frontend/src/components/OrderPage.js index 2ec1454a..7fb246f2 100644 --- a/frontend/src/components/OrderPage.js +++ b/frontend/src/components/OrderPage.js @@ -87,6 +87,7 @@ export default class OrderPage extends Component { delay: this.setDelay(newStateVars.status), currencyCode: this.getCurrencyCode(newStateVars.currency), penalty: newStateVars.penalty, // in case penalty time has finished, it goes back to null + invoice_expired: newStateVars.invoice_expired // in case invoice had expired, it goes back to null when it is valid again }; var completeStateVars = Object.assign({}, newStateVars, otherStateVars); diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 35df69c1..f19409d3 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -1,7 +1,7 @@ import React, { Component } from "react"; -import { Link, Paper, Rating, Button, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" +import { Paper, Rating, Button, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" import QRCode from "react-qr-code"; - +import Countdown from 'react-countdown'; import Chat from "./Chat" // Icons @@ -349,7 +349,7 @@ export default class TradeBox extends Component { showInputInvoice(){ return ( - // TODO Option to upload files and images + // TODO Option to upload using QR from camera {/* In case the taker was very fast to scan the bond, make the taker found alarm sound again */} @@ -662,10 +662,45 @@ handleRatingChange=(e)=>{ ) } - showRoutingFailed(){ - + showRoutingFailed=()=>{ // TODO If it has failed 3 times, ask for a new invoice. - + if(this.props.data.invoice_expired){ + return( + + + + Lightning Routing Failed + + + + + Your invoice has expires or more than 3 payments have been attempted. + + + + + Submit a LN invoice for {pn(this.props.data.invoice_amount)} Sats + + + + + + + + + + ) + }else{ return( @@ -675,18 +710,19 @@ handleRatingChange=(e)=>{ - RoboSats will retry pay your invoice 3 times every 5 minutes. If it keeps failing, you + RoboSats will try to pay your invoice 3 times every 5 minutes. If it keeps failing, you will be able to submit a new invoice. Check whether you have enough inboud liquidity. Remember that lightning nodes must be online in order to receive payments. + - ) + )} } render() {