diff --git a/.env-sample b/.env-sample index f77a1df9..ee4b7a66 100644 --- a/.env-sample +++ b/.env-sample @@ -102,18 +102,20 @@ REWARDS_TIMEOUT_SECONDS = 60 PAYOUT_TIMEOUT_SECONDS = 60 # REVERSE SUBMARINE SWAP PAYOUTS -# 4 parameters needed, min/max change and min/max balance points. E.g. If 25% or more of liquidity +# Shape of fee to available liquidity curve. Either "linear" or "exponential" +SWAP_FEE_SHAPE = 'exponential' +# EXPONENTIAL. fee (%) = MIN_SWAP_FEE + (MAX_SWAP_FEE - MIN_SWAP_FEE) * e ^ (-LAMBDA * onchain_liquidity_fraction) +SWAP_LAMBDA = 8.8 +# LINEAR. 4 parameters needed: min/max fees and min/max balance points. E.g. If 25% or more of liquidity # is onchain the fee for swap is 2% (minimum), if it is 12% fee is 6%, and for 0% fee is 10%. -# Minimum swap fee as fraction (2%) -MIN_SWAP_FEE = 0.02 +# Minimum swap fee as fraction (1%) +MIN_SWAP_FEE = 0.01 # Liquidity split point (LN/onchain) at which we use MIN_SWAP_FEE -MIN_SWAP_POINT = 0.25 +MIN_SWAP_POINT = 0.35 # Maximum swap fee as fraction (~10%) MAX_SWAP_FEE = 0.1 # Liquidity split point (LN/onchain) at which we use MAX_SWAP_FEE MAX_SWAP_POINT = 0 -# Shape of fee to available liquidity curve. Only 'linear' implemented. -SWAP_FEE_SHAPE = 'linear' # Min amount allowed for Swap MIN_SWAP_AMOUNT = 50000 diff --git a/Dockerfile b/Dockerfile index 52c08374..255d80a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ FROM python:3.10.2-bullseye +ARG DEBIAN_FRONTEND=noninteractive RUN mkdir -p /usr/src/robosats # specifying the working dir inside the container WORKDIR /usr/src/robosats +RUN apt-get update +RUN apt-get install -y postgresql-client + RUN python -m pip install --upgrade pip COPY requirements.txt ./ diff --git a/api/admin.py b/api/admin.py index 8f57ee73..50fd2743 100644 --- a/api/admin.py +++ b/api/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django_admin_relation_links import AdminChangeLinksMixin from django.contrib.auth.models import Group, User from django.contrib.auth.admin import UserAdmin -from api.models import Order, LNPayment, Profile, MarketTick, Currency +from api.models import OnchainPayment, Order, LNPayment, Profile, MarketTick, Currency admin.site.unregister(Group) admin.site.unregister(User) @@ -53,6 +53,7 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): "is_fiat_sent", "created_at", "expires_at", + "payout_tx_link", "payout_link", "maker_bond_link", "taker_bond_link", @@ -63,6 +64,7 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): "maker", "taker", "currency", + "payout_tx", "payout", "maker_bond", "taker_bond", @@ -108,6 +110,25 @@ class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin): ordering = ("-expires_at", ) search_fields = ["payment_hash","num_satoshis","sender__username","receiver__username","description"] +@admin.register(OnchainPayment) +class OnchainPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin): + list_display = ( + "id", + "address", + "concept", + "status", + "num_satoshis", + "hash", + "swap_fee_rate", + "mining_fee_sats", + "balance_link", + ) + change_links = ( + "balance", + ) + list_display_links = ("id","address", "concept") + list_filter = ("concept", "status") + search_fields = ["address","num_satoshis","receiver__username","txid"] @admin.register(Profile) class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin): diff --git a/api/logics.py b/api/logics.py index 5c52ef5b..75880c78 100644 --- a/api/logics.py +++ b/api/logics.py @@ -2,7 +2,7 @@ from datetime import timedelta from tkinter import N from django.utils import timezone from api.lightning.node import LNNode -from django.db.models import Q +from django.db.models import Q, Sum from api.models import OnchainPayment, Order, LNPayment, MarketTick, User, Currency from api.tasks import send_message @@ -495,6 +495,8 @@ class Logics: return True, None def compute_swap_fee_rate(balance): + + shape = str(config('SWAP_FEE_SHAPE')) if shape == "linear": @@ -508,23 +510,40 @@ class Logics: 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 * balance.onchain_fraction) + return swap_fee_rate @classmethod - def create_onchain_payment(cls, order, estimate_sats): + def create_onchain_payment(cls, order, 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. ''' onchain_payment = OnchainPayment.objects.create() - onchain_payment.suggested_mining_fee_rate = LNNode.estimate_fee(amount_sats=estimate_sats) - onchain_payment.swap_fee_rate = cls.compute_swap_fee_rate(onchain_payment.balance) + + # 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'] + + available_onchain = confirmed - reserve - pending_txs + if preliminary_amount > available_onchain: # Not enough onchain balance to commit for this swap. + return False + + onchain_payment.suggested_mining_fee_rate = LNNode.estimate_fee(amount_sats=preliminary_amount) + onchain_payment.swap_fee_rate = cls.compute_swap_fee_rate(onchain_payment.preliminary_amount) onchain_payment.save() order.payout_tx = onchain_payment order.save() - return True, None + return True @classmethod def payout_amount(cls, order, user): @@ -553,10 +572,16 @@ class Logics: 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 order.payout_tx == None: - cls.create_onchain_payment(order, estimate_sats=context["invoice_amount"]) + # Creates the OnchainPayment object and checks node balance + valid, _ = cls.create_onchain_payment(order, preliminary_amount=context["invoice_amount"]) + if not valid: + context["swap_allowed"] = False + context["swap_failure_reason"] = "Not enough onchain liquidity available to offer swaps" + return True, context context["swap_allowed"] = True context["suggested_mining_fee_rate"] = order.payout_tx.suggested_mining_fee_rate diff --git a/api/models.py b/api/models.py index b16ab10c..b9ac9698 100644 --- a/api/models.py +++ b/api/models.py @@ -176,6 +176,11 @@ class OnchainPayment(models.Model): VALID = 1, "Valid" # Valid onchain address submitted MEMPO = 2, "In mempool" # Tx is sent to mempool CONFI = 3, "Confirmed" # Tx is confirme +2 blocks + CANCE = 4, "Cancelled" # Cancelled tx + + def get_balance(): + balance = BalanceLog.objects.create() + return balance.time # payment use details concept = models.PositiveSmallIntegerField(choices=Concepts.choices, @@ -218,11 +223,12 @@ class OnchainPayment(models.Model): null=False, blank=False) - # platform onchain/channels balance at creattion, swap fee rate as percent of total volume + # platform onchain/channels balance at creation, swap fee rate as percent of total volume balance = models.ForeignKey(BalanceLog, related_name="balance", on_delete=models.SET_NULL, - default=BalanceLog.objects.create) + null=True, + default=get_balance) swap_fee_rate = models.DecimalField(max_digits=4, decimal_places=2, @@ -248,15 +254,13 @@ class OnchainPayment(models.Model): return f"TX-{txname}: {self.Concepts(self.concept).label} - {self.Status(self.status).label}" class Meta: - verbose_name = "Lightning payment" - verbose_name_plural = "Lightning payments" + verbose_name = "Onchain payment" + verbose_name_plural = "Onchain payments" @property def hash(self): - # Payment hash is the primary key of LNpayments - # However it is too long for the admin panel. - # We created a truncated property for display 'hash' - return truncatechars(self.payment_hash, 10) + # Display txid as 'hash' truncated + return truncatechars(self.txid, 10) class Order(models.Model): @@ -460,14 +464,14 @@ class Order(models.Model): blank=True, ) - payout_tx = models.OneToOneField( - OnchainPayment, - related_name="order_paid_TX", - on_delete=models.SET_NULL, - null=True, - default=None, - blank=True, - ) + # payout_tx = models.OneToOneField( + # OnchainPayment, + # related_name="order_paid_TX", + # on_delete=models.SET_NULL, + # null=True, + # default=None, + # blank=True, + # ) # ratings maker_rated = models.BooleanField(default=False, null=False) diff --git a/control/models.py b/control/models.py index fe1b40e8..ba6ac415 100755 --- a/control/models.py +++ b/control/models.py @@ -74,18 +74,41 @@ class AccountingMonth(models.Model): rewards_claimed = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) class BalanceLog(models.Model): + + def get_total(): + return LNNode.wallet_balance()['total_balance'] + LNNode.channel_balance()['local_balance'] + def get_frac(): + return (LNNode.wallet_balance()['total_balance'] + LNNode.channel_balance()['local_balance']) / LNNode.wallet_balance()['total_balance'] + def get_oc_total(): + return LNNode.wallet_balance()['total_balance'] + def get_oc_conf(): + return LNNode.wallet_balance()['confirmed_balance'] + def get_oc_unconf(): + return LNNode.wallet_balance()['unconfirmed_balance'] + def get_ln_local(): + return LNNode.channel_balance()['local_balance'] + def get_ln_remote(): + return LNNode.channel_balance()['remote_balance'] + def get_ln_local_unsettled(): + return LNNode.channel_balance()['unsettled_local_balance'] + def get_ln_remote_unsettled(): + return LNNode.channel_balance()['unsettled_remote_balance'] + time = models.DateTimeField(primary_key=True, default=timezone.now) # Every field is denominated in Sats - total = models.PositiveBigIntegerField(default=lambda : LNNode.wallet_balance()['total_balance'] + LNNode.channel_balance()['local_balance']) - onchain_fraction = models.DecimalField(max_digits=5, decimal_places=5, default=lambda : (LNNode.wallet_balance()['total_balance'] + LNNode.channel_balance()['local_balance']) / LNNode.wallet_balance()['total_balance']) - onchain_total = models.PositiveBigIntegerField(default=lambda : LNNode.wallet_balance()['total_balance']) - onchain_confirmed = models.PositiveBigIntegerField(default=lambda : LNNode.wallet_balance()['confirmed_balance']) - onchain_unconfirmed = models.PositiveBigIntegerField(default=lambda : LNNode.wallet_balance()['unconfirmed_balance']) - ln_local = models.PositiveBigIntegerField(default=lambda : LNNode.channel_balance()['local_balance']) - ln_remote = models.PositiveBigIntegerField(default=lambda : LNNode.channel_balance()['remote_balance']) - ln_local_unsettled = models.PositiveBigIntegerField(default=lambda : LNNode.channel_balance()['unsettled_local_balance']) - ln_remote_unsettled = models.PositiveBigIntegerField(default=lambda : LNNode.channel_balance()['unsettled_remote_balance']) + total = models.PositiveBigIntegerField(default=get_total) + onchain_fraction = models.DecimalField(max_digits=6, decimal_places=5, default=get_frac) + onchain_total = models.PositiveBigIntegerField(default=get_oc_total) + onchain_confirmed = models.PositiveBigIntegerField(default=get_oc_conf) + onchain_unconfirmed = models.PositiveBigIntegerField(default=get_oc_unconf) + ln_local = models.PositiveBigIntegerField(default=get_ln_local) + ln_remote = models.PositiveBigIntegerField(default=get_ln_remote) + ln_local_unsettled = models.PositiveBigIntegerField(default=get_ln_local_unsettled) + ln_remote_unsettled = models.PositiveBigIntegerField(default=get_ln_remote_unsettled) + + def __str__(self): + return f"Balance at {self.time.strftime('%d/%m/%Y %H:%M:%S')}" class Dispute(models.Model): pass \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d15aa09e..6bfd90fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: backend: build: . + image: backend container_name: django-dev restart: always depends_on: @@ -45,7 +46,7 @@ services: - ./frontend:/usr/src/frontend clean-orders: - build: . + image: backend restart: always container_name: clord-dev command: python3 manage.py clean_orders @@ -55,7 +56,7 @@ services: network_mode: service:tor follow-invoices: - build: . + image: backend container_name: invo-dev restart: always depends_on: @@ -68,7 +69,7 @@ services: network_mode: service:tor telegram-watcher: - build: . + image: backend container_name: tg-dev restart: always command: python3 manage.py telegram_watcher @@ -78,7 +79,7 @@ services: network_mode: service:tor celery: - build: . + image: backend container_name: cele-dev restart: always command: celery -A robosats worker --beat -l info -S django