From 4ac3618fd728c93eb437fe610174a23c10fd819e Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Sun, 5 Jun 2022 09:15:40 -0700 Subject: [PATCH 01/14] Add routing timeout to .env --- .env-sample | 4 ++++ api/lightning/node.py | 5 +++-- api/tasks.py | 8 +++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.env-sample b/.env-sample index efc7b203..c989392e 100644 --- a/.env-sample +++ b/.env-sample @@ -91,11 +91,15 @@ INVOICE_AND_ESCROW_DURATION = 30 # Time to confim chat and confirm fiat (time to Fiat Sent confirmation) HOURS FIAT_EXCHANGE_DURATION = 24 +# ROUTING # Proportional routing fee limit (fraction of total payout: % / 100) PROPORTIONAL_ROUTING_FEE_LIMIT = 0.0002 # Base flat limit fee for routing in Sats (used only when proportional is lower than this) MIN_FLAT_ROUTING_FEE_LIMIT = 10 MIN_FLAT_ROUTING_FEE_LIMIT_REWARD = 2 +# Routing timeouts +REWARDS_TIMEOUT_SECONDS = 60 +PAYOUT_TIMEOUT_SECONDS = 60 # Reward tip. Reward for every finished trade in the referral program (Satoshis) REWARD_TIP = 100 diff --git a/api/lightning/node.py b/api/lightning/node.py index 4781b79c..ed79116c 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -238,7 +238,7 @@ class LNNode: if payout["expires_at"] < timezone.now(): payout["context"] = { - "bad_invoice": f"The invoice provided has already expired" + "bad_invoice": "The invoice provided has already expired" } return payout @@ -257,9 +257,10 @@ class LNNode: lnpayment.num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), )) # 200 ppm or 10 sats + timeout_seconds = int(config("PAYOUT_TIMEOUT_SECONDS")) request = routerrpc.SendPaymentRequest(payment_request=lnpayment.invoice, fee_limit_sat=fee_limit_sat, - timeout_seconds=30) + timeout_seconds=timeout_seconds) for response in cls.routerstub.SendPaymentV2(request, metadata=[("macaroon", diff --git a/api/tasks.py b/api/tasks.py index 85baff34..7b869c77 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -78,12 +78,14 @@ def follow_send_payment(hash): lnpayment.num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT")), - )) # 200 ppm or 10 sats + )) # 1000 ppm or 10 sats + timeout_seconds = int(config("REWARRDS_TIMEOUT_SECONDS")) + request = LNNode.routerrpc.SendPaymentRequest( payment_request=lnpayment.invoice, fee_limit_sat=fee_limit_sat, - timeout_seconds=60, - ) # time out payment in 60 seconds + timeout_seconds=timeout_seconds, + ) order = lnpayment.order_paid try: From f538d263557b253d9fa0ad6d9b38c30036132f54 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Sun, 5 Jun 2022 14:16:03 -0700 Subject: [PATCH 02/14] Create Balance model and log task --- .env-sample | 18 +++++++++++++- api/lightning/node.py | 49 +++++++++++++++++++++++++++++++++++-- control/admin.py | 35 ++++++++++++++++++++++++-- control/models.py | 18 ++++++++++++-- control/tasks.py | 26 ++++++++++++++------ robosats/celery/__init__.py | 4 +++ 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/.env-sample b/.env-sample index c989392e..0a37b7c7 100644 --- a/.env-sample +++ b/.env-sample @@ -93,7 +93,7 @@ FIAT_EXCHANGE_DURATION = 24 # ROUTING # Proportional routing fee limit (fraction of total payout: % / 100) -PROPORTIONAL_ROUTING_FEE_LIMIT = 0.0002 +PROPORTIONAL_ROUTING_FEE_LIMIT = 0.001 # Base flat limit fee for routing in Sats (used only when proportional is lower than this) MIN_FLAT_ROUTING_FEE_LIMIT = 10 MIN_FLAT_ROUTING_FEE_LIMIT_REWARD = 2 @@ -101,6 +101,22 @@ MIN_FLAT_ROUTING_FEE_LIMIT_REWARD = 2 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 +# 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 +# Liquidity split point (LN/onchain) at which we use MIN_SWAP_FEE +MIN_SWAP_POINT = 0.25 +# 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_ = 'linear' +# Min amount allowed for Swap +MIN_SWAP_AMOUNT = 800000 + # Reward tip. Reward for every finished trade in the referral program (Satoshis) REWARD_TIP = 100 # Fraction rewarded to user from the slashed bond of a counterpart. diff --git a/api/lightning/node.py b/api/lightning/node.py index ed79116c..8944746a 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -1,4 +1,4 @@ -import grpc, os, hashlib, secrets +import grpc, os, hashlib, secrets, ring from . import lightning_pb2 as lnrpc, lightning_pb2_grpc as lightningstub from . import invoices_pb2 as invoicesrpc, invoices_pb2_grpc as invoicesstub from . import router_pb2 as routerrpc, router_pb2_grpc as routerstub @@ -8,7 +8,6 @@ from base64 import b64decode from datetime import timedelta, datetime from django.utils import timezone - from api.models import LNPayment ####### @@ -67,6 +66,52 @@ class LNNode: MACAROON.hex())]) return response + @classmethod + def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): + """Returns estimated fee for onchain payouts""" + + # We assume segwit. Use robosats donation address (shortcut so there is no need to have user input) + request = lnrpc.EstimateFeeRequest(AddrToAmount={'bc1q3cpp7ww92n6zp04hv40kd3eyy5avgughx6xqnx':amount_sats}, + target_conf=target_conf, + min_confs=min_confs, + spend_unconfirmed=False) + + response = cls.lightningstub.EstimateFee(request, + metadata=[("macaroon", + MACAROON.hex())]) + + return {'mining_fee_sats': response.fee_sat, 'mining_fee_rate': response.sat_per_vbyte} + + wallet_balance_cache = {} + @ring.dict(wallet_balance_cache, expire=10) # keeps in cache for 10 seconds + @classmethod + def wallet_balance(cls): + """Returns onchain balance""" + request = lnrpc.WalletBalanceRequest() + response = cls.lightningstub.WalletBalance(request, + metadata=[("macaroon", + MACAROON.hex())]) + print(response) + return {'total_balance': response.total_balance, + 'confirmed_balance': response.confirmed_balance, + 'unconfirmed_balance': response.unconfirmed_balance} + + channel_balance_cache = {} + @ring.dict(channel_balance_cache, expire=10) # keeps in cache for 10 seconds + @classmethod + def channel_balance(cls): + """Returns channels balance""" + request = lnrpc.ChannelBalanceRequest() + response = cls.lightningstub.ChannelBalance(request, + metadata=[("macaroon", + MACAROON.hex())]) + + print(response) + return {'local_balance': response.local_balance.sat, + 'remote_balance': response.remote_balance.sat, + 'unsettled_local_balance': response.unsettled_local_balance.sat, + 'unsettled_remote_balance': response.unsettled_remote_balance.sat} + @classmethod def cancel_return_hold_invoice(cls, payment_hash): """Cancels or returns a hold invoice""" diff --git a/control/admin.py b/control/admin.py index 9e5a49af..1287b114 100755 --- a/control/admin.py +++ b/control/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from control.models import AccountingDay, AccountingMonth, Dispute +from control.models import AccountingDay, AccountingMonth, BalanceLog from import_export.admin import ImportExportModelAdmin # Register your models here. @@ -50,4 +50,35 @@ class AccountingMonthAdmin(ImportExportModelAdmin): "rewards_claimed", ) change_links = ["month"] - search_fields = ["month"] \ No newline at end of file + search_fields = ["month"] + + +@admin.register(BalanceLog) +class BalanceLogAdmin(ImportExportModelAdmin): + + list_display = ( + "time", + "total", + "onchain_fraction", + "onchain_total", + "onchain_confirmed", + "onchain_unconfirmed", + "ln_local", + "ln_remote", + "ln_local_unsettled", + "ln_remote_unsettled", + ) + readonly_fields = [ + "time", + "total", + "onchain_fraction", + "onchain_total", + "onchain_confirmed", + "onchain_unconfirmed", + "ln_local", + "ln_remote", + "ln_local_unsettled", + "ln_remote_unsettled", + ] + change_links = ["time"] + search_fields = ["time"] \ No newline at end of file diff --git a/control/models.py b/control/models.py index 82dd2678..fe1b40e8 100755 --- a/control/models.py +++ b/control/models.py @@ -1,6 +1,8 @@ from django.db import models from django.utils import timezone +from api.lightning.node import LNNode + class AccountingDay(models.Model): day = models.DateTimeField(primary_key=True, auto_now=False, auto_now_add=False) @@ -36,8 +38,6 @@ class AccountingDay(models.Model): # Rewards claimed on day rewards_claimed = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - - class AccountingMonth(models.Model): month = models.DateTimeField(primary_key=True, auto_now=False, auto_now_add=False) @@ -73,5 +73,19 @@ class AccountingMonth(models.Model): # Rewards claimed on day rewards_claimed = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) +class BalanceLog(models.Model): + 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']) + class Dispute(models.Model): pass \ No newline at end of file diff --git a/control/tasks.py b/control/tasks.py index 616ae1ff..333362cb 100644 --- a/control/tasks.py +++ b/control/tasks.py @@ -1,10 +1,4 @@ from celery import shared_task -from api.models import Order, LNPayment, Profile, MarketTick -from control.models import AccountingDay, AccountingMonth -from django.utils import timezone -from datetime import timedelta -from django.db.models import Sum -from decouple import config @shared_task(name="do_accounting") def do_accounting(): @@ -12,6 +6,13 @@ def do_accounting(): Does all accounting from the beginning of time ''' + from api.models import Order, LNPayment, Profile, MarketTick + from control.models import AccountingDay + from django.utils import timezone + from datetime import timedelta + from django.db.models import Sum + from decouple import config + all_payments = LNPayment.objects.all() all_ticks = MarketTick.objects.all() today = timezone.now().date() @@ -109,4 +110,15 @@ def do_accounting(): result[str(day)]={'contracted':contracted,'inflow':inflow,'outflow':outflow} day = day + timedelta(days=1) - return result \ No newline at end of file + return result + +@shared_task(name="compute_node_balance", ignore_result=True) +def compute_node_balance(): + ''' + Queries LND for channel and wallet balance + ''' + from control.models import BalanceLog + + BalanceLog.objects.create() + + return \ No newline at end of file diff --git a/robosats/celery/__init__.py b/robosats/celery/__init__.py index 12b2a2fc..0e8f5425 100644 --- a/robosats/celery/__init__.py +++ b/robosats/celery/__init__.py @@ -55,6 +55,10 @@ app.conf.beat_schedule = { "task": "cache_external_market_prices", "schedule": timedelta(seconds=60), }, + "compute-node-balance": { # Logs LND channel and wallet balance + "task":"compute_node_balance", + "schedule": timedelta(minutes=15), + } } app.conf.timezone = "UTC" From 8d0b51822239d4e234b6acffd3359b4655e53ee4 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Mon, 6 Jun 2022 10:57:04 -0700 Subject: [PATCH 03/14] Add onchain logics pt1 --- api/lightning/node.py | 7 +++- api/models.py | 95 ++++++++++++++++++++++++++++++++++++++++++- api/tasks.py | 2 +- api/utils.py | 21 +++++++++- api/views.py | 10 ++++- requirements.txt | 3 +- 6 files changed, 130 insertions(+), 8 deletions(-) diff --git a/api/lightning/node.py b/api/lightning/node.py index 8944746a..3eabb1b0 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -8,7 +8,7 @@ from base64 import b64decode from datetime import timedelta, datetime from django.utils import timezone -from api.models import LNPayment + ####### # Should work with LND (c-lightning in the future if there are features that deserve the work) @@ -176,6 +176,8 @@ class LNNode: @classmethod def validate_hold_invoice_locked(cls, lnpayment): """Checks if hold invoice is locked""" + from api.models import LNPayment + request = invoicesrpc.LookupInvoiceMsg( payment_hash=bytes.fromhex(lnpayment.payment_hash)) response = cls.invoicesstub.LookupInvoiceV2(request, @@ -296,7 +298,8 @@ class LNNode: @classmethod def pay_invoice(cls, lnpayment): """Sends sats. Used for rewards payouts""" - + from api.models import LNPayment + fee_limit_sat = int( max( lnpayment.num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), diff --git a/api/models.py b/api/models.py index b55651ff..c4bc7df6 100644 --- a/api/models.py +++ b/api/models.py @@ -17,8 +17,11 @@ from decouple import config from pathlib import Path import json +from control.models import BalanceLog + MIN_TRADE = int(config("MIN_TRADE")) MAX_TRADE = int(config("MAX_TRADE")) +MIN_SWAP_AMOUNT = int(config("MIN_SWAP_AMOUNT")) FEE = float(config("FEE")) DEFAULT_BOND_SIZE = float(config("DEFAULT_BOND_SIZE")) @@ -118,7 +121,7 @@ class LNPayment(models.Model): blank=True) num_satoshis = models.PositiveBigIntegerField(validators=[ MinValueValidator(100), - MaxValueValidator(MAX_TRADE * (1 + DEFAULT_BOND_SIZE + FEE)), + MaxValueValidator(1.5 * MAX_TRADE), ]) # Fee in sats with mSats decimals fee_msat fee = models.DecimalField(max_digits=10, decimal_places=3, default=0, null=False, blank=False) @@ -163,6 +166,96 @@ class LNPayment(models.Model): # We created a truncated property for display 'hash' return truncatechars(self.payment_hash, 10) +class OnchainPayment(models.Model): + + class Concepts(models.IntegerChoices): + PAYBUYER = 3, "Payment to buyer" + + class Status(models.IntegerChoices): + CREAT = 0, "Created" # User was given platform fees and suggested mining fees + VALID = 1, "Valid" # Valid onchain address submitted + MEMPO = 2, "In mempool" # Tx is sent to mempool + CONFI = 3, "Confirmed" # Tx is confirme +2 blocks + + # payment use details + concept = models.PositiveSmallIntegerField(choices=Concepts.choices, + null=False, + default=Concepts.PAYBUYER) + status = models.PositiveSmallIntegerField(choices=Status.choices, + null=False, + default=Status.VALID) + + # payment info + address = models.CharField(max_length=100, + unique=False, + default=None, + null=True, + blank=True) + + txid = models.CharField(max_length=64, + unique=True, + null=True, + default=None, + blank=True) + + num_satoshis = models.PositiveBigIntegerField(validators=[ + MinValueValidator(0.7 * MIN_SWAP_AMOUNT), + MaxValueValidator(1.5 * MAX_TRADE), + ]) + + # fee in sats/vbyte with mSats decimals fee_msat + suggested_mining_fee_rate = models.DecimalField(max_digits=6, + decimal_places=3, + default=1.05, + null=False, + blank=False) + mining_fee_rate = models.DecimalField(max_digits=6, + decimal_places=3, + default=1.05, + null=False, + blank=False) + mining_fee_sats = models.PositiveBigIntegerField(default=0, + null=False, + blank=False) + + # platform onchain/channels balance at creattion, swap fee rate as percent of total volume + node_balance = models.ForeignKey(BalanceLog, + related_name="balance", + on_delete=models.SET_NULL, + null=True) + swap_fee_rate = models.DecimalField(max_digits=4, + decimal_places=2, + default=2, + null=False, + blank=False) + + created_at = models.DateTimeField(default=timezone.now) + + # involved parties + receiver = models.ForeignKey(User, + related_name="tx_receiver", + on_delete=models.SET_NULL, + null=True, + default=None) + + def __str__(self): + if self.txid: + txname = str(self.txid)[:8] + else: + txname = str(self.id) + + 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" + + @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) class Order(models.Model): diff --git a/api/tasks.py b/api/tasks.py index 7b869c77..f3a8839d 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -79,7 +79,7 @@ def follow_send_payment(hash): float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT")), )) # 1000 ppm or 10 sats - timeout_seconds = int(config("REWARRDS_TIMEOUT_SECONDS")) + timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) request = LNNode.routerrpc.SendPaymentRequest( payment_request=lnpayment.invoice, diff --git a/api/utils.py b/api/utils.py index f1109d1c..89205726 100644 --- a/api/utils.py +++ b/api/utils.py @@ -1,8 +1,7 @@ import requests, ring, os from decouple import config import numpy as np -import requests - +import coinaddrvalidator as addr from api.models import Order def get_tor_session(): @@ -12,6 +11,24 @@ def get_tor_session(): 'https': 'socks5://127.0.0.1:9050'} return session +def validate_onchain_address(address): + ''' + Validates an onchain address + ''' + + validation = addr.validate('btc', address.encode('utf-8')) + + if not validation.valid: + return False + + NETWORK = str(config('NETWORK')) + if NETWORK == 'mainnet': + if validation.network == 'main': + return True + elif NETWORK == 'testnet': + if validation.network == 'test': + return True + market_cache = {} @ring.dict(market_cache, expire=3) # keeps in cache for 3 seconds def get_exchange_rates(currencies): diff --git a/api/views.py b/api/views.py index 3a8ab65a..b31c6de4 100644 --- a/api/views.py +++ b/api/views.py @@ -416,9 +416,10 @@ class OrderView(viewsets.ViewSet): order = Order.objects.get(id=order_id) # action is either 1)'take', 2)'confirm', 3)'cancel', 4)'dispute' , 5)'update_invoice' - # 6)'submit_statement' (in dispute), 7)'rate_user' , 'rate_platform' + # 5.b)'update_address' 6)'submit_statement' (in dispute), 7)'rate_user' , 8)'rate_platform' action = serializer.data.get("action") invoice = serializer.data.get("invoice") + address = serializer.data.get("address") statement = serializer.data.get("statement") rating = serializer.data.get("rating") @@ -464,6 +465,13 @@ class OrderView(viewsets.ViewSet): invoice) if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) + + # 2.b) If action is 'update invoice' + if action == "update_address": + valid, context = Logics.update_address(order, request.user, + address) + if not valid: + return Response(context, status.HTTP_400_BAD_REQUEST) # 3) If action is cancel elif action == "cancel": diff --git a/requirements.txt b/requirements.txt index 43fe469d..cecb8980 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,4 +25,5 @@ psycopg2==2.9.3 SQLAlchemy==1.4.31 django-import-export==2.7.1 requests[socks] -python-gnupg==0.4.9 \ No newline at end of file +python-gnupg==0.4.9 +coinaddrvalidator==1.1.3 \ No newline at end of file From cf82a4d6ae59fa8b7e8d61894a3c1b6226d259d0 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Mon, 6 Jun 2022 13:37:51 -0700 Subject: [PATCH 04/14] Add onchain logics pt2 --- .env-sample | 4 +-- api/lightning/node.py | 2 +- api/logics.py | 61 +++++++++++++++++++++++++++++++++++++++---- api/models.py | 20 ++++++++++---- api/serializers.py | 6 +++++ api/tasks.py | 2 +- api/views.py | 3 ++- 7 files changed, 83 insertions(+), 15 deletions(-) diff --git a/.env-sample b/.env-sample index 0a37b7c7..f77a1df9 100644 --- a/.env-sample +++ b/.env-sample @@ -113,9 +113,9 @@ 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_ = 'linear' +SWAP_FEE_SHAPE = 'linear' # Min amount allowed for Swap -MIN_SWAP_AMOUNT = 800000 +MIN_SWAP_AMOUNT = 50000 # Reward tip. Reward for every finished trade in the referral program (Satoshis) REWARD_TIP = 100 diff --git a/api/lightning/node.py b/api/lightning/node.py index 3eabb1b0..ecc555a0 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -305,7 +305,7 @@ class LNNode: lnpayment.num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), )) # 200 ppm or 10 sats - timeout_seconds = int(config("PAYOUT_TIMEOUT_SECONDS")) + timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) request = routerrpc.SendPaymentRequest(payment_request=lnpayment.invoice, fee_limit_sat=fee_limit_sat, timeout_seconds=timeout_seconds) diff --git a/api/logics.py b/api/logics.py index 1e2d2808..5c52ef5b 100644 --- a/api/logics.py +++ b/api/logics.py @@ -4,7 +4,7 @@ from django.utils import timezone from api.lightning.node import LNNode from django.db.models import Q -from api.models import Order, LNPayment, MarketTick, User, Currency +from api.models import OnchainPayment, Order, LNPayment, MarketTick, User, Currency from api.tasks import send_message from decouple import config @@ -494,10 +494,46 @@ class Logics: 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 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 + + return swap_fee_rate + + @classmethod + def create_onchain_payment(cls, order, estimate_sats): + ''' + 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) + onchain_payment.save() + + order.payout_tx = onchain_payment + order.save() + return True, None + @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""" + 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 @@ -508,10 +544,25 @@ class Logics: reward_tip = int(config('REWARD_TIP')) if user.profile.is_referred else 0 - if cls.is_buyer(order, user): - invoice_amount = round(order.last_satoshis - fee_sats - reward_tip) # Trading fee to buyer is charged here. + 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. - return True, {"invoice_amount": invoice_amount} + # 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 + return True, context + + if order.payout_tx == None: + cls.create_onchain_payment(order, estimate_sats=context["invoice_amount"]) + + 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): diff --git a/api/models.py b/api/models.py index c4bc7df6..b16ab10c 100644 --- a/api/models.py +++ b/api/models.py @@ -219,10 +219,11 @@ class OnchainPayment(models.Model): blank=False) # platform onchain/channels balance at creattion, swap fee rate as percent of total volume - node_balance = models.ForeignKey(BalanceLog, - related_name="balance", - on_delete=models.SET_NULL, - null=True) + balance = models.ForeignKey(BalanceLog, + related_name="balance", + on_delete=models.SET_NULL, + default=BalanceLog.objects.create) + swap_fee_rate = models.DecimalField(max_digits=4, decimal_places=2, default=2, @@ -452,7 +453,16 @@ class Order(models.Model): # buyer payment LN invoice payout = models.OneToOneField( LNPayment, - related_name="order_paid", + related_name="order_paid_LN", + 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, diff --git a/api/serializers.py b/api/serializers.py index 4001e225..fd088afd 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -54,6 +54,10 @@ class UpdateOrderSerializer(serializers.Serializer): allow_null=True, allow_blank=True, default=None) + address = serializers.CharField(max_length=100, + allow_null=True, + allow_blank=True, + default=None) statement = serializers.CharField(max_length=10000, allow_null=True, allow_blank=True, @@ -63,6 +67,7 @@ class UpdateOrderSerializer(serializers.Serializer): "pause", "take", "update_invoice", + "update_address", "submit_statement", "dispute", "cancel", @@ -79,6 +84,7 @@ class UpdateOrderSerializer(serializers.Serializer): default=None, ) amount = serializers.DecimalField(max_digits=18, decimal_places=8, allow_null=True, required=False, default=None) + mining_fee_rate = serializers.DecimalField(max_digits=6, decimal_places=3, allow_null=True, required=False, default=None) class UserGenSerializer(serializers.Serializer): # Mandatory fields diff --git a/api/tasks.py b/api/tasks.py index f3a8839d..469fd1c0 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -79,7 +79,7 @@ def follow_send_payment(hash): float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT")), )) # 1000 ppm or 10 sats - timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) + timeout_seconds = int(config("PAYOUT_TIMEOUT_SECONDS")) request = LNNode.routerrpc.SendPaymentRequest( payment_request=lnpayment.invoice, diff --git a/api/views.py b/api/views.py index b31c6de4..4425f0e2 100644 --- a/api/views.py +++ b/api/views.py @@ -420,6 +420,7 @@ class OrderView(viewsets.ViewSet): action = serializer.data.get("action") invoice = serializer.data.get("invoice") address = serializer.data.get("address") + mining_fee_rate = serializer.data.get("mining_fee_rate") statement = serializer.data.get("statement") rating = serializer.data.get("rating") @@ -469,7 +470,7 @@ class OrderView(viewsets.ViewSet): # 2.b) If action is 'update invoice' if action == "update_address": valid, context = Logics.update_address(order, request.user, - address) + address, mining_fee_rate) if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) From b1d68a39f729669edb8c26234b3c905747967213 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Tue, 7 Jun 2022 15:14:56 -0700 Subject: [PATCH 05/14] Add onchain logics pt3 --- .env-sample | 14 ++++++++------ Dockerfile | 4 ++++ api/admin.py | 23 ++++++++++++++++++++++- api/logics.py | 37 +++++++++++++++++++++++++++++++------ api/models.py | 36 ++++++++++++++++++++---------------- control/models.py | 41 ++++++++++++++++++++++++++++++++--------- docker-compose.yml | 9 +++++---- 7 files changed, 122 insertions(+), 42 deletions(-) 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 From dc9d5e5e2ad9fad7d35392649073fe4dd040987a Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Sat, 11 Jun 2022 06:12:09 -0700 Subject: [PATCH 06/14] Add frontend input address components --- api/logics.py | 31 +++-- api/models.py | 33 ++--- api/views.py | 2 +- control/models.py | 2 +- frontend/src/components/TradeBox.js | 171 ++++++++++++++++++------- frontend/src/components/UserGenPage.js | 2 +- 6 files changed, 168 insertions(+), 73 deletions(-) diff --git a/api/logics.py b/api/logics.py index 75880c78..09908954 100644 --- a/api/logics.py +++ b/api/logics.py @@ -504,7 +504,7 @@ class Logics: MIN_POINT = float(config('MIN_POINT')) MAX_SWAP_FEE = float(config('MAX_SWAP_FEE')) MAX_POINT = float(config('MAX_POINT')) - if balance.onchain_fraction > MIN_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) @@ -514,31 +514,44 @@ class Logics: 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) + swap_fee_rate = MIN_SWAP_FEE + (MAX_SWAP_FEE - MIN_SWAP_FEE) * math.exp(-SWAP_LAMBDA * float(balance.onchain_fraction)) + print("MIN_SWAP_FEE",MIN_SWAP_FEE) + print("MAX_SWAP_FEE",MAX_SWAP_FEE) + print("SWAP_LAMBDA",SWAP_LAMBDA) + print("swap_fee_rate",swap_fee_rate) - return swap_fee_rate + return swap_fee_rate * 100 @classmethod - def create_onchain_payment(cls, order, preliminary_amount): + 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. ''' - onchain_payment = OnchainPayment.objects.create() + 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 == 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 - 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) + 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 = 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 @@ -577,7 +590,7 @@ class Logics: if order.payout_tx == None: # Creates the OnchainPayment object and checks node balance - valid, _ = cls.create_onchain_payment(order, preliminary_amount=context["invoice_amount"]) + 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 swaps" diff --git a/api/models.py b/api/models.py index b9ac9698..9d3d7f1a 100644 --- a/api/models.py +++ b/api/models.py @@ -188,7 +188,7 @@ class OnchainPayment(models.Model): default=Concepts.PAYBUYER) status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, - default=Status.VALID) + default=Status.CREAT) # payment info address = models.CharField(max_length=100, @@ -203,10 +203,11 @@ class OnchainPayment(models.Model): default=None, blank=True) - num_satoshis = models.PositiveBigIntegerField(validators=[ - MinValueValidator(0.7 * MIN_SWAP_AMOUNT), - MaxValueValidator(1.5 * MAX_TRADE), - ]) + num_satoshis = models.PositiveBigIntegerField(null=True, + validators=[ + MinValueValidator(0.7 * MIN_SWAP_AMOUNT), + MaxValueValidator(1.5 * MAX_TRADE), + ]) # fee in sats/vbyte with mSats decimals fee_msat suggested_mining_fee_rate = models.DecimalField(max_digits=6, @@ -232,7 +233,7 @@ class OnchainPayment(models.Model): swap_fee_rate = models.DecimalField(max_digits=4, decimal_places=2, - default=2, + default=float(config("MIN_SWAP_FEE"))*100, null=False, blank=False) @@ -454,6 +455,8 @@ class Order(models.Model): default=None, blank=True, ) + # is buyer payout a LN invoice (false) or on chain address (true) + is_swap = models.BooleanField(default=False, null=False) # buyer payment LN invoice payout = models.OneToOneField( LNPayment, @@ -463,15 +466,15 @@ class Order(models.Model): 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, - # ) + # buyer payment address + 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/api/views.py b/api/views.py index 4425f0e2..0068c59a 100644 --- a/api/views.py +++ b/api/views.py @@ -337,7 +337,7 @@ class OrderView(viewsets.ViewSet): elif data["is_buyer"] and (order.status == Order.Status.WF2 or order.status == Order.Status.WFI): - # If the two bonds are locked, reply with an AMOUNT so he can send the buyer invoice. + # If the two bonds are locked, reply with an AMOUNT and onchain swap cost so he can send the buyer invoice/address if (order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED): valid, context = Logics.payout_amount(order, request.user) diff --git a/control/models.py b/control/models.py index ba6ac415..7222e04a 100755 --- a/control/models.py +++ b/control/models.py @@ -78,7 +78,7 @@ 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'] + return LNNode.wallet_balance()['total_balance'] / (LNNode.wallet_balance()['total_balance'] + LNNode.channel_balance()['local_balance']) def get_oc_total(): return LNNode.wallet_balance()['total_balance'] def get_oc_conf(): diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index d162e9ff..2a0572e1 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -1,6 +1,6 @@ import React, { Component } from "react"; import { withTranslation, Trans} from "react-i18next"; -import { IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" +import { Tabs, Tab, IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" import QRCode from "react-qr-code"; import Countdown, { zeroPad} from 'react-countdown'; import Chat from "./EncryptedChat" @@ -18,6 +18,8 @@ import BalanceIcon from '@mui/icons-material/Balance'; import ContentCopy from "@mui/icons-material/ContentCopy"; import PauseCircleIcon from '@mui/icons-material/PauseCircle'; import PlayCircleIcon from '@mui/icons-material/PlayCircle'; +import BoltIcon from '@mui/icons-material/Bolt'; +import LinkIcon from '@mui/icons-material/Link'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import { NewTabIcon } from "./Icons"; @@ -33,6 +35,7 @@ class TradeBox extends Component { openConfirmFiatReceived: false, openConfirmDispute: false, openEnableTelegram: false, + receiveTab: 0, badInvoice: false, badStatement: false, qrscanner: false, @@ -599,57 +602,133 @@ class TradeBox extends Component { {/* Make confirmation sound for HTLC received. */} {this.Sound("locked-invoice")} - {t("Submit an invoice for {{amountSats}} Sats",{amountSats: pn(this.props.data.invoice_amount)})} + {t("Submit payout info for {{amountSats}} Sats",{amountSats: pn(this.props.data.invoice_amount)})} {" " + this.stepXofY()} + + + + + + {t("The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.", + {amountFiat: parseFloat(parseFloat(this.props.data.amount).toFixed(4)), + currencyCode: this.props.data.currencyCode})} + + + + + + + Lightning} onClick={() => this.setState({receiveTab:0})}/> + Onchain} disabled={!this.props.data.swap_allowed} onClick={() => this.setState({receiveTab:1})} /> + + + + {/* LIGHTNING PAYOUT TAB */} +
+
+ + + + {t("Submit a valid invoice for {{amountSats}} Satoshis.", + {amountSats: pn(this.props.data.invoice_amount)})} + + + + + {this.compatibleWalletsButton()} + + + + + + {this.state.qrscanner ? + + + + : null } + + + + + +
- - - {t("The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC. Please provide a valid invoice for {{amountSats}} Satoshis.", - {amountFiat: parseFloat(parseFloat(this.props.data.amount).toFixed(4)), - currencyCode: this.props.data.currencyCode, - amountSats: pn(this.props.data.invoice_amount)} - ) - } - - + {/* ONCHAIN PAYOUT TAB */} +
+
+ + + + {t("RoboSats will perform an on-the-fly reverse submarine swap and send to an onchain address for a fee. Submit onchain address. Preliminary {{amountSats}} Sats, swap allowed {{swapAllowed}}, swap fee {{swapFee}}%, mining fee suggested {{suggestedMiningFee}} Sats/vbyte", + {amountSats: this.props.data.invoice_amount, + swapAllowed: this.props.data.swap_allowed, + swapFee: this.props.data.swap_fee_rate , + suggestedMiningFee: this.props.data.suggested_mining_fee_rate} + ) + } + + + + + {t("Swap fee: {{swapFeeSats}}Sats ({{swapFeeRate}}%)", + {swapFeeSats: this.props.data.invoice_amount * this.state.swap_fee_rate/100, + swapFeeRate: this.state.swap_fee_rate}) + } + + + {t("Mining fee: {{miningFee}}Sats ({{swapFeeRate}}%)", + {miningFee: this.props.data.suggestedMiningFee * 141})} + + + {t("You receive: {{onchainAmount}}Sats)", + {onchainAmount: pn(parseInt(this.props.data.invoice_amount - (this.props.data.suggestedMiningFee * 141) - (this.props.data.invoice_amount * this.state.swap_fee_rate/100)))})} + + + + + - - {this.compatibleWalletsButton()} - + + + - - - - {this.state.qrscanner ? - - - - : null } - - - - + +
+ + + {this.showBondIsLocked()} diff --git a/frontend/src/components/UserGenPage.js b/frontend/src/components/UserGenPage.js index b810cdac..62bf0113 100644 --- a/frontend/src/components/UserGenPage.js +++ b/frontend/src/components/UserGenPage.js @@ -128,7 +128,7 @@ class UserGenPage extends Component { handleChangeToken=(e)=>{ this.setState({ - token: e.target.value, + token: e.target.value.split(' ').join(''), tokenHasChanged: true, }) } From c4396f504a3ae9517fcf8a8555d67710a7185b73 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Mon, 13 Jun 2022 14:37:14 -0700 Subject: [PATCH 07/14] Add onchain submission form and swap cost details --- frontend/src/components/TradeBox.js | 164 ++++++++++++++++++---------- 1 file changed, 109 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 2a0572e1..64ba4cd7 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -36,7 +36,10 @@ class TradeBox extends Component { openConfirmDispute: false, openEnableTelegram: false, receiveTab: 0, + address: '', + miningFee: 1.05, badInvoice: false, + badAddress: false, badStatement: false, qrscanner: false, } @@ -543,6 +546,42 @@ class TradeBox extends Component { & this.props.completeSetState(data)); } + handleInputAddressChanged=(e)=>{ + this.setState({ + address: e.target.value, + badAddress: false, + }); + } + + handleMiningFeeChanged=(e)=>{ + var fee = e.target.value + if (fee > 50){ + fee = 50 + } + + this.setState({ + miningFee: fee, + }); + } + + handleClickSubmitAddressButton=()=>{ + this.setState({badInvoice:false}); + + const requestOptions = { + method: 'POST', + headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),}, + body: JSON.stringify({ + 'action':'update_address', + 'address': this.state.address, + 'mining_fee_rate': Math.max(1, this.state.miningFee), + }), + }; + fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions) + .then((response) => response.json()) + .then((data) => this.setState({badAddress:data_address}) + & this.props.completeSetState(data)); +} + handleInputDisputeChanged=(e)=>{ this.setState({ statement: e.target.value, @@ -608,7 +647,6 @@ class TradeBox extends Component { - {t("The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.", @@ -616,15 +654,17 @@ class TradeBox extends Component { currencyCode: this.props.data.currencyCode})} - + - + Lightning
} onClick={() => this.setState({receiveTab:0})}/> - Onchain
} disabled={!this.props.data.swap_allowed} onClick={() => this.setState({receiveTab:1})} /> + Onchain} disabled={!this.props.data.swap_allowed} onClick={() => this.setState({receiveTab:1, miningFee: this.props.data.suggested_mining_fee_rate})} /> - +
+
+ {/* LIGHTNING PAYOUT TAB */}
@@ -674,59 +714,73 @@ class TradeBox extends Component {
- {/* ONCHAIN PAYOUT TAB */} -
-
- - - - {t("RoboSats will perform an on-the-fly reverse submarine swap and send to an onchain address for a fee. Submit onchain address. Preliminary {{amountSats}} Sats, swap allowed {{swapAllowed}}, swap fee {{swapFee}}%, mining fee suggested {{suggestedMiningFee}} Sats/vbyte", - {amountSats: this.props.data.invoice_amount, - swapAllowed: this.props.data.swap_allowed, - swapFee: this.props.data.swap_fee_rate , - suggestedMiningFee: this.props.data.suggested_mining_fee_rate} - ) - } - - - - - {t("Swap fee: {{swapFeeSats}}Sats ({{swapFeeRate}}%)", - {swapFeeSats: this.props.data.invoice_amount * this.state.swap_fee_rate/100, - swapFeeRate: this.state.swap_fee_rate}) - } - - - {t("Mining fee: {{miningFee}}Sats ({{swapFeeRate}}%)", - {miningFee: this.props.data.suggestedMiningFee * 141})} - - - {t("You receive: {{onchainAmount}}Sats)", - {onchainAmount: pn(parseInt(this.props.data.invoice_amount - (this.props.data.suggestedMiningFee * 141) - (this.props.data.invoice_amount * this.state.swap_fee_rate/100)))})} - - - - - + {/* ONCHAIN PAYOUT TAB */} +
+ + + + {t("RoboSats will do a swap and send the Sats to your onchain address for a fee.")} + + + + - - - + + + + + + + + + + + + + {pn(parseInt(this.props.data.invoice_amount - (Math.max(1, this.state.miningFee) * 141) - (this.props.data.invoice_amount * this.props.data.swap_fee_rate/100)))+ " Sats"}} + secondary={t("Final amount you will receive")}/> + + + + 50} + helperText={this.state.miningFee < 1 || this.state.miningFee > 50 ? "Invalid" : ''} + label={t("Mining Fee")} + required + sx={{width:110}} + value={this.state.miningFee} + type="number" + inputProps={{ + max:50, + min:1, + style: {textAlign:"center"}, + }} + onChange={this.handleMiningFeeChanged} + /> +
+ + + -
- +
+ From 8f93c8f7b65e6b7454da90cd30c2383f04dba9c9 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Mon, 13 Jun 2022 15:27:09 -0700 Subject: [PATCH 08/14] Add address submission and validation checks --- api/logics.py | 80 +++++++++++++++++++++++++++-- api/models.py | 8 ++- api/views.py | 4 +- frontend/src/components/TradeBox.js | 6 +-- 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/api/logics.py b/api/logics.py index 09908954..9896cfa1 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1,5 +1,5 @@ from datetime import timedelta -from tkinter import N +from tkinter import N, ON from django.utils import timezone from api.lightning.node import LNNode from django.db.models import Q, Sum @@ -7,6 +7,7 @@ from django.db.models import Q, Sum from api.models import OnchainPayment, Order, LNPayment, MarketTick, User, Currency from api.tasks import send_message from decouple import config +from api.utils import validate_onchain_address import gnupg @@ -550,7 +551,7 @@ class Logics: if suggested_mining_fee_rate > 50: suggested_mining_fee_rate = 50 - onchain_payment.suggested_mining_fee_rate = LNNode.estimate_fee(amount_sats=preliminary_amount)["mining_fee_rate"] + 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() @@ -621,6 +622,67 @@ class Logics: 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) + if not validate_onchain_address(address): + return False, { + "bad_address": + "Does not look like a valid address" + } + 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." + } + elif float(mining_fee_rate) > 50: + return False, { + "bad_address": + "The mining fee is too high." + } + 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): @@ -677,6 +739,15 @@ class Logics: }, ) + 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 @@ -706,10 +777,9 @@ class Logics: order.payout.status = LNPayment.Status.FLIGHT order.payout.routing_attempts = 0 order.payout.save() - order.save() - + order.save() - return True, None + return True def add_profile_rating(profile, rating): """adds a new rating to a user profile""" diff --git a/api/models.py b/api/models.py index 9d3d7f1a..f7d02e2d 100644 --- a/api/models.py +++ b/api/models.py @@ -205,10 +205,14 @@ class OnchainPayment(models.Model): num_satoshis = models.PositiveBigIntegerField(null=True, validators=[ - MinValueValidator(0.7 * MIN_SWAP_AMOUNT), + MinValueValidator(0.5 * MIN_SWAP_AMOUNT), + MaxValueValidator(1.5 * MAX_TRADE), + ]) + sent_satoshis = models.PositiveBigIntegerField(null=True, + validators=[ + MinValueValidator(0.5 * MIN_SWAP_AMOUNT), MaxValueValidator(1.5 * MAX_TRADE), ]) - # fee in sats/vbyte with mSats decimals fee_msat suggested_mining_fee_rate = models.DecimalField(max_digits=6, decimal_places=3, diff --git a/api/views.py b/api/views.py index 0068c59a..78ba2049 100644 --- a/api/views.py +++ b/api/views.py @@ -337,7 +337,7 @@ class OrderView(viewsets.ViewSet): elif data["is_buyer"] and (order.status == Order.Status.WF2 or order.status == Order.Status.WFI): - # If the two bonds are locked, reply with an AMOUNT and onchain swap cost so he can send the buyer invoice/address + # If the two bonds are locked, reply with an AMOUNT and onchain swap cost so he can send the buyer invoice/address. if (order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED): valid, context = Logics.payout_amount(order, request.user) @@ -467,7 +467,7 @@ class OrderView(viewsets.ViewSet): if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) - # 2.b) If action is 'update invoice' + # 2.b) If action is 'update address' if action == "update_address": valid, context = Logics.update_address(order, request.user, address, mining_fee_rate) diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 64ba4cd7..68aa7bc5 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -578,7 +578,7 @@ class TradeBox extends Component { }; fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions) .then((response) => response.json()) - .then((data) => this.setState({badAddress:data_address}) + .then((data) => this.setState({badAddress:data.bad_address}) & this.props.completeSetState(data)); } @@ -659,7 +659,7 @@ class TradeBox extends Component { Lightning
} onClick={() => this.setState({receiveTab:0})}/> - Onchain
} disabled={!this.props.data.swap_allowed} onClick={() => this.setState({receiveTab:1, miningFee: this.props.data.suggested_mining_fee_rate})} /> + Onchain
} disabled={!this.props.data.swap_allowed} onClick={() => this.setState({receiveTab:1, miningFee: parseFloat(this.props.data.suggested_mining_fee_rate)})} /> @@ -777,7 +777,7 @@ class TradeBox extends Component {
- +
From efed6b3c0a06ea5396f279653075a09c1a5ad0f4 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 16 Jun 2022 08:31:30 -0700 Subject: [PATCH 09/14] Pay buyer onchain-tx --- .env-sample | 4 +- api/lightning/node.py | 27 +++++++++++++- api/logics.py | 57 ++++++++++++++++++++--------- api/models.py | 2 +- api/views.py | 15 +++++++- frontend/src/components/TradeBox.js | 25 +++++++++++-- 6 files changed, 104 insertions(+), 26 deletions(-) diff --git a/.env-sample b/.env-sample index ee4b7a66..70a6db73 100644 --- a/.env-sample +++ b/.env-sample @@ -99,9 +99,11 @@ MIN_FLAT_ROUTING_FEE_LIMIT = 10 MIN_FLAT_ROUTING_FEE_LIMIT_REWARD = 2 # Routing timeouts REWARDS_TIMEOUT_SECONDS = 60 -PAYOUT_TIMEOUT_SECONDS = 60 +PAYOUT_TIMEOUT_SECONDS = 90 # REVERSE SUBMARINE SWAP PAYOUTS +# Disable on-the-fly swaps feature +DISABLE_ONCHAIN = False # 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) diff --git a/api/lightning/node.py b/api/lightning/node.py index ecc555a0..96ad7e69 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -1,4 +1,6 @@ import grpc, os, hashlib, secrets, ring + +from robosats.api.models import OnchainPayment from . import lightning_pb2 as lnrpc, lightning_pb2_grpc as lightningstub from . import invoices_pb2 as invoicesrpc, invoices_pb2_grpc as invoicesstub from . import router_pb2 as routerrpc, router_pb2_grpc as routerstub @@ -70,7 +72,7 @@ class LNNode: def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): """Returns estimated fee for onchain payouts""" - # We assume segwit. Use robosats donation address (shortcut so there is no need to have user input) + # We assume segwit. Use robosats donation address as shortcut so there is no need of user inputs request = lnrpc.EstimateFeeRequest(AddrToAmount={'bc1q3cpp7ww92n6zp04hv40kd3eyy5avgughx6xqnx':amount_sats}, target_conf=target_conf, min_confs=min_confs, @@ -112,6 +114,29 @@ class LNNode: 'unsettled_local_balance': response.unsettled_local_balance.sat, 'unsettled_remote_balance': response.unsettled_remote_balance.sat} + @classmethod + def pay_onchain(cls, onchainpayment): + """Send onchain transaction for buyer payouts""" + + if bool(config("DISABLE_ONCHAIN")): + return False + + request = lnrpc.SendCoinsRequest(addr=onchainpayment.address, + amount=int(onchainpayment.sent_satoshis), + sat_per_vbyte=int(onchainpayment.mining_fee_rate), + label=str("Payout order #" + str(onchainpayment.order_paid_TX.id)), + spend_unconfirmed=True) + response = cls.lightningstub.SendCoins(request, + metadata=[("macaroon", + MACAROON.hex())]) + + print(response) + onchainpayment.txid = response.txid + onchainpayment.status = OnchainPayment.Status.MEMPO + onchainpayment.save() + + return True + @classmethod def cancel_return_hold_invoice(cls, payment_hash): """Cancels or returns a hold invoice""" diff --git a/api/logics.py b/api/logics.py index 9896cfa1..6bd29e8b 100644 --- a/api/logics.py +++ b/api/logics.py @@ -589,12 +589,17 @@ class Logics: context["swap_failure_reason"] = "Order amount is too small to be eligible for a swap" return True, context + if not bool(config("DISABLE_ONCHAIN")): + context["swap_allowed"] = False + context["swap_failure_reason"] = "On-the-fly submarine swaps are dissabled" + return True, context + if order.payout_tx == 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 swaps" + context["swap_failure_reason"] = "Not enough onchain liquidity available to offer a SWAP" return True, context context["swap_allowed"] = True @@ -1246,7 +1251,6 @@ class Logics: def settle_escrow(order): """Settles the trade escrow hold invoice""" - # TODO ERROR HANDLING if LNNode.settle_hold_invoice(order.trade_escrow.preimage): order.trade_escrow.status = LNPayment.Status.SETLED order.trade_escrow.save() @@ -1254,7 +1258,6 @@ class Logics: def settle_bond(bond): """Settles the bond hold invoice""" - # TODO ERROR HANDLING if LNNode.settle_hold_invoice(bond.preimage): bond.status = LNPayment.Status.SETLED bond.save() @@ -1310,6 +1313,30 @@ class Logics: 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') + return True + + # Pay onchain to address + else: + valid = LNNode.pay_onchain(order.payout_tx) + if valid: + order.status = Order.Status.SUC + order.save() + send_message.delay(order.id,'trade_successful') + return True + return False + @classmethod def confirm_fiat(cls, order, user): """If Order is in the CHAT states: @@ -1318,7 +1345,7 @@ class Logics: 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): @@ -1334,30 +1361,24 @@ class Logics: } # Make sure the trade escrow is at least as big as the buyer invoice - if order.trade_escrow.num_satoshis <= order.payout.num_satoshis: + 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." } - - if cls.settle_escrow( - order - ): ##### !!! KEY LINE - SETTLES THE TRADE ESCROW !!! + + # !!! 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 // Probably best also do it even if payment failed + 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 !!! - ##### 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') + cls.pay_buyer(order) # Add referral rewards (safe) try: diff --git a/api/models.py b/api/models.py index f7d02e2d..c1fde799 100644 --- a/api/models.py +++ b/api/models.py @@ -220,7 +220,7 @@ class OnchainPayment(models.Model): null=False, blank=False) mining_fee_rate = models.DecimalField(max_digits=6, - decimal_places=3, + decimal_places=3, default=1.05, null=False, blank=False) diff --git a/api/views.py b/api/views.py index 78ba2049..1365e60e 100644 --- a/api/views.py +++ b/api/views.py @@ -12,7 +12,7 @@ from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.models import User from api.serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer, ClaimRewardSerializer, PriceSerializer, UserGenSerializer -from api.models import LNPayment, MarketTick, Order, Currency, Profile +from api.models import LNPayment, MarketTick, OnchainPayment, Order, Currency, Profile from control.models import AccountingDay from api.logics import Logics from api.messages import Telegram @@ -399,6 +399,19 @@ class OrderView(viewsets.ViewSet): if order.status == Order.Status.EXP: data["expiry_reason"] = order.expiry_reason data["expiry_message"] = Order.ExpiryReasons(order.expiry_reason).label + + # If status is 'Succes' add final stats and txid if it is a swap + if order.status == Order.Status.SUC: + # TODO: add summary of order for buyer/sellers: sats in/out, fee paid, total time? etc + # If buyer and is a swap, add TXID + if Logics.is_buyer(order,request.user): + if order.is_swap: + data["num_satoshis"] = order.payout_tx.num_satoshis + data["sent_satoshis"] = order.payout_tx.sent_satoshis + if order.payout_tx.status in [OnchainPayment.Status.MEMPO, OnchainPayment.Status.CONFI]: + data["txid"] = order.payout_tx.txid + + return Response(data, status.HTTP_200_OK) diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 68aa7bc5..6b6781be 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -1228,19 +1228,36 @@ handleRatingRobosatsChange=(e)=>{ {this.state.rating_platform==5 ? -

