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, concept = LNPayment.Concepts.PAYBUYER,
type = LNPayment.Types.NORM, type = LNPayment.Types.NORM,
sender = User.objects.get(username=ESCROW_USERNAME), 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, receiver= user,
# if there is a LNPayment matching these above, it updates that one with defaults below. # if there is a LNPayment matching these above, it updates that one with defaults below.
defaults={ defaults={
@ -320,6 +321,10 @@ class Logics():
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.CHA]) order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.CHA])
else: else:
order.status = Order.Status.WFE 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() order.save()
return True, None return True, None
@ -766,10 +771,7 @@ class Logics():
if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash): 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 !!! is_payed, context = follow_send_payment(order.buyer_invoice) ##### !!! KEY LINE - PAYS THE BUYER INVOICE !!!
if is_payed: if is_payed:
order.status = Order.Status.SUC # RETURN THE BONDS // Probably best also do it even if payment failed
order.buyer_invoice.status = LNPayment.Status.SUCCED
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.SUC])
# RETURN THE BONDS
cls.return_bond(order.taker_bond) cls.return_bond(order.taker_bond)
cls.return_bond(order.maker_bond) cls.return_bond(order.maker_bond)
order.save() order.save()

View File

@ -1,10 +1,12 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from api.lightning.node import LNNode from api.lightning.node import LNNode
from api.tasks import follow_send_payment
from api.models import LNPayment, Order from api.models import LNPayment, Order
from api.logics import Logics from api.logics import Logics
from django.utils import timezone from django.utils import timezone
from datetime import timedelta
from decouple import config from decouple import config
from base64 import b64decode from base64 import b64decode
import time import time
@ -12,25 +14,36 @@ import time
MACAROON = b64decode(config('LND_MACAROON_BASE64')) MACAROON = b64decode(config('LND_MACAROON_BASE64'))
class Command(BaseCommand): 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' help = 'Follows all active hold invoices'
rest = 5 # seconds between consecutive checks for invoice updates rest = 5 # seconds between consecutive checks for invoice updates
# def add_arguments(self, parser): def handle(self, *args, **options):
# parser.add_argument('debug', nargs='+', type=boolean) ''' 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 ''' Follows and updates LNpayment objects
until settled or canceled''' until settled or canceled
# TODO handle 'database is locked' 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 = { lnd_state_to_lnpayment_status = {
0: LNPayment.Status.INVGEN, # OPEN 0: LNPayment.Status.INVGEN, # OPEN
@ -41,64 +54,76 @@ class Command(BaseCommand):
stub = LNNode.invoicesstub stub = LNNode.invoicesstub
while True: # time it for debugging
time.sleep(self.rest) t0 = time.time()
queryset = LNPayment.objects.filter(type=LNPayment.Types.HOLD, status__in=[LNPayment.Status.INVGEN, LNPayment.Status.LOCKED])
# time it for debugging debug = {}
t0 = time.time() debug['num_active_invoices'] = len(queryset)
queryset = LNPayment.objects.filter(type=LNPayment.Types.HOLD, status__in=[LNPayment.Status.INVGEN, LNPayment.Status.LOCKED]) debug['invoices'] = []
at_least_one_changed = False
debug = {} for idx, hold_lnpayment in enumerate(queryset):
debug['num_active_invoices'] = len(queryset) old_status = LNPayment.Status(hold_lnpayment.status).label
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
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: except Exception as e:
self.stdout.write(str(timezone.now())) # If it fails at finding the invoice: it has been canceled.
self.stdout.write(str(debug)) # 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): def update_order_status(self, lnpayment):
''' Background process following LND hold invoices ''' Background process following LND hold invoices
@ -123,21 +148,27 @@ class Command(BaseCommand):
elif hasattr(lnpayment, 'order_escrow' ): elif hasattr(lnpayment, 'order_escrow' ):
Logics.trade_escrow_received(lnpayment.order_escrow) Logics.trade_escrow_received(lnpayment.order_escrow)
return return
except Exception as e: except Exception as e:
self.stdout.write(str(e)) self.stdout.write(str(e))
# TODO If a lnpayment goes from LOCKED to INVGED. Totally weird # If the LNPayment goes to CANCEL from INVGEN, the invoice had expired
# halt the order # If it goes to CANCEL from LOCKED the bond was relased. Order had expired in both cases.
if lnpayment.status == LNPayment.Status.LOCKED: # Testing needed for end of time trades!
pass if lnpayment.status == LNPayment.Status.CANCEL :
if hasattr(lnpayment, 'order_made' ):
Logics.order_expires(lnpayment.order_made)
return
def handle(self, *args, **options): elif hasattr(lnpayment, 'order_taken' ):
''' Never mind database locked error, keep going, print them out''' Logics.order_expires(lnpayment.order_taken)
return
try:
self.follow_invoices() elif hasattr(lnpayment, 'order_escrow' ):
except Exception as e: Logics.order_expires(lnpayment.order_escrow)
if 'database is locked' in str(e): return
self.stdout.write('database is locked')
# TODO If a lnpayment goes from LOCKED to INVGEN. Totally weird
self.stdout.write(str(e)) # 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 decouple import config
from base64 import b64decode from base64 import b64decode
from django.utils import timezone
from datetime import timedelta
from api.lightning.node import LNNode from api.lightning.node import LNNode
from api.models import LNPayment, Order from api.models import LNPayment, Order
@ -51,36 +53,54 @@ def follow_send_payment(lnpayment):
timeout_seconds=60) # time out payment in 60 seconds timeout_seconds=60) # time out payment in 60 seconds
order = lnpayment.order_paid order = lnpayment.order_paid
for response in LNNode.routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]): try:
if response.status == 0 : # Status 0 'UNKNOWN' for response in LNNode.routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]):
# Not sure when this status happens if response.status == 0 : # Status 0 'UNKNOWN'
pass # Not sure when this status happens
pass
if response.status == 1 : # Status 1 'IN_FLIGHT' if response.status == 1 : # Status 1 'IN_FLIGHT'
print('IN_FLIGHT') print('IN_FLIGHT')
lnpayment.status = LNPayment.Status.FLIGHT lnpayment.status = LNPayment.Status.FLIGHT
lnpayment.save() lnpayment.save()
order.status = Order.Status.PAY order.status = Order.Status.PAY
order.save() order.save()
if response.status == 3 : # Status 3 'FAILED' if response.status == 3 : # Status 3 'FAILED'
print('FAILED') print('FAILED')
lnpayment.status = LNPayment.Status.FAILRO 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() lnpayment.save()
order.status = Order.Status.FAI order.status = Order.Status.FAI
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.FAI])
order.save() order.save()
context = LNNode.payment_failure_context[response.failure_reason] context = {'routing_failed':'The payout invoice has expired'}
# Call a retry here?
return False, context 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) @shared_task(name="cache_external_market_prices", ignore_result=True)
def cache_market(): def cache_market():

View File

@ -236,6 +236,11 @@ class OrderView(viewsets.ViewSet):
elif order.status == Order.Status.FAI: elif order.status == Order.Status.FAI:
data['retries'] = order.buyer_invoice.routing_attempts data['retries'] = order.buyer_invoice.routing_attempts
data['next_retry_time'] = order.buyer_invoice.last_routing_time + timedelta(minutes=RETRY_TIME) 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) return Response(data, status.HTTP_200_OK)

View File

@ -87,6 +87,7 @@ export default class OrderPage extends Component {
delay: this.setDelay(newStateVars.status), delay: this.setDelay(newStateVars.status),
currencyCode: this.getCurrencyCode(newStateVars.currency), currencyCode: this.getCurrencyCode(newStateVars.currency),
penalty: newStateVars.penalty, // in case penalty time has finished, it goes back to null 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); var completeStateVars = Object.assign({}, newStateVars, otherStateVars);

View File

@ -1,7 +1,7 @@
import React, { Component } from "react"; 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 QRCode from "react-qr-code";
import Countdown from 'react-countdown';
import Chat from "./Chat" import Chat from "./Chat"
// Icons // Icons
@ -349,7 +349,7 @@ export default class TradeBox extends Component {
showInputInvoice(){ showInputInvoice(){
return ( return (
// TODO Option to upload files and images // TODO Option to upload using QR from camera
<Grid container spacing={1}> <Grid container spacing={1}>
{/* In case the taker was very fast to scan the bond, make the taker found alarm sound again */} {/* 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. // 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( return(
<Grid container spacing={1}> <Grid container spacing={1}>
<Grid item xs={12} align="center"> <Grid item xs={12} align="center">
@ -675,18 +710,19 @@ handleRatingChange=(e)=>{
</Grid> </Grid>
<Grid item xs={12} align="center"> <Grid item xs={12} align="center">
<Typography component="body2" variant="body2" 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. 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. Remember that lightning nodes must be online in order to receive payments.
</Typography> </Typography>
<List> <List>
<Divider/>
<ListItemText secondary="Next attempt in"> <ListItemText secondary="Next attempt in">
<Countdown date={new Date(this.props.data.next_retry_time)} renderer={this.countdownRenderer} /> <Countdown date={new Date(this.props.data.next_retry_time)} renderer={this.countdownRenderer} />
</ListItemText> </ListItemText>
</List> </List>
</Grid> </Grid>
</Grid> </Grid>
) )}
} }
render() { render() {