Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A privacy friendly referral program #62

Merged
merged 10 commits into from
Mar 6, 2022
4 changes: 4 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
"avatar_tag",
"id",
"user_link",
"is_referred",
"telegram_enabled",
"total_contracts",
"platform_rating",
Expand Down
22 changes: 10 additions & 12 deletions api/lightning/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,31 +222,31 @@ def validate_ln_invoice(cls, invoice, num_satoshis):
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)

for response in cls.routerstub.SendPaymentV2(request,
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.
Expand All @@ -256,12 +256,10 @@ def pay_invoice(cls, invoice, num_satoshis):
"""
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
Expand Down
73 changes: 69 additions & 4 deletions api/logics.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,10 @@ def payout_amount(cls, order, user):

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}

Expand All @@ -399,10 +401,12 @@ def escrow_amount(cls, order, user):
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}

Expand Down Expand Up @@ -1029,12 +1033,19 @@ def confirm_fiat(cls, order, user):
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:
Expand Down Expand Up @@ -1082,3 +1093,57 @@ def rate_platform(cls, user, rating):
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

27 changes: 27 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class Concepts(models.IntegerChoices):
TAKEBOND = 1, "Taker bond"
TRESCROW = 2, "Trade escrow"
PAYBUYER = 3, "Payment to buyer"
WITHREWA = 4, "Withdraw rewards"

class Status(models.IntegerChoices):
INVGEN = 0, "Generated"
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
27 changes: 26 additions & 1 deletion api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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(
Expand Down
7 changes: 3 additions & 4 deletions api/urls.py
Original file line number Diff line number Diff line change
@@ -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"
}),
Expand All @@ -14,4 +12,5 @@
path("book/", BookView.as_view()),
# path('robot/') # Profile Info
path("info/", InfoView.as_view()),
path("reward/", RewardView.as_view()),
]
Loading