Merge branch 'onchain-buyer-payouts' into main #160

This commit is contained in:
Reckless_Satoshi 2022-06-17 05:19:59 -07:00
commit 164a960b62
No known key found for this signature in database
GPG Key ID: 9C4585B561315571
22 changed files with 922 additions and 181 deletions

View File

@ -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

View File

@ -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 ./

View File

@ -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):

View File

@ -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",

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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=[

View File

@ -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):

View File

@ -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)
@ -400,6 +400,20 @@ class OrderView(viewsets.ViewSet):
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)
def take_update_confirm_dispute_cancel(self, request, format=None):
@ -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")
@ -465,6 +481,13 @@ class OrderView(viewsets.ViewSet):
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":
valid, context = Logics.cancel_order(order, request.user)
@ -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)

View File

@ -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"]
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"]

View File

@ -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']
class AccountingMonth(models.Model):
month = models.DateTimeField(primary_key=True, auto_now=False, auto_now_add=False)
time = models.DateTimeField(primary_key=True, default=timezone.now)
# 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)
# 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)
def __str__(self):
return f"Balance at {self.time.strftime('%d/%m/%Y %H:%M:%S')}"
class Dispute(models.Model):
pass

View File

@ -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
@ -110,3 +122,14 @@ def do_accounting():
day = day + timedelta(days=1)
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

View File

@ -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

View File

@ -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}
/>
<ProfileDialog

View File

