diff --git a/.env-sample b/.env-sample index 799143f6..3417a7ff 100644 --- a/.env-sample +++ b/.env-sample @@ -91,11 +91,35 @@ 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 +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 +# Routing timeouts +REWARDS_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) +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 (1%) +MIN_SWAP_FEE = 0.01 +# Liquidity split point (LN/onchain) at which we use MIN_SWAP_FEE +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 +# Min amount allowed for Swap +MIN_SWAP_AMOUNT = 50000 # Reward tip. Reward for every finished trade in the referral program (Satoshis) REWARD_TIP = 100 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/lightning/node.py b/api/lightning/node.py index 4781b79c..015c2dc0 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -1,4 +1,6 @@ -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 @@ -9,7 +11,6 @@ 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) @@ -67,6 +68,74 @@ 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 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, + 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())]) + + 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())]) + + + 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 pay_onchain(cls, onchainpayment): + """Send onchain transaction for buyer payouts""" + + if config("DISABLE_ONCHAIN", cast=bool): + 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())]) + + onchainpayment.txid = response.txid + onchainpayment.save() + + return True + @classmethod def cancel_return_hold_invoice(cls, payment_hash): """Cancels or returns a hold invoice""" @@ -131,28 +200,25 @@ 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, 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() @@ -183,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" @@ -238,7 +303,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 @@ -251,15 +316,17 @@ 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")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), )) # 200 ppm or 10 sats + timeout_seconds = int(config("REWARDS_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/logics.py b/api/logics.py index 1e2d2808..0ce55f5f 100644 --- a/api/logics.py +++ b/api/logics.py @@ -1,12 +1,14 @@ from datetime import timedelta -from tkinter import N +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 +from django.db.models import Q, Sum -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 +from api.utils import validate_onchain_address import gnupg @@ -494,10 +496,78 @@ 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 float(balance.onchain_fraction) > MIN_POINT: + swap_fee_rate = MIN_SWAP_FEE + else: + slope = (MAX_SWAP_FEE - MIN_SWAP_FEE) / (MAX_POINT - MIN_POINT) + swap_fee_rate = slope * (balance.onchain_fraction - MAX_POINT) + MAX_SWAP_FEE + + elif shape == "exponential": + MIN_SWAP_FEE = float(config('MIN_SWAP_FEE')) + MAX_SWAP_FEE = float(config('MAX_SWAP_FEE')) + SWAP_LAMBDA = float(config('SWAP_LAMBDA')) + swap_fee_rate = MIN_SWAP_FEE + (MAX_SWAP_FEE - MIN_SWAP_FEE) * math.exp(-SWAP_LAMBDA * float(balance.onchain_fraction)) + + return swap_fee_rate * 100 + + @classmethod + def create_onchain_payment(cls, order, user, preliminary_amount): + ''' + Creates an empty OnchainPayment for order.payout_tx. + It sets the fees to be applied to this order if onchain Swap is used. + If the user submits a LN invoice instead. The returned OnchainPayment goes unused. + ''' + # Make sure no invoice payout is attached to order + order.payout = None + + # Create onchain_payment + onchain_payment = OnchainPayment.objects.create(receiver=user) + + # Compute a safer available onchain liquidity: (confirmed_utxos - reserve - pending_outgoing_txs)) + # Accounts for already committed outgoing TX for previous users. + confirmed = onchain_payment.balance.onchain_confirmed + reserve = 0.01 * onchain_payment.balance.total # We assume a reserve of 1% + pending_txs = OnchainPayment.objects.filter(status=OnchainPayment.Status.VALID).aggregate(Sum('num_satoshis'))['num_satoshis__sum'] + + if pending_txs == None: + pending_txs = 0 + + available_onchain = confirmed - reserve - pending_txs + if preliminary_amount > available_onchain: # Not enough onchain balance to commit for this swap. + return False + + suggested_mining_fee_rate = LNNode.estimate_fee(amount_sats=preliminary_amount)["mining_fee_rate"] + + # Hardcap mining fee suggested at 50 sats/vbyte + if suggested_mining_fee_rate > 50: + suggested_mining_fee_rate = 50 + + onchain_payment.suggested_mining_fee_rate = max(1.05, LNNode.estimate_fee(amount_sats=preliminary_amount)["mining_fee_rate"]) + onchain_payment.swap_fee_rate = cls.compute_swap_fee_rate(onchain_payment.balance) + onchain_payment.save() + + order.payout_tx = onchain_payment + order.save() + return True + @classmethod def payout_amount(cls, order, user): """Computes buyer invoice amount. Uses order.last_satoshis, - that is the final trade amount set at Taker Bond time""" + 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 +578,36 @@ 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 + context["swap_failure_reason"] = "Order amount is too small to be eligible for a swap" + return True, context + + if config("DISABLE_ONCHAIN", cast=bool): + context["swap_allowed"] = False + context["swap_failure_reason"] = "On-the-fly submarine swaps are dissabled" + return True, context + + if order.payout_tx == None: + # Creates the OnchainPayment object and checks node balance + valid = cls.create_onchain_payment(order, user, preliminary_amount=context["invoice_amount"]) + if not valid: + context["swap_allowed"] = False + context["swap_failure_reason"] = "Not enough onchain liquidity available to offer a SWAP" + return True, context + + context["swap_allowed"] = True + context["suggested_mining_fee_rate"] = order.payout_tx.suggested_mining_fee_rate + context["swap_fee_rate"] = order.payout_tx.swap_fee_rate + + return True, context @classmethod def escrow_amount(cls, order, user): @@ -532,6 +628,66 @@ 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) + valid, context = validate_onchain_address(address) + if not valid: + return False, context + + if mining_fee_rate: + # not a valid mining fee + if float(mining_fee_rate) <= 1: + return False, { + "bad_address": + "The mining fee is too low." + } + 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): @@ -563,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) @@ -573,7 +734,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. @@ -588,6 +749,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 @@ -617,10 +787,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""" @@ -1087,7 +1256,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() @@ -1095,7 +1263,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() @@ -1151,6 +1318,35 @@ 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: + 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 + order.payout_tx.save() + 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: @@ -1159,7 +1355,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): @@ -1175,30 +1371,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 b55651ff..c1fde799 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,106 @@ 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 + CANCE = 4, "Cancelled" # Cancelled tx + + def get_balance(): + balance = BalanceLog.objects.create() + return balance.time + + # payment use details + concept = models.PositiveSmallIntegerField(choices=Concepts.choices, + null=False, + default=Concepts.PAYBUYER) + status = models.PositiveSmallIntegerField(choices=Status.choices, + null=False, + default=Status.CREAT) + + # 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(null=True, + validators=[ + 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, + 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 creation, swap fee rate as percent of total volume + balance = models.ForeignKey(BalanceLog, + related_name="balance", + on_delete=models.SET_NULL, + null=True, + default=get_balance) + + swap_fee_rate = models.DecimalField(max_digits=4, + decimal_places=2, + default=float(config("MIN_SWAP_FEE"))*100, + 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 = "Onchain payment" + verbose_name_plural = "Onchain payments" + + @property + def hash(self): + # Display txid as 'hash' truncated + return truncatechars(self.txid, 10) class Order(models.Model): @@ -356,10 +459,21 @@ 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, - related_name="order_paid", + related_name="order_paid_LN", + 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, 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 b35bc928..f3fde198 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -78,14 +78,16 @@ 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("PAYOUT_TIMEOUT_SECONDS")) + request = LNNode.routerrpc.SendPaymentRequest( payment_request=lnpayment.invoice, fee_limit_sat=fee_limit_sat, - timeout_seconds=75, - ) # time out payment in 75 seconds + 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/api/utils.py b/api/utils.py index 9409d1f6..8cf2b12a 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,37 @@ 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, { + "bad_address": + "Does not look like a valid address" + } + + NETWORK = str(config('NETWORK')) + if NETWORK == 'mainnet': + if validation.network == 'main': + return True, None + else: + return False, { + "bad_address": + "This is not a bitcoin mainnet address" + } + elif NETWORK == 'testnet': + if validation.network == 'test': + 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 def get_exchange_rates(currencies): diff --git a/api/views.py b/api/views.py index 3a8ab65a..bc0d6853 100644 --- a/api/views.py +++ b/api/views.py @@ -12,8 +12,8 @@ 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 control.models import AccountingDay +from api.models import LNPayment, MarketTick, OnchainPayment, Order, Currency, Profile +from control.models import AccountingDay, BalanceLog from api.logics import Logics from api.messages import Telegram from secrets import token_urlsafe @@ -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) @@ -399,6 +399,20 @@ 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 + data["network"] = str(config("NETWORK")) + + return Response(data, status.HTTP_200_OK) @@ -416,9 +430,11 @@ 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") + mining_fee_rate = serializer.data.get("mining_fee_rate") statement = serializer.data.get("statement") rating = serializer.data.get("rating") @@ -464,6 +480,13 @@ class OrderView(viewsets.ViewSet): invoice) if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) + + # 2.b) If action is 'update address' + if action == "update_address": + valid, context = Logics.update_address(order, request.user, + address, mining_fee_rate) + if not valid: + return Response(context, status.HTTP_400_BAD_REQUEST) # 3) If action is cancel elif action == "cancel": @@ -870,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/control/admin.py b/control/admin.py index 9e5a49af..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, Dispute +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,26 +29,32 @@ class AccountingDayAdmin(ImportExportModelAdmin): change_links = ["day"] search_fields = ["day"] -@admin.register(AccountingMonth) -class AccountingMonthAdmin(ImportExportModelAdmin): +@admin.register(BalanceLog) +class BalanceLogAdmin(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", + "time", + "total", + "onchain_fraction", + "onchain_total", + "onchain_confirmed", + "onchain_unconfirmed", + "ln_local", + "ln_remote", + "ln_local_unsettled", + "ln_remote_unsettled", ) - change_links = ["month"] - search_fields = ["month"] \ No newline at end of file + 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..cc3af699 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) @@ -21,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) @@ -36,42 +40,42 @@ 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 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.wallet_balance()['total_balance'] + LNNode.channel_balance()['local_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) -class AccountingMonth(models.Model): - month = models.DateTimeField(primary_key=True, auto_now=False, auto_now_add=False) + # Every field is denominated in Sats + 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) - # 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) + 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/control/tasks.py b/control/tasks.py index 616ae1ff..d1a925a6 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, OnchainPayment, 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() @@ -35,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 @@ -58,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, ) @@ -67,11 +71,19 @@ 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.trade_escrow.num_satoshis + 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]) + for payout_tx in payouts_tx: + escrows_settled += payout_tx.order_paid_TX.trade_escrow.num_satoshis + payouts_paid += payout_tx.sent_satoshis + costs += payout_tx.fee + # account for those orders where bonds were lost # + Settled bonds / bond_split @@ -83,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 @@ -109,4 +121,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/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 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 c4afc652..c76926e4 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 { 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" @@ -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,7 +35,11 @@ class TradeBox extends Component { openConfirmFiatReceived: false, openConfirmDispute: false, openEnableTelegram: false, + receiveTab: 0, + address: '', + miningFee: 1.05, badInvoice: false, + badAddress: false, badStatement: false, qrscanner: false, } @@ -540,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.bad_address}) + & this.props.completeSetState(data)); +} + handleInputDisputeChanged=(e)=>{ this.setState({ statement: e.target.value, @@ -599,57 +641,153 @@ 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("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})} + + + - - - {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)} - ) - } - + + + this.setState({receiveTab:0})}> +
{t("Lightning")}
+
+ this.setState({receiveTab:1, miningFee: parseFloat(this.props.data.suggested_mining_fee_rate)})} > +
{t("Onchain")}
+
+
+ - - {this.compatibleWalletsButton()} - + {/* LIGHTNING PAYOUT TAB */} +
+
+ + + + {t("Submit a valid invoice for {{amountSats}} Satoshis.", + {amountSats: pn(this.props.data.invoice_amount)})} + + - - - - {this.state.qrscanner ? - - - - : null } - - - - + + {this.compatibleWalletsButton()} + + + + + + {this.state.qrscanner ? + + + + : null } + + + + + +
+ + {/* ONCHAIN PAYOUT TAB */} +
+ + + + {t("EXPERIMENTAL: ")}{t("RoboSats will do a swap and send the Sats to your onchain address.")} + + + + + + + + + + + + + + + + + + + {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} + /> +
+ + + + +
+ + + {this.showBondIsLocked()} @@ -803,7 +941,7 @@ class TradeBox extends Component { - {t("Your invoice looks good!")} {" " + this.stepXofY()} + {t("Your info looks good!")} {" " + this.stepXofY()} @@ -1095,19 +1233,42 @@ 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")} + + {navigator.clipboard.writeText(this.props.data.txid)}}> + + + + + + {this.props.data.txid} + + + + : null} + 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, }) } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 7f0fbeaa..48c3a0be 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", @@ -313,10 +314,8 @@ "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 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!", @@ -390,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 ef03aad3..2483681a 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -181,6 +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 actual de recibir onchain", + "Lightning":"Lightning", + "Onchain":"Onchain", "ORDER PAGE - OrderPage.js": "Order details page", "Order Box": "Orden", @@ -312,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!", @@ -388,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", diff --git a/requirements.txt b/requirements.txt index 6f5f533e..44b2abfe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,4 @@ django-import-export==2.7.1 requests[socks] python-gnupg==0.4.9 daphne==3.0.2 +coinaddrvalidator==1.1.3 diff --git a/robosats/celery/__init__.py b/robosats/celery/__init__.py index 12b2a2fc..1cfdcf48 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=60), + } } app.conf.timezone = "UTC"