diff --git a/.env-sample b/.env-sample
index bbef5413..f7fe89f3 100644
--- a/.env-sample
+++ b/.env-sample
@@ -89,6 +89,10 @@ FIAT_EXCHANGE_DURATION = 24
PROPORTIONAL_ROUTING_FEE_LIMIT = 0.0002
# Base flat limit fee for routing in Sats (used only when proportional is lower than this)
MIN_FLAT_ROUTING_FEE_LIMIT = 10
+MIN_FLAT_ROUTING_FEE_LIMIT_REWARD = 2
+
+# Reward tip. Reward for every finished trade in the referral program (Satoshis)
+REWARD_TIP = 100
# Username for HTLCs escrows
ESCROW_USERNAME = 'admin'
diff --git a/api/admin.py b/api/admin.py
index a04c0de5..af24add4 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -103,6 +103,7 @@ class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
"avatar_tag",
"id",
"user_link",
+ "is_referred",
"telegram_enabled",
"total_contracts",
"platform_rating",
diff --git a/api/lightning/node.py b/api/lightning/node.py
index c6214eb2..b0da223c 100644
--- a/api/lightning/node.py
+++ b/api/lightning/node.py
@@ -222,15 +222,15 @@ class LNNode:
return payout
@classmethod
- def pay_invoice(cls, invoice, num_satoshis):
- """Sends sats to buyer"""
+ def pay_invoice(cls, lnpayment):
+ """Sends sats. Used for rewards payouts"""
fee_limit_sat = int(
max(
- num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
- float(config("MIN_FLAT_ROUTING_FEE_LIMIT")),
+ lnpayment.num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
+ float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")),
)) # 200 ppm or 10 sats
- request = routerrpc.SendPaymentRequest(payment_request=invoice,
+ request = routerrpc.SendPaymentRequest(payment_request=lnpayment.invoice,
fee_limit_sat=fee_limit_sat,
timeout_seconds=60)
@@ -238,15 +238,15 @@ class LNNode:
metadata=[("macaroon",
MACAROON.hex())
]):
- print(response)
- print(response.status)
- # TODO ERROR HANDLING
if response.status == 0: # Status 0 'UNKNOWN'
+ # Not sure when this status happens
pass
+
if response.status == 1: # Status 1 'IN_FLIGHT'
return True, "In flight"
- if response.status == 3: # 4 'FAILED' ??
+
+ if response.status == 3: # Status 3 'FAILED'
"""0 Payment isn't failed (yet).
1 There are more routes to try, but the payment timeout was exceeded.
2 All possible routes were tried and failed permanently. Or were no routes to the destination at all.
@@ -256,12 +256,10 @@ class LNNode:
"""
context = cls.payment_failure_context[response.failure_reason]
return False, context
+
if response.status == 2: # STATUS 'SUCCEEDED'
return True, None
- # How to catch the errors like:"grpc_message":"invoice is already paid","grpc_status":6}
- # These are not in the response only printed to commandline
-
return False
@classmethod
diff --git a/api/logics.py b/api/logics.py
index a7a368b3..8d0d22d0 100644
--- a/api/logics.py
+++ b/api/logics.py
@@ -384,8 +384,10 @@ class Logics:
fee_sats = order.last_satoshis * fee_fraction
+ 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) # Trading fee to buyer is charged here.
+ invoice_amount = round(order.last_satoshis - fee_sats - reward_tip) # Trading fee to buyer is charged here.
return True, {"invoice_amount": invoice_amount}
@@ -399,10 +401,12 @@ class Logics:
elif user == order.taker:
fee_fraction = FEE * (1 - MAKER_FEE_SPLIT)
- fee_sats = order.last_satoshis * fee_fraction
+ fee_sats = order.last_satoshis * fee_fraction
+
+ reward_tip = int(config('REWARD_TIP')) if user.profile.is_referred else 0
if cls.is_seller(order, user):
- escrow_amount = round(order.last_satoshis + fee_sats) # Trading fee to seller is charged here.
+ escrow_amount = round(order.last_satoshis + fee_sats + reward_tip) # Trading fee to seller is charged here.
return True, {"escrow_amount": escrow_amount}
@@ -1029,12 +1033,19 @@ class Logics:
cls.return_bond(order.taker_bond)
cls.return_bond(order.maker_bond)
##### !!! KEY LINE - PAYS THE BUYER INVOICE !!!
- ##### Backgroun process "follow_invoices" will try to pay this invoice until success
+ ##### 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')
+
+ # Add referral rewards (safe)
+ try:
+ Logics.add_rewards(order)
+ except:
+ pass
+
return True, None
else:
@@ -1082,3 +1093,57 @@ class Logics:
user.profile.save()
return True, None
+ @classmethod
+ def add_rewards(cls, order):
+ '''
+ This function is called when a trade is finished.
+ If participants of the order were referred, the reward is given to the referees.
+ '''
+
+ if order.maker.profile.is_referred:
+ profile = order.maker.profile.referred_by
+ profile.pending_rewards += int(config('REWARD_TIP'))
+ profile.save()
+
+ if order.taker.profile.is_referred:
+ profile = order.taker.profile.referred_by
+ profile.pending_rewards += int(config('REWARD_TIP'))
+ profile.save()
+
+ return
+
+ @classmethod
+ def withdraw_rewards(cls, user, invoice):
+
+ # only a user with positive withdraw balance can use this
+
+ if user.profile.earned_rewards < 1:
+ return False, {"bad_invoice": "You have not earned rewards"}
+
+ num_satoshis = user.profile.earned_rewards
+ reward_payout = LNNode.validate_ln_invoice(invoice, num_satoshis)
+
+ if not reward_payout["valid"]:
+ return False, reward_payout["context"]
+
+ lnpayment = LNPayment.objects.create(
+ concept= LNPayment.Concepts.WITHREWA,
+ type= LNPayment.Types.NORM,
+ sender= User.objects.get(username=ESCROW_USERNAME),
+ status= LNPayment.Status.VALIDI,
+ receiver=user,
+ invoice= invoice,
+ num_satoshis= num_satoshis,
+ description= reward_payout["description"],
+ payment_hash= reward_payout["payment_hash"],
+ created_at= reward_payout["created_at"],
+ expires_at= reward_payout["expires_at"],
+ )
+
+ if LNNode.pay_invoice(lnpayment):
+ user.profile.earned_rewards = 0
+ user.profile.claimed_rewards += num_satoshis
+ user.profile.save()
+
+ return True, None
+
diff --git a/api/models.py b/api/models.py
index 638035d2..797cbbcd 100644
--- a/api/models.py
+++ b/api/models.py
@@ -60,6 +60,7 @@ class LNPayment(models.Model):
TAKEBOND = 1, "Taker bond"
TRESCROW = 2, "Trade escrow"
PAYBUYER = 3, "Payment to buyer"
+ WITHREWA = 4, "Withdraw rewards"
class Status(models.IntegerChoices):
INVGEN = 0, "Generated"
@@ -405,6 +406,32 @@ class Profile(models.Model):
default=False,
null=False
)
+
+ # Referral program
+ is_referred = models.BooleanField(
+ default=False,
+ null=False
+ )
+ referred_by = models.ForeignKey(
+ 'self',
+ related_name="referee",
+ on_delete=models.SET_NULL,
+ null=True,
+ default=None,
+ blank=True,
+ )
+ referral_code = models.CharField(
+ max_length=15,
+ null=True,
+ blank=True
+ )
+ # Recent rewards from referred trades that will be "earned" at a later point to difficult spionage.
+ pending_rewards = models.PositiveIntegerField(null=False, default=0)
+ # Claimable rewards
+ earned_rewards = models.PositiveIntegerField(null=False, default=0)
+ # Total claimed rewards
+ claimed_rewards = models.PositiveIntegerField(null=False, default=0)
+
# Disputes
num_disputes = models.PositiveIntegerField(null=False, default=0)
lost_disputes = models.PositiveIntegerField(null=False, default=0)
diff --git a/api/serializers.py b/api/serializers.py
index 18b13319..c8e97e19 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -66,3 +66,9 @@ class UpdateOrderSerializer(serializers.Serializer):
allow_blank=True,
default=None,
)
+
+class ClaimRewardSerializer(serializers.Serializer):
+ invoice = serializers.CharField(max_length=2000,
+ allow_null=True,
+ allow_blank=True,
+ default=None)
\ No newline at end of file
diff --git a/api/tasks.py b/api/tasks.py
index dc981f01..8decf569 100644
--- a/api/tasks.py
+++ b/api/tasks.py
@@ -17,9 +17,11 @@ def users_cleansing():
queryset = User.objects.filter(~Q(last_login__range=active_time_range))
queryset = queryset.filter(is_staff=False) # Do not delete staff users
- # And do not have an active trade or any past contract.
+ # And do not have an active trade, any past contract or any reward.
deleted_users = []
for user in queryset:
+ if user.profile.pending_rewards > 0 or user.profile.earned_rewards > 0 or user.profile.claimed_rewards > 0:
+ continue
if not user.profile.total_contracts == 0:
continue
valid, _, _ = Logics.validate_already_maker_or_taker(user)
@@ -33,6 +35,28 @@ def users_cleansing():
}
return results
+@shared_task(name="give_rewards")
+def users_cleansing():
+ """
+ Referral rewards go from pending to earned.
+ Happens asynchronously so the referral program cannot be easily used to spy.
+ """
+ from api.models import Profile
+
+ # Users who's last login has not been in the last 6 hours
+ queryset = Profile.objects.filter(pending_rewards__gt=0)
+
+ # And do not have an active trade, any past contract or any reward.
+ results = {}
+ for profile in queryset:
+ given_reward = profile.pending_rewards
+ profile.earned_rewards += given_reward
+ profile.pending_rewards = 0
+ profile.save()
+
+ results[profile.user.username] = {'given_reward':given_reward,'earned_rewards':profile.earned_rewards}
+
+ return results
@shared_task(name="follow_send_payment")
def follow_send_payment(lnpayment):
@@ -45,6 +69,7 @@ def follow_send_payment(lnpayment):
from api.lightning.node import LNNode, MACAROON
from api.models import LNPayment, Order
+ from api.logics import Logics
fee_limit_sat = int(
max(
diff --git a/api/urls.py b/api/urls.py
index ada3031e..50197bd4 100644
--- a/api/urls.py
+++ b/api/urls.py
@@ -1,11 +1,9 @@
from django.urls import path
-from .views import MakerView, OrderView, UserView, BookView, InfoView
+from .views import MakerView, OrderView, UserView, BookView, InfoView, RewardView
urlpatterns = [
path("make/", MakerView.as_view()),
- path(
- "order/",
- OrderView.as_view({
+ path("order/",OrderView.as_view({
"get": "get",
"post": "take_update_confirm_dispute_cancel"
}),
@@ -14,4 +12,5 @@ urlpatterns = [
path("book/", BookView.as_view()),
# path('robot/') # Profile Info
path("info/", InfoView.as_view()),
+ path("reward/", RewardView.as_view()),
]
diff --git a/api/views.py b/api/views.py
index f8ef8976..4a5c95e8 100644
--- a/api/views.py
+++ b/api/views.py
@@ -1,6 +1,6 @@
import os
from re import T
-from django.db.models import query
+from django.db.models import Sum
from rest_framework import status, viewsets
from rest_framework.generics import CreateAPIView, ListAPIView
from rest_framework.views import APIView
@@ -9,10 +9,11 @@ from rest_framework.response import Response
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
-from api.serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer
-from api.models import LNPayment, MarketTick, Order, Currency
+from api.serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer, ClaimRewardSerializer
+from api.models import LNPayment, MarketTick, Order, Currency, Profile
from api.logics import Logics
from api.messages import Telegram
+from secrets import token_urlsafe
from api.utils import get_lnd_version, get_commit_robosats, compute_premium_percentile
from .nick_generator.nick_generator import NickGenerator
@@ -445,7 +446,6 @@ class OrderView(viewsets.ViewSet):
class UserView(APIView):
- lookup_url_kwarg = "token"
NickGen = NickGenerator(lang="English",
use_adv=False,
use_adj=True,
@@ -475,12 +475,8 @@ class UserView(APIView):
"bad_request"] = f"You are already logged in as {request.user} and have an active order"
return Response(context, status.HTTP_400_BAD_REQUEST)
- # Does not allow this 'mistake' if the last login was sometime ago (5 minutes)
- # if request.user.last_login < timezone.now() - timedelta(minutes=5):
- # context['bad_request'] = f'You are already logged in as {request.user}'
- # return Response(context, status.HTTP_400_BAD_REQUEST)
-
- token = request.GET.get(self.lookup_url_kwarg)
+ token = request.GET.get("token")
+ ref_code = request.GET.get("ref_code")
# Compute token entropy
value, counts = np.unique(list(token), return_counts=True)
@@ -514,14 +510,27 @@ class UserView(APIView):
with open(image_path, "wb") as f:
rh.img.save(f, format="png")
+
+
# Create new credentials and login if nickname is new
if len(User.objects.filter(username=nickname)) == 0:
User.objects.create_user(username=nickname,
password=token,
is_staff=False)
user = authenticate(request, username=nickname, password=token)
- user.profile.avatar = "static/assets/avatars/" + nickname + ".png"
login(request, user)
+
+ context['referral_code'] = token_urlsafe(8)
+ user.profile.referral_code = context['referral_code']
+ user.profile.avatar = "static/assets/avatars/" + nickname + ".png"
+
+ # If the ref_code was created by another robot, this robot was referred.
+ queryset = Profile.objects.filter(referral_code=ref_code)
+ if len(queryset) == 1:
+ user.profile.is_referred = True
+ user.profile.referred_by = queryset[0]
+
+ user.profile.save()
return Response(context, status=status.HTTP_201_CREATED)
else:
@@ -635,6 +644,8 @@ class InfoView(ListAPIView):
context["num_public_sell_orders"] = len(
Order.objects.filter(type=Order.Types.SELL,
status=Order.Status.PUB))
+ context["book_liquidity"] = Order.objects.filter(status=Order.Status.PUB).aggregate(Sum('last_satoshis'))['last_satoshis__sum']
+ context["book_liquidity"] = 0 if context["book_liquidity"] == None else context["book_liquidity"]
# Number of active users (logged in in last 30 minutes)
today = datetime.today()
@@ -679,11 +690,45 @@ class InfoView(ListAPIView):
context["maker_fee"] = float(config("FEE"))*float(config("MAKER_FEE_SPLIT"))
context["taker_fee"] = float(config("FEE"))*(1 - float(config("MAKER_FEE_SPLIT")))
context["bond_size"] = float(config("BOND_SIZE"))
+
if request.user.is_authenticated:
context["nickname"] = request.user.username
+ context["referral_link"] = str(config('HOST_NAME'))+'/ref/'+str(request.user.profile.referral_code)
+ context["earned_rewards"] = request.user.profile.earned_rewards
has_no_active_order, _, order = Logics.validate_already_maker_or_taker(
request.user)
if not has_no_active_order:
context["active_order_id"] = order.id
return Response(context, status.HTTP_200_OK)
+
+
+class RewardView(CreateAPIView):
+ serializer_class = ClaimRewardSerializer
+
+ def post(self, request):
+ serializer = self.serializer_class(data=request.data)
+
+ if not request.user.is_authenticated:
+ return Response(
+ {
+ "bad_request":
+ "Woops! It seems you do not have a robot avatar"
+ },
+ status.HTTP_400_BAD_REQUEST,
+ )
+
+ if not serializer.is_valid():
+ return Response(status=status.HTTP_400_BAD_REQUEST)
+
+ invoice = serializer.data.get("invoice")
+
+ valid, context = Logics.withdraw_rewards(request.user, invoice)
+
+ if not valid:
+ context['successful_withdrawal'] = False
+ return Response(context, status.HTTP_400_BAD_REQUEST)
+
+
+
+ return Response({"successful_withdrawal": True}, status.HTTP_200_OK)
diff --git a/frontend/src/components/BottomBar.js b/frontend/src/components/BottomBar.js
index 89d4e11d..30587f6a 100644
--- a/frontend/src/components/BottomBar.js
+++ b/frontend/src/components/BottomBar.js
@@ -1,5 +1,5 @@
import React, { Component } from 'react'
-import {Badge, Tooltip, TextField, ListItemAvatar, Avatar,Paper, Grid, IconButton, Typography, Select, MenuItem, List, ListItemText, ListItem, ListItemIcon, ListItemButton, Divider, Dialog, DialogContent} from "@mui/material";
+import {Chip, CircularProgress, Badge, Tooltip, TextField, ListItemAvatar, Button, Avatar,Paper, Grid, IconButton, Typography, Select, MenuItem, List, ListItemText, ListItem, ListItemIcon, ListItemButton, Divider, Dialog, DialogContent} from "@mui/material";
import MediaQuery from 'react-responsive'
import { Link } from 'react-router-dom'
@@ -21,14 +21,37 @@ import PasswordIcon from '@mui/icons-material/Password';
import ContentCopy from "@mui/icons-material/ContentCopy";
import DnsIcon from '@mui/icons-material/Dns';
import WebIcon from '@mui/icons-material/Web';
+import BookIcon from '@mui/icons-material/Book';
+import PersonAddAltIcon from '@mui/icons-material/PersonAddAlt';
+import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
// pretty numbers
function pn(x) {
- var parts = x.toString().split(".");
- parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
- return parts.join(".");
+ if(x == null){
+ return 'null'
+ }else{
+ var parts = x.toString().split(".");
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ return parts.join(".");
+ }
}
+function getCookie(name) {
+ let cookieValue = null;
+ if (document.cookie && document.cookie !== '') {
+ const cookies = document.cookie.split(';');
+ for (let i = 0; i < cookies.length; i++) {
+ const cookie = cookies[i].trim();
+ // Does this cookie string begin with the name we want?
+ if (cookie.substring(0, name.length + 1) === (name + '=')) {
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+ break;
+ }
+ }
+ }
+ return cookieValue;
+ }
+
export default class BottomBar extends Component {
constructor(props) {
super(props);
@@ -36,8 +59,10 @@ export default class BottomBar extends Component {
openStatsForNerds: false,
openCommuniy: false,
openExchangeSummary:false,
+ openClaimRewards: false,
num_public_buy_orders: 0,
num_public_sell_orders: 0,
+ book_liquidity: 0,
active_robots_today: 0,
maker_fee: 0,
taker_fee: 0,
@@ -49,6 +74,12 @@ export default class BottomBar extends Component {
profileShown: false,
alternative_site: 'robosats...',
node_id: '00000000',
+ referral_link: 'No referral link',
+ earned_rewards: 0,
+ rewardInvoice: null,
+ badInvoice: false,
+ showRewardsSpinner: false,
+ withdrawn: false,
};
this.getInfo();
}
@@ -215,6 +246,30 @@ export default class BottomBar extends Component {
this.setState({openProfile: false});
};
+ handleSubmitInvoiceClicked=()=>{
+ this.setState({
+ badInvoice:false,
+ showRewardsSpinner: true,
+ });
+
+ const requestOptions = {
+ method: 'POST',
+ headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
+ body: JSON.stringify({
+ 'invoice': this.state.rewardInvoice,
+ }),
+ };
+ fetch('/api/reward/', requestOptions)
+ .then((response) => response.json())
+ .then((data) => console.log(data) & this.setState({
+ badInvoice:data.bad_invoice,
+ openClaimRewards: data.successful_withdrawal ? false : true,
+ earned_rewards: data.successful_withdrawal ? 0 : this.state.earned_rewards,
+ withdrawn: data.successful_withdrawal ? true : false,
+ showRewardsSpinner: false,
+ }));
+ }
+
dialogProfile =() =>{
return(
+
Thank you! RoboSats loves you too ❤️
+Thank you! RoboSats loves you too ❤️
RoboSats gets better with more liquidity and users. Tell a bitcoiner friend about Robosats!
Thank you for using Robosats!
+Let us know how the platform could improve + (Telegram / Github)