@ -19,6 +19,7 @@ import SmartToyIcon from '@mui/icons-material/SmartToy';
import PercentIcon from '@mui/icons-material/Percent';
import PriceChangeIcon from '@mui/icons-material/PriceChange';
import BookIcon from '@mui/icons-material/Book';
import LinkIcon from '@mui/icons-material/Link';
import { pn } from "../../utils/prettyNumbers";
@ -32,6 +33,7 @@ type Props = {
lastDayNonkycBtcPremium: number;
makerFee: number;
takerFee: number;
swapFeeRate: number;
}
const ExchangeSummaryDialog = ({
@ -44,8 +46,12 @@ const ExchangeSummaryDialog = ({
lastDayNonkycBtcPremium,
makerFee,
takerFee,
swapFeeRate,
}: Props): JSX.Element => {
const { t } = useTranslation();
if (swapFeeRate === null || swapFeeRate === undefined) {
swapFeeRate = 0
}
return (
<Dialog
@ -160,6 +166,22 @@ const ExchangeSummaryDialog = ({
</Grid>
</Grid>
</ListItem>
<Divider />
<ListItem >
<ListItemIcon>
<LinkIcon />
</ListItemIcon>
<ListItemText
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={`${swapFeeRate.toPrecision(3)}%`}
secondary={t("Current onchain payout fee")}
/>
</ListItem>
</List>
</DialogContent>
</Dialog>

View File

@ -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")}
<Typography color="primary" variant="subtitle1">
<b> {t("Submit an invoice for {{amountSats}} Sats",{amountSats: pn(this.props.data.invoice_amount)})}
<b> {t("Submit payout info for {{amountSats}} Sats",{amountSats: pn(this.props.data.invoice_amount)})}
</b> {" " + this.stepXofY()}
</Typography>
</Grid>
<Grid item xs={12} align="left">
<Typography variant="body2">
{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)}
)
}
</Typography>
</Grid>
<List dense={true}>
<Divider/>
<ListItem>
<Typography variant="body2">
{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})}
</Typography>
</ListItem>
</List>
<Grid item xs={12} align="center">
{this.compatibleWalletsButton()}
<ToggleButtonGroup
value={this.state.receiveTab}
exclusive >
<ToggleButton value={0} disableRipple={true} onClick={() => this.setState({receiveTab:0})}>
<div style={{display:'flex', alignItems:'center', justifyContent:'center', flexWrap:'wrap'}}><BoltIcon/> {t("Lightning")}</div>
</ToggleButton>
<ToggleButton value={1} disabled={!this.props.data.swap_allowed} onClick={() => this.setState({receiveTab:1, miningFee: parseFloat(this.props.data.suggested_mining_fee_rate)})} >
<div style={{display:'flex', alignItems:'center', justifyContent:'center', flexWrap:'wrap'}}><LinkIcon/> {t("Onchain")}</div>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
<Grid item xs={12} align="center">
<TextField
error={this.state.badInvoice}
helperText={this.state.badInvoice ? t(this.state.badInvoice) : "" }
label={t("Payout Lightning Invoice")}
required
value={this.state.invoice}
inputProps={{
style: {textAlign:"center"},
maxHeight: 200,
}}
multiline
minRows={5}
maxRows={this.state.qrscanner ? 5 : 10}
onChange={this.handleInputInvoiceChanged}
/>
</Grid>
{this.state.qrscanner ?
<Grid item xs={12} align="center">
<QrReader
delay={300}
onError={this.handleError}
onScan={this.handleScan}
style={{ width: '75%' }}
/>
</Grid>
: null }
<Grid item xs={12} align="center">
<IconButton><QrCodeScannerIcon onClick={this.handleQRbutton}/></IconButton>
<Button onClick={this.handleClickSubmitInvoiceButton} variant='contained' color='primary'>{t("Submit")}</Button>
</Grid>
{/* LIGHTNING PAYOUT TAB */}
<div style={{display: this.state.receiveTab == 0 ? '':'none'}}>
<div style={{height:15}}/>
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography variant="body2">
{t("Submit a valid invoice for {{amountSats}} Satoshis.",
{amountSats: pn(this.props.data.invoice_amount)})}
</Typography>
</Grid>
<Grid item xs={12} align="center">
{this.compatibleWalletsButton()}
</Grid>
<Grid item xs={12} align="center">
<TextField
error={this.state.badInvoice}
helperText={this.state.badInvoice ? t(this.state.badInvoice) : "" }
label={t("Payout Lightning Invoice")}
required
value={this.state.invoice}
inputProps={{
style: {textAlign:"center"},
maxHeight: 200,
}}
multiline
minRows={4}
maxRows={this.state.qrscanner ? 4 : 8}
onChange={this.handleInputInvoiceChanged}
/>
</Grid>
{this.state.qrscanner ?
<Grid item xs={12} align="center">
<QrReader
delay={300}
onError={this.handleError}
onScan={this.handleScan}
style={{ width: '75%' }}
/>
</Grid>
: null }
<Grid item xs={12} align="center">
<IconButton><QrCodeScannerIcon onClick={this.handleQRbutton}/></IconButton>
<Button onClick={this.handleClickSubmitInvoiceButton} variant='contained' color='primary'>{t("Submit")}</Button>
</Grid>
</Grid>
</div>
{/* ONCHAIN PAYOUT TAB */}
<div style={{display: this.state.receiveTab == 1 ? '':'none'}}>
<List dense={true}>
<ListItem>
<Typography variant="body2">
<b>{t("EXPERIMENTAL: ")}</b>{t("RoboSats will do a swap and send the Sats to your onchain address.")}
</Typography>
</ListItem>
<Divider/>
<ListItem>
<ListItemText
primary={pn(parseInt(this.props.data.invoice_amount * this.props.data.swap_fee_rate/100)) + " Sats (" + this.props.data.swap_fee_rate + "%)"}
secondary={t("Swap fee")}/>
</ListItem>
<Divider/>
<ListItem>
<ListItemText
primary={pn(parseInt(Math.max(1 , this.state.miningFee) * 141)) + " Sats (" + Math.max(1, this.state.miningFee) + " Sats/vByte)"}
secondary={t("Mining fee")}/>
</ListItem>
<Divider/>
<ListItem>
<ListItemText
primary={<b>{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"}</b>}
secondary={t("Final amount you will receive")}/>
</ListItem>
</List>
<TextField
error={this.state.badAddress}
helperText={this.state.badAddress ? t(this.state.badAddress) : "" }
label={t("Bitcoin Address")}
required
value={this.state.invoice}
sx={{width:170}}
inputProps={{
style: {textAlign:"center"},
}}
onChange={this.handleInputAddressChanged}
/>
<TextField
error={this.state.miningFee < 1 || this.state.miningFee > 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}
/>
<div style={{height:10}}/>
<Grid item xs={12} align="center">
<Button onClick={this.handleClickSubmitAddressButton} variant='contained' color='primary'>{t("Submit")}</Button>
</Grid>
</div>
<List>
<Divider/>
</List>
{this.showBondIsLocked()}
</Grid>
@ -803,7 +941,7 @@ class TradeBox extends Component {
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography variant="subtitle1">
<b>{t("Your invoice looks good!")}</b> {" " + this.stepXofY()}
<b>{t("Your info looks good!")}</b> {" " + this.stepXofY()}
</Typography>
</Grid>
<Grid item xs={12} align="center">
@ -1095,19 +1233,42 @@ handleRatingRobosatsChange=(e)=>{
{this.state.rating_platform==5 ?
<Grid item xs={12} align="center">
<Typography variant="body2" align="center">
<p><b>{t("Thank you! RoboSats loves you too ❤️")}</b></p>
<p>{t("RoboSats gets better with more liquidity and users. Tell a bitcoiner friend about Robosats!")}</p>
<b>{t("Thank you! RoboSats loves you too ❤️")}</b>
</Typography>
<Typography variant="body2" align="center">
{t("RoboSats gets better with more liquidity and users. Tell a bitcoiner friend about Robosats!")}
</Typography>
</Grid>
: null}
{this.state.rating_platform!=5 & this.state.rating_platform!=null ?
<Grid item xs={12} align="center">
<Typography variant="body2" align="center">
<p><b>{t("Thank you for using Robosats!")}</b></p>
<p><Trans i18nKey="let_us_know_hot_to_improve">Let us know how the platform could improve (<Link target='_blank' href="https://t.me/robosats">Telegram</Link> / <Link target='_blank' href="https://github.com/Reckless-Satoshi/robosats/issues">Github</Link>)</Trans></p>
<b>{t("Thank you for using Robosats!")}</b>
</Typography>
<Typography variant="body2" align="center">
<Trans i18nKey="let_us_know_hot_to_improve">Let us know how the platform could improve (<Link target='_blank' href="https://t.me/robosats">Telegram</Link> / <Link target='_blank' href="https://github.com/Reckless-Satoshi/robosats/issues">Github</Link>)</Trans>
</Typography>
</Grid>
: null}
{/* SHOW TXID IF USER RECEIVES ONCHAIN */}
{this.props.data.txid ?
<Grid item xs={12} align="left">
<Alert severity="success">
<AlertTitle>{t("Your TXID")}
<Tooltip disableHoverListener enterTouchDelay={0} title={t("Copied!")}>
<IconButton color="inherit" onClick={() => {navigator.clipboard.writeText(this.props.data.txid)}}>
<ContentCopy sx={{width:16,height:16}}/>
</IconButton>
</Tooltip>
</AlertTitle>
<Typography variant="body2" align="center" sx={{ wordWrap: "break-word", width:220}}>
<Link target='_blank' href={"http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/"+(this.props.data.network =="testnet"? "testnet/": "")+"tx/"+this.props.data.txid}>{this.props.data.txid}</Link>
</Typography>
</Alert>
</Grid>
: null}
<Grid item xs={12} align="center">
<Button color='primary' onClick={() => {this.props.push('/')}}>{t("Start Again")}</Button>
</Grid>

View File

@ -128,7 +128,7 @@ class UserGenPage extends Component {
handleChangeToken=(e)=>{
this.setState({
token: e.target.value,
token: e.target.value.split(' ').join(''),
tokenHasChanged: true,
})
}

View File

@ -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",

View File

@ -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",

View File

@ -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

View File

@ -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"