mirror of
https://github.com/RoboSats/robosats.git
synced 2024-12-26 05:43:34 +03:00
Merge branch 'onchain-buyer-payouts' into main #160
This commit is contained in:
commit
164a960b62
26
.env-sample
26
.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
|
||||
|
@ -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 ./
|
||||
|
23
api/admin.py
23
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):
|
||||
|
@ -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",
|
||||
|
246
api/logics.py
246
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:
|
||||
|
118
api/models.py
118
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,
|
||||
|
@ -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
|
||||
|
10
api/tasks.py
10
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=[
|
||||
|
34
api/utils.py
34
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):
|
||||
|
33
api/views.py
33
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)
|
||||
|
@ -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"]
|
@ -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
|
@ -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
|
||||
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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
<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="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 item xs={12} align="center">
|
||||
<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">
|
||||
{this.compatibleWalletsButton()}
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
|
@ -128,7 +128,7 @@ class UserGenPage extends Component {
|
||||
|
||||
handleChangeToken=(e)=>{
|
||||
this.setState({
|
||||
token: e.target.value,
|
||||
token: e.target.value.split(' ').join(''),
|
||||
tokenHasChanged: true,
|
||||
})
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user