mirror of
https://github.com/RoboSats/robosats.git
synced 2024-12-15 03:46:24 +00:00
1733 lines
67 KiB
Python
1733 lines
67 KiB
Python
import ast
|
|
import math
|
|
from datetime import timedelta
|
|
|
|
import gnupg
|
|
from decouple import config
|
|
from django.db.models import Q, Sum
|
|
from django.utils import timezone
|
|
|
|
from api.lightning.node import LNNode
|
|
from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order, User
|
|
from api.tasks import send_message
|
|
from api.utils import validate_onchain_address
|
|
|
|
FEE = float(config("FEE"))
|
|
MAKER_FEE_SPLIT = float(config("MAKER_FEE_SPLIT"))
|
|
|
|
ESCROW_USERNAME = config("ESCROW_USERNAME")
|
|
PENALTY_TIMEOUT = int(config("PENALTY_TIMEOUT"))
|
|
|
|
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"))
|
|
|
|
BLOCK_TIME = float(config("BLOCK_TIME"))
|
|
MAX_MINING_NETWORK_SPEEDUP_EXPECTED = float(
|
|
config("MAX_MINING_NETWORK_SPEEDUP_EXPECTED")
|
|
)
|
|
|
|
INVOICE_AND_ESCROW_DURATION = int(config("INVOICE_AND_ESCROW_DURATION"))
|
|
FIAT_EXCHANGE_DURATION = int(config("FIAT_EXCHANGE_DURATION"))
|
|
|
|
|
|
class Logics:
|
|
@classmethod
|
|
def validate_already_maker_or_taker(cls, user):
|
|
"""Validates if a use is already not part of an active order"""
|
|
|
|
active_order_status = [
|
|
Order.Status.WFB,
|
|
Order.Status.PUB,
|
|
Order.Status.PAU,
|
|
Order.Status.TAK,
|
|
Order.Status.WF2,
|
|
Order.Status.WFE,
|
|
Order.Status.WFI,
|
|
Order.Status.CHA,
|
|
Order.Status.FSE,
|
|
Order.Status.DIS,
|
|
Order.Status.WFR,
|
|
]
|
|
"""Checks if the user is already partipant of an active order"""
|
|
queryset = Order.objects.filter(maker=user, status__in=active_order_status)
|
|
if queryset.exists():
|
|
return (
|
|
False,
|
|
{"bad_request": "You are already maker of an active order"},
|
|
queryset[0],
|
|
)
|
|
|
|
queryset = Order.objects.filter(taker=user, status__in=active_order_status)
|
|
if queryset.exists():
|
|
return (
|
|
False,
|
|
{"bad_request": "You are already taker of an active order"},
|
|
queryset[0],
|
|
)
|
|
|
|
# Edge case when the user is in an order that is failing payment and he is the buyer
|
|
queryset = Order.objects.filter(
|
|
Q(maker=user) | Q(taker=user),
|
|
status__in=[Order.Status.FAI, Order.Status.PAY],
|
|
)
|
|
if queryset.exists():
|
|
order = queryset[0]
|
|
if cls.is_buyer(order, user):
|
|
return (
|
|
False,
|
|
{
|
|
"bad_request": "You are still pending a payment from a recent order"
|
|
},
|
|
order,
|
|
)
|
|
|
|
return True, None, None
|
|
|
|
def validate_pgp_keys(pub_key, enc_priv_key):
|
|
"""Validates PGP valid keys. Formats them in a way understandable by the frontend"""
|
|
gpg = gnupg.GPG()
|
|
|
|
# Standarize format with linux linebreaks '\n'. Windows users submitting their own keys have '\r\n' breaking communication.
|
|
enc_priv_key = enc_priv_key.replace("\r\n", "\n")
|
|
pub_key = pub_key.replace("\r\n", "\n")
|
|
|
|
# Try to import the public key
|
|
import_pub_result = gpg.import_keys(pub_key)
|
|
if not import_pub_result.imported == 1:
|
|
return (
|
|
False,
|
|
{
|
|
"bad_request": "Your PGP public key does not seem valid.\n"
|
|
+ f"Stderr: {str(import_pub_result.stderr)}\n"
|
|
+ f"ReturnCode: {str(import_pub_result.returncode)}\n"
|
|
+ f"Summary: {str(import_pub_result.summary)}\n"
|
|
+ f"Results: {str(import_pub_result.results)}\n"
|
|
+ f"Imported: {str(import_pub_result.imported)}\n"
|
|
},
|
|
None,
|
|
None,
|
|
)
|
|
# Exports the public key again for uniform formatting.
|
|
pub_key = gpg.export_keys(import_pub_result.fingerprints[0])
|
|
|
|
# Try to import the encrypted private key (without passphrase)
|
|
import_priv_result = gpg.import_keys(enc_priv_key)
|
|
if not import_priv_result.sec_imported == 1:
|
|
return (
|
|
False,
|
|
{
|
|
"bad_request": "Your PGP encrypted private key does not seem valid.\n"
|
|
+ f"Stderr: {str(import_priv_result.stderr)}\n"
|
|
+ f"ReturnCode: {str(import_priv_result.returncode)}\n"
|
|
+ f"Summary: {str(import_priv_result.summary)}\n"
|
|
+ f"Results: {str(import_priv_result.results)}\n"
|
|
+ f"Sec Imported: {str(import_priv_result.sec_imported)}\n"
|
|
},
|
|
None,
|
|
None,
|
|
)
|
|
|
|
return True, None, pub_key, enc_priv_key
|
|
|
|
@classmethod
|
|
def validate_order_size(cls, order):
|
|
"""Validates if order size in Sats is within limits at t0"""
|
|
if not order.has_range:
|
|
if order.t0_satoshis > MAX_TRADE:
|
|
return False, {
|
|
"bad_request": "Your order is too big. It is worth "
|
|
+ "{:,}".format(order.t0_satoshis)
|
|
+ " Sats now, but the limit is "
|
|
+ "{:,}".format(MAX_TRADE)
|
|
+ " Sats"
|
|
}
|
|
if order.t0_satoshis < MIN_TRADE:
|
|
return False, {
|
|
"bad_request": "Your order is too small. It is worth "
|
|
+ "{:,}".format(order.t0_satoshis)
|
|
+ " Sats now, but the limit is "
|
|
+ "{:,}".format(MIN_TRADE)
|
|
+ " Sats"
|
|
}
|
|
elif order.has_range:
|
|
min_sats = cls.calc_sats(
|
|
order.min_amount, order.currency.exchange_rate, order.premium
|
|
)
|
|
max_sats = cls.calc_sats(
|
|
order.max_amount, order.currency.exchange_rate, order.premium
|
|
)
|
|
if min_sats > max_sats / 1.5:
|
|
return False, {
|
|
"bad_request": "Maximum range amount must be at least 50 percent higher than the minimum amount"
|
|
}
|
|
elif max_sats > MAX_TRADE:
|
|
return False, {
|
|
"bad_request": "Your order maximum amount is too big. It is worth "
|
|
+ "{:,}".format(int(max_sats))
|
|
+ " Sats now, but the limit is "
|
|
+ "{:,}".format(MAX_TRADE)
|
|
+ " Sats"
|
|
}
|
|
elif min_sats < MIN_TRADE:
|
|
return False, {
|
|
"bad_request": "Your order minimum amount is too small. It is worth "
|
|
+ "{:,}".format(int(min_sats))
|
|
+ " Sats now, but the limit is "
|
|
+ "{:,}".format(MIN_TRADE)
|
|
+ " Sats"
|
|
}
|
|
elif min_sats < max_sats / 8:
|
|
return False, {
|
|
"bad_request": "Your order amount range is too large. Max amount can only be 8 times bigger than min amount"
|
|
}
|
|
|
|
return True, None
|
|
|
|
def validate_amount_within_range(order, amount):
|
|
if amount > float(order.max_amount) or amount < float(order.min_amount):
|
|
return False, {
|
|
"bad_request": "The amount specified is outside the range specified by the maker"
|
|
}
|
|
|
|
return True, None
|
|
|
|
def user_activity_status(last_seen):
|
|
if last_seen > (timezone.now() - timedelta(minutes=2)):
|
|
return "Active"
|
|
elif last_seen > (timezone.now() - timedelta(minutes=10)):
|
|
return "Seen recently"
|
|
else:
|
|
return "Inactive"
|
|
|
|
@classmethod
|
|
def take(cls, order, user, amount=None):
|
|
is_penalized, time_out = cls.is_penalized(user)
|
|
if is_penalized:
|
|
return False, {
|
|
"bad_request",
|
|
f"You need to wait {time_out} seconds to take an order",
|
|
}
|
|
else:
|
|
if order.has_range:
|
|
order.amount = amount
|
|
order.taker = user
|
|
order.status = Order.Status.TAK
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.TAK)
|
|
)
|
|
order.save()
|
|
return True, None
|
|
|
|
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 calc_sats(amount, exchange_rate, premium):
|
|
exchange_rate = float(exchange_rate)
|
|
premium_rate = exchange_rate * (1 + float(premium) / 100)
|
|
return (float(amount) / premium_rate) * 100 * 1000 * 1000
|
|
|
|
@classmethod
|
|
def satoshis_now(cls, order):
|
|
"""checks trade amount in sats"""
|
|
if order.is_explicit:
|
|
satoshis_now = order.satoshis
|
|
else:
|
|
amount = order.amount if order.amount is not None else order.max_amount
|
|
satoshis_now = cls.calc_sats(
|
|
amount, order.currency.exchange_rate, order.premium
|
|
)
|
|
|
|
return int(satoshis_now)
|
|
|
|
def price_and_premium_now(order):
|
|
"""computes order price and premium with current rates"""
|
|
exchange_rate = float(order.currency.exchange_rate)
|
|
if not order.is_explicit:
|
|
premium = order.premium
|
|
price = exchange_rate * (1 + float(premium) / 100)
|
|
else:
|
|
amount = order.amount if not order.has_range else order.max_amount
|
|
order_rate = float(amount) / (float(order.satoshis) / 100000000)
|
|
premium = order_rate / exchange_rate - 1
|
|
premium = int(premium * 10000) / 100 # 2 decimals left
|
|
price = order_rate
|
|
|
|
significant_digits = 5
|
|
price = round(
|
|
price, significant_digits - int(math.floor(math.log10(abs(price)))) - 1
|
|
)
|
|
|
|
return price, premium
|
|
|
|
@classmethod
|
|
def order_expires(cls, order):
|
|
"""General cases when time runs out."""
|
|
|
|
# Do not change order status if an order in any with
|
|
# any of these status is sent to expire here
|
|
does_not_expire = [
|
|
Order.Status.UCA,
|
|
Order.Status.EXP,
|
|
Order.Status.TLD,
|
|
Order.Status.DIS,
|
|
Order.Status.CCA,
|
|
Order.Status.PAY,
|
|
Order.Status.SUC,
|
|
Order.Status.FAI,
|
|
Order.Status.MLD,
|
|
]
|
|
|
|
# in any case, if order is_swap and there is an onchain_payment, cancel it.
|
|
if order.status not in does_not_expire:
|
|
cls.cancel_onchain_payment(order)
|
|
|
|
if order.status in does_not_expire:
|
|
return False
|
|
|
|
elif order.status == Order.Status.WFB:
|
|
order.status = Order.Status.EXP
|
|
order.expiry_reason = Order.ExpiryReasons.NMBOND
|
|
cls.cancel_bond(order.maker_bond)
|
|
order.save()
|
|
return True
|
|
|
|
elif order.status in [Order.Status.PUB, Order.Status.PAU]:
|
|
cls.return_bond(order.maker_bond)
|
|
order.status = Order.Status.EXP
|
|
order.expiry_reason = Order.ExpiryReasons.NTAKEN
|
|
order.save()
|
|
send_message.delay(order.id, "order_expired_untaken")
|
|
return True
|
|
|
|
elif order.status == Order.Status.TAK:
|
|
cls.cancel_bond(order.taker_bond)
|
|
cls.kick_taker(order)
|
|
return True
|
|
|
|
elif order.status == Order.Status.WF2:
|
|
"""Weird case where an order expires and both participants
|
|
did not proceed with the contract. Likely the site was
|
|
down or there was a bug. Still bonds must be charged
|
|
to avoid service DDOS."""
|
|
|
|
cls.settle_bond(order.maker_bond)
|
|
cls.settle_bond(order.taker_bond)
|
|
cls.cancel_escrow(order)
|
|
order.status = Order.Status.EXP
|
|
order.expiry_reason = Order.ExpiryReasons.NESINV
|
|
order.save()
|
|
return True
|
|
|
|
elif order.status == Order.Status.WFE:
|
|
maker_is_seller = cls.is_seller(order, order.maker)
|
|
# If maker is seller, settle the bond and order goes to expired
|
|
if maker_is_seller:
|
|
cls.settle_bond(order.maker_bond)
|
|
cls.return_bond(order.taker_bond)
|
|
# If seller is offline the escrow LNpayment does not exist
|
|
try:
|
|
cls.cancel_escrow(order)
|
|
except Exception:
|
|
pass
|
|
order.status = Order.Status.EXP
|
|
order.expiry_reason = Order.ExpiryReasons.NESCRO
|
|
order.save()
|
|
# Reward taker with part of the maker bond
|
|
cls.add_slashed_rewards(order.maker_bond, order.taker.profile)
|
|
return True
|
|
|
|
# If maker is buyer, settle the taker's bond order goes back to public
|
|
else:
|
|
cls.settle_bond(order.taker_bond)
|
|
# If seller is offline the escrow LNpayment does not even exist
|
|
try:
|
|
cls.cancel_escrow(order)
|
|
except Exception:
|
|
pass
|
|
taker_bond = order.taker_bond
|
|
order.taker = None
|
|
order.taker_bond = None
|
|
order.trade_escrow = None
|
|
order.payout = None
|
|
cls.publish_order(order)
|
|
send_message.delay(order.id, "order_published")
|
|
# Reward maker with part of the taker bond
|
|
cls.add_slashed_rewards(taker_bond, order.maker.profile)
|
|
return True
|
|
|
|
elif order.status == Order.Status.WFI:
|
|
# The trade could happen without a buyer invoice. However, this user
|
|
# is likely AFK; will probably desert the contract as well.
|
|
|
|
maker_is_buyer = cls.is_buyer(order, order.maker)
|
|
# If maker is buyer, settle the bond and order goes to expired
|
|
if maker_is_buyer:
|
|
cls.settle_bond(order.maker_bond)
|
|
cls.return_bond(order.taker_bond)
|
|
cls.return_escrow(order)
|
|
order.status = Order.Status.EXP
|
|
order.expiry_reason = Order.ExpiryReasons.NINVOI
|
|
order.save()
|
|
# Reward taker with part of the maker bond
|
|
cls.add_slashed_rewards(order.maker_bond, order.taker.profile)
|
|
return True
|
|
|
|
# If maker is seller settle the taker's bond, order goes back to public
|
|
else:
|
|
cls.settle_bond(order.taker_bond)
|
|
cls.return_escrow(order)
|
|
taker_bond = order.taker_bond
|
|
order.taker = None
|
|
order.taker_bond = None
|
|
order.trade_escrow = None
|
|
cls.publish_order(order)
|
|
send_message.delay(order.id, "order_published")
|
|
# Reward maker with part of the taker bond
|
|
cls.add_slashed_rewards(taker_bond, order.maker.profile)
|
|
return True
|
|
|
|
elif order.status in [Order.Status.CHA, Order.Status.FSE]:
|
|
# Another weird case. The time to confirm 'fiat sent or received' expired. Yet no dispute
|
|
# was opened. Hint: a seller-scammer could persuade a buyer to not click "fiat
|
|
# sent", we assume this is a dispute case by default.
|
|
cls.open_dispute(order)
|
|
return True
|
|
|
|
@classmethod
|
|
def kick_taker(cls, order):
|
|
"""The taker did not lock the taker_bond. Now he has to go"""
|
|
# Add a time out to the taker
|
|
if order.taker:
|
|
profile = order.taker.profile
|
|
profile.penalty_expiration = timezone.now() + timedelta(
|
|
seconds=PENALTY_TIMEOUT
|
|
)
|
|
profile.save()
|
|
|
|
# Make order public again
|
|
order.taker = None
|
|
order.taker_bond = None
|
|
cls.publish_order(order)
|
|
return True
|
|
|
|
@classmethod
|
|
def open_dispute(cls, order, user=None):
|
|
|
|
# Always settle escrow and bonds during a dispute. Disputes
|
|
# can take long to resolve, it might trigger force closure
|
|
# for unresolved HTLCs) Dispute winner will have to submit a
|
|
# new invoice for value of escrow + bond.
|
|
|
|
valid_status_open_dispute = [
|
|
Order.Status.CHA,
|
|
Order.Status.FSE,
|
|
]
|
|
|
|
if order.status not in valid_status_open_dispute:
|
|
return False, {
|
|
"bad_request": "You cannot open a dispute of this order at this stage"
|
|
}
|
|
|
|
if not order.trade_escrow.status == LNPayment.Status.SETLED:
|
|
cls.settle_escrow(order)
|
|
cls.settle_bond(order.maker_bond)
|
|
cls.settle_bond(order.taker_bond)
|
|
|
|
order.is_disputed = True
|
|
order.status = Order.Status.DIS
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.DIS)
|
|
)
|
|
order.save()
|
|
|
|
# User could be None if a dispute is open automatically due to weird expiration.
|
|
if user is not None:
|
|
profile = user.profile
|
|
profile.num_disputes = profile.num_disputes + 1
|
|
if profile.orders_disputes_started is None:
|
|
profile.orders_disputes_started = [str(order.id)]
|
|
else:
|
|
profile.orders_disputes_started = list(
|
|
profile.orders_disputes_started
|
|
).append(str(order.id))
|
|
profile.save()
|
|
|
|
send_message.delay(order.id, "dispute_opened")
|
|
return True, None
|
|
|
|
def dispute_statement(order, user, statement):
|
|
"""Updates the dispute statements"""
|
|
|
|
if not order.status == Order.Status.DIS:
|
|
return False, {
|
|
"bad_request": "Only orders in dispute accept dispute statements"
|
|
}
|
|
|
|
if len(statement) > 5000:
|
|
return False, {
|
|
"bad_statement": "The statement is longer than 5000 characters"
|
|
}
|
|
|
|
if len(statement) < 100:
|
|
return False, {
|
|
"bad_statement": "The statement is too short. Make sure to be thorough."
|
|
}
|
|
|
|
if order.maker == user:
|
|
order.maker_statement = statement
|
|
else:
|
|
order.taker_statement = statement
|
|
|
|
# If both statements are in, move status to wait for dispute resolution
|
|
if order.maker_statement not in [None, ""] and order.taker_statement not in [
|
|
None,
|
|
"",
|
|
]:
|
|
order.status = Order.Status.WFR
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.WFR)
|
|
)
|
|
|
|
order.save()
|
|
return True, None
|
|
|
|
def compute_swap_fee_rate(balance):
|
|
|
|
shape = str(config("SWAP_FEE_SHAPE"))
|
|
|
|
if shape == "linear":
|
|
MIN_SWAP_FEE = float(config("MIN_SWAP_FEE"))
|
|
MIN_POINT = float(config("MIN_POINT"))
|
|
MAX_SWAP_FEE = float(config("MAX_SWAP_FEE"))
|
|
MAX_POINT = float(config("MAX_POINT"))
|
|
if float(balance.onchain_fraction) > MIN_POINT:
|
|
swap_fee_rate = MIN_SWAP_FEE
|
|
else:
|
|
slope = (MAX_SWAP_FEE - MIN_SWAP_FEE) / (MAX_POINT - MIN_POINT)
|
|
swap_fee_rate = (
|
|
slope * (balance.onchain_fraction - MAX_POINT) + MAX_SWAP_FEE
|
|
)
|
|
|
|
elif shape == "exponential":
|
|
MIN_SWAP_FEE = float(config("MIN_SWAP_FEE"))
|
|
MAX_SWAP_FEE = float(config("MAX_SWAP_FEE"))
|
|
SWAP_LAMBDA = float(config("SWAP_LAMBDA"))
|
|
swap_fee_rate = MIN_SWAP_FEE + (MAX_SWAP_FEE - MIN_SWAP_FEE) * math.exp(
|
|
-SWAP_LAMBDA * float(balance.onchain_fraction)
|
|
)
|
|
|
|
return swap_fee_rate * 100
|
|
|
|
@classmethod
|
|
def create_onchain_payment(cls, order, user, preliminary_amount):
|
|
"""
|
|
Creates an empty OnchainPayment for order.payout_tx.
|
|
It sets the fees to be applied to this order if onchain Swap is used.
|
|
If the user submits a LN invoice instead. The returned OnchainPayment goes unused.
|
|
"""
|
|
# Make sure no invoice payout is attached to order
|
|
order.payout = None
|
|
|
|
# Create onchain_payment
|
|
onchain_payment = OnchainPayment.objects.create(receiver=user)
|
|
|
|
# Compute a safer available onchain liquidity: (confirmed_utxos - reserve - pending_outgoing_txs))
|
|
# Accounts for already committed outgoing TX for previous users.
|
|
confirmed = onchain_payment.balance.onchain_confirmed
|
|
reserve = 0.01 * onchain_payment.balance.total # We assume a reserve of 1%
|
|
pending_txs = OnchainPayment.objects.filter(
|
|
status=OnchainPayment.Status.VALID
|
|
).aggregate(Sum("num_satoshis"))["num_satoshis__sum"]
|
|
|
|
if pending_txs is None:
|
|
pending_txs = 0
|
|
|
|
available_onchain = confirmed - reserve - pending_txs
|
|
if (
|
|
preliminary_amount > available_onchain
|
|
): # Not enough onchain balance to commit for this swap.
|
|
return False
|
|
|
|
suggested_mining_fee_rate = LNNode.estimate_fee(amount_sats=preliminary_amount)[
|
|
"mining_fee_rate"
|
|
]
|
|
|
|
# Hardcap mining fee suggested at 50 sats/vbyte
|
|
if suggested_mining_fee_rate > 50:
|
|
suggested_mining_fee_rate = 50
|
|
|
|
onchain_payment.suggested_mining_fee_rate = max(
|
|
1.05, LNNode.estimate_fee(amount_sats=preliminary_amount)["mining_fee_rate"]
|
|
)
|
|
onchain_payment.swap_fee_rate = cls.compute_swap_fee_rate(
|
|
onchain_payment.balance
|
|
)
|
|
onchain_payment.save()
|
|
|
|
order.payout_tx = onchain_payment
|
|
order.save()
|
|
return True
|
|
|
|
@classmethod
|
|
def payout_amount(cls, order, user):
|
|
"""Computes buyer invoice amount. Uses order.last_satoshis,
|
|
that is the final trade amount set at Taker Bond time
|
|
Adds context for onchain swap.
|
|
"""
|
|
if not cls.is_buyer(order, user):
|
|
return False, None
|
|
|
|
if user == order.maker:
|
|
fee_fraction = FEE * MAKER_FEE_SPLIT
|
|
elif user == order.taker:
|
|
fee_fraction = FEE * (1 - MAKER_FEE_SPLIT)
|
|
|
|
fee_sats = order.last_satoshis * fee_fraction
|
|
|
|
reward_tip = int(config("REWARD_TIP")) if user.profile.is_referred else 0
|
|
|
|
context = {}
|
|
# context necessary for the user to submit a LN invoice
|
|
context["invoice_amount"] = round(
|
|
order.last_satoshis - fee_sats - reward_tip
|
|
) # Trading fee to buyer is charged here.
|
|
|
|
# context necessary for the user to submit an onchain address
|
|
MIN_SWAP_AMOUNT = int(config("MIN_SWAP_AMOUNT"))
|
|
|
|
if context["invoice_amount"] < MIN_SWAP_AMOUNT:
|
|
context["swap_allowed"] = False
|
|
context[
|
|
"swap_failure_reason"
|
|
] = "Order amount is too small to be eligible for a swap"
|
|
return True, context
|
|
|
|
if config("DISABLE_ONCHAIN", cast=bool):
|
|
context["swap_allowed"] = False
|
|
context["swap_failure_reason"] = "On-the-fly submarine swaps are dissabled"
|
|
return True, context
|
|
|
|
if order.payout_tx is None:
|
|
# Creates the OnchainPayment object and checks node balance
|
|
valid = cls.create_onchain_payment(
|
|
order, user, preliminary_amount=context["invoice_amount"]
|
|
)
|
|
if not valid:
|
|
context["swap_allowed"] = False
|
|
context[
|
|
"swap_failure_reason"
|
|
] = "Not enough onchain liquidity available to offer a swap"
|
|
return True, context
|
|
|
|
context["swap_allowed"] = True
|
|
context["suggested_mining_fee_rate"] = order.payout_tx.suggested_mining_fee_rate
|
|
context["swap_fee_rate"] = order.payout_tx.swap_fee_rate
|
|
|
|
return True, context
|
|
|
|
@classmethod
|
|
def escrow_amount(cls, order, user):
|
|
"""Computes escrow invoice amount. Uses order.last_satoshis,
|
|
that is the final trade amount set at Taker Bond time"""
|
|
|
|
if user == order.maker:
|
|
fee_fraction = FEE * MAKER_FEE_SPLIT
|
|
elif user == order.taker:
|
|
fee_fraction = FEE * (1 - MAKER_FEE_SPLIT)
|
|
|
|
fee_sats = order.last_satoshis * fee_fraction
|
|
|
|
reward_tip = int(config("REWARD_TIP")) if user.profile.is_referred else 0
|
|
|
|
if cls.is_seller(order, user):
|
|
escrow_amount = round(
|
|
order.last_satoshis + fee_sats + reward_tip
|
|
) # Trading fee to seller is charged here.
|
|
|
|
return True, {"escrow_amount": escrow_amount}
|
|
|
|
@classmethod
|
|
def update_address(cls, order, user, address, mining_fee_rate):
|
|
|
|
# Empty address?
|
|
if not address:
|
|
return False, {"bad_address": "You submitted an empty invoice"}
|
|
# only the buyer can post a buyer address
|
|
if not cls.is_buyer(order, user):
|
|
return False, {
|
|
"bad_request": "Only the buyer of this order can provide a payout address."
|
|
}
|
|
# not the right time to submit
|
|
if (
|
|
not (
|
|
order.taker_bond.status
|
|
== order.maker_bond.status
|
|
== LNPayment.Status.LOCKED
|
|
)
|
|
and not order.status == Order.Status.FAI
|
|
):
|
|
return False, {"bad_request": "You cannot submit an adress are not locked."}
|
|
# not a valid address (does not accept Taproot as of now)
|
|
valid, context = validate_onchain_address(address)
|
|
if not valid:
|
|
return False, context
|
|
|
|
if mining_fee_rate:
|
|
# not a valid mining fee
|
|
if float(mining_fee_rate) < 1:
|
|
return False, {
|
|
"bad_address": "The mining fee is too low, must be higher than 1 Sat/vbyte"
|
|
}
|
|
elif float(mining_fee_rate) > 50:
|
|
return False, {
|
|
"bad_address": "The mining fee is too high, must be less than 50 Sats/vbyte"
|
|
}
|
|
order.payout_tx.mining_fee_rate = float(mining_fee_rate)
|
|
# If not mining ee provider use backend's suggested fee rate
|
|
else:
|
|
order.payout_tx.mining_fee_rate = order.payout_tx.suggested_mining_fee_rate
|
|
|
|
tx = order.payout_tx
|
|
tx.address = address
|
|
tx.mining_fee_sats = int(tx.mining_fee_rate * 141)
|
|
tx.num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"]
|
|
tx.sent_satoshis = int(
|
|
float(tx.num_satoshis)
|
|
- float(tx.num_satoshis) * float(tx.swap_fee_rate) / 100
|
|
- float(tx.mining_fee_sats)
|
|
)
|
|
tx.status = OnchainPayment.Status.VALID
|
|
tx.save()
|
|
|
|
order.is_swap = True
|
|
order.save()
|
|
|
|
cls.move_state_updated_payout_method(order)
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def update_invoice(cls, order, user, invoice):
|
|
|
|
# Empty invoice?
|
|
if not invoice:
|
|
return False, {"bad_invoice": "You submitted an empty invoice"}
|
|
# only the buyer can post a buyer invoice
|
|
if not cls.is_buyer(order, user):
|
|
return False, {
|
|
"bad_request": "Only the buyer of this order can provide a buyer invoice."
|
|
}
|
|
if not order.taker_bond:
|
|
return False, {"bad_request": "Wait for your order to be taken."}
|
|
if (
|
|
not (
|
|
order.taker_bond.status
|
|
== order.maker_bond.status
|
|
== LNPayment.Status.LOCKED
|
|
)
|
|
and not order.status == Order.Status.FAI
|
|
):
|
|
return False, {
|
|
"bad_request": "You cannot submit a invoice while bonds are not locked."
|
|
}
|
|
if order.status == Order.Status.FAI:
|
|
if order.payout.status != LNPayment.Status.EXPIRE:
|
|
return False, {
|
|
"bad_request": "You cannot submit an invoice only after expiration or 3 failed attempts"
|
|
}
|
|
|
|
# cancel onchain_payout if existing
|
|
cls.cancel_onchain_payment(order)
|
|
|
|
num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"]
|
|
payout = LNNode.validate_ln_invoice(invoice, num_satoshis)
|
|
|
|
if not payout["valid"]:
|
|
return False, payout["context"]
|
|
|
|
order.payout, _ = LNPayment.objects.update_or_create(
|
|
concept=LNPayment.Concepts.PAYBUYER,
|
|
type=LNPayment.Types.NORM,
|
|
sender=User.objects.get(username=ESCROW_USERNAME),
|
|
order_paid_LN=order, # In case this user has other payouts, 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={
|
|
"invoice": invoice,
|
|
"status": LNPayment.Status.VALIDI,
|
|
"num_satoshis": num_satoshis,
|
|
"description": payout["description"],
|
|
"payment_hash": payout["payment_hash"],
|
|
"created_at": payout["created_at"],
|
|
"expires_at": payout["expires_at"],
|
|
},
|
|
)
|
|
|
|
order.is_swap = False
|
|
order.save()
|
|
|
|
cls.move_state_updated_payout_method(order)
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def move_state_updated_payout_method(cls, order):
|
|
# If the order status is 'Waiting for invoice'. Move forward to 'chat'
|
|
if order.status == Order.Status.WFI:
|
|
order.status = Order.Status.CHA
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.CHA)
|
|
)
|
|
send_message.delay(order.id, "fiat_exchange_starts")
|
|
|
|
# If the order status is 'Waiting for both'. Move forward to 'waiting for escrow'
|
|
elif order.status == Order.Status.WF2:
|
|
# If the escrow does not exist, or is not locked move to WFE.
|
|
if order.trade_escrow is None:
|
|
order.status = Order.Status.WFE
|
|
# If the escrow is locked move to Chat.
|
|
elif order.trade_escrow.status == LNPayment.Status.LOCKED:
|
|
order.status = Order.Status.CHA
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.CHA)
|
|
)
|
|
send_message.delay(order.id, "fiat_exchange_starts")
|
|
else:
|
|
order.status = Order.Status.WFE
|
|
|
|
# If the order status is 'Failed Routing'. Retry payment.
|
|
elif order.status == Order.Status.FAI:
|
|
if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash):
|
|
order.status = Order.Status.PAY
|
|
order.payout.status = LNPayment.Status.FLIGHT
|
|
order.payout.routing_attempts = 0
|
|
order.payout.save()
|
|
|
|
order.save()
|
|
return True
|
|
|
|
def add_profile_rating(profile, rating):
|
|
"""adds a new rating to a user profile"""
|
|
|
|
# TODO Unsafe, does not update ratings, it adds more ratings everytime a new rating is clicked.
|
|
profile.total_ratings += 1
|
|
latest_ratings = profile.latest_ratings
|
|
if latest_ratings is None:
|
|
profile.latest_ratings = [rating]
|
|
profile.avg_rating = rating
|
|
|
|
else:
|
|
latest_ratings = ast.literal_eval(latest_ratings)
|
|
latest_ratings.append(rating)
|
|
profile.latest_ratings = latest_ratings
|
|
profile.avg_rating = sum(list(map(int, latest_ratings))) / len(
|
|
latest_ratings
|
|
) # Just an average, but it is a list of strings. Has to be converted to int.
|
|
|
|
profile.save()
|
|
|
|
def is_penalized(user):
|
|
"""Checks if a user that is not participant of orders
|
|
has a limit on taking or making a order"""
|
|
|
|
if user.profile.penalty_expiration:
|
|
if user.profile.penalty_expiration > timezone.now():
|
|
time_out = (user.profile.penalty_expiration - timezone.now()).seconds
|
|
return True, time_out
|
|
|
|
return False, None
|
|
|
|
@classmethod
|
|
def cancel_order(cls, order, user, state=None):
|
|
|
|
# Do not change order status if an is in order
|
|
# any of these status
|
|
do_not_cancel = [
|
|
Order.Status.UCA,
|
|
Order.Status.EXP,
|
|
Order.Status.TLD,
|
|
Order.Status.DIS,
|
|
Order.Status.CCA,
|
|
Order.Status.PAY,
|
|
Order.Status.SUC,
|
|
Order.Status.FAI,
|
|
Order.Status.MLD,
|
|
]
|
|
|
|
if order.status in do_not_cancel:
|
|
return False, {"bad_request": "You cannot cancel this order"}
|
|
|
|
# 1) When maker cancels before bond
|
|
# The order never shows up on the book and order
|
|
# status becomes "cancelled"
|
|
if order.status == Order.Status.WFB and order.maker == user:
|
|
cls.cancel_bond(order.maker_bond)
|
|
order.status = Order.Status.UCA
|
|
order.save()
|
|
return True, None
|
|
|
|
# 2.a) When maker cancels after bond
|
|
#
|
|
# The order dissapears from book and goes to cancelled. If strict, maker is charged the bond
|
|
# to prevent DDOS on the LN node and order book. If not strict, maker is returned
|
|
# the bond (more user friendly).
|
|
elif (
|
|
order.status in [Order.Status.PUB, Order.Status.PAU] and order.maker == user
|
|
):
|
|
# Return the maker bond (Maker gets returned the bond for cancelling public order)
|
|
if cls.return_bond(order.maker_bond):
|
|
order.status = Order.Status.UCA
|
|
order.save()
|
|
send_message.delay(order.id, "public_order_cancelled")
|
|
return True, None
|
|
|
|
# 2.b) When maker cancels after bond and before taker bond is locked
|
|
#
|
|
# The order dissapears from book and goes to cancelled.
|
|
# The bond maker bond is returned.
|
|
elif order.status == Order.Status.TAK and order.maker == user:
|
|
# Return the maker bond (Maker gets returned the bond for cancelling public order)
|
|
if cls.return_bond(order.maker_bond):
|
|
cls.cancel_bond(order.taker_bond)
|
|
order.status = Order.Status.UCA
|
|
order.save()
|
|
send_message.delay(order.id, "public_order_cancelled")
|
|
return True, None
|
|
|
|
# 3) When taker cancels before bond
|
|
# The order goes back to the book as public.
|
|
# LNPayment "order.taker_bond" is deleted()
|
|
elif order.status == Order.Status.TAK and order.taker == user:
|
|
# adds a timeout penalty
|
|
cls.cancel_bond(order.taker_bond)
|
|
cls.kick_taker(order)
|
|
return True, None
|
|
|
|
# 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.
|
|
|
|
# 4.a) When maker cancel after bond (before escrow)
|
|
# The order into cancelled status if maker cancels.
|
|
elif (
|
|
order.status in [Order.Status.WF2, Order.Status.WFE] and order.maker == user
|
|
):
|
|
# cancel onchain payment if existing
|
|
cls.cancel_onchain_payment(order)
|
|
# Settle the maker bond (Maker loses the bond for canceling an ongoing trade)
|
|
valid = cls.settle_bond(order.maker_bond)
|
|
cls.return_bond(order.taker_bond) # returns taker bond
|
|
|
|
if valid:
|
|
order.status = Order.Status.UCA
|
|
order.save()
|
|
# Reward taker with part of the maker bond
|
|
cls.add_slashed_rewards(order.maker_bond, order.taker.profile)
|
|
return True, None
|
|
|
|
# 4.b) When taker cancel after bond (before escrow)
|
|
# The order into cancelled status if mtker cancels.
|
|
elif (
|
|
order.status in [Order.Status.WF2, Order.Status.WFE] and order.taker == user
|
|
):
|
|
# cancel onchain payment if existing
|
|
cls.cancel_onchain_payment(order)
|
|
# Settle the maker bond (Maker loses the bond for canceling an ongoing trade)
|
|
valid = cls.settle_bond(order.taker_bond)
|
|
if valid:
|
|
order.taker = None
|
|
order.payout = None
|
|
order.trade_escrow = None
|
|
cls.publish_order(order)
|
|
send_message.delay(order.id, "order_published")
|
|
# Reward maker with part of the taker bond
|
|
cls.add_slashed_rewards(order.taker_bond, order.maker.profile)
|
|
return True, None
|
|
|
|
# 5) When trade collateral has been posted (after escrow)
|
|
#
|
|
# Always goes to CCA status. Collaboration is needed.
|
|
# When a user asks for cancel, 'order.m/t/aker_asked_cancel' goes True.
|
|
# When the second user asks for cancel. Order is totally cancelled.
|
|
# Must have a small cost for both parties to prevent node DDOS.
|
|
elif order.status in [Order.Status.WFI, Order.Status.CHA]:
|
|
|
|
# if the maker had asked, and now the taker does: cancel order, return everything
|
|
if order.maker_asked_cancel and user == order.taker:
|
|
cls.collaborative_cancel(order)
|
|
return True, None
|
|
|
|
# if the taker had asked, and now the maker does: cancel order, return everything
|
|
elif order.taker_asked_cancel and user == order.maker:
|
|
cls.collaborative_cancel(order)
|
|
return True, None
|
|
|
|
# Otherwise just make true the asked for cancel flags
|
|
elif user == order.taker:
|
|
order.taker_asked_cancel = True
|
|
order.save()
|
|
return True, None
|
|
|
|
elif user == order.maker:
|
|
order.maker_asked_cancel = True
|
|
order.save()
|
|
return True, None
|
|
|
|
else:
|
|
return False, {"bad_request": "You cannot cancel this order"}
|
|
|
|
@classmethod
|
|
def collaborative_cancel(cls, order):
|
|
if order.status not in [Order.Status.WFI, Order.Status.CHA]:
|
|
return
|
|
# cancel onchain payment if existing
|
|
cls.cancel_onchain_payment(order)
|
|
cls.return_bond(order.maker_bond)
|
|
cls.return_bond(order.taker_bond)
|
|
cls.return_escrow(order)
|
|
order.status = Order.Status.CCA
|
|
order.save()
|
|
send_message.delay(order.id, "collaborative_cancelled")
|
|
return
|
|
|
|
@classmethod
|
|
def publish_order(cls, order):
|
|
order.status = Order.Status.PUB
|
|
order.expires_at = order.created_at + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.PUB)
|
|
)
|
|
if order.has_range:
|
|
order.amount = None
|
|
order.last_satoshis = cls.satoshis_now(order)
|
|
order.last_satoshis_time = timezone.now()
|
|
order.save()
|
|
# send_message.delay(order.id,'order_published') # too spammy
|
|
return
|
|
|
|
def compute_cltv_expiry_blocks(order, invoice_concept):
|
|
"""Computes timelock CLTV expiry of the last hop in blocks for hodl invoices
|
|
|
|
invoice_concepts (str): maker_bond, taker_bond, trade_escrow
|
|
"""
|
|
# Every invoice_concept must be locked by at least the fiat exchange duration
|
|
# Every invoice must also be locked for deposit_time (order.escrow_duration or WFE status)
|
|
cltv_expiry_secs = order.t_to_expire(Order.Status.CHA)
|
|
cltv_expiry_secs += order.t_to_expire(Order.Status.WFE)
|
|
|
|
# Maker bond must also be locked for the full public duration plus the taker bond locking time
|
|
if invoice_concept == "maker_bond":
|
|
cltv_expiry_secs += order.t_to_expire(Order.Status.PUB)
|
|
cltv_expiry_secs += order.t_to_expire(Order.Status.TAK)
|
|
|
|
# Add a safety marging by multiplying by the maxium expected mining network speed up
|
|
safe_cltv_expiry_secs = cltv_expiry_secs * MAX_MINING_NETWORK_SPEEDUP_EXPECTED
|
|
# Convert to blocks using assummed average block time (~8 mins/block)
|
|
cltv_expiry_blocks = int(safe_cltv_expiry_secs / (BLOCK_TIME * 60))
|
|
print(
|
|
invoice_concept,
|
|
" cltv_expiry_hours:",
|
|
cltv_expiry_secs / 3600,
|
|
" cltv_expiry_blocks:",
|
|
cltv_expiry_blocks,
|
|
)
|
|
|
|
return cltv_expiry_blocks
|
|
|
|
@classmethod
|
|
def is_maker_bond_locked(cls, order):
|
|
if order.maker_bond.status == LNPayment.Status.LOCKED:
|
|
return True
|
|
elif LNNode.validate_hold_invoice_locked(order.maker_bond):
|
|
cls.publish_order(order)
|
|
send_message.delay(order.id, "order_published")
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def gen_maker_hold_invoice(cls, order, user):
|
|
|
|
# Do not gen and cancel if order is older than expiry time
|
|
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 cls.is_maker_bond_locked(order):
|
|
return False, None
|
|
elif order.maker_bond.status == LNPayment.Status.INVGEN:
|
|
return True, {
|
|
"bond_invoice": order.maker_bond.invoice,
|
|
"bond_satoshis": order.maker_bond.num_satoshis,
|
|
}
|
|
|
|
# If there was no maker_bond object yet, generates one
|
|
order.last_satoshis = cls.satoshis_now(order)
|
|
order.last_satoshis_time = timezone.now()
|
|
bond_satoshis = int(order.last_satoshis * order.bond_size / 100)
|
|
|
|
if user.profile.wants_stealth:
|
|
description = f"This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally. Payment reference: {order.reference}"
|
|
else:
|
|
description = f"RoboSats - Publishing '{str(order)}' - Maker bond - This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally."
|
|
|
|
# Gen hold Invoice
|
|
try:
|
|
hold_payment = LNNode.gen_hold_invoice(
|
|
bond_satoshis,
|
|
description,
|
|
invoice_expiry=order.t_to_expire(Order.Status.WFB),
|
|
cltv_expiry_blocks=cls.compute_cltv_expiry_blocks(order, "maker_bond"),
|
|
)
|
|
except Exception as e:
|
|
print(str(e))
|
|
if "failed to connect to all addresses" in str(e):
|
|
return False, {
|
|
"bad_request": "The Lightning Network Daemon (LND) is down. Write in the Telegram group to make sure the staff is aware."
|
|
}
|
|
elif "wallet locked" in str(e):
|
|
return False, {
|
|
"bad_request": "This is weird, RoboSats' lightning wallet is locked. Check in the Telegram group, maybe the staff has died."
|
|
}
|
|
|
|
order.maker_bond = LNPayment.objects.create(
|
|
concept=LNPayment.Concepts.MAKEBOND,
|
|
type=LNPayment.Types.HOLD,
|
|
sender=user,
|
|
receiver=User.objects.get(username=ESCROW_USERNAME),
|
|
invoice=hold_payment["invoice"],
|
|
preimage=hold_payment["preimage"],
|
|
status=LNPayment.Status.INVGEN,
|
|
num_satoshis=bond_satoshis,
|
|
description=description,
|
|
payment_hash=hold_payment["payment_hash"],
|
|
created_at=hold_payment["created_at"],
|
|
expires_at=hold_payment["expires_at"],
|
|
cltv_expiry=hold_payment["cltv_expiry"],
|
|
)
|
|
|
|
order.save()
|
|
return True, {
|
|
"bond_invoice": hold_payment["invoice"],
|
|
"bond_satoshis": bond_satoshis,
|
|
}
|
|
|
|
@classmethod
|
|
def finalize_contract(cls, order):
|
|
"""When the taker locks the taker_bond
|
|
the contract is final"""
|
|
|
|
# THE TRADE AMOUNT IS FINAL WITH THE CONFIRMATION OF THE TAKER BOND!
|
|
# (This is the last update to "last_satoshis", it becomes the escrow amount next)
|
|
order.last_satoshis = cls.satoshis_now(order)
|
|
order.last_satoshis_time = timezone.now()
|
|
order.taker_bond.status = LNPayment.Status.LOCKED
|
|
order.taker_bond.save()
|
|
|
|
# With the bond confirmation the order is extended 'public_order_duration' hours
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.WF2)
|
|
)
|
|
order.status = Order.Status.WF2
|
|
order.save()
|
|
|
|
# Both users profiles are added one more contract // Unsafe can add more than once.
|
|
order.maker.profile.total_contracts += 1
|
|
order.taker.profile.total_contracts += 1
|
|
order.maker.profile.save()
|
|
order.taker.profile.save()
|
|
|
|
# Log a market tick
|
|
try:
|
|
MarketTick.log_a_tick(order)
|
|
except Exception:
|
|
pass
|
|
send_message.delay(order.id, "order_taken_confirmed")
|
|
return True
|
|
|
|
@classmethod
|
|
def is_taker_bond_locked(cls, order):
|
|
if order.taker_bond.status == LNPayment.Status.LOCKED:
|
|
return True
|
|
elif LNNode.validate_hold_invoice_locked(order.taker_bond):
|
|
cls.finalize_contract(order)
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def gen_taker_hold_invoice(cls, order, user):
|
|
|
|
# Do not gen and kick out the taker if order is older than expiry time
|
|
if order.expires_at < timezone.now():
|
|
cls.order_expires(order)
|
|
return False, {
|
|
"bad_request": "Invoice expired. You did not confirm taking the order in time."
|
|
}
|
|
|
|
# Do not gen if a taker invoice exist. Do not return if it is already locked. Return the old one if still waiting.
|
|
if order.taker_bond:
|
|
if cls.is_taker_bond_locked(order):
|
|
return False, None
|
|
elif order.taker_bond.status == LNPayment.Status.INVGEN:
|
|
return True, {
|
|
"bond_invoice": order.taker_bond.invoice,
|
|
"bond_satoshis": order.taker_bond.num_satoshis,
|
|
}
|
|
|
|
# If there was no taker_bond object yet, generates one
|
|
order.last_satoshis = cls.satoshis_now(order)
|
|
order.last_satoshis_time = timezone.now()
|
|
bond_satoshis = int(order.last_satoshis * order.bond_size / 100)
|
|
pos_text = "Buying" if cls.is_buyer(order, user) else "Selling"
|
|
if user.profile.wants_stealth:
|
|
description = f"This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally. Payment reference: {order.reference}"
|
|
else:
|
|
description = (
|
|
f"RoboSats - Taking 'Order {order.id}' {pos_text} BTC for {str(float(order.amount)) + Currency.currency_dict[str(order.currency.currency)]}"
|
|
+ " - Taker bond - This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally."
|
|
)
|
|
|
|
# Gen hold Invoice
|
|
try:
|
|
hold_payment = LNNode.gen_hold_invoice(
|
|
bond_satoshis,
|
|
description,
|
|
invoice_expiry=order.t_to_expire(Order.Status.TAK),
|
|
cltv_expiry_blocks=cls.compute_cltv_expiry_blocks(order, "taker_bond"),
|
|
)
|
|
|
|
except Exception as e:
|
|
if "status = StatusCode.UNAVAILABLE" in str(e):
|
|
return False, {
|
|
"bad_request": "The Lightning Network Daemon (LND) is down. Write in the Telegram group to make sure the staff is aware."
|
|
}
|
|
|
|
order.taker_bond = LNPayment.objects.create(
|
|
concept=LNPayment.Concepts.TAKEBOND,
|
|
type=LNPayment.Types.HOLD,
|
|
sender=user,
|
|
receiver=User.objects.get(username=ESCROW_USERNAME),
|
|
invoice=hold_payment["invoice"],
|
|
preimage=hold_payment["preimage"],
|
|
status=LNPayment.Status.INVGEN,
|
|
num_satoshis=bond_satoshis,
|
|
description=description,
|
|
payment_hash=hold_payment["payment_hash"],
|
|
created_at=hold_payment["created_at"],
|
|
expires_at=hold_payment["expires_at"],
|
|
cltv_expiry=hold_payment["cltv_expiry"],
|
|
)
|
|
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.TAK)
|
|
)
|
|
order.save()
|
|
return True, {
|
|
"bond_invoice": hold_payment["invoice"],
|
|
"bond_satoshis": bond_satoshis,
|
|
}
|
|
|
|
def trade_escrow_received(order):
|
|
"""Moves the order forward"""
|
|
# If status is 'Waiting for both' move to Waiting for invoice
|
|
if order.status == Order.Status.WF2:
|
|
order.status = Order.Status.WFI
|
|
# If status is 'Waiting for invoice' move to Chat
|
|
elif order.status == Order.Status.WFE:
|
|
order.status = Order.Status.CHA
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.CHA)
|
|
)
|
|
send_message.delay(order.id, "fiat_exchange_starts")
|
|
order.save()
|
|
|
|
@classmethod
|
|
def is_trade_escrow_locked(cls, order):
|
|
if order.trade_escrow.status == LNPayment.Status.LOCKED:
|
|
cls.trade_escrow_received(order)
|
|
return True
|
|
elif LNNode.validate_hold_invoice_locked(order.trade_escrow):
|
|
cls.trade_escrow_received(order)
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def gen_escrow_hold_invoice(cls, order, user):
|
|
|
|
# Do not generate if escrow deposit time has expired
|
|
if order.expires_at < timezone.now():
|
|
cls.order_expires(order)
|
|
return False, {
|
|
"bad_request": "Invoice expired. You did not send the escrow in time."
|
|
}
|
|
|
|
# Do not gen if an escrow invoice exist. Do not return if it is already locked. Return the old one if still waiting.
|
|
if order.trade_escrow:
|
|
# Check if status is INVGEN and still not expired
|
|
if cls.is_trade_escrow_locked(order):
|
|
return False, None
|
|
elif order.trade_escrow.status == LNPayment.Status.INVGEN:
|
|
return True, {
|
|
"escrow_invoice": order.trade_escrow.invoice,
|
|
"escrow_satoshis": order.trade_escrow.num_satoshis,
|
|
}
|
|
|
|
# If there was no taker_bond object yet, generate one
|
|
escrow_satoshis = cls.escrow_amount(order, user)[1][
|
|
"escrow_amount"
|
|
] # Amount was fixed when taker bond was locked, fee applied here
|
|
if user.profile.wants_stealth:
|
|
description = f"This payment WILL FREEZE IN YOUR WALLET, check on the website if it was successful. It will automatically return unless you cheat or cancel unilaterally. Payment reference: {order.reference}"
|
|
else:
|
|
description = f"RoboSats - Escrow amount for '{str(order)}' - It WILL FREEZE IN YOUR WALLET. It will be released to the buyer once you confirm you received the fiat. It will automatically return if buyer does not confirm the payment."
|
|
|
|
# Gen hold Invoice
|
|
try:
|
|
hold_payment = LNNode.gen_hold_invoice(
|
|
escrow_satoshis,
|
|
description,
|
|
invoice_expiry=order.t_to_expire(Order.Status.WF2),
|
|
cltv_expiry_blocks=cls.compute_cltv_expiry_blocks(
|
|
order, "trade_escrow"
|
|
),
|
|
)
|
|
|
|
except Exception as e:
|
|
if "status = StatusCode.UNAVAILABLE" in str(e):
|
|
return False, {
|
|
"bad_request": "The Lightning Network Daemon (LND) is down. Write in the Telegram group to make sure the staff is aware."
|
|
}
|
|
|
|
order.trade_escrow = LNPayment.objects.create(
|
|
concept=LNPayment.Concepts.TRESCROW,
|
|
type=LNPayment.Types.HOLD,
|
|
sender=user,
|
|
receiver=User.objects.get(username=ESCROW_USERNAME),
|
|
invoice=hold_payment["invoice"],
|
|
preimage=hold_payment["preimage"],
|
|
status=LNPayment.Status.INVGEN,
|
|
num_satoshis=escrow_satoshis,
|
|
description=description,
|
|
payment_hash=hold_payment["payment_hash"],
|
|
created_at=hold_payment["created_at"],
|
|
expires_at=hold_payment["expires_at"],
|
|
cltv_expiry=hold_payment["cltv_expiry"],
|
|
)
|
|
|
|
order.save()
|
|
return True, {
|
|
"escrow_invoice": hold_payment["invoice"],
|
|
"escrow_satoshis": escrow_satoshis,
|
|
}
|
|
|
|
def settle_escrow(order):
|
|
"""Settles the trade escrow hold invoice"""
|
|
if LNNode.settle_hold_invoice(order.trade_escrow.preimage):
|
|
order.trade_escrow.status = LNPayment.Status.SETLED
|
|
order.trade_escrow.save()
|
|
return True
|
|
|
|
def settle_bond(bond):
|
|
"""Settles the bond hold invoice"""
|
|
if LNNode.settle_hold_invoice(bond.preimage):
|
|
bond.status = LNPayment.Status.SETLED
|
|
bond.save()
|
|
return True
|
|
|
|
def return_escrow(order):
|
|
"""returns the trade escrow"""
|
|
if LNNode.cancel_return_hold_invoice(order.trade_escrow.payment_hash):
|
|
order.trade_escrow.status = LNPayment.Status.RETNED
|
|
order.trade_escrow.save()
|
|
return True
|
|
|
|
def cancel_escrow(order):
|
|
"""returns the trade escrow"""
|
|
# Same as return escrow, but used when the invoice was never LOCKED
|
|
if LNNode.cancel_return_hold_invoice(order.trade_escrow.payment_hash):
|
|
order.trade_escrow.status = LNPayment.Status.CANCEL
|
|
order.trade_escrow.save()
|
|
return True
|
|
|
|
def return_bond(bond):
|
|
"""returns a bond"""
|
|
if bond is None:
|
|
return
|
|
try:
|
|
LNNode.cancel_return_hold_invoice(bond.payment_hash)
|
|
bond.status = LNPayment.Status.RETNED
|
|
bond.save()
|
|
return True
|
|
except Exception as e:
|
|
if "invoice already settled" in str(e):
|
|
bond.status = LNPayment.Status.SETLED
|
|
bond.save()
|
|
return True
|
|
else:
|
|
raise e
|
|
|
|
def cancel_onchain_payment(order):
|
|
"""Cancel onchain_payment if existing"""
|
|
|
|
if order.payout_tx:
|
|
order.payout_tx.status = OnchainPayment.Status.CANCE
|
|
order.payout_tx.save()
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def cancel_bond(bond):
|
|
"""cancel a bond"""
|
|
# Same as return bond, but used when the invoice was never LOCKED
|
|
if bond is None:
|
|
return True
|
|
try:
|
|
LNNode.cancel_return_hold_invoice(bond.payment_hash)
|
|
bond.status = LNPayment.Status.CANCEL
|
|
bond.save()
|
|
return True
|
|
except Exception as e:
|
|
if "invoice already settled" in str(e):
|
|
bond.status = LNPayment.Status.SETLED
|
|
bond.save()
|
|
return True
|
|
else:
|
|
raise e
|
|
|
|
@classmethod
|
|
def pay_buyer(cls, order):
|
|
"""Pays buyer invoice or onchain address"""
|
|
|
|
# Pay to buyer invoice
|
|
if not order.is_swap:
|
|
# Background process "follow_invoices" will try to pay this invoice until success
|
|
order.status = Order.Status.PAY
|
|
order.payout.status = LNPayment.Status.FLIGHT
|
|
order.payout.save()
|
|
order.save()
|
|
send_message.delay(order.id, "trade_successful")
|
|
order.contract_finalization_time = timezone.now()
|
|
order.save()
|
|
return True
|
|
|
|
# Pay onchain to address
|
|
else:
|
|
if not order.payout_tx.status == OnchainPayment.Status.VALID:
|
|
return False
|
|
|
|
valid = LNNode.pay_onchain(
|
|
order.payout_tx,
|
|
valid_code=OnchainPayment.Status.VALID,
|
|
on_mempool_code=OnchainPayment.Status.MEMPO,
|
|
)
|
|
if valid:
|
|
order.status = Order.Status.SUC
|
|
order.save()
|
|
send_message.delay(order.id, "trade_successful")
|
|
order.contract_finalization_time = timezone.now()
|
|
order.save()
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def confirm_fiat(cls, order, user):
|
|
"""If Order is in the CHAT states:
|
|
If user is buyer: fiat_sent goes to true.
|
|
If User is seller and fiat_sent is true: settle the escrow and pay buyer invoice!"""
|
|
|
|
if order.status == Order.Status.CHA or order.status == Order.Status.FSE:
|
|
|
|
# If buyer, settle escrow and mark fiat sent
|
|
if cls.is_buyer(order, user):
|
|
order.status = Order.Status.FSE
|
|
order.is_fiat_sent = True
|
|
|
|
# If seller and fiat was sent, SETTLE ESCROW AND 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."
|
|
}
|
|
|
|
# Make sure the trade escrow is at least as big as the buyer invoice
|
|
num_satoshis = (
|
|
order.payout_tx.num_satoshis
|
|
if order.is_swap
|
|
else order.payout.num_satoshis
|
|
)
|
|
if order.trade_escrow.num_satoshis <= num_satoshis:
|
|
return False, {
|
|
"bad_request": "Woah, something broke badly. Report in the public channels, or open a Github Issue."
|
|
}
|
|
|
|
# !!! KEY LINE - SETTLES THE TRADE ESCROW !!!
|
|
if cls.settle_escrow(order):
|
|
order.trade_escrow.status = LNPayment.Status.SETLED
|
|
|
|
# Double check the escrow is settled.
|
|
if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash):
|
|
# RETURN THE BONDS
|
|
cls.return_bond(order.taker_bond)
|
|
cls.return_bond(order.maker_bond)
|
|
# !!! KEY LINE - PAYS THE BUYER INVOICE !!!
|
|
cls.pay_buyer(order)
|
|
|
|
# Add referral rewards (safe)
|
|
try:
|
|
cls.add_rewards(order)
|
|
except Exception:
|
|
pass
|
|
|
|
return True, None
|
|
|
|
else:
|
|
return False, {
|
|
"bad_request": "You cannot confirm the fiat payment at this stage"
|
|
}
|
|
|
|
order.save()
|
|
return True, None
|
|
|
|
def pause_unpause_public_order(order, user):
|
|
if not order.maker == user:
|
|
return False, {
|
|
"bad_request": "You cannot pause or unpause an order you did not make"
|
|
}
|
|
else:
|
|
if order.status == Order.Status.PUB:
|
|
order.status = Order.Status.PAU
|
|
elif order.status == Order.Status.PAU:
|
|
order.status = Order.Status.PUB
|
|
else:
|
|
return False, {
|
|
"bad_request": "You can only pause/unpause an order that is either public or paused"
|
|
}
|
|
order.save()
|
|
return True, None
|
|
|
|
@classmethod
|
|
def rate_counterparty(cls, order, user, rating):
|
|
"""
|
|
Not in use
|
|
"""
|
|
|
|
rating_allowed_status = [
|
|
Order.Status.PAY,
|
|
Order.Status.SUC,
|
|
Order.Status.FAI,
|
|
Order.Status.MLD,
|
|
Order.Status.TLD,
|
|
]
|
|
|
|
# If the trade is finished
|
|
if order.status in rating_allowed_status:
|
|
# if maker, rates taker
|
|
if order.maker == user and order.maker_rated is False:
|
|
cls.add_profile_rating(order.taker.profile, rating)
|
|
order.maker_rated = True
|
|
order.save()
|
|
# if taker, rates maker
|
|
if order.taker == user and order.taker_rated is False:
|
|
cls.add_profile_rating(order.maker.profile, rating)
|
|
order.taker_rated = True
|
|
order.save()
|
|
else:
|
|
return False, {"bad_request": "You cannot rate your counterparty yet."}
|
|
|
|
return True, None
|
|
|
|
@classmethod
|
|
def rate_platform(cls, user, rating):
|
|
user.profile.platform_rating = rating
|
|
user.profile.save()
|
|
return True, None
|
|
|
|
@classmethod
|
|
def add_rewards(cls, order):
|
|
"""
|
|
This function is called when a trade is finished.
|
|
If participants of the order were referred, the reward is given to the referees.
|
|
"""
|
|
|
|
if order.maker.profile.is_referred:
|
|
profile = order.maker.profile.referred_by
|
|
profile.pending_rewards += int(config("REWARD_TIP"))
|
|
profile.save()
|
|
|
|
if order.taker.profile.is_referred:
|
|
profile = order.taker.profile.referred_by
|
|
profile.pending_rewards += int(config("REWARD_TIP"))
|
|
profile.save()
|
|
|
|
return
|
|
|
|
@classmethod
|
|
def add_slashed_rewards(cls, bond, profile):
|
|
"""
|
|
When a bond is slashed due to overtime, rewards the user that was waiting.
|
|
If participants of the order were referred, the reward is given to the referees.
|
|
"""
|
|
reward_fraction = float(config("SLASHED_BOND_REWARD_SPLIT"))
|
|
reward = int(bond.num_satoshis * reward_fraction)
|
|
profile.earned_rewards += reward
|
|
profile.save()
|
|
|
|
return
|
|
|
|
@classmethod
|
|
def withdraw_rewards(cls, user, invoice):
|
|
|
|
# only a user with positive withdraw balance can use this
|
|
|
|
if user.profile.earned_rewards < 1:
|
|
return False, {"bad_invoice": "You have not earned rewards"}
|
|
|
|
num_satoshis = user.profile.earned_rewards
|
|
|
|
reward_payout = LNNode.validate_ln_invoice(invoice, num_satoshis)
|
|
|
|
if not reward_payout["valid"]:
|
|
return False, reward_payout["context"]
|
|
|
|
try:
|
|
lnpayment = LNPayment.objects.create(
|
|
concept=LNPayment.Concepts.WITHREWA,
|
|
type=LNPayment.Types.NORM,
|
|
sender=User.objects.get(username=ESCROW_USERNAME),
|
|
status=LNPayment.Status.VALIDI,
|
|
receiver=user,
|
|
invoice=invoice,
|
|
num_satoshis=num_satoshis,
|
|
description=reward_payout["description"],
|
|
payment_hash=reward_payout["payment_hash"],
|
|
created_at=reward_payout["created_at"],
|
|
expires_at=reward_payout["expires_at"],
|
|
)
|
|
# Might fail if payment_hash already exists in DB
|
|
except Exception:
|
|
return False, {"bad_invoice": "Give me a new invoice"}
|
|
|
|
user.profile.earned_rewards = 0
|
|
user.profile.save()
|
|
|
|
# Pays the invoice.
|
|
paid, failure_reason = LNNode.pay_invoice(lnpayment)
|
|
if paid:
|
|
user.profile.earned_rewards = 0
|
|
user.profile.claimed_rewards += num_satoshis
|
|
user.profile.save()
|
|
return True, None
|
|
|
|
# If fails, adds the rewards again.
|
|
else:
|
|
user.profile.earned_rewards = num_satoshis
|
|
user.profile.save()
|
|
context = {}
|
|
context["bad_invoice"] = failure_reason
|
|
return False, context
|
|
|
|
@classmethod
|
|
def summarize_trade(cls, order, user):
|
|
"""
|
|
Summarizes a finished order. Returns a dict with
|
|
amounts, fees, costs, etc, for buyer and seller.
|
|
"""
|
|
if order.status not in [Order.Status.SUC, Order.Status.PAY, Order.Status.FAI]:
|
|
return False, {"bad_summary": "Order has not finished yet"}
|
|
|
|
context = {}
|
|
|
|
users = {"taker": order.taker, "maker": order.maker}
|
|
for order_user in users:
|
|
|
|
summary = {}
|
|
summary["trade_fee_percent"] = (
|
|
FEE * MAKER_FEE_SPLIT
|
|
if order_user == "maker"
|
|
else FEE * (1 - MAKER_FEE_SPLIT)
|
|
)
|
|
summary["bond_size_sats"] = (
|
|
order.maker_bond.num_satoshis
|
|
if order_user == "maker"
|
|
else order.taker_bond.num_satoshis
|
|
)
|
|
summary["bond_size_percent"] = order.bond_size
|
|
summary["is_buyer"] = cls.is_buyer(order, users[order_user])
|
|
|
|
if summary["is_buyer"]:
|
|
summary["sent_fiat"] = order.amount
|
|
if order.is_swap:
|
|
summary["received_sats"] = order.payout_tx.sent_satoshis
|
|
else:
|
|
summary["received_sats"] = order.payout.num_satoshis
|
|
summary["trade_fee_sats"] = round(
|
|
order.last_satoshis - summary["received_sats"]
|
|
)
|
|
# Only add context for swap costs if the user is the swap recipient. Peer should not know whether it was a swap
|
|
if users[order_user] == user and order.is_swap:
|
|
summary["is_swap"] = order.is_swap
|
|
summary["received_onchain_sats"] = order.payout_tx.sent_satoshis
|
|
summary["mining_fee_sats"] = order.payout_tx.mining_fee_sats
|
|
summary["swap_fee_sats"] = round(
|
|
order.payout_tx.num_satoshis
|
|
- order.payout_tx.mining_fee_sats
|
|
- order.payout_tx.sent_satoshis
|
|
)
|
|
summary["swap_fee_percent"] = order.payout_tx.swap_fee_rate
|
|
summary["trade_fee_sats"] = round(
|
|
order.last_satoshis
|
|
- summary["received_sats"]
|
|
- summary["mining_fee_sats"]
|
|
- summary["swap_fee_sats"]
|
|
)
|
|
else:
|
|
summary["sent_sats"] = order.trade_escrow.num_satoshis
|
|
summary["received_fiat"] = order.amount
|
|
summary["trade_fee_sats"] = round(
|
|
summary["sent_sats"] - order.last_satoshis
|
|
)
|
|
context[f"{order_user}_summary"] = summary
|
|
|
|
platform_summary = {}
|
|
platform_summary["contract_exchange_rate"] = float(order.amount) / (
|
|
float(order.last_satoshis) / 100000000
|
|
)
|
|
if order.last_satoshis_time is not None:
|
|
platform_summary["contract_timestamp"] = order.last_satoshis_time
|
|
platform_summary["contract_total_time"] = (
|
|
order.contract_finalization_time - order.last_satoshis_time
|
|
)
|
|
if not order.is_swap:
|
|
platform_summary["routing_fee_sats"] = order.payout.fee
|
|
platform_summary["trade_revenue_sats"] = int(
|
|
order.trade_escrow.num_satoshis
|
|
- order.payout.num_satoshis
|
|
- order.payout.fee
|
|
)
|
|
else:
|
|
platform_summary["routing_fee_sats"] = 0
|
|
platform_summary["trade_revenue_sats"] = int(
|
|
order.trade_escrow.num_satoshis - order.payout_tx.num_satoshis
|
|
)
|
|
context["platform_summary"] = platform_summary
|
|
|
|
return True, context
|