{t("Thank you! RoboSats loves you too ❤️")}

-

{t("RoboSats gets better with more liquidity and users. Tell a bitcoiner friend about Robosats!")}

+ {t("Thank you! RoboSats loves you too ❤️")} +
+ + {t("RoboSats gets better with more liquidity and users. Tell a bitcoiner friend about Robosats!")}
: null} {this.state.rating_platform!=5 & this.state.rating_platform!=null ? -

{t("Thank you for using Robosats!")}

-

Let us know how the platform could improve (Telegram / Github)

+ {t("Thank you for using Robosats!")} +
+ + Let us know how the platform could improve (Telegram / Github)
: null} + + {/* SHOW TXID IF USER RECEIVES ONCHAIN */} + {this.props.data.txid ? + + + {t("Your TXID:")} + + + {this.props.data.txid} + + + : null} + From 5c87c5ad8523260645dc809372cadf1c3b3c703a Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 16 Jun 2022 13:01:10 -0700 Subject: [PATCH 10/14] Add UI elements for swap fee and TXID payout. Fix bugs. --- api/lightning/node.py | 7 +++--- api/logics.py | 10 ++++---- api/views.py | 5 +++- frontend/src/components/BottomBar.js | 1 + .../components/Dialogs/ExchangeSummary.tsx | 22 +++++++++++++++++ frontend/src/components/TradeBox.js | 24 ++++++++++++------- robosats/celery/__init__.py | 2 +- 7 files changed, 50 insertions(+), 21 deletions(-) diff --git a/api/lightning/node.py b/api/lightning/node.py index 96ad7e69..655d5528 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -1,6 +1,6 @@ import grpc, os, hashlib, secrets, ring -from robosats.api.models import OnchainPayment + from . import lightning_pb2 as lnrpc, lightning_pb2_grpc as lightningstub from . import invoices_pb2 as invoicesrpc, invoices_pb2_grpc as invoicesstub from . import router_pb2 as routerrpc, router_pb2_grpc as routerstub @@ -118,7 +118,7 @@ class LNNode: def pay_onchain(cls, onchainpayment): """Send onchain transaction for buyer payouts""" - if bool(config("DISABLE_ONCHAIN")): + if config("DISABLE_ONCHAIN", cast=bool): return False request = lnrpc.SendCoinsRequest(addr=onchainpayment.address, @@ -126,13 +126,12 @@ class LNNode: sat_per_vbyte=int(onchainpayment.mining_fee_rate), label=str("Payout order #" + str(onchainpayment.order_paid_TX.id)), spend_unconfirmed=True) + response = cls.lightningstub.SendCoins(request, metadata=[("macaroon", MACAROON.hex())]) - print(response) onchainpayment.txid = response.txid - onchainpayment.status = OnchainPayment.Status.MEMPO onchainpayment.save() return True diff --git a/api/logics.py b/api/logics.py index 6bd29e8b..f7b9a8a9 100644 --- a/api/logics.py +++ b/api/logics.py @@ -516,10 +516,6 @@ class Logics: 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)) - print("MIN_SWAP_FEE",MIN_SWAP_FEE) - print("MAX_SWAP_FEE",MAX_SWAP_FEE) - print("SWAP_LAMBDA",SWAP_LAMBDA) - print("swap_fee_rate",swap_fee_rate) return swap_fee_rate * 100 @@ -589,7 +585,7 @@ class Logics: context["swap_failure_reason"] = "Order amount is too small to be eligible for a swap" return True, context - if not bool(config("DISABLE_ONCHAIN")): + if config("DISABLE_ONCHAIN", cast=bool): context["swap_allowed"] = False context["swap_failure_reason"] = "On-the-fly submarine swaps are dissabled" return True, context @@ -1316,7 +1312,7 @@ class Logics: @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 @@ -1331,6 +1327,8 @@ class Logics: else: valid = LNNode.pay_onchain(order.payout_tx) if valid: + order.payout_tx.status = OnchainPayment.Status.MEMPO + order.payout_tx.save() order.status = Order.Status.SUC order.save() send_message.delay(order.id,'trade_successful') diff --git a/api/views.py b/api/views.py index 1365e60e..bc0d6853 100644 --- a/api/views.py +++ b/api/views.py @@ -13,7 +13,7 @@ from django.contrib.auth.models import User from api.serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer, ClaimRewardSerializer, PriceSerializer, UserGenSerializer from api.models import LNPayment, MarketTick, OnchainPayment, Order, Currency, Profile -from control.models import AccountingDay +from control.models import AccountingDay, BalanceLog from api.logics import Logics from api.messages import Telegram from secrets import token_urlsafe @@ -410,6 +410,7 @@ class OrderView(viewsets.ViewSet): data["sent_satoshis"] = order.payout_tx.sent_satoshis if order.payout_tx.status in [OnchainPayment.Status.MEMPO, OnchainPayment.Status.CONFI]: data["txid"] = order.payout_tx.txid + data["network"] = str(config("NETWORK")) @@ -892,6 +893,8 @@ class InfoView(ListAPIView): context["taker_fee"] = float(config("FEE"))*(1 - float(config("MAKER_FEE_SPLIT"))) context["bond_size"] = float(config("DEFAULT_BOND_SIZE")) + context["current_swap_fee_rate"] = Logics.compute_swap_fee_rate(BalanceLog.objects.latest('time')) + if request.user.is_authenticated: context["nickname"] = request.user.username context["referral_code"] = str(request.user.profile.referral_code) diff --git a/frontend/src/components/BottomBar.js b/frontend/src/components/BottomBar.js index e37c5ace..2daef79d 100644 --- a/frontend/src/components/BottomBar.js +++ b/frontend/src/components/BottomBar.js @@ -400,6 +400,7 @@ bottomBarPhone =()=>{ lastDayNonkycBtcPremium={this.state.last_day_nonkyc_btc_premium} makerFee={this.state.maker_fee} takerFee={this.state.taker_fee} + swapFeeRate={this.state.current_swap_fee_rate} /> { const { t } = useTranslation(); + if (swapFeeRate === null || swapFeeRate === undefined) { + swapFeeRate = 0 + } return ( + + + + + + + + + + +
diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 6b6781be..2f31c3ac 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -1,6 +1,6 @@ import React, { Component } from "react"; import { withTranslation, Trans} from "react-i18next"; -import { Tabs, Tab, IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" +import { Alert, AlertTitle, Tabs, Tab, IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" import QRCode from "react-qr-code"; import Countdown, { zeroPad} from 'react-countdown'; import Chat from "./EncryptedChat" @@ -719,7 +719,7 @@ class TradeBox extends Component { - {t("RoboSats will do a swap and send the Sats to your onchain address for a fee.")} + {t("EXPERIMENTAL: ")}{t("RoboSats will do a swap and send the Sats to your onchain address.")} @@ -1248,13 +1248,19 @@ handleRatingRobosatsChange=(e)=>{ {/* SHOW TXID IF USER RECEIVES ONCHAIN */} {this.props.data.txid ? - - - {t("Your TXID:")} - - - {this.props.data.txid} - + + + {t("Your TXID")} + + {navigator.clipboard.writeText(this.props.data.txid)}}> + + + + + + {this.props.data.txid} + + : null} diff --git a/robosats/celery/__init__.py b/robosats/celery/__init__.py index 0e8f5425..1cfdcf48 100644 --- a/robosats/celery/__init__.py +++ b/robosats/celery/__init__.py @@ -57,7 +57,7 @@ app.conf.beat_schedule = { }, "compute-node-balance": { # Logs LND channel and wallet balance "task":"compute_node_balance", - "schedule": timedelta(minutes=15), + "schedule": timedelta(minutes=60), } } From 43b85d79d4bd119c437227c2de29732b48ff01ec Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 16 Jun 2022 15:45:36 -0700 Subject: [PATCH 11/14] Fix invoice payouts. Add onchain summary to accounting days. --- api/lightning/node.py | 10 ++------ api/logics.py | 2 +- api/tasks.py | 2 +- control/admin.py | 28 ++-------------------- control/models.py | 37 ++--------------------------- control/tasks.py | 26 +++++++++++++++----- frontend/src/components/TradeBox.js | 2 +- frontend/src/locales/en.json | 3 ++- frontend/src/locales/es.json | 1 + 9 files changed, 32 insertions(+), 79 deletions(-) diff --git a/api/lightning/node.py b/api/lightning/node.py index 655d5528..015c2dc0 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -93,7 +93,7 @@ class LNNode: response = cls.lightningstub.WalletBalance(request, metadata=[("macaroon", MACAROON.hex())]) - print(response) + return {'total_balance': response.total_balance, 'confirmed_balance': response.confirmed_balance, 'unconfirmed_balance': response.unconfirmed_balance} @@ -108,7 +108,7 @@ class LNNode: metadata=[("macaroon", MACAROON.hex())]) - print(response) + return {'local_balance': response.local_balance.sat, 'remote_balance': response.remote_balance.sat, 'unsettled_local_balance': response.unsettled_local_balance.sat, @@ -208,22 +208,17 @@ class LNNode: metadata=[("macaroon", MACAROON.hex()) ]) - print("status here") - print(response.state) - # TODO ERROR HANDLING # Will fail if 'unable to locate invoice'. Happens if invoice expiry # time has passed (but these are 15% padded at the moment). Should catch it # and report back that the invoice has expired (better robustness) if response.state == 0: # OPEN - print("STATUS: OPEN") pass if response.state == 1: # SETTLED pass if response.state == 2: # CANCELLED pass if response.state == 3: # ACCEPTED (LOCKED) - print("STATUS: ACCEPTED") lnpayment.expiry_height = response.htlcs[0].expiry_height lnpayment.status = LNPayment.Status.LOCKED lnpayment.save() @@ -254,7 +249,6 @@ class LNNode: try: payreq_decoded = cls.decode_payreq(invoice) - print(payreq_decoded) except: payout["context"] = { "bad_invoice": "Does not look like a valid lightning invoice" diff --git a/api/logics.py b/api/logics.py index f7b9a8a9..11fb8384 100644 --- a/api/logics.py +++ b/api/logics.py @@ -725,7 +725,7 @@ class Logics: concept=LNPayment.Concepts.PAYBUYER, type=LNPayment.Types.NORM, sender=User.objects.get(username=ESCROW_USERNAME), - order_paid= + 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. diff --git a/api/tasks.py b/api/tasks.py index 469fd1c0..c9e0052d 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -87,7 +87,7 @@ def follow_send_payment(hash): timeout_seconds=timeout_seconds, ) - order = lnpayment.order_paid + order = lnpayment.order_paid_LN try: for response in LNNode.routerstub.SendPaymentV2(request, metadata=[ diff --git a/control/admin.py b/control/admin.py index 1287b114..2740c408 100755 --- a/control/admin.py +++ b/control/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from control.models import AccountingDay, AccountingMonth, BalanceLog +from control.models import AccountingDay, BalanceLog from import_export.admin import ImportExportModelAdmin # Register your models here. @@ -17,6 +17,7 @@ class AccountingDayAdmin(ImportExportModelAdmin): "inflow", "outflow", "routing_fees", + "mining_fees", "cashflow", "outstanding_earned_rewards", "outstanding_pending_disputes", @@ -28,31 +29,6 @@ class AccountingDayAdmin(ImportExportModelAdmin): change_links = ["day"] search_fields = ["day"] -@admin.register(AccountingMonth) -class AccountingMonthAdmin(ImportExportModelAdmin): - - list_display = ( - "month", - "contracted", - "num_contracts", - "net_settled", - "net_paid", - "net_balance", - "inflow", - "outflow", - "routing_fees", - "cashflow", - "outstanding_earned_rewards", - "outstanding_pending_disputes", - "lifetime_rewards_claimed", - "outstanding_earned_rewards", - "pending_disputes", - "rewards_claimed", - ) - change_links = ["month"] - search_fields = ["month"] - - @admin.register(BalanceLog) class BalanceLogAdmin(ImportExportModelAdmin): diff --git a/control/models.py b/control/models.py index 7222e04a..cc3af699 100755 --- a/control/models.py +++ b/control/models.py @@ -23,6 +23,8 @@ class AccountingDay(models.Model): outflow = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) # Total cost in routing fees routing_fees = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) + # Total cost in minig fees + mining_fees = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) # Total inflows minus outflows and routing fees cashflow = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) # Balance on earned rewards (referral rewards, slashed bonds and solved disputes) @@ -38,41 +40,6 @@ class AccountingDay(models.Model): # Rewards claimed on day rewards_claimed = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) -class AccountingMonth(models.Model): - month = models.DateTimeField(primary_key=True, auto_now=False, auto_now_add=False) - - # Every field is denominated in Sats with (3 decimals for millisats) - # Total volume contracted - contracted = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Number of contracts - num_contracts = models.BigIntegerField(default=0, null=False, blank=False) - # Net volume of trading invoices settled (excludes disputes) - net_settled = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Net volume of trading invoices paid (excludes rewards and disputes) - net_paid = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Sum of net settled and net paid - net_balance = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Total volume of invoices settled - inflow = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Total volume of invoices paid - outflow = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Total cost in routing fees - routing_fees = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Total inflows minus outflows and routing fees - cashflow = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Balance on earned rewards (referral rewards, slashed bonds and solved disputes) - outstanding_earned_rewards = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Balance on pending disputes (not resolved yet) - outstanding_pending_disputes = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Rewards claimed lifetime - lifetime_rewards_claimed = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Balance change from last day on earned rewards (referral rewards, slashed bonds and solved disputes) - earned_rewards = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Balance change on pending disputes (not resolved yet) - pending_disputes = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - # Rewards claimed on day - rewards_claimed = models.DecimalField(max_digits=15, decimal_places=3, default=0, null=False, blank=False) - class BalanceLog(models.Model): def get_total(): diff --git a/control/tasks.py b/control/tasks.py index 333362cb..69905b6f 100644 --- a/control/tasks.py +++ b/control/tasks.py @@ -6,7 +6,7 @@ def do_accounting(): Does all accounting from the beginning of time ''' - from api.models import Order, LNPayment, Profile, MarketTick + from api.models import Order, LNPayment, OnchainPayment, Profile, MarketTick from control.models import AccountingDay from django.utils import timezone from datetime import timedelta @@ -36,14 +36,16 @@ def do_accounting(): result = {} while day <= today: day_payments = all_payments.filter(created_at__gte=day,created_at__lte=day+timedelta(days=1)) + day_onchain_payments = OnchainPayment.objects.filter(created_at__gte=day,created_at__lte=day+timedelta(days=1)) day_ticks = all_ticks.filter(timestamp__gte=day,timestamp__lte=day+timedelta(days=1)) - # Coarse accounting based on LNpayment objects + # Coarse accounting based on LNpayment and OnchainPayment objects contracted = day_ticks.aggregate(Sum('volume'))['volume__sum'] num_contracts = day_ticks.count() inflow = day_payments.filter(type=LNPayment.Types.HOLD,status=LNPayment.Status.SETLED).aggregate(Sum('num_satoshis'))['num_satoshis__sum'] - outflow = day_payments.filter(type=LNPayment.Types.NORM,status=LNPayment.Status.SUCCED).aggregate(Sum('num_satoshis'))['num_satoshis__sum'] + outflow = day_payments.filter(type=LNPayment.Types.NORM,status=LNPayment.Status.SUCCED).aggregate(Sum('num_satoshis'))['num_satoshis__sum'] + day_onchain_payments.filter(status__in=[OnchainPayment.Status.MEMPO,OnchainPayment.Status.CONFI]).aggregate(Sum('sent_satoshis'))['sent_satoshis__sum'] routing_fees = day_payments.filter(type=LNPayment.Types.NORM,status=LNPayment.Status.SUCCED).aggregate(Sum('fee'))['fee__sum'] + mining_fees = day_onchain_payments.filter(status__in=[OnchainPayment.Status.MEMPO,OnchainPayment.Status.CONFI]).aggregate(Sum('mining_fee_sats'))['mining_fee_sats__sum'] rewards_claimed = day_payments.filter(type=LNPayment.Types.NORM,concept=LNPayment.Concepts.WITHREWA,status=LNPayment.Status.SUCCED).aggregate(Sum('num_satoshis'))['num_satoshis__sum'] contracted = 0 if contracted == None else contracted @@ -59,6 +61,7 @@ def do_accounting(): inflow = inflow, outflow = outflow, routing_fees = routing_fees, + mining_fees = mining_fees, cashflow = inflow - outflow - routing_fees, rewards_claimed = rewards_claimed, ) @@ -70,9 +73,20 @@ def do_accounting(): payouts_paid = 0 routing_cost = 0 for payout in payouts: - escrows_settled += payout.order_paid.trade_escrow.num_satoshis + escrows_settled += payout.order_paid_LN.trade_escrow.num_satoshis payouts_paid += payout.num_satoshis routing_cost += payout.fee + + # Same for orders that use onchain payments. + payouts_tx = day_onchain_payments.filter(status__in=[OnchainPayment.Status.MEMPO,OnchainPayment.Status.CONFI]) + escrows_settled = 0 + payouts_tx_paid = 0 + mining_cost = 0 + for payout_tx in payouts_tx: + escrows_settled += payout_tx.order_paid_TX.trade_escrow.num_satoshis + payouts_tx_paid += payout_tx.sent_satoshis + mining_cost += payout_tx.fee + # account for those orders where bonds were lost # + Settled bonds / bond_split @@ -117,8 +131,8 @@ def compute_node_balance(): ''' Queries LND for channel and wallet balance ''' + from control.models import BalanceLog - BalanceLog.objects.create() - + return \ No newline at end of file diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 2f31c3ac..527e6ec8 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -936,7 +936,7 @@ class TradeBox extends Component { - {t("Your invoice looks good!")} {" " + this.stepXofY()} + {t("Your info looks good!")} {" " + this.stepXofY()} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 73f1ba85..659907e9 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -181,6 +181,7 @@ "You do not have previous orders":"You do not have previous orders", "Join RoboSats' Subreddit":"Join RoboSats' Subreddit", "RoboSats in Reddit":"RoboSats in Reddit", + "Current onchain payout fee":"Current onchain payout fee", "ORDER PAGE - OrderPage.js": "Order details page", "Order Box":"Order Box", @@ -316,7 +317,7 @@ "Submit an invoice for {{amountSats}} Sats":"Submit an invoice for {{amountSats}} Sats", "The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC. Please provide a valid invoice for {{amountSats}} Satoshis.":"The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC. Please provide a valid invoice for {{amountSats}} Satoshis.", "Payout Lightning Invoice":"Payout Lightning Invoice", - "Your invoice looks good!":"Your invoice looks good!", + "Your info looks good!":"Your info looks good!", "We are waiting for the seller to lock the trade amount.":"We are waiting for the seller to lock the trade amount.", "Just hang on for a moment. If the seller does not deposit, you will get your bond back automatically. In addition, you will receive a compensation (check the rewards in your profile).":"Just hang on for a moment. If the seller does not deposit, you will get your bond back automatically. In addition, you will receive a compensation (check the rewards in your profile).", "The trade collateral is locked!":"The trade collateral is locked!", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index e68d222f..2fae7ad9 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -181,6 +181,7 @@ "You do not have previous orders":"No tienes órdenes previas", "Join RoboSats' Subreddit":"Únete al subreddit de RoboSats", "RoboSats in Reddit":"RoboSats en Reddit", + "Current onchain payout fee":"Coste por envio onchain actual", "ORDER PAGE - OrderPage.js": "Order details page", "Order Box": "Orden", From 253215f7f6c08a14e8ef6f1c1dbdf5c576be13fa Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 16 Jun 2022 16:02:55 -0700 Subject: [PATCH 12/14] Fix net daily summaries --- control/tasks.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/control/tasks.py b/control/tasks.py index 69905b6f..d1a925a6 100644 --- a/control/tasks.py +++ b/control/tasks.py @@ -71,21 +71,18 @@ def do_accounting(): payouts = day_payments.filter(type=LNPayment.Types.NORM,concept=LNPayment.Concepts.PAYBUYER, status=LNPayment.Status.SUCCED) escrows_settled = 0 payouts_paid = 0 - routing_cost = 0 + costs = 0 for payout in payouts: escrows_settled += payout.order_paid_LN.trade_escrow.num_satoshis payouts_paid += payout.num_satoshis - routing_cost += payout.fee + costs += payout.fee # Same for orders that use onchain payments. payouts_tx = day_onchain_payments.filter(status__in=[OnchainPayment.Status.MEMPO,OnchainPayment.Status.CONFI]) - escrows_settled = 0 - payouts_tx_paid = 0 - mining_cost = 0 for payout_tx in payouts_tx: escrows_settled += payout_tx.order_paid_TX.trade_escrow.num_satoshis - payouts_tx_paid += payout_tx.sent_satoshis - mining_cost += payout_tx.fee + payouts_paid += payout_tx.sent_satoshis + costs += payout_tx.fee # account for those orders where bonds were lost @@ -98,7 +95,7 @@ def do_accounting(): collected_slashed_bonds = 0 accounted_day.net_settled = escrows_settled + collected_slashed_bonds - accounted_day.net_paid = payouts_paid + routing_cost + accounted_day.net_paid = payouts_paid + costs accounted_day.net_balance = float(accounted_day.net_settled) - float(accounted_day.net_paid) # Differential accounting based on change of outstanding states and disputes unreslved From 228927425167890bbeec338799a73d99bccbe1b1 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 17 Jun 2022 04:36:27 -0700 Subject: [PATCH 13/14] Improve toggle button onchain/LN, add bad address messages --- api/logics.py | 22 +++++++++++++++++----- api/utils.py | 19 ++++++++++++++++--- frontend/src/components/TradeBox.js | 25 +++++++++++++++---------- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/api/logics.py b/api/logics.py index 11fb8384..0ce55f5f 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1,5 +1,6 @@ from datetime import timedelta from tkinter import N, ON +from tokenize import Octnumber from django.utils import timezone from api.lightning.node import LNNode from django.db.models import Q, Sum @@ -526,6 +527,10 @@ class Logics: 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)) @@ -647,11 +652,10 @@ class Logics: "You cannot submit an adress are not locked." } # not a valid address (does not accept Taproot as of now) - if not validate_onchain_address(address): - return False, { - "bad_address": - "Does not look like a valid address" - } + 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: @@ -715,6 +719,11 @@ class Logics: "You cannot submit an invoice only after expiration or 3 failed attempts" } + # cancel onchain_payout if existing + if order.payout_tx: + order.payout_tx.status = OnchainPayment.Status.CANCE + order.payout_tx.save() + num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"] payout = LNNode.validate_ln_invoice(invoice, num_satoshis) @@ -1325,6 +1334,9 @@ class Logics: # Pay onchain to address else: + if not order.payout_tx.status == OnchainPayment.Status.VALID: + return False + valid = LNNode.pay_onchain(order.payout_tx) if valid: order.payout_tx.status = OnchainPayment.Status.MEMPO diff --git a/api/utils.py b/api/utils.py index 89205726..b303a24e 100644 --- a/api/utils.py +++ b/api/utils.py @@ -19,15 +19,28 @@ def validate_onchain_address(address): validation = addr.validate('btc', address.encode('utf-8')) if not validation.valid: - return False + return False, { + "bad_address": + "Does not look like a valid address" + } NETWORK = str(config('NETWORK')) if NETWORK == 'mainnet': if validation.network == 'main': - return True + return True, None + else: + return False, { + "bad_address": + "This is not a bitcoin mainnet address" + } elif NETWORK == 'testnet': if validation.network == 'test': - return True + return True, None + else: + return False, { + "bad_address": + "This is not a bitcoin testnet address" + } market_cache = {} @ring.dict(market_cache, expire=3) # keeps in cache for 3 seconds diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 527e6ec8..5d5700fb 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -1,6 +1,6 @@ import React, { Component } from "react"; import { withTranslation, Trans} from "react-i18next"; -import { Alert, AlertTitle, Tabs, Tab, IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" +import { Alert, AlertTitle, ToggleButtonGroup, ToggleButton, IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" import QRCode from "react-qr-code"; import Countdown, { zeroPad} from 'react-countdown'; import Chat from "./EncryptedChat" @@ -654,16 +654,21 @@ class TradeBox extends Component { currencyCode: this.props.data.currencyCode})} + + + + + this.setState({receiveTab:0})}> +
Lightning
+
+ this.setState({receiveTab:1, miningFee: parseFloat(this.props.data.suggested_mining_fee_rate)})} > +
Onchain
+
+
+
- - - - Lightning} onClick={() => this.setState({receiveTab:0})}/> - Onchain} disabled={!this.props.data.swap_allowed} onClick={() => this.setState({receiveTab:1, miningFee: parseFloat(this.props.data.suggested_mining_fee_rate)})} /> - - - - {/* LIGHTNING PAYOUT TAB */}
From 3141018baabb85d372b8b6eed12b7005de704c52 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 17 Jun 2022 05:03:18 -0700 Subject: [PATCH 14/14] Add locale strings for onchain swap texts --- frontend/src/components/TradeBox.js | 6 +++--- frontend/src/locales/en.json | 22 ++++++++++++++++++++-- frontend/src/locales/es.json | 22 ++++++++++++++++++---- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 5d5700fb..3ebd4155 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -649,7 +649,7 @@ class TradeBox extends Component { - {t("The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.", + {t("Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.", {amountFiat: parseFloat(parseFloat(this.props.data.amount).toFixed(4)), currencyCode: this.props.data.currencyCode})} @@ -661,10 +661,10 @@ class TradeBox extends Component { value={this.state.receiveTab} exclusive > this.setState({receiveTab:0})}> -
Lightning
+
{t("Lightning")}
this.setState({receiveTab:1, miningFee: parseFloat(this.props.data.suggested_mining_fee_rate)})} > -
Onchain
+
{t("Onchain")}
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 659907e9..7574a98f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -314,8 +314,6 @@ "Among public {{currencyCode}} orders (higher is cheaper)": "Among public {{currencyCode}} orders (higher is cheaper)", "A taker has been found!":"A taker has been found!", "Please wait for the taker to lock a bond. If the taker does not lock a bond in time, the order will be made public again.":"Please wait for the taker to lock a bond. If the taker does not lock a bond in time, the order will be made public again.", - "Submit an invoice for {{amountSats}} Sats":"Submit an invoice for {{amountSats}} Sats", - "The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC. Please provide a valid invoice for {{amountSats}} Satoshis.":"The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC. Please provide a valid invoice for {{amountSats}} Satoshis.", "Payout Lightning Invoice":"Payout Lightning Invoice", "Your info looks good!":"Your info looks good!", "We are waiting for the seller to lock the trade amount.":"We are waiting for the seller to lock the trade amount.", @@ -391,6 +389,26 @@ "Does not look like a valid lightning invoice":"Does not look like a valid lightning invoice", "The invoice provided has already expired":"The invoice provided has already expired", "Make sure to EXPORT the chat log. The staff might request your exported chat log JSON in order to solve discrepancies. It is your responsibility to store it.":"Make sure to EXPORT the chat log. The staff might request your exported chat log JSON in order to solve discrepancies. It is your responsibility to store it.", + "Does not look like a valid address":"Does not look like a valid address", + "This is not a bitcoin mainnet address":"This is not a bitcoin mainnet address", + "This is not a bitcoin testnet address":"This is not a bitcoin testnet address", + "Submit payout info for {{amountSats}} Sats":"Submit payout info for {{amountSats}} Sats", + "Submit a valid invoice for {{amountSats}} Satoshis.":"Submit a valid invoice for {{amountSats}} Satoshis.", + "Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.":"Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.", + "RoboSats will do a swap and send the Sats to your onchain address.":"RoboSats will do a swap and send the Sats to your onchain address.", + "Swap fee":"Swap fee", + "Mining fee":"Mining fee", + "Mining Fee":"Mining Fee", + "Final amount you will receive":"Final amount you will receive", + "Bitcoin Address":"Bitcoin Address", + "Your TXID":"Your TXID", + "Lightning":"Lightning", + "Onchain":"Onchain", + + + + + "INFO DIALOG - InfoDiagog.js":"App information and clarifications and terms of use", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 2fae7ad9..9c10bd9d 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -181,7 +181,9 @@ "You do not have previous orders":"No tienes órdenes previas", "Join RoboSats' Subreddit":"Únete al subreddit de RoboSats", "RoboSats in Reddit":"RoboSats en Reddit", - "Current onchain payout fee":"Coste por envio onchain actual", + "Current onchain payout fee":"Coste actual de recibir onchain", + "Lightning":"Lightning", + "Onchain":"Onchain", "ORDER PAGE - OrderPage.js": "Order details page", "Order Box": "Orden", @@ -313,10 +315,8 @@ "Among public {{currencyCode}} orders (higher is cheaper)": "Entre las órdenes públicas de {{currencyCode}} (más alto, más barato)", "A taker has been found!": "¡Un tomador ha sido encontrado!", "Please wait for the taker to lock a bond. If the taker does not lock a bond in time, the order will be made public again.": "Por favor, espera a que el tomador bloquee su fianza. Si no lo hace a tiempo, la orden será pública de nuevo.", - "Submit an invoice for {{amountSats}} Sats": "Envía una factura por {{amountSats}} Sats", - "The taker is committed! Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC. Please provide a valid invoice for {{amountSats}} Satoshis.": "¡El tomador está comprometido! Antes de dejarte enviar {{amountFiat}} {{currencyCode}}, queremos asegurarnos de que puedes recibir en Lightning. Por favor proporciona una factura válida por {{amountSats}} Sats.", "Payout Lightning Invoice": "Factura Lightning", - "Your invoice looks good!": "¡Tu factura es buena!", + "Your info looks good!": "¡Info del envio recibida!", "We are waiting for the seller lock the trade amount.": "Esperando a que el vendedor bloquee el colateral.", "Just hang on for a moment. If the seller does not deposit, you will get your bond back automatically. In addition, you will receive a compensation (check the rewards in your profile).": "Espera un momento. Si el vendedor no deposita, recuperarás tu fianza automáticamente. Además, recibirás una compensación (comprueba las recompensas en tu perfil).", "The trade collateral is locked!": "¡El colateral está bloqueado!", @@ -389,6 +389,20 @@ "The invoice provided has no explicit amount":"La factura entregada no contiene una cantidad explícita", "Does not look like a valid lightning invoice":"No parece ser una factura lightning válida", "The invoice provided has already expired":"La factura que has entregado ya ha caducado", + "Make sure to EXPORT the chat log. The staff might request your exported chat log JSON in order to solve discrepancies. It is your responsibility to store it.":"Asegurate de EXPORTAR el registro del chat. Los administradores pueden pedirte el registro del chat en caso de discrepancias. Es tu responsabilidad proveerlo.", + "Does not look like a valid address":"No parece una dirección Bitcoin válida", + "This is not a bitcoin mainnet address":"No es una dirección de mainnet", + "This is not a bitcoin testnet address":"No es una dirección de testnet", + "Submit payout info for {{amountSats}} Sats":"Envia info para recibir {{amountSats}} Sats", + "Submit a valid invoice for {{amountSats}} Satoshis.": "Envía una factura por {{amountSats}} Sats", + "Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.":"Antes de dejarte enviar {{amountFiat}} {{currencyCode}}, queremos asegurarnos de que puedes recibir el Bitcoin.", + "RoboSats will do a swap and send the Sats to your onchain address.":"RoboSats hará un swap y enviará los Sats a tu dirección en la cadena.", + "Swap fee":"Comisión del swap", + "Mining fee":"Comisión minera", + "Mining Fee":"Comisión Minera", + "Final amount you will receive":"Monto final que vas a recibir", + "Bitcoin Address":"Dirección Bitcoin", + "Your TXID":"Tu TXID", "INFO DIALOG - InfoDiagog.js": "App information and clarifications and terms of use",