Add logics for payment retry, first iteration.

This commit is contained in:
Reckless_Satoshi 2022-01-24 14:53:55 -08:00
parent 25ab5fdf2e
commit 2d1a2e4c5c
No known key found for this signature in database
GPG Key ID: 9C4585B561315571
6 changed files with 210 additions and 115 deletions

View File

@ -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()

View File

@ -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))
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

View File

@ -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():

View File

@ -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)

View File

@ -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);

View File

@ -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
<Grid container spacing={1}>
{/* 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(
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography component="h6" variant="h6">
Lightning Routing Failed
</Typography>
</Grid>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2" align="center">
Your invoice has expires or more than 3 payments have been attempted.
</Typography>
</Grid>
<Grid item xs={12} align="center">
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b> Submit a LN invoice for {pn(this.props.data.invoice_amount)} Sats </b>
</Typography>
</Grid>
<Grid item xs={12} align="center">
<TextField
error={this.state.badInvoice}
helperText={this.state.badInvoice ? this.state.badInvoice : "" }
label={"Payout Lightning Invoice"}
required
inputProps={{
style: {textAlign:"center"}
}}
multiline
onChange={this.handleInputInvoiceChanged}
/>
</Grid>
<Grid item xs={12} align="center">
<Button onClick={this.handleClickSubmitInvoiceButton} variant='contained' color='primary'>Submit</Button>
</Grid>
</Grid>
)
}else{
return(
<Grid container spacing={1}>
<Grid item xs={12} align="center">
@ -675,18 +710,19 @@ handleRatingChange=(e)=>{
</Grid>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2" align="center">
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.
</Typography>
<List>
<Divider/>
<ListItemText secondary="Next attempt in">
<Countdown date={new Date(this.props.data.next_retry_time)} renderer={this.countdownRenderer} />
</ListItemText>
</List>
</Grid>
</Grid>
)
)}
}
render() {