mirror of
https://github.com/RoboSats/robosats.git
synced 2024-12-14 11:26:24 +00:00
344 lines
15 KiB
Python
344 lines
15 KiB
Python
from datetime import timedelta
|
|
from django.utils import timezone
|
|
import requests
|
|
from .lightning import LNNode
|
|
|
|
from .models import Order, LNPayment, User
|
|
from decouple import config
|
|
|
|
FEE = float(config('FEE'))
|
|
BOND_SIZE = float(config('BOND_SIZE'))
|
|
MARKET_PRICE_API = config('MARKET_PRICE_API')
|
|
ESCROW_USERNAME = config('ESCROW_USERNAME')
|
|
|
|
MIN_TRADE = int(config('MIN_TRADE'))
|
|
MAX_TRADE = int(config('MAX_TRADE'))
|
|
|
|
EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE'))
|
|
EXP_TAKER_BOND_INVOICE = int(config('EXP_TAKER_BOND_INVOICE'))
|
|
EXP_TRADE_ESCR_INVOICE = int(config('EXP_TRADE_ESCR_INVOICE'))
|
|
|
|
BOND_EXPIRY = int(config('BOND_EXPIRY'))
|
|
ESCROW_EXPIRY = int(config('ESCROW_EXPIRY'))
|
|
|
|
class Logics():
|
|
|
|
def validate_already_maker_or_taker(user):
|
|
'''Checks if the user is already partipant of an order'''
|
|
queryset = Order.objects.filter(maker=user)
|
|
if queryset.exists():
|
|
return False, {'bad_request':'You are already maker of an order'}
|
|
queryset = Order.objects.filter(taker=user)
|
|
if queryset.exists():
|
|
return False, {'bad_request':'You are already taker of an order'}
|
|
return True, None
|
|
|
|
def validate_order_size(order):
|
|
'''Checks if order is withing limits at t0'''
|
|
if order.t0_satoshis > MAX_TRADE:
|
|
return False, {'bad_request': f'Your order is too big. It is worth {order.t0_satoshis} now. But maximum is {MAX_TRADE}'}
|
|
if order.t0_satoshis < MIN_TRADE:
|
|
return False, {'bad_request': f'Your order is too small. It is worth {order.t0_satoshis} now. But minimum is {MIN_TRADE}'}
|
|
return True, None
|
|
|
|
def take(order, user):
|
|
order.taker = user
|
|
order.status = Order.Status.TAK
|
|
order.save()
|
|
|
|
def is_buyer(order, user):
|
|
is_maker = order.maker == user
|
|
is_taker = order.taker == user
|
|
return (is_maker and order.type == Order.Types.BUY) or (is_taker and order.type == Order.Types.SELL)
|
|
|
|
def is_seller(order, user):
|
|
is_maker = order.maker == user
|
|
is_taker = order.taker == user
|
|
return (is_maker and order.type == Order.Types.SELL) or (is_taker and order.type == Order.Types.BUY)
|
|
|
|
def satoshis_now(order):
|
|
''' checks trade amount in sats '''
|
|
if order.is_explicit:
|
|
satoshis_now = order.satoshis
|
|
else:
|
|
# TODO Add fallback Public APIs and error handling
|
|
# Think about polling price data in a different way (e.g. store locally every t seconds)
|
|
market_prices = requests.get(MARKET_PRICE_API).json()
|
|
exchange_rate = float(market_prices[Order.Currencies(order.currency).label]['last'])
|
|
satoshis_now = ((float(order.amount) * 1+float(order.premium)) / exchange_rate) * 100*1000*1000
|
|
|
|
return int(satoshis_now)
|
|
|
|
def order_expires(order):
|
|
order.status = Order.Status.EXP
|
|
order.maker = None
|
|
order.taker = None
|
|
order.save()
|
|
|
|
@classmethod
|
|
def buyer_invoice_amount(cls, order, user):
|
|
''' Computes buyer invoice amount. Uses order.last_satoshis,
|
|
that is the final trade amount set at Taker Bond time'''
|
|
|
|
if cls.is_buyer(order, user):
|
|
invoice_amount = int(order.last_satoshis * (1-FEE)) # Trading FEE is charged here.
|
|
|
|
return True, {'invoice_amount': invoice_amount}
|
|
|
|
@classmethod
|
|
def update_invoice(cls, order, user, invoice):
|
|
is_valid_invoice, num_satoshis, description, payment_hash, expires_at = LNNode.validate_ln_invoice(invoice)
|
|
# only user is the buyer and a valid LN invoice
|
|
if not (cls.is_buyer(order, user) or is_valid_invoice):
|
|
return False, {'bad_request':'Invalid Lightning Network Invoice. It starts by LNTB...'}
|
|
|
|
order.buyer_invoice, _ = LNPayment.objects.update_or_create(
|
|
concept = LNPayment.Concepts.PAYBUYER,
|
|
type = LNPayment.Types.NORM,
|
|
sender = User.objects.get(username=ESCROW_USERNAME),
|
|
receiver= user,
|
|
# if there is a LNPayment matching these above, it updates that one with defaults below.
|
|
defaults={
|
|
'invoice' : invoice,
|
|
'status' : LNPayment.Status.VALIDI,
|
|
'num_satoshis' : num_satoshis,
|
|
'description' : description,
|
|
'payment_hash' : payment_hash,
|
|
'expires_at' : expires_at}
|
|
)
|
|
|
|
# If the order status is 'Waiting for invoice'. Move forward to 'waiting for invoice'
|
|
if order.status == Order.Status.WFE: order.status = Order.Status.CHA
|
|
|
|
# If the order status is 'Waiting for both'. Move forward to 'waiting for escrow' or to 'chat'
|
|
if order.status == Order.Status.WF2:
|
|
print(order.trade_escrow)
|
|
if order.trade_escrow:
|
|
if order.trade_escrow.status == LNPayment.Status.LOCKED:
|
|
order.status = Order.Status.CHA
|
|
else:
|
|
order.status = Order.Status.WFE
|
|
|
|
# If the order status was Payment Failed. Move forward to invoice Updated.
|
|
if order.status == Order.Status.FAI:
|
|
order.status = Order.Status.UPI
|
|
|
|
order.save()
|
|
return True, None
|
|
|
|
@classmethod
|
|
def rate_counterparty(cls, order, user, rating):
|
|
|
|
# If the trade is finished
|
|
if order.status > Order.Status.PAY:
|
|
|
|
# if maker, rates taker
|
|
if order.maker == user:
|
|
order.taker.profile.total_ratings = order.taker.profile.total_ratings + 1
|
|
last_ratings = list(order.taker.profile.last_ratings).append(rating)
|
|
order.taker.profile.total_ratings = sum(last_ratings) / len(last_ratings)
|
|
|
|
# if taker, rates maker
|
|
if order.taker == user:
|
|
order.maker.profile.total_ratings = order.maker.profile.total_ratings + 1
|
|
last_ratings = list(order.maker.profile.last_ratings).append(rating)
|
|
order.maker.profile.total_ratings = sum(last_ratings) / len(last_ratings)
|
|
else:
|
|
return False, {'bad_request':'You cannot rate your counterparty yet.'}
|
|
|
|
order.save()
|
|
return True, None
|
|
|
|
@classmethod
|
|
def cancel_order(cls, order, user, state):
|
|
|
|
# 1) When maker cancels before bond
|
|
'''The order never shows up on the book and order
|
|
status becomes "cancelled". That's it.'''
|
|
if order.status == Order.Status.WFB and order.maker == user:
|
|
order.maker = None
|
|
order.status = Order.Status.UCA
|
|
order.save()
|
|
return True, None
|
|
|
|
# 2) When maker cancels after bond
|
|
'''The order dissapears from book and goes to cancelled.
|
|
Maker is charged a small amount of sats, to prevent DDOS
|
|
on the LN node and order book'''
|
|
|
|
# 3) When taker cancels before bond
|
|
''' The order goes back to the book as public.
|
|
LNPayment "order.taker_bond" is deleted() '''
|
|
|
|
# 4) When taker or maker cancel after bond (before escrow)
|
|
'''The order goes into cancelled status if maker cancels.
|
|
The order goes into the public book if taker cancels.
|
|
In both cases there is a small fee.'''
|
|
|
|
# 5) When trade collateral has been posted (after escrow)
|
|
'''Always goes to cancelled status. Collaboration is needed.
|
|
When a user asks for cancel, 'order.is_pending_cancel' goes True.
|
|
When the second user asks for cancel. Order is totally cancelled.
|
|
Has a small cost for both parties to prevent node DDOS.'''
|
|
|
|
else:
|
|
return False, {'bad_request':'You cannot cancel this order'}
|
|
|
|
@classmethod
|
|
def gen_maker_hodl_invoice(cls, order, user):
|
|
|
|
# Do not gen and cancel if order is more than 5 minutes old
|
|
if order.expires_at < timezone.now():
|
|
cls.order_expires(order)
|
|
return False, {'bad_request':'Invoice expired. You did not confirm publishing the order in time. Make a new order.'}
|
|
|
|
# Return the previous invoice if there was one and is still unpaid
|
|
if order.maker_bond:
|
|
if order.maker_bond.status == LNPayment.Status.INVGEN:
|
|
return True, {'bond_invoice':order.maker_bond.invoice,'bond_satoshis':order.maker_bond.num_satoshis}
|
|
else:
|
|
return False, None
|
|
|
|
order.last_satoshis = cls.satoshis_now(order)
|
|
bond_satoshis = int(order.last_satoshis * BOND_SIZE)
|
|
description = f'RoboSats - Publishing {str(order)} - This bond will return to you if you do not cheat.'
|
|
|
|
# Gen HODL Invoice
|
|
invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
|
|
|
|
order.maker_bond = LNPayment.objects.create(
|
|
concept = LNPayment.Concepts.MAKEBOND,
|
|
type = LNPayment.Types.HODL,
|
|
sender = user,
|
|
receiver = User.objects.get(username=ESCROW_USERNAME),
|
|
invoice = invoice,
|
|
status = LNPayment.Status.INVGEN,
|
|
num_satoshis = bond_satoshis,
|
|
description = description,
|
|
payment_hash = payment_hash,
|
|
expires_at = expires_at)
|
|
|
|
order.save()
|
|
return True, {'bond_invoice':invoice,'bond_satoshis':bond_satoshis}
|
|
|
|
@classmethod
|
|
def gen_taker_hodl_invoice(cls, order, user):
|
|
|
|
# Do not gen and cancel if a taker invoice is there and older than X minutes and unpaid still
|
|
if order.taker_bond:
|
|
# Check if status is INVGEN and still not expired
|
|
if order.taker_bond.status == LNPayment.Status.INVGEN:
|
|
if order.taker_bond.created_at > (timezone.now()+timedelta(minutes=EXP_TAKER_BOND_INVOICE)):
|
|
cls.cancel_order(order, user, 3) # State 3, cancel order before taker bond
|
|
return False, {'bad_request':'Invoice expired. You did not confirm taking the order in time.'}
|
|
# Return the previous invoice there was with INVGEN status
|
|
else:
|
|
return True, {'bond_invoice':order.taker_bond.invoice,'bond_satoshis':order.taker_bond.num_satoshis}
|
|
# Invoice exists, but was already locked or settled
|
|
else:
|
|
return False, None
|
|
|
|
order.last_satoshis = cls.satoshis_now(order) # LOCKS THE AMOUNT OF SATOSHIS FOR THE TRADE
|
|
bond_satoshis = int(order.last_satoshis * BOND_SIZE)
|
|
description = f'RoboSats - Taking {str(order)} - This bond will return to you if you do not cheat.'
|
|
|
|
# Gen HODL Invoice
|
|
invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
|
|
|
|
order.taker_bond = LNPayment.objects.create(
|
|
concept = LNPayment.Concepts.TAKEBOND,
|
|
type = LNPayment.Types.HODL,
|
|
sender = user,
|
|
receiver = User.objects.get(username=ESCROW_USERNAME),
|
|
invoice = invoice,
|
|
status = LNPayment.Status.INVGEN,
|
|
num_satoshis = bond_satoshis,
|
|
description = description,
|
|
payment_hash = payment_hash,
|
|
expires_at = expires_at)
|
|
|
|
order.save()
|
|
return True, {'bond_invoice':invoice,'bond_satoshis': bond_satoshis}
|
|
|
|
@classmethod
|
|
def gen_escrow_hodl_invoice(cls, order, user):
|
|
|
|
# Do not generate and cancel if an invoice is there and older than X minutes and unpaid still
|
|
if order.trade_escrow:
|
|
# Check if status is INVGEN and still not expired
|
|
if order.taker_bond.status == LNPayment.Status.INVGEN:
|
|
if order.taker_bond.created_at > (timezone.now()+timedelta(minutes=EXP_TRADE_ESCR_INVOICE)): # Expired
|
|
cls.cancel_order(order, user, 4) # State 4, cancel order before trade escrow locked
|
|
return False, {'bad_request':'Invoice expired. You did not lock the trade escrow in time.'}
|
|
# Return the previous invoice there was with INVGEN status
|
|
else:
|
|
return True, {'escrow_invoice':order.trade_escrow.invoice,'escrow_satoshis':order.trade_escrow.num_satoshis}
|
|
# Invoice exists, but was already locked or settled
|
|
else:
|
|
return False, None # Does not return any context of a healthy locked escrow
|
|
|
|
escrow_satoshis = order.last_satoshis # Trade sats amount was fixed at the time of taker bond generation (order.last_satoshis)
|
|
description = f'RoboSats - Escrow amount for {str(order)} - This escrow will be released to the buyer once you confirm you received the fiat.'
|
|
|
|
# Gen HODL Invoice
|
|
invoice, payment_hash, expires_at = LNNode.gen_hodl_invoice(escrow_satoshis, description, ESCROW_EXPIRY*3600)
|
|
|
|
order.taker_bond = LNPayment.objects.create(
|
|
concept = LNPayment.Concepts.TRESCROW,
|
|
type = LNPayment.Types.HODL,
|
|
sender = user,
|
|
receiver = User.objects.get(username=ESCROW_USERNAME),
|
|
invoice = invoice,
|
|
status = LNPayment.Status.INVGEN,
|
|
num_satoshis = escrow_satoshis,
|
|
description = description,
|
|
payment_hash = payment_hash,
|
|
expires_at = expires_at)
|
|
|
|
order.save()
|
|
return True, {'escrow_invoice':invoice,'escrow_satoshis': escrow_satoshis}
|
|
|
|
def settle_escrow(order):
|
|
''' Settles the trade escrow HTLC'''
|
|
# TODO ERROR HANDLING
|
|
|
|
valid = LNNode.settle_hodl_htlcs(order.trade_escrow.payment_hash)
|
|
return valid
|
|
|
|
def pay_buyer_invoice(order):
|
|
''' Settles the trade escrow HTLC'''
|
|
# TODO ERROR HANDLING
|
|
|
|
valid = LNNode.pay_invoice(order.buyer_invoice.payment_hash)
|
|
return valid
|
|
|
|
@classmethod
|
|
def confirm_fiat(cls, order, user):
|
|
''' If Order is in the CHAT states:
|
|
If user is buyer: mark the FIAT SENT andettle escrow!
|
|
If User is the seller and FIAT was already sent: Pay buyer invoice!'''
|
|
|
|
if order.status == Order.Status.CHA or order.status == Order.Status.FSE: # TODO Alternatively, if all collateral is locked? test out
|
|
|
|
# If buyer, settle escrow and mark fiat sent
|
|
if cls.is_buyer(order, user):
|
|
if cls.settle_escrow(order): # KEY LINE - SETTLES THE TRADE ESCROW !!
|
|
order.trade_escrow.status = LNPayment.Status.SETLED
|
|
order.status = Order.Status.FSE
|
|
order.is_fiat_sent = True
|
|
|
|
# If seller and fiat sent, pay buyer invoice
|
|
elif cls.is_seller(order, user):
|
|
if not order.is_fiat_sent:
|
|
return False, {'bad_request':'You cannot confirm to have received the fiat before it is confirmed to be sent by the buyer.'}
|
|
|
|
# Double check the escrow is settled.
|
|
if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash):
|
|
if cls.pay_buyer_invoice(order): # KEY LINE - PAYS THE BUYER !!
|
|
order.status = Order.Status.PAY
|
|
order.buyer_invoice.status = LNPayment.Status.PAYING
|
|
else:
|
|
return False, {'bad_request':'You cannot confirm the fiat payment at this stage'}
|
|
|
|
order.save()
|
|
return True, None |