Merge pull request #36 from Reckless-Satoshi/polish-and-debug-before-0.1.0-mvp

Merging debugging and polishing before v0.1.0-mvp
Fixes #22 #30 #32
This commit is contained in:
Reckless_Satoshi 2022-01-31 15:41:41 +00:00 committed by GitHub
commit d3cbc89b98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1834 additions and 702 deletions

View File

@ -21,12 +21,14 @@ FEE = 0.002
BOND_SIZE = 0.01
# Time out penalty for canceling takers in SECONDS
PENALTY_TIMEOUT = 60
# Time between routing attempts of buyer invoice in MINUTES
RETRY_TIME = 5
# Trade limits in satoshis
MIN_TRADE = 10000
MAX_TRADE = 500000
# Expiration time for HODL invoices and returning collateral in HOURS
# Expiration (CLTV_expiry) time for HODL invoices in HOURS // 7 min/block assumed
BOND_EXPIRY = 14
ESCROW_EXPIRY = 8
@ -41,5 +43,10 @@ INVOICE_AND_ESCROW_DURATION = 30
# Time to confim chat and confirm fiat (time to Fiat Sent confirmation) HOURS
FIAT_EXCHANGE_DURATION = 4
# Proportional routing fee limit (fraction of total payout: % / 100)
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
# Username for HTLCs escrows
ESCROW_USERNAME = 'admin'

View File

@ -1,11 +1,14 @@
## RoboSats - Buy and sell Satoshis Privately.
[![release](https://img.shields.io/badge/release-v0.1.0%20MVP-orange)](https://github.com/Reckless-Satoshi/robosats/releases)
## RoboSats - Buy and sell Satoshis Privately
[![release](https://img.shields.io/badge/release-v0.1.0%20MVP-red)](https://github.com/Reckless-Satoshi/robosats/releases)
[![AGPL-3.0 license](https://img.shields.io/badge/license-AGPL--3.0-blue)](https://github.com/Reckless-Satoshi/robosats/blob/main/LICENSE)
[![Telegram](https://img.shields.io/badge/chat-telegram-brightgreen)](https://t.me/robosats)
RoboSats is a simple and private way to exchange bitcoin for national currencies. Robosats simplifies the peer-to-peer user experience and uses lightning hold invoices to minimize custody and trust requirements. The deterministically generated avatars help users stick to best privacy practices.
## Try it out
<div align="center">
<img width="75%" src="https://raw.githubusercontent.com/Reckless-Satoshi/robosats/frontend/static/assets/images/robosats_0.1.0_banner.png">
</div>
**Bitcoin mainnet:**
- Tor: robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion (Coming soon)
@ -15,7 +18,7 @@ RoboSats is a simple and private way to exchange bitcoin for national currencies
**Bitcoin testnet:**
- Tor: robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion (Active - On Dev Node)
- Url: testnet.robosats.com (Coming soon)
- Commit height: Latest commit.
- Latest commit.
*Always use [Tor Browser](https://www.torproject.org/download/) and .onion for best anonymity.*
@ -39,7 +42,7 @@ Alice wants to buy satoshis privately:
See [CONTRIBUTING.md](CONTRIBUTING.md)
## Original idea
The concept of a simple custody-minimized lightning exchange using hold invoices is heavily inspired by [P2PLNBOT](https://github.com/grunch/p2plnbot) by @grunch
The concept of a simple custody-minimized lightning exchange using hold invoices is heavily inspired in [P2PLNBOT](https://github.com/grunch/p2plnbot) by @grunch
## License

View File

@ -19,23 +19,25 @@ class EUserAdmin(UserAdmin):
inlines = [ProfileInline]
list_display = ('avatar_tag','id','username','last_login','date_joined','is_staff')
list_display_links = ('id','username')
ordering = ('-id',)
def avatar_tag(self, obj):
return obj.profile.avatar_tag()
@admin.register(Order)
class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
list_display = ('id','type','maker_link','taker_link','status','amount','currency_link','t0_satoshis','is_disputed','is_fiat_sent','created_at','expires_at', 'buyer_invoice_link','maker_bond_link','taker_bond_link','trade_escrow_link')
list_display = ('id','type','maker_link','taker_link','status','amount','currency_link','t0_satoshis','is_disputed','is_fiat_sent','created_at','expires_at', 'payout_link','maker_bond_link','taker_bond_link','trade_escrow_link')
list_display_links = ('id','type')
change_links = ('maker','taker','currency','buyer_invoice','maker_bond','taker_bond','trade_escrow')
change_links = ('maker','taker','currency','payout','maker_bond','taker_bond','trade_escrow')
list_filter = ('is_disputed','is_fiat_sent','type','currency','status')
@admin.register(LNPayment)
class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
list_display = ('hash','concept','status','num_satoshis','type','expires_at','sender_link','receiver_link','order_made','order_taken','order_escrow','order_paid')
list_display_links = ('hash','concept','order_made','order_taken','order_escrow','order_paid')
change_links = ('sender','receiver')
list_display = ('hash','concept','status','num_satoshis','type','expires_at','expiry_height','sender_link','receiver_link','order_made_link','order_taken_link','order_escrow_link','order_paid_link')
list_display_links = ('hash','concept')
change_links = ('sender','receiver','order_made','order_taken','order_escrow','order_paid')
list_filter = ('type','concept','status')
ordering = ('-expires_at',)
@admin.register(Profile)
class UserProfileAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
@ -49,9 +51,11 @@ class CurrencieAdmin(admin.ModelAdmin):
list_display = ('id','currency','exchange_rate','timestamp')
list_display_links = ('id','currency')
readonly_fields = ('currency','exchange_rate','timestamp')
ordering = ('id',)
@admin.register(MarketTick)
class MarketTickAdmin(admin.ModelAdmin):
list_display = ('timestamp','price','volume','premium','currency','fee')
readonly_fields = ('timestamp','price','volume','premium','currency','fee')
list_filter = ['currency']
ordering = ('-timestamp',)

View File

@ -9,6 +9,7 @@ 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)
#######
@ -64,7 +65,7 @@ class LNNode():
return str(response)=="" # True if no response, false otherwise.
@classmethod
def gen_hold_invoice(cls, num_satoshis, description, expiry):
def gen_hold_invoice(cls, num_satoshis, description, invoice_expiry, cltv_expiry_secs):
'''Generates hold invoice'''
hold_payment = {}
@ -74,11 +75,15 @@ class LNNode():
# Its hash is used to generate the hold invoice
r_hash = hashlib.sha256(preimage).digest()
# timelock expiry for the last hop, computed based on a 10 minutes block with 30% padding (~7 min block)
cltv_expiry_blocks = int(cltv_expiry_secs / (7*60))
request = invoicesrpc.AddHoldInvoiceRequest(
memo=description,
value=num_satoshis,
hash=r_hash,
expiry=expiry)
expiry=int(invoice_expiry*1.5), # actual expiry is padded by 50%, if tight, wrong client system clock will say invoice is expired.
cltv_expiry=cltv_expiry_blocks,
)
response = cls.invoicesstub.AddHoldInvoice(request, metadata=[('macaroon', MACAROON.hex())])
hold_payment['invoice'] = response.payment_request
@ -87,18 +92,22 @@ class LNNode():
hold_payment['payment_hash'] = payreq_decoded.payment_hash
hold_payment['created_at'] = timezone.make_aware(datetime.fromtimestamp(payreq_decoded.timestamp))
hold_payment['expires_at'] = hold_payment['created_at'] + timedelta(seconds=payreq_decoded.expiry)
hold_payment['cltv_expiry'] = cltv_expiry_blocks
return hold_payment
@classmethod
def validate_hold_invoice_locked(cls, payment_hash):
def validate_hold_invoice_locked(cls, lnpayment):
'''Checks if hold invoice is locked'''
request = invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(payment_hash))
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
@ -108,31 +117,34 @@ class LNNode():
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()
return True
@classmethod
def check_until_invoice_locked(cls, payment_hash, expiration):
'''Checks until hold invoice is locked.
When invoice is locked, returns true.
If time expires, return False.'''
# Experimental, might need asyncio. Best if subscribing all invoices and running a background task
# Maybe best to pass LNpayment object and change status live.
# @classmethod
# def check_until_invoice_locked(cls, payment_hash, expiration):
# '''Checks until hold invoice is locked.
# When invoice is locked, returns true.
# If time expires, return False.'''
# # Experimental, might need asyncio. Best if subscribing all invoices and running a background task
# # Maybe best to pass LNpayment object and change status live.
request = invoicesrpc.SubscribeSingleInvoiceRequest(r_hash=payment_hash)
for invoice in cls.invoicesstub.SubscribeSingleInvoice(request):
print(invoice)
if timezone.now > expiration:
break
if invoice.state == 3: # True if hold invoice is accepted.
return True
return False
# request = invoicesrpc.SubscribeSingleInvoiceRequest(r_hash=payment_hash)
# for invoice in cls.invoicesstub.SubscribeSingleInvoice(request):
# print(invoice)
# if timezone.now > expiration:
# break
# if invoice.state == 3: # True if hold invoice is accepted.
# return True
# return False
@classmethod
def validate_ln_invoice(cls, invoice, num_satoshis):
'''Checks if the submited LN invoice comforms to expectations'''
buyer_invoice = {
payout = {
'valid': False,
'context': None,
'description': None,
@ -145,36 +157,36 @@ class LNNode():
payreq_decoded = cls.decode_payreq(invoice)
print(payreq_decoded)
except:
buyer_invoice['context'] = {'bad_invoice':'Does not look like a valid lightning invoice'}
return buyer_invoice
payout['context'] = {'bad_invoice':'Does not look like a valid lightning invoice'}
return payout
if payreq_decoded.num_satoshis == 0:
buyer_invoice['context'] = {'bad_invoice':'The invoice provided has no explicit amount'}
return buyer_invoice
payout['context'] = {'bad_invoice':'The invoice provided has no explicit amount'}
return payout
if not payreq_decoded.num_satoshis == num_satoshis:
buyer_invoice['context'] = {'bad_invoice':'The invoice provided is not for '+'{:,}'.format(num_satoshis)+ ' Sats'}
return buyer_invoice
payout['context'] = {'bad_invoice':'The invoice provided is not for '+'{:,}'.format(num_satoshis)+ ' Sats'}
return payout
buyer_invoice['created_at'] = timezone.make_aware(datetime.fromtimestamp(payreq_decoded.timestamp))
buyer_invoice['expires_at'] = buyer_invoice['created_at'] + timedelta(seconds=payreq_decoded.expiry)
payout['created_at'] = timezone.make_aware(datetime.fromtimestamp(payreq_decoded.timestamp))
payout['expires_at'] = payout['created_at'] + timedelta(seconds=payreq_decoded.expiry)
if buyer_invoice['expires_at'] < timezone.now():
buyer_invoice['context'] = {'bad_invoice':f'The invoice provided has already expired'}
return buyer_invoice
if payout['expires_at'] < timezone.now():
payout['context'] = {'bad_invoice':f'The invoice provided has already expired'}
return payout
buyer_invoice['valid'] = True
buyer_invoice['description'] = payreq_decoded.description
buyer_invoice['payment_hash'] = payreq_decoded.payment_hash
payout['valid'] = True
payout['description'] = payreq_decoded.description
payout['payment_hash'] = payreq_decoded.payment_hash
return buyer_invoice
return payout
@classmethod
def pay_invoice(cls, invoice, num_satoshis):
'''Sends sats to buyer'''
fee_limit_sat = max(num_satoshis * 0.0002, 10) # 200 ppm or 10 sats
fee_limit_sat = int(max(num_satoshis * float(config('PROPORTIONAL_ROUTING_FEE_LIMIT')), float(config('MIN_FLAT_ROUTING_FEE_LIMIT')))) # 200 ppm or 10 sats
request = routerrpc.SendPaymentRequest(
payment_request=invoice,
fee_limit_sat=fee_limit_sat,

View File

@ -40,19 +40,19 @@ class Logics():
'''Checks if the user is already partipant of an active order'''
queryset = Order.objects.filter(maker=user, status__in=active_order_status)
if queryset.exists():
return False, {'bad_request':'You are already maker of an active order'}
return False, {'bad_request':'You are already maker of an active order'}, queryset[0]
queryset = Order.objects.filter(taker=user, status__in=active_order_status)
if queryset.exists():
return False, {'bad_request':'You are already taker of an active order'}
return True, None
return False, {'bad_request':'You are already taker of an active order'}, queryset[0]
return True, None, None
def validate_order_size(order):
'''Validates if order is withing limits in satoshis at t0'''
if order.t0_satoshis > MAX_TRADE:
return False, {'bad_request': 'Your order is too big. It is worth '+'{:,}'.format(order.t0_satoshis)+' Sats now. But limit is '+'{:,}'.format(MAX_TRADE)+ ' Sats'}
return False, {'bad_request': 'Your order is too big. It is worth '+'{:,}'.format(order.t0_satoshis)+' Sats now, but the limit is '+'{:,}'.format(MAX_TRADE)+ ' Sats'}
if order.t0_satoshis < MIN_TRADE:
return False, {'bad_request': 'Your order is too small. It is worth '+'{:,}'.format(order.t0_satoshis)+' Sats now. But limit is '+'{:,}'.format(MIN_TRADE)+ ' Sats'}
return False, {'bad_request': 'Your order is too small. It is worth '+'{:,}'.format(order.t0_satoshis)+' Sats now, but the limit is '+'{:,}'.format(MIN_TRADE)+ ' Sats'}
return True, None
@classmethod
@ -89,7 +89,7 @@ class Logics():
return int(satoshis_now)
def price_and_premium_now(order):
''' computes order premium live '''
''' computes order price and premium with current rates '''
exchange_rate = float(order.currency.exchange_rate)
if not order.is_explicit:
premium = order.premium
@ -111,14 +111,13 @@ class Logics():
# Do not change order status if an order in any with
# any of these status is sent to expire here
do_nothing = [Order.Status.DEL, Order.Status.UCA,
Order.Status.EXP, Order.Status.FSE,
does_not_expire = [Order.Status.DEL, Order.Status.UCA,
Order.Status.EXP, Order.Status.TLD,
Order.Status.DIS, Order.Status.CCA,
Order.Status.PAY, Order.Status.SUC,
Order.Status.FAI, Order.Status.MLD,
Order.Status.TLD]
Order.Status.FAI, Order.Status.MLD]
if order.status in do_nothing:
if order.status in does_not_expire:
return False
elif order.status == Order.Status.WFB:
@ -196,8 +195,8 @@ class Logics():
cls.publish_order(order)
return True
elif order.status == Order.Status.CHA:
# Another weird case. The time to confirm 'fiat sent' expired. Yet no dispute
elif order.status in [Order.Status.CHA, Order.Status.FSE]:
# Another weird case. The time to confirm 'fiat sent or received' expired. Yet no dispute
# was opened. Hint: a seller-scammer could persuade a buyer to not click "fiat
# sent", we assume this is a dispute case by default.
cls.open_dispute(order)
@ -221,7 +220,7 @@ class Logics():
@classmethod
def open_dispute(cls, order, user=None):
# Always settle the escrow during a dispute (same as with 'Fiat Sent')
# Always settle the escrow during a dispute
# Dispute winner will have to submit a new invoice.
if not order.trade_escrow.status == LNPayment.Status.SETLED:
@ -236,13 +235,17 @@ class Logics():
if not user == None:
profile = user.profile
profile.num_disputes = profile.num_disputes + 1
if profile.orders_disputes_started == None:
profile.orders_disputes_started = [str(order.id)]
else:
profile.orders_disputes_started = list(profile.orders_disputes_started).append(str(order.id))
profile.save()
return True, None
def dispute_statement(order, user, statement):
''' Updates the dispute statements in DB'''
''' Updates the dispute statements'''
if not order.status == Order.Status.DIS:
return False, {'bad_request':'Only orders in dispute accept a dispute statements'}
@ -263,7 +266,7 @@ class Logics():
return True, None
@classmethod
def buyer_invoice_amount(cls, order, user):
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'''
@ -276,33 +279,35 @@ class Logics():
def update_invoice(cls, order, user, invoice):
# only the buyer can post a buyer invoice
if not cls.is_buyer(order, user):
return False, {'bad_request':'Only the buyer of this order can provide a buyer invoice.'}
if not order.taker_bond:
return False, {'bad_request':'Wait for your order to be taken.'}
if not (order.taker_bond.status == order.maker_bond.status == LNPayment.Status.LOCKED):
return False, {'bad_request':'You cannot a invoice while bonds are not posted.'}
return False, {'bad_request':'You cannot submit a invoice while bonds are not locked.'}
num_satoshis = cls.buyer_invoice_amount(order, user)[1]['invoice_amount']
buyer_invoice = LNNode.validate_ln_invoice(invoice, num_satoshis)
num_satoshis = cls.payout_amount(order, user)[1]['invoice_amount']
payout = LNNode.validate_ln_invoice(invoice, num_satoshis)
if not buyer_invoice['valid']:
return False, buyer_invoice['context']
if not payout['valid']:
return False, payout['context']
order.buyer_invoice, _ = LNPayment.objects.update_or_create(
order.payout, _ = LNPayment.objects.update_or_create(
concept = LNPayment.Concepts.PAYBUYER,
type = LNPayment.Types.NORM,
sender = User.objects.get(username=ESCROW_USERNAME),
order_paid = 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.
defaults={
'invoice' : invoice,
'status' : LNPayment.Status.VALIDI,
'num_satoshis' : num_satoshis,
'description' : buyer_invoice['description'],
'payment_hash' : buyer_invoice['payment_hash'],
'created_at' : buyer_invoice['created_at'],
'expires_at' : buyer_invoice['expires_at']}
'description' : payout['description'],
'payment_hash' : payout['payment_hash'],
'created_at' : payout['created_at'],
'expires_at' : payout['expires_at']}
)
# If the order status is 'Waiting for invoice'. Move forward to 'chat'
@ -312,13 +317,22 @@ class Logics():
# If the order status is 'Waiting for both'. Move forward to 'waiting for escrow'
if order.status == Order.Status.WF2:
# If the escrow is lock move to Chat.
if order.trade_escrow.status == LNPayment.Status.LOCKED:
# If the escrow does not exist, or is not locked move to WFE.
if order.trade_escrow == None:
order.status = Order.Status.WFE
# If the escrow is locked move to Chat.
elif order.trade_escrow.status == LNPayment.Status.LOCKED:
order.status = Order.Status.CHA
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.CHA])
else:
order.status = Order.Status.WFE
# If the order status is 'Failed Routing'. Retry payment.
if order.status == Order.Status.FAI:
# Double check the escrow is settled.
if LNNode.double_check_htlc_is_settled(order.trade_escrow.payment_hash):
follow_send_payment(order.payout)
order.save()
return True, None
@ -326,7 +340,7 @@ class Logics():
''' adds a new rating to a user profile'''
# TODO Unsafe, does not update ratings, it adds more ratings everytime a new rating is clicked.
profile.total_ratings = profile.total_ratings + 1
profile.total_ratings += 1
latest_ratings = profile.latest_ratings
if latest_ratings == None:
profile.latest_ratings = [rating]
@ -355,20 +369,32 @@ class Logics():
@classmethod
def cancel_order(cls, order, user, state=None):
# Do not change order status if an is in order
# any of these status
do_not_cancel = [Order.Status.DEL, Order.Status.UCA,
Order.Status.EXP, Order.Status.TLD,
Order.Status.DIS, Order.Status.CCA,
Order.Status.PAY, Order.Status.SUC,
Order.Status.FAI, Order.Status.MLD]
if order.status in do_not_cancel:
return False, {'bad_request':'You cannot cancel this order'}
# 1) When maker cancels before bond
'''The order never shows up on the book and order
status becomes "cancelled". That's it.'''
status becomes "cancelled" '''
if order.status == Order.Status.WFB and order.maker == user:
order.status = Order.Status.UCA
order.save()
return True, None
# 2) When maker cancels after bond
'''The order dissapears from book and goes to cancelled. Maker is charged the bond to prevent DDOS
on the LN node and order book. TODO Only charge a small part of the bond (requires maker submitting an invoice)'''
'''The order dissapears from book and goes to cancelled. If strict, maker is charged the bond
to prevent DDOS on the LN node and order book. If not strict, maker is returned
the bond (more user friendly).'''
elif order.status == Order.Status.PUB and order.maker == user:
#Settle the maker bond (Maker loses the bond for cancelling public order)
if cls.settle_bond(order.maker_bond):
if cls.return_bond(order.maker_bond): # strict: cls.settle_bond(order.maker_bond):
order.status = Order.Status.UCA
order.save()
return True, None
@ -389,7 +415,7 @@ class Logics():
# 4.a) When maker cancel after bond (before escrow)
'''The order into cancelled status if maker cancels.'''
elif order.status > Order.Status.PUB and order.status < Order.Status.CHA and order.maker == user:
elif order.status in [Order.Status.PUB, Order.Status.TAK, Order.Status.WF2, Order.Status.WFE] and order.maker == user:
#Settle the maker bond (Maker loses the bond for canceling an ongoing trade)
valid = cls.settle_bond(order.maker_bond)
if valid:
@ -408,14 +434,46 @@ class Logics():
return True, None
# 5) When trade collateral has been posted (after escrow)
'''Always goes to cancelled status. Collaboration is needed.
When a user asks for cancel, 'order.is_pending_cancel' goes True.
'''Always goes to CCA status. Collaboration is needed.
When a user asks for cancel, 'order.m/t/aker_asked_cancel' goes True.
When the second user asks for cancel. Order is totally cancelled.
Has a small cost for both parties to prevent node DDOS.'''
Must have a small cost for both parties to prevent node DDOS.'''
elif order.status in [Order.Status.WFI, Order.Status.CHA, Order.Status.FSE]:
# if the maker had asked, and now the taker does: cancel order, return everything
if order.maker_asked_cancel and user == order.taker:
cls.collaborative_cancel(order)
return True, None
# if the taker had asked, and now the maker does: cancel order, return everything
elif order.taker_asked_cancel and user == order.maker:
cls.collaborative_cancel(order)
return True, None
# Otherwise just make true the asked for cancel flags
elif user == order.taker:
order.taker_asked_cancel = True
order.save()
return True, None
elif user == order.maker:
order.maker_asked_cancel = True
order.save()
return True, None
else:
return False, {'bad_request':'You cannot cancel this order'}
@classmethod
def collaborative_cancel(cls, order):
cls.return_bond(order.maker_bond)
cls.return_bond(order.taker_bond)
cls.return_escrow(order)
order.status = Order.Status.CCA
order.save()
return
def publish_order(order):
order.status = Order.Status.PUB
order.expires_at = order.created_at + timedelta(seconds=Order.t_to_expire[Order.Status.PUB])
@ -426,9 +484,7 @@ class Logics():
def is_maker_bond_locked(cls, order):
if order.maker_bond.status == LNPayment.Status.LOCKED:
return True
elif LNNode.validate_hold_invoice_locked(order.maker_bond.payment_hash):
order.maker_bond.status = LNPayment.Status.LOCKED
order.maker_bond.save()
elif LNNode.validate_hold_invoice_locked(order.maker_bond):
cls.publish_order(order)
return True
return False
@ -455,7 +511,10 @@ class Logics():
description = f"RoboSats - Publishing '{str(order)}' - This is a maker bond, it will freeze in your wallet temporarily and automatically return. It will be charged if you cheat or cancel."
# Gen hold Invoice
hold_payment = LNNode.gen_hold_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
hold_payment = LNNode.gen_hold_invoice(bond_satoshis,
description,
invoice_expiry=Order.t_to_expire[Order.Status.WFB],
cltv_expiry_secs=BOND_EXPIRY*3600)
order.maker_bond = LNPayment.objects.create(
concept = LNPayment.Concepts.MAKEBOND,
@ -469,7 +528,8 @@ class Logics():
description = description,
payment_hash = hold_payment['payment_hash'],
created_at = hold_payment['created_at'],
expires_at = hold_payment['expires_at'])
expires_at = hold_payment['expires_at'],
cltv_expiry = hold_payment['cltv_expiry'])
order.save()
return True, {'bond_invoice':hold_payment['invoice'], 'bond_satoshis':bond_satoshis}
@ -504,7 +564,7 @@ class Logics():
def is_taker_bond_locked(cls, order):
if order.taker_bond.status == LNPayment.Status.LOCKED:
return True
elif LNNode.validate_hold_invoice_locked(order.taker_bond.payment_hash):
elif LNNode.validate_hold_invoice_locked(order.taker_bond):
cls.finalize_contract(order)
return True
return False
@ -514,8 +574,7 @@ class Logics():
# Do not gen and kick out the taker if order is older than expiry time
if order.expires_at < timezone.now():
cls.cancel_bond(order.taker_bond)
cls.kick_taker(order)
cls.order_expires(order)
return False, {'bad_request':'Invoice expired. You did not confirm taking the order in time.'}
# Do not gen if a taker invoice exist. Do not return if it is already locked. Return the old one if still waiting.
@ -533,7 +592,10 @@ class Logics():
+ " - This is a taker bond, it will freeze in your wallet temporarily and automatically return. It will be charged if you cheat or cancel.")
# Gen hold Invoice
hold_payment = LNNode.gen_hold_invoice(bond_satoshis, description, BOND_EXPIRY*3600)
hold_payment = LNNode.gen_hold_invoice(bond_satoshis,
description,
invoice_expiry=Order.t_to_expire[Order.Status.TAK],
cltv_expiry_secs=BOND_EXPIRY*3600)
order.taker_bond = LNPayment.objects.create(
concept = LNPayment.Concepts.TAKEBOND,
@ -547,7 +609,8 @@ class Logics():
description = description,
payment_hash = hold_payment['payment_hash'],
created_at = hold_payment['created_at'],
expires_at = hold_payment['expires_at'])
expires_at = hold_payment['expires_at'],
cltv_expiry = hold_payment['cltv_expiry'])
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.TAK])
order.save()
@ -568,9 +631,7 @@ class Logics():
def is_trade_escrow_locked(cls, order):
if order.trade_escrow.status == LNPayment.Status.LOCKED:
return True
elif LNNode.validate_hold_invoice_locked(order.trade_escrow.payment_hash):
order.trade_escrow.status = LNPayment.Status.LOCKED
order.trade_escrow.save()
elif LNNode.validate_hold_invoice_locked(order.trade_escrow):
cls.trade_escrow_received(order)
return True
return False
@ -580,7 +641,7 @@ class Logics():
# Do not generate if escrow deposit time has expired
if order.expires_at < timezone.now():
cls.cancel_order(order,user)
cls.order_expires(order)
return False, {'bad_request':'Invoice expired. You did not send the escrow in time.'}
# Do not gen if an escrow invoice exist. Do not return if it is already locked. Return the old one if still waiting.
@ -596,7 +657,10 @@ class Logics():
description = f"RoboSats - Escrow amount for '{str(order)}' - The escrow will be released to the buyer once you confirm you received the fiat. It will automatically return if buyer does not confirm the payment."
# Gen hold Invoice
hold_payment = LNNode.gen_hold_invoice(escrow_satoshis, description, ESCROW_EXPIRY*3600)
hold_payment = LNNode.gen_hold_invoice(escrow_satoshis,
description,
invoice_expiry=Order.t_to_expire[Order.Status.WF2],
cltv_expiry_secs=ESCROW_EXPIRY*3600)
order.trade_escrow = LNPayment.objects.create(
concept = LNPayment.Concepts.TRESCROW,
@ -610,7 +674,8 @@ class Logics():
description = description,
payment_hash = hold_payment['payment_hash'],
created_at = hold_payment['created_at'],
expires_at = hold_payment['expires_at'])
expires_at = hold_payment['expires_at'],
cltv_expiry = hold_payment['cltv_expiry'])
order.save()
return True, {'escrow_invoice':hold_payment['invoice'],'escrow_satoshis': escrow_satoshis}
@ -635,6 +700,7 @@ class Logics():
'''returns the trade escrow'''
if LNNode.cancel_return_hold_invoice(order.trade_escrow.payment_hash):
order.trade_escrow.status = LNPayment.Status.RETNED
order.trade_escrow.save()
return True
def cancel_escrow(order):
@ -642,6 +708,7 @@ class Logics():
# Same as return escrow, but used when the invoice was never LOCKED
if LNNode.cancel_return_hold_invoice(order.trade_escrow.payment_hash):
order.trade_escrow.status = LNPayment.Status.CANCEL
order.trade_escrow.save()
return True
def return_bond(bond):
@ -651,10 +718,12 @@ class Logics():
try:
LNNode.cancel_return_hold_invoice(bond.payment_hash)
bond.status = LNPayment.Status.RETNED
bond.save()
return True
except Exception as e:
if 'invoice already settled' in str(e):
bond.status = LNPayment.Status.SETLED
bond.save()
return True
else:
raise e
@ -667,58 +736,53 @@ class Logics():
try:
LNNode.cancel_return_hold_invoice(bond.payment_hash)
bond.status = LNPayment.Status.CANCEL
bond.save()
return True
except Exception as e:
if 'invoice already settled' in str(e):
bond.status = LNPayment.Status.SETLED
bond.save()
return True
else:
raise e
def pay_buyer_invoice(order):
''' Pay buyer invoice'''
suceeded, context = follow_send_payment(order.buyer_invoice)
return suceeded, context
@classmethod
def confirm_fiat(cls, order, user):
''' If Order is in the CHAT states:
If user is buyer: mark FIAT SENT and settle escrow!
If User is the seller and FIAT is SENT: Pay buyer invoice!'''
If user is buyer: fiat_sent goes to true.
If User is seller and fiat_sent is true: settle the escrow and pay buyer invoice!'''
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):
if cls.settle_escrow(order): ##### !!! KEY LINE - SETTLES THE TRADE ESCROW !!!
order.trade_escrow.status = LNPayment.Status.SETLED
order.status = Order.Status.FSE
order.is_fiat_sent = True
# If seller and fiat sent, pay buyer invoice
# If seller and fiat was sent, SETTLE ESCROW AND PAY BUYER INVOICE
elif cls.is_seller(order, user):
if not order.is_fiat_sent:
return False, {'bad_request':'You cannot confirm to have received the fiat before it is confirmed to be sent by the buyer.'}
# Make sure the trade escrow is at least as big as the buyer invoice
if order.trade_escrow.num_satoshis <= order.buyer_invoice.num_satoshis:
if order.trade_escrow.num_satoshis <= order.payout.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 !!!
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):
is_payed, context = cls.pay_buyer_invoice(order) ##### !!! KEY LINE - PAYS THE BUYER INVOICE !!!
if is_payed:
order.status = Order.Status.SUC
order.buyer_invoice.status = LNPayment.Status.SUCCED
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.SUC])
order.save()
# RETURN THE BONDS
# RETURN THE BONDS // Probably best also do it even if payment failed
cls.return_bond(order.taker_bond)
cls.return_bond(order.maker_bond)
is_payed, context = follow_send_payment(order.payout) ##### !!! KEY LINE - PAYS THE BUYER INVOICE !!!
if is_payed:
order.save()
return True, context
else:
# error handling here
pass
return False, context
else:
return False, {'bad_request':'You cannot confirm the fiat payment at this stage'}
@ -728,8 +792,10 @@ class Logics():
@classmethod
def rate_counterparty(cls, order, user, rating):
rating_allowed_status = [Order.Status.PAY, Order.Status.SUC, Order.Status.FAI, Order.Status.MLD, Order.Status.TLD]
# If the trade is finished
if order.status > Order.Status.PAY:
if order.status in rating_allowed_status:
# if maker, rates taker
if order.maker == user and order.maker_rated == False:
cls.add_profile_rating(order.taker.profile, rating)

View File

@ -11,16 +11,18 @@ class Command(BaseCommand):
# def add_arguments(self, parser):
# parser.add_argument('debug', nargs='+', type=boolean)
def handle(self, *args, **options):
def clean_orders(self, *args, **options):
''' Continuously checks order expiration times for 1 hour. If order
has expires, it calls the logics module for expiration handling.'''
# TODO handle 'database is locked'
do_nothing = [Order.Status.DEL, Order.Status.UCA,
Order.Status.EXP, Order.Status.FSE,
Order.Status.DIS, Order.Status.CCA,
Order.Status.PAY, Order.Status.SUC,
Order.Status.FAI, Order.Status.MLD,
Order.Status.TLD]
Order.Status.TLD, Order.Status.WFR]
while True:
time.sleep(5)
@ -34,9 +36,30 @@ class Command(BaseCommand):
for idx, order in enumerate(queryset):
context = str(order)+ " was "+ Order.Status(order.status).label
try:
if Logics.order_expires(order): # Order send to expire here
debug['expired_orders'].append({idx:context})
# It should not happen, but if it cannot locate the hold invoice
# it probably was cancelled by another thread, make it expire anyway.
except Exception as e:
if 'unable to locate invoice' in str(e):
self.stdout.write(str(e))
order.status = Order.Status.EXP
order.save()
debug['expired_orders'].append({idx:context})
if debug['num_expired_orders'] > 0:
self.stdout.write(str(timezone.now()))
self.stdout.write(str(debug))
def handle(self, *args, **options):
''' Never mind database locked error, keep going, print them out'''
try:
self.clean_orders()
except Exception as e:
if 'database is locked' in str(e):
self.stdout.write('database is locked')
self.stdout.write(str(e))

View File

@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand, CommandError
from api.lightning.node import LNNode
from api.tasks import follow_send_payment
from api.models import LNPayment, Order
from api.logics import Logics
@ -13,7 +14,30 @@ import time
MACAROON = b64decode(config('LND_MACAROON_BASE64'))
class Command(BaseCommand):
'''
help = 'Follows all active hold invoices'
rest = 5 # seconds between consecutive checks for invoice updates
def handle(self, *args, **options):
''' Infinite loop to check invoices and retry payments.
ever mind database locked error, keep going, print out'''
while True:
time.sleep(self.rest)
try:
self.follow_hold_invoices()
self.retry_payments()
except Exception as e:
if 'database is locked' in str(e):
self.stdout.write('database is locked')
self.stdout.write(str(e))
def follow_hold_invoices(self):
''' Follows and updates LNpayment objects
until settled or canceled
Background: SubscribeInvoices stub iterator would be great to use here.
However, it only sends updates when the invoice is OPEN (new) or SETTLED.
We are very interested on the other two states (CANCELLED and ACCEPTED).
@ -21,16 +45,6 @@ class Command(BaseCommand):
objects and do InvoiceLookupV2 every X seconds to update their state 'live'
'''
help = 'Follows all active hold invoices'
rest = 5 # seconds between consecutive checks for invoice updates
# def add_arguments(self, parser):
# parser.add_argument('debug', nargs='+', type=boolean)
def handle(self, *args, **options):
''' Follows and updates LNpayment objects
until settled or canceled'''
lnd_state_to_lnpayment_status = {
0: LNPayment.Status.INVGEN, # OPEN
1: LNPayment.Status.SETLED, # SETTLED
@ -40,9 +54,6 @@ class Command(BaseCommand):
stub = LNNode.invoicesstub
while True:
time.sleep(self.rest)
# time it for debugging
t0 = time.time()
queryset = LNPayment.objects.filter(type=LNPayment.Types.HOLD, status__in=[LNPayment.Status.INVGEN, LNPayment.Status.LOCKED])
@ -54,20 +65,29 @@ class Command(BaseCommand):
for idx, hold_lnpayment in enumerate(queryset):
old_status = LNPayment.Status(hold_lnpayment.status).label
try:
# this is similar to LNNnode.validate_hold_invoice_locked
request = LNNode.invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(hold_lnpayment.payment_hash))
response = stub.LookupInvoiceV2(request, metadata=[('macaroon', MACAROON.hex())])
hold_lnpayment.status = lnd_state_to_lnpayment_status[response.state]
# try saving expiry height
if hasattr(response, 'htlcs' ):
try:
hold_lnpayment.expiry_height = response.htlcs[0].expiry_height
except:
pass
except Exception as e:
# If it fails at finding the invoice it has been canceled.
# On RoboSats DB we make a distinction between cancelled and returned (LND does not)
# If it fails at finding the invoice: it has been canceled.
# In RoboSats DB we make a distinction between cancelled and returned (LND does not)
if 'unable to locate invoice' in str(e):
self.stdout.write(str(e))
hold_lnpayment.status = LNPayment.Status.CANCEL
# LND restarted.
if 'wallet locked, unlock it' in str(e):
self.stdout.write(str(timezone.now())+':: Wallet Locked')
self.stdout.write(str(timezone.now())+' :: Wallet Locked')
# Other write to logs
else:
self.stdout.write(str(e))
@ -78,8 +98,8 @@ class Command(BaseCommand):
changed = not old_status==new_status
if changed:
# self.handle_status_change(hold_lnpayment, old_status)
hold_lnpayment.save()
self.update_order_status(hold_lnpayment)
hold_lnpayment.save()
# Report for debugging
new_status = LNPayment.Status(hold_lnpayment.status).label
@ -97,6 +117,21 @@ class Command(BaseCommand):
self.stdout.write(str(timezone.now()))
self.stdout.write(str(debug))
def retry_payments(self):
''' Checks if any payment is due for retry, and tries to pay it'''
queryset = LNPayment.objects.filter(type=LNPayment.Types.NORM,
status__in=[LNPayment.Status.VALIDI, LNPayment.Status.FAILRO],
routing_attempts__lt=4,
last_routing_time__lt=(timezone.now()-timedelta(minutes=int(config('RETRY_TIME')))))
for lnpayment in queryset:
success, _ = follow_send_payment(lnpayment)
# If already 3 attempts and last failed. Make it expire (ask for a new invoice) an reset attempts.
if not success and lnpayment.routing_attempts == 3:
lnpayment.status = LNPayment.Status.EXPIRE
lnpayment.routing_attempts = 0
lnpayment.save()
def update_order_status(self, lnpayment):
''' Background process following LND hold invoices
@ -121,10 +156,27 @@ class Command(BaseCommand):
elif hasattr(lnpayment, 'order_escrow' ):
Logics.trade_escrow_received(lnpayment.order_escrow)
return
except Exception as e:
self.stdout.write(str(e))
# TODO If a lnpayment goes from LOCKED to INVGED. Totally weird
# If the LNPayment goes to CANCEL from INVGEN, the invoice had expired
# If it goes to CANCEL from LOCKED the bond was relased. Order had expired in both cases.
# Testing needed for end of time trades!
if lnpayment.status == LNPayment.Status.CANCEL :
if hasattr(lnpayment, 'order_made' ):
Logics.order_expires(lnpayment.order_made)
return
elif hasattr(lnpayment, 'order_taken' ):
Logics.order_expires(lnpayment.order_taken)
return
elif hasattr(lnpayment, 'order_escrow' ):
Logics.order_expires(lnpayment.order_escrow)
return
# TODO If a lnpayment goes from LOCKED to INVGEN. Totally weird
# halt the order
if lnpayment.status == LNPayment.Status.LOCKED:
if lnpayment.status == LNPayment.Status.INVGEN:
pass

View File

@ -19,7 +19,7 @@ BOND_SIZE = float(config('BOND_SIZE'))
class Currency(models.Model):
currency_dict = json.load(open('./frontend/static/assets/currencies.json'))
currency_dict = json.load(open('frontend/static/assets/currencies.json'))
currency_choices = [(int(val), label) for val, label in list(currency_dict.items())]
currency = models.PositiveSmallIntegerField(choices=currency_choices, null=False, unique=True)
@ -37,7 +37,7 @@ class Currency(models.Model):
class LNPayment(models.Model):
class Types(models.IntegerChoices):
NORM = 0, 'Regular invoice' # Only outgoing buyer payment will be a regular invoice (Non-hold)
NORM = 0, 'Regular invoice'
HOLD = 1, 'hold invoice'
class Concepts(models.IntegerChoices):
@ -58,12 +58,10 @@ class LNPayment(models.Model):
SUCCED = 8, 'Succeeded'
FAILRO = 9, 'Routing failed'
# payment use details
type = models.PositiveSmallIntegerField(choices=Types.choices, null=False, default=Types.HOLD)
concept = models.PositiveSmallIntegerField(choices=Concepts.choices, null=False, default=Concepts.MAKEBOND)
status = models.PositiveSmallIntegerField(choices=Status.choices, null=False, default=Status.INVGEN)
routing_retries = models.PositiveSmallIntegerField(null=False, default=0)
# payment info
payment_hash = models.CharField(max_length=100, unique=True, default=None, blank=True, primary_key=True)
@ -73,6 +71,12 @@ class LNPayment(models.Model):
num_satoshis = models.PositiveBigIntegerField(validators=[MinValueValidator(MIN_TRADE*BOND_SIZE), MaxValueValidator(MAX_TRADE*(1+BOND_SIZE+FEE))])
created_at = models.DateTimeField()
expires_at = models.DateTimeField()
cltv_expiry = models.PositiveSmallIntegerField(null=True, default=None, blank=True)
expiry_height = models.PositiveBigIntegerField(null=True, default=None, blank=True)
# routing
routing_attempts = models.PositiveSmallIntegerField(null=False, default=0)
last_routing_time = models.DateTimeField(null=True, default=None, blank=True)
# involved parties
sender = models.ForeignKey(User, related_name='sender', on_delete=models.CASCADE, null=True, default=None)
@ -141,9 +145,12 @@ class Order(models.Model):
last_satoshis = models.PositiveBigIntegerField(null=True, validators=[MinValueValidator(0), MaxValueValidator(MAX_TRADE*2)], blank=True) # sats last time checked. Weird if 2* trade max...
# order participants
maker = models.ForeignKey(User, related_name='maker', on_delete=models.CASCADE, null=True, default=None) # unique = True, a maker can only make one order
maker = models.ForeignKey(User, related_name='maker', on_delete=models.SET_NULL, null=True, default=None) # unique = True, a maker can only make one order
taker = models.ForeignKey(User, related_name='taker', on_delete=models.SET_NULL, null=True, default=None, blank=True) # unique = True, a taker can only take one order
is_pending_cancel = models.BooleanField(default=False, null=False) # When collaborative cancel is needed and one partner has cancelled.
maker_last_seen = models.DateTimeField(null=True,default=None, blank=True)
taker_last_seen = models.DateTimeField(null=True,default=None, blank=True)
maker_asked_cancel = models.BooleanField(default=False, null=False) # When collaborative cancel is needed and one partner has cancelled.
taker_asked_cancel = models.BooleanField(default=False, null=False) # When collaborative cancel is needed and one partner has cancelled.
is_fiat_sent = models.BooleanField(default=False, null=False)
# in dispute
@ -156,9 +163,8 @@ class Order(models.Model):
maker_bond = models.OneToOneField(LNPayment, related_name='order_made', on_delete=models.SET_NULL, null=True, default=None, blank=True)
taker_bond = models.OneToOneField(LNPayment, related_name='order_taken', on_delete=models.SET_NULL, null=True, default=None, blank=True)
trade_escrow = models.OneToOneField(LNPayment, related_name='order_escrow', on_delete=models.SET_NULL, null=True, default=None, blank=True)
# buyer payment LN invoice
buyer_invoice = models.OneToOneField(LNPayment, related_name='order_paid', on_delete=models.SET_NULL, null=True, default=None, blank=True)
payout = models.OneToOneField(LNPayment, related_name='order_paid', on_delete=models.SET_NULL, null=True, default=None, blank=True)
# ratings
maker_rated = models.BooleanField(default=False, null=False)
@ -176,12 +182,12 @@ class Order(models.Model):
8 : 60*int(config('INVOICE_AND_ESCROW_DURATION')), # 'Waiting only for buyer invoice'
9 : 60*60*int(config('FIAT_EXCHANGE_DURATION')), # 'Sending fiat - In chatroom'
10 : 60*60*int(config('FIAT_EXCHANGE_DURATION')), # 'Fiat sent - In chatroom'
11 : 10*24*60*60, # 'In dispute'
11 : 1*24*60*60, # 'In dispute'
12 : 0, # 'Collaboratively cancelled'
13 : 24*60*60, # 'Sending satoshis to buyer'
14 : 24*60*60, # 'Sucessful trade'
15 : 24*60*60, # 'Failed lightning network routing'
16 : 24*60*60, # 'Wait for dispute resolution'
16 : 10*24*60*60, # 'Wait for dispute resolution'
17 : 24*60*60, # 'Maker lost dispute'
18 : 24*60*60, # 'Taker lost dispute'
}
@ -192,7 +198,7 @@ class Order(models.Model):
@receiver(pre_delete, sender=Order)
def delete_lnpayment_at_order_deletion(sender, instance, **kwargs):
to_delete = (instance.maker_bond, instance.buyer_invoice, instance.taker_bond, instance.trade_escrow)
to_delete = (instance.maker_bond, instance.payout, instance.taker_bond, instance.trade_escrow)
for lnpayment in to_delete:
try:
@ -222,7 +228,7 @@ class Profile(models.Model):
avatar = models.ImageField(default="static/assets/misc/unknown_avatar.png", verbose_name='Avatar', blank=True)
# Penalty expiration (only used then taking/cancelling repeatedly orders in the book before comitting bond)
penalty_expiration = models.DateTimeField(null=True)
penalty_expiration = models.DateTimeField(null=True,default=None, blank=True)
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):

View File

@ -4773,6 +4773,7 @@ adjectives = [
"Compound",
"Important",
"Robotic",
"Satoshi",
"Alltoocommon",
"Informative",
"Anxious",

View File

@ -6346,6 +6346,7 @@ nouns = [
"Nair",
"Nairo",
"Naivete",
"Nakamoto",
"Name",
"Namesake",
"Nanometer",
@ -12229,6 +12230,7 @@ nouns = [
"Sand",
"Sandwich",
"Satisfaction",
"Satoshi",
"Save",
"Savings",
"Scale",

View File

@ -21,7 +21,7 @@ def users_cleansing():
for user in queryset:
if not user.profile.total_contracts == 0:
continue
valid, _ = Logics.validate_already_maker_or_taker(user)
valid, _, _ = Logics.validate_already_maker_or_taker(user)
if valid:
deleted_users.append(str(user))
user.delete()
@ -38,6 +38,8 @@ def follow_send_payment(lnpayment):
from decouple import config
from base64 import b64decode
from django.utils import timezone
from datetime import timedelta
from api.lightning.node import LNNode
from api.models import LNPayment, Order
@ -51,6 +53,7 @@ def follow_send_payment(lnpayment):
timeout_seconds=60) # time out payment in 60 seconds
order = lnpayment.order_paid
try:
for response in LNNode.routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]):
if response.status == 0 : # Status 0 'UNKNOWN'
# Not sure when this status happens
@ -66,11 +69,15 @@ def follow_send_payment(lnpayment):
if response.status == 3 : # Status 3 'FAILED'
print('FAILED')
lnpayment.status = LNPayment.Status.FAILRO
lnpayment.last_routing_time = timezone.now()
lnpayment.routing_attempts += 1
lnpayment.save()
order.status = Order.Status.FAI
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.FAI])
order.save()
context = LNNode.payment_failure_context[response.failure_reason]
# Call for a retry here
context = {'routing_failed': LNNode.payment_failure_context[response.failure_reason]}
print(context)
# Call a retry in 5 mins here?
return False, context
if response.status == 2 : # Status 2 'SUCCEEDED'
@ -78,9 +85,22 @@ def follow_send_payment(lnpayment):
lnpayment.status = LNPayment.Status.SUCCED
lnpayment.save()
order.status = Order.Status.SUC
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.SUC])
order.save()
return True, None
except Exception as e:
if "invoice expired" in str(e):
print('INVOICE EXPIRED')
lnpayment.status = LNPayment.Status.EXPIRE
lnpayment.last_routing_time = timezone.now()
lnpayment.save()
order.status = Order.Status.FAI
order.expires_at = timezone.now() + timedelta(seconds=Order.t_to_expire[Order.Status.FAI])
order.save()
context = {'routing_failed':'The payout invoice has expired'}
return False, context
@shared_task(name="cache_external_market_prices", ignore_result=True)
def cache_market():

View File

@ -72,7 +72,7 @@ premium_percentile = {}
@ring.dict(premium_percentile, expire=300)
def compute_premium_percentile(order):
queryset = Order.objects.filter(currency=order.currency, status=Order.Status.PUB)
queryset = Order.objects.filter(currency=order.currency, status=Order.Status.PUB).exclude(id=order.id)
print(len(queryset))
if len(queryset) <= 1:

View File

@ -26,6 +26,7 @@ from decouple import config
EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE'))
FEE = float(config('FEE'))
RETRY_TIME = int(config('RETRY_TIME'))
avatar_path = Path('frontend/static/assets/avatars')
avatar_path.mkdir(parents=True, exist_ok=True)
@ -38,6 +39,9 @@ class MakerView(CreateAPIView):
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)
type = serializer.data.get('type')
@ -48,7 +52,7 @@ class MakerView(CreateAPIView):
satoshis = serializer.data.get('satoshis')
is_explicit = serializer.data.get('is_explicit')
valid, context = Logics.validate_already_maker_or_taker(request.user)
valid, context, _ = Logics.validate_already_maker_or_taker(request.user)
if not valid: return Response(context, status.HTTP_409_CONFLICT)
# Creates a new order
@ -83,6 +87,9 @@ class OrderView(viewsets.ViewSet):
'''
order_id = request.GET.get(self.lookup_url_kwarg)
if not request.user.is_authenticated:
return Response({'bad_request':'You must have a robot avatar to see the order details'}, status=status.HTTP_400_BAD_REQUEST)
if order_id == None:
return Response({'bad_request':'Order ID parameter not found in request'}, status=status.HTTP_400_BAD_REQUEST)
@ -107,7 +114,7 @@ class OrderView(viewsets.ViewSet):
# if user is under a limit (penalty), inform him.
is_penalized, time_out = Logics.is_penalized(request.user)
if is_penalized:
data['penalty'] = time_out
data['penalty'] = request.user.profile.penalty_expiration
# Add booleans if user is maker, taker, partipant, buyer or seller
data['is_maker'] = order.maker == request.user
@ -118,6 +125,32 @@ class OrderView(viewsets.ViewSet):
if not data['is_participant'] and order.status != Order.Status.PUB:
return Response({'bad_request':'You are not allowed to see this order'},status.HTTP_403_FORBIDDEN)
# WRITE Update last_seen for maker and taker.
# Note down that the taker/maker was here recently, so counterpart knows if the user is paying attention.
if order.maker == request.user:
order.maker_last_seen = timezone.now()
order.save()
if order.taker == request.user:
order.taker_last_seen = timezone.now()
order.save()
# Add activity status of participants based on last_seen
if order.taker_last_seen != None:
if order.taker_last_seen > (timezone.now() - timedelta(minutes=2)):
data['taker_status'] = 'active'
elif order.taker_last_seen > (timezone.now() - timedelta(minutes=10)):
data['taker_status'] = 'seen_recently'
else:
data['taker_status'] = 'inactive'
if order.maker_last_seen != None:
if order.maker_last_seen > (timezone.now() - timedelta(minutes=2)):
data['maker_status'] = 'active'
elif order.maker_last_seen > (timezone.now() - timedelta(minutes=10)):
data['maker_status'] = 'seen_recently'
else:
data['maker_status'] = 'inactive'
# 3.b If order is between public and WF2
if order.status >= Order.Status.PUB and order.status < Order.Status.WF2:
data['price_now'], data['premium_now'] = Logics.price_and_premium_now(order)
@ -169,7 +202,7 @@ class OrderView(viewsets.ViewSet):
data['trade_satoshis'] = order.last_satoshis
# Buyer sees the amount he receives
elif data['is_buyer']:
data['trade_satoshis'] = Logics.buyer_invoice_amount(order, request.user)[1]['invoice_amount']
data['trade_satoshis'] = Logics.payout_amount(order, request.user)[1]['invoice_amount']
# 5) If status is 'waiting for maker bond' and user is MAKER, reply with a MAKER hold invoice.
if order.status == Order.Status.WFB and data['is_maker']:
@ -189,7 +222,6 @@ class OrderView(viewsets.ViewSet):
# 7 a. ) If seller and status is 'WF2' or 'WFE'
elif data['is_seller'] and (order.status == Order.Status.WF2 or order.status == Order.Status.WFE):
# If the two bonds are locked, reply with an ESCROW hold invoice.
if order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED:
valid, context = Logics.gen_escrow_hold_invoice(order, request.user)
@ -203,20 +235,42 @@ class OrderView(viewsets.ViewSet):
# If the two bonds are locked, reply with an AMOUNT so he can send the buyer invoice.
if order.maker_bond.status == order.taker_bond.status == LNPayment.Status.LOCKED:
valid, context = Logics.buyer_invoice_amount(order, request.user)
valid, context = Logics.payout_amount(order, request.user)
if valid:
data = {**data, **context}
else:
return Response(context, status.HTTP_400_BAD_REQUEST)
# 8) If status is 'CHA' or 'FSE' and all HTLCS are in LOCKED
elif order.status == Order.Status.CHA or order.status == Order.Status.FSE: # TODO Add the other status
elif order.status in [Order.Status.WFI, Order.Status.CHA, Order.Status.FSE]:
# If all bonds are locked.
if order.maker_bond.status == order.taker_bond.status == order.trade_escrow.status == LNPayment.Status.LOCKED:
# add whether a collaborative cancel is pending
data['pending_cancel'] = order.is_pending_cancel
# add whether a collaborative cancel is pending or has been asked
if (data['is_maker'] and order.taker_asked_cancel) or (data['is_taker'] and order.maker_asked_cancel):
data['pending_cancel'] = True
elif (data['is_maker'] and order.maker_asked_cancel) or (data['is_taker'] and order.taker_asked_cancel):
data['asked_for_cancel'] = True
else:
data['asked_for_cancel'] = False
# 9) If status is 'DIS' and all HTLCS are in LOCKED
elif order.status == Order.Status.DIS:
# add whether the dispute statement has been received
if data['is_maker']:
data['statement_submitted'] = (order.maker_statement != None and order.maker_statement != "")
elif data['is_taker']:
data['statement_submitted'] = (order.taker_statement != None and order.maker_statement != "")
# 9) If status is 'Failed routing', reply with retry amounts, time of next retry and ask for invoice at third.
elif order.status == Order.Status.FAI and order.payout.receiver == request.user: # might not be the buyer if after a dispute where winner wins
data['retries'] = order.payout.routing_attempts
data['next_retry_time'] = order.payout.last_routing_time + timedelta(minutes=RETRY_TIME)
if order.payout.status == LNPayment.Status.EXPIRE:
data['invoice_expired'] = True
# Add invoice amount once again if invoice was expired.
data['invoice_amount'] = int(order.last_satoshis * (1-FEE))
return Response(data, status.HTTP_200_OK)
@ -243,7 +297,7 @@ class OrderView(viewsets.ViewSet):
# 1) If action is take, it is a taker request!
if action == 'take':
if order.status == Order.Status.PUB:
valid, context = Logics.validate_already_maker_or_taker(request.user)
valid, context, _ = Logics.validate_already_maker_or_taker(request.user)
if not valid: return Response(context, status=status.HTTP_409_CONFLICT)
valid, context = Logics.take(order, request.user)
if not valid: return Response(context, status=status.HTTP_403_FORBIDDEN)
@ -318,7 +372,7 @@ class UserView(APIView):
# If an existing user opens the main page by mistake, we do not want it to create a new nickname/profile for him
if request.user.is_authenticated:
context = {'nickname': request.user.username}
not_participant, _ = Logics.validate_already_maker_or_taker(request.user)
not_participant, _, _ = Logics.validate_already_maker_or_taker(request.user)
# Does not allow this 'mistake' if an active order
if not not_participant:
@ -386,23 +440,22 @@ class UserView(APIView):
def delete(self,request):
''' Pressing "give me another" deletes the logged in user '''
user = request.user
if not user:
if not user.is_authenticated:
return Response(status.HTTP_403_FORBIDDEN)
# Only delete if user life is shorter than 30 minutes. Helps deleting users by mistake
# Only delete if user life is shorter than 30 minutes. Helps to avoid deleting users by mistake
if user.date_joined < (timezone.now() - timedelta(minutes=30)):
return Response(status.HTTP_400_BAD_REQUEST)
# Check if it is not a maker or taker!
if not Logics.validate_already_maker_or_taker(user):
not_participant, _, _ = Logics.validate_already_maker_or_taker(user)
if not not_participant:
return Response({'bad_request':'User cannot be deleted while he is part of an order'}, status.HTTP_400_BAD_REQUEST)
logout(request)
user.delete()
return Response({'user_deleted':'User deleted permanently'}, status.HTTP_301_MOVED_PERMANENTLY)
class BookView(ListAPIView):
serializer_class = ListOrderSerializer
queryset = Order.objects.filter(status=Order.Status.PUB)
@ -424,7 +477,6 @@ class BookView(ListAPIView):
if len(queryset)== 0:
return Response({'not_found':'No orders found, be the first to make one'}, status=status.HTTP_404_NOT_FOUND)
# queryset = queryset.order_by('created_at')
book_data = []
for order in queryset:
data = ListOrderSerializer(order).data
@ -468,12 +520,27 @@ class InfoView(ListAPIView):
avg_premium = 0
total_volume = 0
queryset = MarketTick.objects.all()
if not len(queryset) == 0:
volume_settled = []
for tick in queryset:
volume_settled.append(tick.volume)
lifetime_volume_settled = int(sum(volume_settled)*100000000)
else:
lifetime_volume_settled = 0
context['today_avg_nonkyc_btc_premium'] = round(avg_premium,2)
context['today_total_volume'] = total_volume
context['lifetime_satoshis_settled'] = lifetime_volume_settled
context['lnd_version'] = get_lnd_version()
context['robosats_running_commit_hash'] = get_commit_robosats()
context['fee'] = FEE
context['bond_size'] = float(config('BOND_SIZE'))
if request.user.is_authenticated:
context['nickname'] = request.user.username
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)

Binary file not shown.

81
faq.md
View File

@ -1,81 +0,0 @@
# Buy and sell non-KYC Bitcoin using the lightning network.
## What is this?
{project_name} is a BTC/FIAT peer-to-peer exchange over lightning. It simplifies matchmaking and minimizes the trust needed to trade with a peer.
## Thats cool, so how it works?
Alice wants to sell sats, posts a sell order. Bob wants to buy sats, and takes Alice's order. Alice posts the sats as collateral using a hodl LN invoice. Bob also posts some sats as a bond to prove he is real. {project_name} locks the sats until Bob confirms he sent the fiat to Alice. Once Alice confirms she received the fiat, she tells {project_name} to release her sats to Bob. Enjoy your sats Bob!
At no point, Alice and Bob have to trust the funds to each other. In case Alice and Bob have a conflict, {project_name} staff will resolve the dispute.
(TODO: Long explanation and tutorial step by step, link)
## Nice, and fiat payments method are...?
Basically all of them. It is up to you to select your preferred payment methods. You will need to search for a peer who also accepts that method. Lightning is fast, so we highly recommend using instant fiat payment rails. Be aware trades have a expiry time of 8 hours. Paypal or credit card are not advice due to chargeback risk.
## Trust
The buyer and the seller never have to trust each other. Some trust on {project_name} is needed. Linking the sellers hodl invoice and buyer payment is not atomic (yet, research ongoing). In addition, disputes are solved by the {project_name} staff.
Note: this is not an escrow service. While trust requirements are minimized, {project_name} could run away with your sats. It could be argued that it is not worth it, as it would instantly destroy {project_name} reputation. However, you should hesitate and only trade small quantities at a time. For larger amounts and safety assurance use an escrow service such as Bisq or Hodlhodl.
You can build more trust on {project_name} by inspecting the source code, link.
## If {project_name} suddenly disappears during a trade my sats…
Your sats will most likely return to you. Any hodl invoice that is not settled would be automatically returned even if {project_name} goes down forever. This is true for both, locked bonds and traded sats. However, in the window between the buyer confirms FIAT SENT and the sats have not been released yet by the seller, the fund could be lost.
## Limits
Max trade size is 500K Sats to minimize failures in lightning routing. The limit will be raised as LN grows.
## Privacy
User token is generated locally as the unique identifier (back it up on paper! If lost {project_name} cannot help recover it). {project_name} doesnt know anything about you and doesnt want to know.
Your trading peer is the only one who can potentially guess anything about you. Keep chat short and concise. Avoid providing non-essential information other than strictly necessary for the fiat payment.
The chat with your peer is end-to-end encrypted, {project_name} cannot read. It can only be decrypted with your user token. The chat encryption makes it hard to resolve disputes. Therefore, by opening a dispute you are sending a viewkey to {project_name} staff. The encrypted chat cannot be revisited as it is deleted automatically when the trade is finalized (check the source code).
For best anonymity use Tor Browser and access the .onion hidden service.
## So {project_name} is a decentralized exchange?
Not quite, though it shares some elements.
A simple comparisson:
* Privacy worst to best: Coinbase/Binance/others < hodlhodl < {project_name} < Bisq
* Safety (not your keys, not your coins): Coinbase/Binance/others < {project_name} < hodlhodl < Bisq
*(take with a pinch of salt)*
So, if bisq is best for both privacy and safety, why {project_name} exists? Bisq is great, but it is difficult, slow, high-fee and needs extra steps to move to lightning. {project_name} aims to be as easy as Binance/Coinbase greatly improving on privacy and requiring minimal trust.
## Any risk?
Sure, this is a beta bot, things could go wrong. Trade small amounts!
The seller faces the same chargeback risk as with any other peer-to-peer exchange. Avoid accepting payment methods with easy chargeback!
## What are the fees?
{project_name} takes a 0.2% fee of the trade to cover lightning routing costs. This is akin to a Binance trade fee (but hey, you do not have to sell your soul to the devil, nor pay the withdrawal fine...).
The loser of a dispute pays a 1% fee that is slashed from the collateral posted when the trade starts. This fee is necessary to disincentive cheating and keep the site healthy. It also helps to cover the staff cost of dispute solving.
Note: your selected fiat payment rails might have other fees, these are to be covered by the buyer.
## I am a pro and {project_name} is too simple, it lacks features…
Indeed, this site is a simple front-end that aims for user friendliness and forces best privacy for casual users.
If you are a big maker, liquidity provider, or want to create many trades simultaneously use the API: {API_LINK_DOCUMENTATION}
## Is it legal to use {project_name} in my country?
In many countries using {project_name} is not different than buying something from a peer on Ebay or Craiglist. Your regulation may vary, you need to figure out.
## Disclaimer
This tool is provided as is. It is in active development and can be buggy. Be aware that you could lose your funds: trade with the utmost caution. There is no private support. Support is only offered via public channels (link telegram groups). {project_name} will never contact you. And {project_name} will definitely never ask for your user token.

View File

@ -3253,6 +3253,11 @@
"which": "^2.0.1"
}
},
"css-mediaquery": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",
"integrity": "sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA="
},
"css-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz",
@ -4820,6 +4825,11 @@
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM="
},
"jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="
},
"jss": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.9.0.tgz",
@ -5040,6 +5050,14 @@
"object-visit": "^1.0.0"
}
},
"matchmediaquery": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.3.1.tgz",
"integrity": "sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==",
"requires": {
"css-mediaquery": "^0.1.2"
}
},
"material-ui-image": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/material-ui-image/-/material-ui-image-3.3.2.tgz",
@ -6439,11 +6457,32 @@
"qr.js": "0.0.0"
}
},
"react-qr-reader": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-qr-reader/-/react-qr-reader-2.2.1.tgz",
"integrity": "sha512-EL5JEj53u2yAOgtpAKAVBzD/SiKWn0Bl7AZy6ZrSf1lub7xHwtaXe6XSx36Wbhl1VMGmvmrwYMRwO1aSCT2fwA==",
"requires": {
"jsqr": "^1.2.0",
"prop-types": "^15.7.2",
"webrtc-adapter": "^7.2.1"
}
},
"react-refresh": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.4.3.tgz",
"integrity": "sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA=="
},
"react-responsive": {
"version": "9.0.0-beta.6",
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-9.0.0-beta.6.tgz",
"integrity": "sha512-Flk6UrnpBBByreva6ja/TsbXiXq4BXOlDEKL6Ur+nshUs3CcN5W0BpGe6ClFWrKcORkMZAAYy7A4N4xlMmpgVw==",
"requires": {
"hyphenate-style-name": "^1.0.0",
"matchmediaquery": "^0.3.0",
"prop-types": "^15.6.1",
"shallow-equal": "^1.2.1"
}
},
"react-router": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz",
@ -6718,6 +6757,14 @@
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
"integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA=="
},
"rtcpeerconnection-shim": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz",
"integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==",
"requires": {
"sdp": "^2.6.0"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -6965,6 +7012,11 @@
"ajv-keywords": "^3.5.2"
}
},
"sdp": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
"integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw=="
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
@ -7076,6 +7128,11 @@
"kind-of": "^6.0.2"
}
},
"shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -7858,6 +7915,15 @@
"integrity": "sha512-cp5qdmHnu5T8wRg2G3vZZHoJPN14aqQ89SyQ11NpGH5zEMDCclt49rzo+MaRazk7/UeILhAI+/sEtcM+7Fr0nw==",
"dev": true
},
"webrtc-adapter": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz",
"integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==",
"requires": {
"rtcpeerconnection-shim": "^1.2.15",
"sdp": "^2.12.0"
}
},
"websocket": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz",

View File

@ -35,6 +35,8 @@
"react-native": "^0.66.4",
"react-native-svg": "^12.1.1",
"react-qr-code": "^2.0.3",
"react-qr-reader": "^2.2.1",
"react-responsive": "^9.0.0-beta.6",
"react-router-dom": "^5.2.0",
"websocket": "^1.0.34"
}

View File

@ -7,17 +7,20 @@ import BottomBar from "./BottomBar";
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
nickname: null,
token: null,
}
}
setAppState=(newState)=>{
this.setState(newState)
}
render() {
return (
<>
<div className='appCenter'>
<HomePage />
</div>
<div className='bottomBar'>
<BottomBar />
</div>
<HomePage setAppState={this.setAppState}/>
</>
);
}

View File

@ -2,6 +2,8 @@ import React, { Component } from "react";
import { Paper, Button , CircularProgress, ListItemButton, Typography, Grid, Select, MenuItem, FormControl, FormHelperText, List, ListItem, ListItemText, Avatar, RouterLink, ListItemAvatar} from "@mui/material";
import { Link } from 'react-router-dom'
import { DataGrid } from '@mui/x-data-grid';
import MediaQuery from 'react-responsive'
import getFlags from './getFlags'
export default class BookPage extends Component {
@ -10,7 +12,7 @@ export default class BookPage extends Component {
this.state = {
orders: new Array({id:0,}),
currency: 0,
type: 1,
type: 2,
currencies_dict: {"0":"ANY"},
loading: true,
};
@ -22,8 +24,7 @@ export default class BookPage extends Component {
getOrderDetails(type, currency) {
fetch('/api/book' + '?currency=' + currency + "&type=" + type)
.then((response) => response.json())
.then((data) => console.log(data) &
this.setState({
.then((data) => this.setState({
orders: data,
not_found: data.not_found,
loading: false,
@ -31,7 +32,6 @@ export default class BookPage extends Component {
}
handleRowClick=(e)=>{
console.log(e)
this.props.history.push('/order/' + e);
}
@ -69,7 +69,7 @@ export default class BookPage extends Component {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
bookListTable=()=>{
bookListTableDesktop=()=>{
return (
<div style={{ height: 475, width: '100%' }}>
<DataGrid
@ -93,7 +93,7 @@ export default class BookPage extends Component {
renderCell: (params) => {return (
<ListItemButton style={{ cursor: "pointer" }}>
<ListItemAvatar>
<Avatar alt={params.row.robosat} src={params.row.avatar} />
<Avatar className="flippedSmallAvatar" alt={params.row.robosat} src={params.row.avatar} />
</ListItemAvatar>
<ListItemText primary={params.row.robosat}/>
</ListItemButton>
@ -124,9 +124,61 @@ export default class BookPage extends Component {
);
}
bookListTablePhone=()=>{
return (
<div style={{ height: 425, width: '100%' }}>
<DataGrid
rows={
this.state.orders.map((order) =>
({id: order.id,
avatar: window.location.origin +'/static/assets/avatars/' + order.maker_nick + '.png',
robosat: order.maker_nick,
type: order.type ? "Sell": "Buy",
amount: parseFloat(parseFloat(order.amount).toFixed(4)),
currency: this.getCurrencyCode(order.currency),
payment_method: order.payment_method,
price: order.price,
premium: order.premium,
})
)}
columns={[
// { field: 'id', headerName: 'ID', width: 40 },
{ field: 'robosat', headerName: 'Robot', width: 80,
renderCell: (params) => {return (
<ListItemButton style={{ cursor: "pointer" }}>
<Avatar className="flippedSmallAvatar" alt={params.row.robosat} src={params.row.avatar} />
</ListItemButton>
);
} },
{ field: 'type', headerName: 'Type', width: 60, hide:'true'},
{ field: 'amount', headerName: 'Amount', type: 'number', width: 80 },
{ field: 'currency', headerName: 'Currency', width: 100,
renderCell: (params) => {return (
<div style={{ cursor: "pointer" }}>{params.row.currency + " " + getFlags(params.row.currency)}</div>
)} },
{ field: 'payment_method', headerName: 'Payment Method', width: 180, hide:'true'},
{ field: 'price', headerName: 'Price', type: 'number', width: 140, hide:'true',
renderCell: (params) => {return (
<div style={{ cursor: "pointer" }}>{this.pn(params.row.price) + " " +params.row.currency+ "/BTC" }</div>
)} },
{ field: 'premium', headerName: 'Premium', type: 'number', width: 85,
renderCell: (params) => {return (
<div style={{ cursor: "pointer" }}>{parseFloat(parseFloat(params.row.premium).toFixed(4))+"%" }</div>
)} },
]}
pageSize={6}
onRowClick={(params) => this.handleRowClick(params.row.id)} // Whole row is clickable, but the mouse only looks clickly in some places.
rowsPerPageOptions={[6]}
/>
</div>
);
}
render() {
return (
<Grid className='orderBook' container spacing={1}>
<Grid className='orderBook' container spacing={1} sx={{minWidth:400}}>
<Grid item xs={12} align="center">
<Typography component="h2" variant="h2">
Order Book
@ -194,20 +246,31 @@ export default class BookPage extends Component {
No orders found to {this.state.type == 0 ? ' sell ' :' buy ' } BTC for {this.state.currencyCode}
</Typography>
</Grid>
<br/>
<Grid item>
<Button variant="contained" color='primary' to='/make/' component={Link}>Make Order</Button>
</Grid>
<Typography component="body1" variant="body1">
Be the first one to create an order
<br/>
<br/>
</Typography>
</Grid>)
:
<Grid item xs={12} align="center">
{/* Desktop Book */}
<MediaQuery minWidth={920}>
<Paper elevation={0} style={{width: 910, maxHeight: 500, overflow: 'auto'}}>
{this.state.loading ? null : this.bookListTable()}
{this.state.loading ? null : this.bookListTableDesktop()}
</Paper>
</MediaQuery>
{/* Smartphone Book */}
<MediaQuery maxWidth={919}>
<Paper elevation={0} style={{width: 380, maxHeight: 450, overflow: 'auto'}}>
{this.state.loading ? null : this.bookListTablePhone()}
</Paper>
</MediaQuery>
</Grid>
}
<Grid item xs={12} align="center">

View File

@ -1,5 +1,7 @@
import React, { Component } from 'react'
import {Paper, Grid, IconButton, Typography, Select, MenuItem, List, ListItemText, ListItem, ListItemIcon, ListItemButton, Divider, Dialog, DialogContent} from "@mui/material";
import {Badge, TextField, ListItemAvatar, 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'
// Icons
import SettingsIcon from '@mui/icons-material/Settings';
@ -14,6 +16,14 @@ import GitHubIcon from '@mui/icons-material/GitHub';
import EqualizerIcon from '@mui/icons-material/Equalizer';
import SendIcon from '@mui/icons-material/Send';
import PublicIcon from '@mui/icons-material/Public';
import NumbersIcon from '@mui/icons-material/Numbers';
import PasswordIcon from '@mui/icons-material/Password';
import ContentCopy from "@mui/icons-material/ContentCopy";
// pretty numbers
function pn(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
export default class BottomBar extends Component {
constructor(props) {
@ -21,12 +31,17 @@ export default class BottomBar extends Component {
this.state = {
openStatsForNerds: false,
openCommuniy: false,
openExchangeSummary:false,
num_public_buy_orders: 0,
num_public_sell_orders: 0,
active_robots_today: 0,
fee: 0,
today_avg_nonkyc_btc_premium: 0,
today_total_volume: 0,
lifetime_satoshis_settled: 0,
robosats_running_commit_hash: '000000000000000',
openProfile: false,
profileShown: false,
};
this.getInfo();
}
@ -39,19 +54,20 @@ export default class BottomBar extends Component {
this.setState(null)
fetch('/api/info/')
.then((response) => response.json())
.then((data) => {console.log(data) &
this.setState(data)
});
.then((data) => this.setState(data) &
this.props.setAppState({nickname:data.nickname}));
}
handleClickOpenStatsForNerds = () => {
this.setState({openStatsForNerds: true});
};
handleClickCloseStatsForNerds = () => {
this.setState({openStatsForNerds: false});
};
StatsDialog =() =>{
return(
<Dialog
open={this.state.openStatsForNerds}
@ -61,7 +77,7 @@ export default class BottomBar extends Component {
>
<DialogContent>
<Typography component="h5" variant="h5">Stats For Nerds</Typography>
<List>
<List dense>
<Divider/>
<ListItem>
<ListItemIcon><BoltIcon/></ListItemIcon>
@ -71,9 +87,9 @@ export default class BottomBar extends Component {
<Divider/>
<ListItem>
<ListItemIcon><GitHubIcon/></ListItemIcon>
<ListItemText secondary="Currently running commit height">
<ListItemText secondary="Currently running commit hash">
<a href={"https://github.com/Reckless-Satoshi/robosats/tree/"
+ this.state.robosats_running_commit_hash}>{this.state.robosats_running_commit_hash}
+ this.state.robosats_running_commit_hash}>{this.state.robosats_running_commit_hash.slice(0, 12)+"..."}
</a>
</ListItemText>
</ListItem>
@ -84,6 +100,12 @@ export default class BottomBar extends Component {
<ListItemText primary={this.state.today_total_volume+" BTC"} secondary="Today traded volume"/>
</ListItem>
<Divider/>
<ListItem>
<ListItemIcon><EqualizerIcon/></ListItemIcon>
<ListItemText primary={pn(this.state.lifetime_satoshis_settled)+" Sats"} secondary="Lifetime settled volume"/>
</ListItem>
<Divider/>
<ListItem>
<ListItemIcon><PublicIcon/></ListItemIcon>
@ -104,6 +126,7 @@ export default class BottomBar extends Component {
};
CommunityDialog =() =>{
return(
<Dialog
open={this.state.openCommuniy}
@ -150,20 +173,108 @@ export default class BottomBar extends Component {
)
}
handleClickOpenProfile = () => {
this.getInfo();
this.setState({openProfile: true, profileShown: true});
};
handleClickCloseProfile = () => {
this.setState({openProfile: false});
};
render() {
return (
dialogProfile =() =>{
return(
<Dialog
open={this.state.openProfile}
onClose={this.handleClickCloseProfile}
aria-labelledby="profile-title"
aria-describedby="profile-description"
>
<DialogContent>
<Typography component="h5" variant="h5">Your Profile</Typography>
<List>
<Divider/>
<ListItem className="profileNickname">
<ListItemText secondary="Your robot">
<Typography component="h6" variant="h6">
{this.props.nickname ? "⚡"+this.props.nickname+"⚡" : ""}
</Typography>
</ListItemText>
<ListItemAvatar>
<Avatar className='profileAvatar'
sx={{ width: 65, height:65 }}
alt={this.props.nickname}
src={this.props.nickname ? window.location.origin +'/static/assets/avatars/' + this.props.nickname + '.png' : null}
/>
</ListItemAvatar>
</ListItem>
<Divider/>
{this.state.active_order_id ?
// TODO Link to router and do this.props.history.push
<ListItemButton onClick={this.handleClickCloseProfile} to={'/order/'+this.state.active_order_id} component={Link}>
<ListItemIcon>
<Badge badgeContent="" color="primary">
<NumbersIcon color="primary"/>
</Badge>
</ListItemIcon>
<ListItemText primary={'One active order #'+this.state.active_order_id} secondary="Your current order"/>
</ListItemButton>
:
<ListItem>
<ListItemIcon><NumbersIcon/></ListItemIcon>
<ListItemText primary="No active orders" secondary="Your current order"/>
</ListItem>
}
<ListItem>
<ListItemIcon>
<PasswordIcon/>
</ListItemIcon>
<ListItemText secondary="Your token">
{this.props.token ?
<TextField
disabled
label='Store safely'
value={this.props.token }
variant='filled'
size='small'
InputProps={{
endAdornment:
<IconButton onClick= {()=>navigator.clipboard.writeText(this.props.token)}>
<ContentCopy />
</IconButton>,
}}
/>
:
'Cannot remember'}
</ListItemText>
</ListItem>
</List>
</DialogContent>
</Dialog>
)
}
bottomBarDesktop =()=>{
return(
<Paper elevation={6} style={{height:40}}>
<this.StatsDialog/>
<this.CommunityDialog/>
<this.dialogProfile/>
<Grid container xs={12}>
<Grid item xs={1}>
<IconButton color="primary"
aria-label="Stats for Nerds"
onClick={this.handleClickOpenStatsForNerds} >
<SettingsIcon />
</IconButton>
<Grid item xs={2}>
<ListItemButton onClick={this.handleClickOpenProfile} >
<ListItemAvatar sx={{ width: 30, height: 30 }} >
<Badge badgeContent={(this.state.active_order_id > 0 & !this.state.profileShown) ? "": null} color="primary">
<Avatar className='flippedSmallAvatar' sx={{margin: 0, top: -13}}
alt={this.props.nickname}
src={this.props.nickname ? window.location.origin +'/static/assets/avatars/' + this.props.nickname + '.png' : null}
/>
</Badge>
</ListItemAvatar>
<ListItemText primary={this.props.nickname}/>
</ListItemButton>
</Grid>
<Grid item xs={2}>
@ -214,11 +325,11 @@ export default class BottomBar extends Component {
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={this.state.today_avg_nonkyc_btc_premium+"%"}
secondary="Today Non-KYC Avg Premium" />
secondary="Today Avg Premium" />
</ListItem>
</Grid>
<Grid item xs={2}>
<Grid item xs={1}>
<ListItem className="bottomItem">
<ListItemIcon size="small">
<PercentIcon/>
@ -227,12 +338,11 @@ export default class BottomBar extends Component {
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={this.state.fee*100}
secondary="Trading Fee" />
secondary="Trade Fee" />
</ListItem>
</Grid>
<Grid container item xs={1}>
<Grid item xs={2}/>
<Grid item xs={6}>
<Select
size = 'small'
@ -243,10 +353,186 @@ export default class BottomBar extends Component {
<MenuItem value={1}>EN</MenuItem>
</Select>
</Grid>
<Grid item xs={4}>
<Grid item xs={3}>
<IconButton
color="primary"
aria-label="Telegram"
aria-label="Community"
onClick={this.handleClickOpenCommunity} >
<PeopleIcon />
</IconButton>
</Grid>
<Grid item xs={3}>
<IconButton color="primary"
aria-label="Stats for Nerds"
onClick={this.handleClickOpenStatsForNerds} >
<SettingsIcon />
</IconButton>
</Grid>
</Grid>
</Grid>
</Paper>
)
}
handleClickOpenExchangeSummary = () => {
this.getInfo();
this.setState({openExchangeSummary: true});
};
handleClickCloseExchangeSummary = () => {
this.setState({openExchangeSummary: false});
};
exchangeSummaryDialogPhone =() =>{
return(
<Dialog
open={this.state.openExchangeSummary}
onClose={this.handleClickCloseExchangeSummary}
aria-labelledby="exchange-summary-title"
aria-describedby="exchange-summary-description"
>
<DialogContent>
<Typography component="h5" variant="h5">Exchange Summary</Typography>
<List dense>
<ListItem >
<ListItemIcon size="small">
<InventoryIcon/>
</ListItemIcon>
<ListItemText
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={this.state.num_public_buy_orders}
secondary="Public buy orders" />
</ListItem>
<Divider/>
<ListItem >
<ListItemIcon size="small">
<SellIcon/>
</ListItemIcon>
<ListItemText
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={this.state.num_public_sell_orders}
secondary="Public sell orders" />
</ListItem>
<Divider/>
<ListItem >
<ListItemIcon size="small">
<SmartToyIcon/>
</ListItemIcon>
<ListItemText
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={this.state.active_robots_today}
secondary="Today active robots" />
</ListItem>
<Divider/>
<ListItem >
<ListItemIcon size="small">
<PriceChangeIcon/>
</ListItemIcon>
<ListItemText
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={this.state.today_avg_nonkyc_btc_premium+"%"}
secondary="Today non-KYC average premium" />
</ListItem>
<Divider/>
<ListItem >
<ListItemIcon size="small">
<PercentIcon/>
</ListItemIcon>
<ListItemText
primaryTypographyProps={{fontSize: '14px'}}
secondaryTypographyProps={{fontSize: '12px'}}
primary={this.state.fee*100+"%"}
secondary="Trading fee" />
</ListItem>
</List>
</DialogContent>
</Dialog>
)
}
bottomBarPhone =()=>{
return(
<Paper elevation={6} style={{height:40}}>
<this.StatsDialog/>
<this.CommunityDialog/>
<this.exchangeSummaryDialogPhone/>
<this.dialogProfile/>
<Grid container xs={12}>
<Grid item xs={1.6}>
<IconButton onClick={this.handleClickOpenProfile} sx={{margin: 0, top: -13, }} >
<Badge badgeContent={(this.state.active_order_id >0 & !this.state.profileShown) ? "": null} color="primary">
<Avatar className='flippedSmallAvatar'
alt={this.props.nickname}
src={this.props.nickname ? window.location.origin +'/static/assets/avatars/' + this.props.nickname + '.png' : null}
/>
</Badge>
</IconButton>
</Grid>
<Grid item xs={1.6} align="center">
<IconButton onClick={this.handleClickOpenExchangeSummary} >
<Badge badgeContent={this.state.num_public_buy_orders} color="action">
<InventoryIcon />
</Badge>
</IconButton>
</Grid>
<Grid item xs={1.6} align="center">
<IconButton onClick={this.handleClickOpenExchangeSummary} >
<Badge badgeContent={this.state.num_public_sell_orders} color="action">
<SellIcon />
</Badge>
</IconButton>
</Grid>
<Grid item xs={1.6} align="center">
<IconButton onClick={this.handleClickOpenExchangeSummary} >
<Badge badgeContent={this.state.active_robots_today} color="action">
<SmartToyIcon />
</Badge>
</IconButton>
</Grid>
<Grid item xs={1.8} align="center">
<IconButton onClick={this.handleClickOpenExchangeSummary} >
<Badge badgeContent={this.state.today_avg_nonkyc_btc_premium+"%"} color="action">
<PriceChangeIcon />
</Badge>
</IconButton>
</Grid>
<Grid container item xs={3.8}>
<Grid item xs={6}>
<Select
size = 'small'
defaultValue={1}
inputProps={{
style: {textAlign:"center"}
}}>
<MenuItem value={1}>EN</MenuItem>
</Select>
</Grid>
<Grid item xs={3}>
<IconButton color="primary"
aria-label="Stats for Nerds"
onClick={this.handleClickOpenStatsForNerds} >
<SettingsIcon />
</IconButton>
</Grid>
<Grid item xs={3}>
<IconButton
color="primary"
aria-label="Community"
onClick={this.handleClickOpenCommunity} >
<PeopleIcon />
</IconButton>
@ -256,5 +542,19 @@ export default class BottomBar extends Component {
</Grid>
</Paper>
)
}
render() {
return (
<div>
<MediaQuery minWidth={1200}>
<this.bottomBarDesktop/>
</MediaQuery>
<MediaQuery maxWidth={1199}>
<this.bottomBarPhone/>
</MediaQuery>
</div>
)
}
}

View File

@ -1,8 +1,6 @@
import React, { Component } from 'react';
import { w3cwebsocket as W3CWebSocket } from "websocket";
import {Button, TextField, Link, Grid, Typography, Container, Card, CardHeader, Paper, Avatar} from "@mui/material";
import { withStyles } from "@mui/material";
import {Button, TextField, Grid, Container, Card, CardHeader, Paper, Avatar, FormHelperText} from "@mui/material";
export default class Chat extends Component {
@ -53,7 +51,7 @@ export default class Chat extends Component {
this.client.send(JSON.stringify({
type: "message",
message: this.state.value,
nick: this.props.urNick,
nick: this.props.ur_nick,
}));
this.state.value = ''
e.preventDefault();
@ -66,7 +64,7 @@ export default class Chat extends Component {
{this.state.messages.map(message => <>
<Card elevation={5} align="left" >
{/* If message sender is not our nick, gray color, if it is our nick, green color */}
{message.userNick == this.props.urNick ?
{message.userNick == this.props.ur_nick ?
<CardHeader
avatar={
<Avatar
@ -113,6 +111,7 @@ export default class Chat extends Component {
</Grid>
</Grid>
</form>
<FormHelperText>This chat has no memory. If you leave and come back the messages are lost.</FormHelperText>
</Container>
)
}

View File

@ -1,25 +1,43 @@
import React, { Component } from "react";
import { BrowserRouter as Router, Switch, Route, Link, Redirect } from "react-router-dom";
import { BrowserRouter as Router, Switch, Route, Link, Redirect,useHistory } from "react-router-dom";
import UserGenPage from "./UserGenPage";
import MakerPage from "./MakerPage";
import BookPage from "./BookPage";
import OrderPage from "./OrderPage";
import BottomBar from "./BottomBar";
export default class HomePage extends Component {
constructor(props) {
super(props);
this.state = {
nickname: null,
token: null,
}
}
setAppState=(newState)=>{
this.setState(newState)
}
redirectTo(location) {
this.props.history.push(location);
}
render() {
return (
<Router >
<div className='appCenter'>
<Switch>
<Route exact path='/' component={UserGenPage}/>
<Route path='/home'><p>You are at the start page</p></Route>
<Route exact path='/' render={(props) => <UserGenPage setAppState={this.setAppState}/>}/>
<Route path='/make' component={MakerPage}/>
<Route path='/book' component={BookPage}/>
<Route path="/order/:orderId" component={OrderPage}/>
</Switch>
</div>
<div className='bottomBar'>
<BottomBar redirectTo={this.redirectTo} {...this.state} setAppState={this.setAppState} />
</div>
</Router>
);
}

View File

@ -1,36 +1,69 @@
import {Typography, DialogTitle, DialogContent, DialogContentText, Button } from "@mui/material"
import {Typography, DialogActions, DialogContent, Button, Grid} from "@mui/material"
import React, { Component } from 'react'
import Image from 'material-ui-image'
import MediaQuery from 'react-responsive'
import { maxWidth, minWidth } from "@mui/system"
export default class InfoDialog extends Component {
render() {
return (
<div>
<DialogContent>
<Typography component="h5" variant="h5">What is <i>RoboSats</i>?</Typography>
<MediaQuery minWidth={475}>
<Grid container xs={12}>
<Grid item xs={8}>
<Typography component="h4" variant="h4">What is <i>RoboSats</i>?</Typography>
<Typography component="body2" variant="body2">
<p>It is a BTC/FIAT peer-to-peer exchange over lightning. It simplifies
matchmaking and minimizes the trust needed to trade with a peer.</p>
<p>It is a BTC/FIAT peer-to-peer exchange over lightning. <br/> It simplifies
matchmaking and minimizes the need of trust. RoboSats focuses in privacy and speed.</p>
<p>RoboSats is an open source project <a
href='https://github.com/reckless-satoshi/robosats'>(GitHub).</a>
</p>
</Typography>
</Grid>
<Grid item xs={4} align="center">
<Image className='newAvatar'
disableError='true'
cover='true'
color='null'
src={window.location.origin +'/static/assets/images/robosats_0.1.0.png'}
/>
</Grid>
</Grid>
</MediaQuery>
<MediaQuery maxWidth={474}>
<Typography component="h4" variant="h4">What is <i>RoboSats</i>?</Typography>
<Typography component="body2" variant="body2">
<p>It is a BTC/FIAT peer-to-peer exchange over lightning. It simplifies
matchmaking and minimizes the need of trust. RoboSats focuses in privacy and speed.</p>
<img
width='100%'
src={window.location.origin +'/static/assets/images/robosats_0.1.0_banner.png'}
/>
<p>RoboSats is an open source project <a
href='https://github.com/reckless-satoshi/robosats'>(GitHub).</a>
</p>
</Typography>
</MediaQuery>
<Typography component="h5" variant="h5">How does it work?</Typography>
<Typography component="body2" variant="body2">
<p>AdequateAlice01 wants to sell bitcoin. She posts a sell order.
<p> AnonymousAlice01 wants to sell bitcoin. She posts a sell order.
BafflingBob02 wants to buy bitcoin and he takes Alice's order.
Both have to post a small bond using lightning to prove they are real
robots. Then, Alice posts the trade collateral also using a lightning
hold invoice. <i>RoboSats</i> locks the invoice until Bob confirms he sent
the fiat to Alice. Once Alice confirms she received the fiat, she
tells <i>RoboSats</i> to release the satoshis to Bob. Enjoy your satoshis,
hold invoice. <i>RoboSats</i> locks the invoice until Alice confirms she
received the fiat, then the satoshis are released to Bob. Enjoy your satoshis,
Bob!</p>
<p>At no point, AdequateAlice01 and BafflingBob02 have to trust the
bitcoin to each other. In case they have a conflict, <i>RoboSats</i> staff
will help resolving the dispute.</p>
<p>At no point, AnonymousAlice01 and BafflingBob02 have to trust the
bitcoin funds to each other. In case they have a conflict, <i>RoboSats</i> staff
will help resolving the dispute. You can find a step-by-step
description of the trade pipeline in <a href='https://github.com/Reckless-Satoshi/robosats/blob/main/README.md#how-it-works'>How it works</a></p>
</Typography>
<Typography component="h5" variant="h5">What payment methods are accepted?</Typography>
@ -51,8 +84,9 @@ export default class InfoDialog extends Component {
<Typography component="h5" variant="h5">Is <i>RoboSats</i> private?</Typography>
<Typography component="body2" variant="body2">
<p> RoboSats will never ask you for your name, country or ID. For
best anonymity use Tor Browser and access the .onion hidden service. </p>
<p> RoboSats will never ask you for your name, country or ID. RoboSats does
not custody your funds, and doesn't care who you are. For best anonymity
use Tor Browser and access the .onion hidden service. </p>
<p>Your trading peer is the only one who can potentially guess
anything about you. Keep your chat short and concise. Avoid
@ -73,29 +107,33 @@ export default class InfoDialog extends Component {
<Typography component="h5" variant="h5">What is the trust model?</Typography>
<Typography component="body2" variant="body2">
<p> The buyer and the seller never have to trust each other.
Some trust on <i>RoboSats</i> staff is needed since linking
the seller's hold invoice and buyer payment is not atomic (yet).
Some trust on <i>RoboSats</i> is needed since linking the
seller's hold invoice and buyer payment is not atomic (yet).
In addition, disputes are solved by the <i>RoboSats</i> staff.
</p>
<p> While trust requirements are minimized, <i>RoboSats</i> could
run away with your satoshis. It could be argued that it is not
worth it, as it would instantly destroy <i>RoboSats</i> reputation.
<p> To be totally clear. Trust requirements are minimized. However, there is still
one way <i>RoboSats </i> could run away with your satoshis: by not releasing
the satoshis to the buyer. It could be argued that such move is not in <i>RoboSats' </i>
interest as it would damage the reputation for a small payout.
However, you should hesitate and only trade small quantities at a
time. For large amounts use an onchain escrow service such as <i>Bisq</i>
</p>
<p> You can build more trust on <i>RoboSats</i> by <a href='https://github.com/reckless-satoshi/robosats'>
inspecting the source code </a> </p>
inspecting the source code. </a> </p>
</Typography>
<Typography component="h5" variant="h5">What happens if <i>RoboSats</i> suddenly disapears?</Typography>
<Typography component="body2" variant="body2">
<p> Your sats will most likely return to you. Any hold invoice that is not
<p> Your sats will return to you. Any hold invoice that is not
settled would be automatically returned even if <i>RoboSats</i> goes down
forever. This is true for both, locked bonds and trading escrows. However,
there is a small window between the buyer confirms FIAT SENT and the moment
the seller releases the satoshis when the funds could be lost.
there is a small window between the seller confirms FIAT RECEIVED and the moment
the buyer receives the satoshis when the funds could be permanentely lost if
<i> RoboSats</i> disappears. This window is about 1 second long. Make sure to have enough
inbound liquidity to avoid routing failures. If you have any problem, reach out
trough the <i>RoboSats</i> public channels.
</p>
</Typography>
@ -116,9 +154,10 @@ export default class InfoDialog extends Component {
RoboSats</i> will definitely never ask for your robot token.
</p>
</Typography>
<DialogActions>
<Button onClick={this.props.handleCloseInfo}>Close</Button>
</DialogActions>
</DialogContent>
</div>
)
}

View File

@ -1,7 +1,6 @@
import React, { Component } from 'react';
import { Paper, Alert, AlertTitle, Button , Grid, Typography, TextField, Select, FormHelperText, MenuItem, FormControl, Radio, FormControlLabel, RadioGroup, Menu} from "@mui/material"
import { Paper, Button , Grid, Typography, TextField, Select, FormHelperText, MenuItem, FormControl, Radio, FormControlLabel, RadioGroup} from "@mui/material"
import { Link } from 'react-router-dom'
import getFlags from './getFlags'
function getCookie(name) {
@ -37,7 +36,7 @@ export default class MakerPage extends Component {
constructor(props) {
super(props);
this.state={
isExplicit: false,
is_explicit: false,
type: 0,
currency: this.defaultCurrency,
currencyCode: this.defaultCurrencyCode,
@ -89,14 +88,14 @@ export default class MakerPage extends Component {
}
handleClickRelative=(e)=>{
this.setState({
isExplicit: false,
is_explicit: false,
satoshis: null,
premium: 0,
});
}
handleClickExplicit=(e)=>{
this.setState({
isExplicit: true,
is_explicit: true,
premium: null,
});
}
@ -104,7 +103,6 @@ export default class MakerPage extends Component {
handleCreateOfferButtonPressed=()=>{
this.state.amount == null ? this.setState({amount: 0}) : null;
console.log(this.state)
const requestOptions = {
method: 'POST',
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken')},
@ -113,7 +111,7 @@ export default class MakerPage extends Component {
currency: this.state.currency,
amount: this.state.amount,
payment_method: this.state.payment_method,
is_explicit: this.state.isExplicit,
is_explicit: this.state.is_explicit,
premium: this.state.premium,
satoshis: this.state.satoshis,
}),
@ -241,7 +239,7 @@ export default class MakerPage extends Component {
</FormControl>
</Grid>
{/* conditional shows either Premium % field or Satoshis field based on pricing method */}
{ this.state.isExplicit
{ this.state.is_explicit
? <Grid item xs={12} align="center">
<TextField
label="Satoshis"
@ -287,7 +285,7 @@ export default class MakerPage extends Component {
<Typography component="subtitle2" variant="subtitle2">
<div align='center'>
Create a BTC {this.state.type==0 ? "buy":"sell"} order for {this.state.amount} {this.state.currencyCode}
{this.state.isExplicit ? " of " + this.state.satoshis + " Satoshis" :
{this.state.is_explicit ? " of " + this.state.satoshis + " Satoshis" :
(this.state.premium == 0 ? " at market price" :
(this.state.premium > 0 ? " at a " + this.state.premium + "% premium":" at a " + -this.state.premium + "% discount")
)

View File

@ -1,6 +1,8 @@
import React, { Component } from "react";
import { Alert, Paper, CircularProgress, Button , Grid, Typography, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, Avatar, Divider, Box, LinearProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"
import { Badge, Tab, Tabs, Alert, Paper, CircularProgress, Button , Grid, Typography, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, Avatar, Divider, Box, LinearProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"
import Countdown, { zeroPad, calcTimeDelta } from 'react-countdown';
import MediaQuery from 'react-responsive'
import TradeBox from "./TradeBox";
import getFlags from './getFlags'
@ -9,9 +11,7 @@ import AccessTimeIcon from '@mui/icons-material/AccessTime';
import NumbersIcon from '@mui/icons-material/Numbers';
import PriceChangeIcon from '@mui/icons-material/PriceChange';
import PaymentsIcon from '@mui/icons-material/Payments';
import MoneyIcon from '@mui/icons-material/Money';
import ArticleIcon from '@mui/icons-material/Article';
import ContentCopy from "@mui/icons-material/ContentCopy";
function getCookie(name) {
let cookieValue = null;
@ -39,86 +39,69 @@ export default class OrderPage extends Component {
constructor(props) {
super(props);
this.state = {
isExplicit: false,
is_explicit: false,
delay: 60000, // Refresh every 60 seconds by default
currencies_dict: {"1":"USD"},
total_secs_expiry: 300,
total_secs_exp: 300,
loading: true,
openCancel: false,
openCollaborativeCancel: false,
showContractBox: 1,
};
this.orderId = this.props.match.params.orderId;
this.getCurrencyDict();
this.getOrderDetails();
// Refresh delais according to Order status
// Refresh delays according to Order status
this.statusToDelay = {
"0": 3000, //'Waiting for maker bond'
"1": 30000, //'Public'
"0": 2000, //'Waiting for maker bond'
"1": 25000, //'Public'
"2": 9999999, //'Deleted'
"3": 3000, //'Waiting for taker bond'
"3": 2000, //'Waiting for taker bond'
"4": 9999999, //'Cancelled'
"5": 999999, //'Expired'
"6": 3000, //'Waiting for trade collateral and buyer invoice'
"7": 3000, //'Waiting only for seller trade collateral'
"8": 10000, //'Waiting only for buyer invoice'
"8": 8000, //'Waiting only for buyer invoice'
"9": 10000, //'Sending fiat - In chatroom'
"10": 15000, //'Fiat sent - In chatroom'
"11": 60000, //'In dispute'
"12": 9999999,//'Collaboratively cancelled'
"10": 10000, //'Fiat sent - In chatroom'
"11": 30000, //'In dispute'
"12": 9999999, //'Collaboratively cancelled'
"13": 3000, //'Sending satoshis to buyer'
"14": 9999999,//'Sucessful trade'
"14": 9999999, //'Sucessful trade'
"15": 10000, //'Failed lightning network routing'
"16": 9999999,//'Maker lost dispute'
"17": 9999999,//'Taker lost dispute'
"16": 9999999, //'Maker lost dispute'
"17": 9999999, //'Taker lost dispute'
}
}
completeSetState=(newStateVars)=>{
// In case the reply only has "bad_request"
// Do not substitute these two for "undefined" as
// otherStateVars will fail to assign values
if (newStateVars.currency == null){
newStateVars.currency = this.state.currency
newStateVars.status = this.state.status
}
var otherStateVars = {
loading: false,
delay: this.setDelay(newStateVars.status),
currencyCode: this.getCurrencyCode(newStateVars.currency),
penalty: newStateVars.penalty, // in case penalty time has finished, it goes back to null
invoice_expired: newStateVars.invoice_expired // in case invoice had expired, it goes back to null when it is valid again
};
var completeStateVars = Object.assign({}, newStateVars, otherStateVars);
this.setState(completeStateVars);
}
getOrderDetails() {
this.setState(null)
fetch('/api/order' + '?order_id=' + this.orderId)
.then((response) => response.json())
.then((data) => {console.log(data) &
this.setState({
loading: false,
delay: this.setDelay(data.status),
id: data.id,
statusCode: data.status,
statusText: data.status_message,
type: data.type,
currency: data.currency,
currencyCode: this.getCurrencyCode(data.currency),
amount: data.amount,
paymentMethod: data.payment_method,
isExplicit: data.is_explicit,
premium: data.premium,
satoshis: data.satoshis,
makerId: data.maker,
isParticipant: data.is_participant,
urNick: data.ur_nick,
makerNick: data.maker_nick,
takerId: data.taker,
takerNick: data.taker_nick,
isMaker: data.is_maker,
isTaker: data.is_taker,
isBuyer: data.is_buyer,
isSeller: data.is_seller,
penalty: data.penalty,
expiresAt: data.expires_at,
badRequest: data.bad_request,
bondInvoice: data.bond_invoice,
bondSatoshis: data.bond_satoshis,
escrowInvoice: data.escrow_invoice,
escrowSatoshis: data.escrow_satoshis,
invoiceAmount: data.invoice_amount,
total_secs_expiry: data.total_secs_exp,
numSimilarOrders: data.num_similar_orders,
priceNow: data.price_now,
premiumNow: data.premium_now,
robotsInBook: data.robots_in_book,
premiumPercentile: data.premium_percentile,
numSimilarOrders: data.num_similar_orders
})
});
.then((data) => this.completeSetState(data));
}
// These are used to refresh the data
@ -137,11 +120,6 @@ export default class OrderPage extends Component {
this.getOrderDetails();
}
// Fix to use proper react props
handleClickBackButton=()=>{
window.history.back();
}
// Countdown Renderer callback with condition
countdownRenderer = ({ total, hours, minutes, seconds, completed }) => {
if (completed) {
@ -150,7 +128,7 @@ export default class OrderPage extends Component {
} else {
var col = 'black'
var fraction_left = (total/1000) / this.state.total_secs_expiry
var fraction_left = (total/1000) / this.state.total_secs_exp
// Make orange at 25% of time left
if (fraction_left < 0.25){col = 'orange'}
// Make red at 10% of time left
@ -163,14 +141,27 @@ export default class OrderPage extends Component {
}
};
// Countdown Renderer callback with condition
countdownPenaltyRenderer = ({ minutes, seconds, completed }) => {
if (completed) {
// Render a completed state
return (<span> Penalty lifted, good to go!</span>);
} else {
return (
<span> Wait {zeroPad(minutes)}m {zeroPad(seconds)}s </span>
);
}
};
LinearDeterminate =()=> {
const [progress, setProgress] = React.useState(0);
React.useEffect(() => {
const timer = setInterval(() => {
setProgress((oldProgress) => {
var left = calcTimeDelta( new Date(this.state.expiresAt)).total /1000;
return (left / this.state.total_secs_expiry) * 100;
var left = calcTimeDelta( new Date(this.state.expires_at)).total /1000;
return (left / this.state.total_secs_exp) * 100;
});
}, 1000);
@ -187,7 +178,6 @@ export default class OrderPage extends Component {
}
handleClickTakeOrderButton=()=>{
console.log(this.state)
const requestOptions = {
method: 'POST',
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
@ -197,9 +187,7 @@ export default class OrderPage extends Component {
};
fetch('/api/order/' + '?order_id=' + this.orderId, requestOptions)
.then((response) => response.json())
.then((data) => (this.setState({badRequest:data.bad_request})
& console.log(data)
& this.getOrderDetails(data.id)));
.then((data) => this.completeSetState(data));
}
getCurrencyDict() {
fetch('/static/assets/currencies.json')
@ -221,7 +209,6 @@ export default class OrderPage extends Component {
}
handleClickConfirmCancelButton=()=>{
console.log(this.state)
const requestOptions = {
method: 'POST',
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
@ -231,7 +218,7 @@ export default class OrderPage extends Component {
};
fetch('/api/order/' + '?order_id=' + this.orderId, requestOptions)
.then((response) => response.json())
.then((data) => (console.log(data) & this.getOrderDetails(data.id)));
.then((data) => this.getOrderDetails(data.id));
this.handleClickCloseConfirmCancelDialog();
}
@ -266,11 +253,57 @@ export default class OrderPage extends Component {
)
}
handleClickConfirmCollaborativeCancelButton=()=>{
const requestOptions = {
method: 'POST',
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken'),},
body: JSON.stringify({
'action':'cancel',
}),
};
fetch('/api/order/' + '?order_id=' + this.orderId, requestOptions)
.then((response) => response.json())
.then((data) => this.getOrderDetails(data.id));
this.handleClickCloseCollaborativeCancelDialog();
}
handleClickOpenCollaborativeCancelDialog = () => {
this.setState({openCollaborativeCancel: true});
};
handleClickCloseCollaborativeCancelDialog = () => {
this.setState({openCollaborativeCancel: false});
};
CollaborativeCancelDialog =() =>{
return(
<Dialog
open={this.state.openCollaborativeCancel}
onClose={this.handleClickCloseCollaborativeCancelDialog}
aria-labelledby="collaborative-cancel-dialog-title"
aria-describedby="collaborative-cancel-dialog-description"
>
<DialogTitle id="cancel-dialog-title">
{"Collaborative cancel the order?"}
</DialogTitle>
<DialogContent>
<DialogContentText id="cancel-dialog-description">
The trade escrow has been posted. The order can be cancelled only if both, maker and
taker, agree to cancel.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleClickCloseCollaborativeCancelDialog} autoFocus>Go back</Button>
<Button onClick={this.handleClickConfirmCollaborativeCancelButton}> Ask for Cancel </Button>
</DialogActions>
</Dialog>
)
}
CancelButton = () => {
// If maker and Waiting for Bond. Or if taker and Waiting for bond.
// Simply allow to cancel without showing the cancel dialog.
if ((this.state.isMaker & this.state.statusCode == 0) || this.state.isTaker & this.state.statusCode == 3){
if ((this.state.is_maker & [0,1].includes(this.state.status)) || this.state.is_taker & this.state.status == 3){
return(
<Grid item xs={12} align="center">
<Button variant='contained' color='secondary' onClick={this.handleClickConfirmCancelButton}>Cancel</Button>
@ -278,51 +311,79 @@ export default class OrderPage extends Component {
)}
// If the order does not yet have an escrow deposited. Show dialog
// to confirm forfeiting the bond
if (this.state.statusCode < 8){
if ([3,6,7].includes(this.state.status)){
return(
<div id="openDialogCancelButton">
<Grid item xs={12} align="center">
<this.CancelDialog/>
<Button variant='contained' color='secondary' onClick={this.handleClickOpenConfirmCancelDialog}>Cancel</Button>
</Grid>
</div>
)}
// TODO If the escrow is Locked, show the collaborative cancel button.
// If the escrow is Locked, show the collaborative cancel button.
if ([8,9].includes(this.state.status)){
return(
<Grid item xs={12} align="center">
<this.CollaborativeCancelDialog/>
<Button variant='contained' color='secondary' onClick={this.handleClickOpenCollaborativeCancelDialog}>Collaborative Cancel</Button>
</Grid>
)}
// If none of the above do not return a cancel button.
return(null)
}
// Colors for the status badges
statusBadgeColor(status){
if(status=='active'){
return("success")
}
if(status=='seen_recently'){
return("warning")
}
if(status=='inactive'){
return('error')
}
}
orderBox=()=>{
return(
<Grid container spacing={1}>
<Grid container spacing={1} >
<Grid item xs={12} align="center">
<MediaQuery minWidth={920}>
<Typography component="h5" variant="h5">
Order Details
</Typography>
</MediaQuery>
<Paper elevation={12} style={{ padding: 8,}}>
<List dense="true">
<ListItem >
<ListItemAvatar sx={{ width: 56, height: 56 }}>
<Avatar
alt={this.state.makerNick}
src={window.location.origin +'/static/assets/avatars/' + this.state.makerNick + '.png'}
<Badge variant="dot" badgeContent="" color={this.statusBadgeColor(this.state.maker_status)}>
<Avatar className="flippedSmallAvatar"
alt={this.state.maker_nick}
src={window.location.origin +'/static/assets/avatars/' + this.state.maker_nick + '.png'}
/>
</Badge>
</ListItemAvatar>
<ListItemText primary={this.state.makerNick + (this.state.type ? " (Seller)" : " (Buyer)")} secondary="Order maker" align="right"/>
<ListItemText primary={this.state.maker_nick + (this.state.type ? " (Seller)" : " (Buyer)")} secondary="Order maker" align="right"/>
</ListItem>
<Divider />
{this.state.isParticipant ?
{this.state.is_participant ?
<>
{this.state.takerNick!='None' ?
{this.state.taker_nick!='None' ?
<>
<ListItem align="left">
<ListItemText primary={this.state.takerNick + (this.state.type ? " (Buyer)" : " (Seller)")} secondary="Order taker"/>
<ListItemText primary={this.state.taker_nick + (this.state.type ? " (Buyer)" : " (Seller)")} secondary="Order taker"/>
<ListItemAvatar >
<Avatar
alt={this.state.makerNick}
src={window.location.origin +'/static/assets/avatars/' + this.state.takerNick + '.png'}
<Badge variant="dot" badgeContent="" color={this.statusBadgeColor(this.state.taker_status)}>
<Avatar className="smallAvatar"
alt={this.state.taker_nick}
src={window.location.origin +'/static/assets/avatars/' + this.state.taker_nick + '.png'}
/>
</Badge>
</ListItemAvatar>
</ListItem>
<Divider />
@ -333,7 +394,7 @@ export default class OrderPage extends Component {
<ListItemIcon>
<ArticleIcon/>
</ListItemIcon>
<ListItemText primary={this.state.statusText} secondary="Order status"/>
<ListItemText primary={this.state.status_message} secondary="Order status"/>
</ListItem>
<Divider />
</>
@ -352,7 +413,7 @@ export default class OrderPage extends Component {
<ListItemIcon>
<PaymentsIcon/>
</ListItemIcon>
<ListItemText primary={this.state.paymentMethod} secondary="Accepted payment methods"/>
<ListItemText primary={this.state.payment_method} secondary="Accepted payment methods"/>
</ListItem>
<Divider />
@ -361,10 +422,10 @@ export default class OrderPage extends Component {
<ListItemIcon>
<PriceChangeIcon/>
</ListItemIcon>
{this.state.priceNow?
<ListItemText primary={pn(this.state.priceNow)+" "+this.state.currencyCode+"/BTC - Premium: "+this.state.premiumNow+"%"} secondary="Price and Premium"/>
{this.state.price_now?
<ListItemText primary={pn(this.state.price_now)+" "+this.state.currencyCode+"/BTC - Premium: "+this.state.premium_now+"%"} secondary="Price and Premium"/>
:
(this.state.isExplicit ?
(this.state.is_explicit ?
<ListItemText primary={pn(this.state.satoshis)} secondary="Amount of Satoshis"/>
:
<ListItemText primary={parseFloat(parseFloat(this.state.premium).toFixed(2))+"%"} secondary="Premium over market price"/>
@ -385,7 +446,7 @@ export default class OrderPage extends Component {
<AccessTimeIcon/>
</ListItemIcon>
<ListItemText secondary="Expires in">
<Countdown date={new Date(this.state.expiresAt)} renderer={this.countdownRenderer} />
<Countdown date={new Date(this.state.expires_at)} renderer={this.countdownRenderer} />
</ListItemText>
</ListItem>
<this.LinearDeterminate />
@ -397,7 +458,31 @@ export default class OrderPage extends Component {
<Divider />
<Grid item xs={12} align="center">
<Alert severity="warning" sx={{maxWidth:360}}>
You cannot take an order yet! Wait {this.state.penalty} seconds
You cannot take an order yet! <Countdown date={new Date(this.state.penalty)} renderer={this.countdownPenaltyRenderer} />
</Alert>
</Grid>
</>
: null}
{/* If the counterparty asked for collaborative cancel */}
{this.state.pending_cancel ?
<>
<Divider />
<Grid item xs={12} align="center">
<Alert severity="warning" sx={{maxWidth:360}}>
{this.state.is_maker ? this.state.taker_nick : this.state.maker_nick} is asking for a collaborative cancel
</Alert>
</Grid>
</>
: null}
{/* If the user has asked for a collaborative cancel */}
{this.state.asked_for_cancel ?
<>
<Divider />
<Grid item xs={12} align="center">
<Alert severity="warning" sx={{maxWidth:360}}>
You asked for a collaborative cancellation
</Alert>
</Grid>
</>
@ -406,45 +491,100 @@ export default class OrderPage extends Component {
</Paper>
</Grid>
<Grid item xs={12} align="center">
{/* Participants can see the "Cancel" Button, but cannot see the "Back" or "Take Order" buttons */}
{this.state.isParticipant ?
{this.state.is_participant ?
<this.CancelButton/>
:
<>
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Button variant='contained' color='primary' onClick={this.handleClickTakeOrderButton}>Take Order</Button>
</Grid>
<Grid item xs={12} align="center">
<Button variant='contained' color='secondary' onClick={this.handleClickBackButton}>Back</Button>
<Button variant='contained' color='secondary' onClick={this.props.history.goBack}>Back</Button>
</Grid>
</Grid>
</>
}
</Grid>
</Grid>
)
}
orderDetailsPage (){
doubleOrderPageDesktop=()=>{
return(
this.state.badRequest ?
<div align='center'>
<Typography component="subtitle2" variant="subtitle2" color="secondary" >
{this.state.badRequest}<br/>
</Typography>
<Button variant='contained' color='secondary' onClick={this.handleClickBackButton}>Back</Button>
</div>
:
(this.state.isParticipant ?
<Grid container xs={12} align="center" spacing={2}>
<Grid item xs={6} align="left">
<Grid container xs={12} align="center" spacing={2} >
<Grid item xs={6} align="left" style={{ width:330}} >
{this.orderBox()}
</Grid>
<Grid item xs={6} align="left">
<TradeBox data={this.state}/>
<TradeBox width={330} data={this.state} completeSetState={this.completeSetState} />
</Grid>
</Grid>
)
}
a11yProps(index) {
return {
id: `simple-tab-${index}`,
'aria-controls': `simple-tabpanel-${index}`,
};
}
doubleOrderPagePhone=()=>{
const [value, setValue] = React.useState(this.state.showContractBox);
const handleChange = (event, newValue) => {
this.setState({showContractBox:newValue})
setValue(newValue);
};
return(
<Box sx={{ width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={value} onChange={handleChange} variant="fullWidth" >
<Tab label="Order Details" {...this.a11yProps(0)} />
<Tab label="Contract Box" {...this.a11yProps(1)} />
</Tabs>
</Box>
<Grid container spacing={2}>
<Grid item >
<div style={{ width:330, display: this.state.showContractBox == 0 ? '':'none'}}>
{this.orderBox()}
</div>
<div style={{display: this.state.showContractBox == 1 ? '':'none'}}>
<TradeBox width={330} data={this.state} completeSetState={this.completeSetState} />
</div>
</Grid>
</Grid>
</Box>
);
}
orderDetailsPage (){
return(
this.state.bad_request ?
<div align='center'>
<Typography component="subtitle2" variant="subtitle2" color="secondary" >
{this.state.bad_request}<br/>
</Typography>
<Button variant='contained' color='secondary' onClick={this.props.history.goBack}>Back</Button>
</div>
:
<Grid item xs={12} align="center">
(this.state.is_participant ?
<>
{/* Desktop View */}
<MediaQuery minWidth={920}>
<this.doubleOrderPageDesktop/>
</MediaQuery>
{/* SmarPhone View */}
<MediaQuery maxWidth={919}>
<this.doubleOrderPagePhone/>
</MediaQuery>
</>
:
<Grid item xs={12} align="center" style={{ width:330}}>
{this.orderBox()}
</Grid>)
)

View File

@ -1,15 +1,15 @@
import React, { Component } from "react";
import { Link, Paper, Rating, Button, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"
import { IconButton, Paper, Rating, Button, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"
import QRCode from "react-qr-code";
import Countdown from 'react-countdown';
import Chat from "./Chat"
import MediaQuery from 'react-responsive'
import QrReader from 'react-qr-reader'
// Icons
import SmartToyIcon from '@mui/icons-material/SmartToy';
import PercentIcon from '@mui/icons-material/Percent';
import BookIcon from '@mui/icons-material/Book';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
function getCookie(name) {
let cookieValue = null;
@ -41,9 +41,21 @@ export default class TradeBox extends Component {
openConfirmDispute: false,
badInvoice: false,
badStatement: false,
qrscanner: false,
}
}
Sound = ({soundFileName}) => (
// Four filenames: "locked-invoice", "taker-found", "open-chat", "sucessful"
<audio autoPlay src={`/static/assets/sounds/${soundFileName}.mp3`} />
)
togglePlay = () => {
this.setState({ playSound: !this.state.playSound }, () => {
this.state.playSound ? this.audio.play() : this.audio.pause();
});
}
handleClickOpenConfirmDispute = () => {
this.setState({openConfirmDispute: true});
};
@ -61,7 +73,7 @@ export default class TradeBox extends Component {
};
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
.then((response) => response.json())
.then((data) => (this.props.data = data));
.then((data) => this.props.completeSetState(data));
this.handleClickCloseConfirmDispute();
}
@ -78,15 +90,15 @@ export default class TradeBox extends Component {
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
The RoboSats staff will examine the statements and evidence provided by the participants.
It is best if you provide a burner contact method on your statement for the staff to contact you.
The satoshis in the trade escrow will be sent to the dispute winner, while the dispute
loser will lose the bond.
The RoboSats staff will examine the statements and evidence provided. You need to build
a complete case, as the staff cannot read the chat. It is best to provide a burner contact
method with your statement. The satoshis in the trade escrow will be sent to the dispute winner,
while the dispute loser will lose the bond.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleClickCloseConfirmDispute} autoFocus>Disagree</Button>
<Button onClick={this.handleClickAgreeDisputeButton}> Agree </Button>
<Button onClick={this.handleClickAgreeDisputeButton}> Agree and open dispute </Button>
</DialogActions>
</Dialog>
)
@ -136,32 +148,32 @@ export default class TradeBox extends Component {
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2">
Robosats show commitment to their peers
Robots show commitment to their peers
</Typography>
</Grid>
<Grid item xs={12} align="center">
{this.props.data.isMaker ?
{this.props.data.is_maker ?
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b>Lock {pn(this.props.data.bondSatoshis)} Sats to PUBLISH order </b>
<b>Lock {pn(this.props.data.bond_satoshis)} Sats to PUBLISH order </b>
</Typography>
:
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b>Lock {pn(this.props.data.bondSatoshis)} Sats to TAKE the order </b>
<b>Lock {pn(this.props.data.bond_satoshis)} Sats to TAKE the order </b>
</Typography>
}
</Grid>
<Grid item xs={12} align="center">
<QRCode value={this.props.data.bondInvoice} size={305}/>
<Button size="small" color="inherit" onClick={() => {navigator.clipboard.writeText(this.props.data.bondInvoice)}} align="center"> 📋Copy to clipboard</Button>
<QRCode value={this.props.data.bond_invoice} size={305}/>
<Button size="small" color="inherit" onClick={() => {navigator.clipboard.writeText(this.props.data.bond_invoice)}} align="center"> 📋Copy to clipboard</Button>
</Grid>
<Grid item xs={12} align="center">
<TextField
hiddenLabel
variant="standard"
size="small"
defaultValue={this.props.data.bondInvoice}
defaultValue={this.props.data.bond_invoice}
disabled="true"
helperText="This is a hold invoice. It will be charged only if you cancel or lose a dispute."
helperText="This is a hold invoice, it will freeze in your wallet. It will be charged only if you cancel or lose a dispute."
color = "secondary"
/>
</Grid>
@ -173,7 +185,7 @@ export default class TradeBox extends Component {
return (
<Grid item xs={12} align="center">
<Typography color="primary" component="subtitle1" variant="subtitle1" align="center">
🔒 Your {this.props.data.isMaker ? 'maker' : 'taker'} bond is locked
🔒 Your {this.props.data.is_maker ? 'maker' : 'taker'} bond is locked
</Typography>
</Grid>
);
@ -182,23 +194,25 @@ export default class TradeBox extends Component {
showEscrowQRInvoice=()=>{
return (
<Grid container spacing={1}>
{/* Make confirmation sound for HTLC received. */}
<this.Sound soundFileName="locked-invoice"/>
<Grid item xs={12} align="center">
<Typography color="green" component="subtitle1" variant="subtitle1">
<b>Deposit {pn(this.props.data.escrowSatoshis)} Sats as trade collateral </b>
<b>Deposit {pn(this.props.data.escrow_satoshis)} Sats as trade collateral </b>
</Typography>
</Grid>
<Grid item xs={12} align="center">
<QRCode value={this.props.data.escrowInvoice} size={305}/>
<Button size="small" color="inherit" onClick={() => {navigator.clipboard.writeText(this.props.data.escrowInvoice)}} align="center"> 📋Copy to clipboard</Button>
<QRCode value={this.props.data.escrow_invoice} size={305}/>
<Button size="small" color="inherit" onClick={() => {navigator.clipboard.writeText(this.props.data.escrow_invoice)}} align="center"> 📋Copy to clipboard</Button>
</Grid>
<Grid item xs={12} align="center">
<TextField
hiddenLabel
variant="filled"
size="small"
defaultValue={this.props.data.escrowInvoice}
defaultValue={this.props.data.escrow_invoice}
disabled="true"
helperText="This is a hold invoice. It will be charged once the buyer confirms he sent the fiat."
helperText={"This is a hold invoice, it will freeze in your wallet. It will be released to the buyer once you confirm to have received the "+this.props.data.currencyCode+"."}
color = "secondary"
/>
</Grid>
@ -208,11 +222,10 @@ export default class TradeBox extends Component {
}
showTakerFound=()=>{
// TODO Make some sound here! The maker might have been waiting for long
return (
<Grid container spacing={1}>
{/* Make bell sound when taker is found */}
<this.Sound soundFileName="taker-found"/>
<Grid item xs={12} align="center">
<Typography component="subtitle1" variant="subtitle1">
<b>A taker has been found! </b>
@ -221,7 +234,9 @@ export default class TradeBox extends Component {
<Divider/>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2">
Please wait for the taker to confirm his commitment by locking a bond.
Please wait for the taker to confirm by locking a bond.
If the taker does not lock a bond in time the orer will be made
public again.
</Typography>
</Grid>
{this.showBondIsLocked()}
@ -232,6 +247,8 @@ export default class TradeBox extends Component {
showMakerWait=()=>{
return (
<Grid container spacing={1}>
{/* Make confirmation sound for HTLC received. */}
<this.Sound soundFileName="locked-invoice"/>
<Grid item xs={12} align="center">
<Typography component="subtitle1" variant="subtitle1">
<b> Your order is public. Wait for a taker. </b>
@ -251,20 +268,12 @@ export default class TradeBox extends Component {
</Typography>
</ListItem>
{/* TODO API sends data for a more confortable wait */}
<Divider/>
<ListItem>
<ListItemIcon>
<SmartToyIcon/>
</ListItemIcon>
<ListItemText primary={'000 coming soon'} secondary="Robots looking at the book"/>
</ListItem>
<Divider/>
<ListItem>
<ListItemIcon>
<BookIcon/>
</ListItemIcon>
<ListItemText primary={this.props.data.numSimilarOrders} secondary={"Public orders for " + this.props.data.currencyCode}/>
<ListItemText primary={this.props.data.num_similar_orders} secondary={"Public orders for " + this.props.data.currencyCode}/>
</ListItem>
<Divider/>
@ -272,7 +281,7 @@ export default class TradeBox extends Component {
<ListItemIcon>
<PercentIcon/>
</ListItemIcon>
<ListItemText primary={"Premium rank " + this.props.data.premiumPercentile*100+"%"}
<ListItemText primary={"Premium rank " + this.props.data.premium_percentile*100+"%"}
secondary={"Among public " + this.props.data.currencyCode + " orders (higher is cheaper)"} />
</ListItem>
<Divider/>
@ -305,7 +314,7 @@ export default class TradeBox extends Component {
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
.then((response) => response.json())
.then((data) => this.setState({badInvoice:data.bad_invoice})
& console.log(data));
& this.props.completeSetState(data));
}
handleInputDisputeChanged=(e)=>{
@ -329,25 +338,38 @@ export default class TradeBox extends Component {
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
.then((response) => response.json())
.then((data) => this.setState({badStatement:data.bad_statement})
& console.log(data));
}
& this.props.completeSetState(data));
}
handleScan = data => {
if (data) {
this.setState({
invoice: data
})
}
}
handleError = err => {
console.error(err)
}
handleQRbutton = () => {
this.setState({qrscanner: !this.state.qrscanner});
}
showInputInvoice(){
return (
// TODO Option to upload files and images
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b> Submit a LN invoice for {pn(this.props.data.invoiceAmount)} Sats </b>
<b> Submit a LN invoice for {pn(this.props.data.invoice_amount)} Sats </b>
</Typography>
</Grid>
<Grid item xs={12} align="left">
<Typography component="body2" variant="body2">
The taker is committed! Before letting you send {" "+ parseFloat(parseFloat(this.props.data.amount).toFixed(4))+
" "+ this.props.data.currencyCode}, we want to make sure you are able to receive the BTC. Please provide a
valid invoice for {pn(this.props.data.invoiceAmount)} Satoshis.
valid invoice for {pn(this.props.data.invoice_amount)} Satoshis.
</Typography>
</Grid>
@ -357,14 +379,29 @@ export default class TradeBox extends Component {
helperText={this.state.badInvoice ? this.state.badInvoice : "" }
label={"Payout Lightning Invoice"}
required
value={this.state.invoice}
inputProps={{
style: {textAlign:"center"}
style: {textAlign:"center"},
maxHeight: 200,
}}
multiline
minRows={5}
maxRows={this.state.qrscanner ? 5 : 14}
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'>Submit</Button>
</Grid>
@ -374,7 +411,24 @@ export default class TradeBox extends Component {
}
// Asks the user for a dispute statement.
showInDisputeStatement(){
showInDisputeStatement=()=>{
if(this.props.data.statement_submitted){
return (
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b> We have received your statement </b>
</Typography>
</Grid>
<Grid item xs={12} align="left">
<Typography component="body2" variant="body2">
We are waiting for your trade counterparty statement.
</Typography>
</Grid>
{this.showBondIsLocked()}
</Grid>
)
}else{
return (
// TODO Option to upload files
@ -412,6 +466,25 @@ export default class TradeBox extends Component {
<Button onClick={this.handleClickSubmitStatementButton} variant='contained' color='primary'>Submit</Button>
</Grid>
{this.showBondIsLocked()}
</Grid>
)}
}
showWaitForDisputeResolution=()=>{
return (
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b> We have the statements </b>
</Typography>
</Grid>
<Grid item xs={12} align="left">
<Typography component="body2" variant="body2">
Wait for the staff to resolve the dispute. The dispute winner
will be asked to submit a LN invoice.
</Typography>
</Grid>
{this.showBondIsLocked()}
</Grid>
)
@ -427,8 +500,7 @@ export default class TradeBox extends Component {
</Grid>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2" align="left">
<p>We are waiting for the seller to deposit the full trade BTC amount
into the escrow.</p>
<p>We are waiting for the seller lock the trade amount. </p>
<p> Just hang on for a moment. If the seller does not deposit,
you will get your bond back automatically.</p>
</Typography>
@ -441,6 +513,8 @@ export default class TradeBox extends Component {
showWaitingForBuyerInvoice(){
return(
<Grid container spacing={1}>
{/* Make confirmation sound for HTLC received. */}
<this.Sound soundFileName="locked-invoice"/>
<Grid item xs={12} align="center">
<Typography component="subtitle1" variant="subtitle1">
<b>The trade collateral is locked! 🎉 </b>
@ -470,7 +544,7 @@ export default class TradeBox extends Component {
};
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
.then((response) => response.json())
.then((data) => (this.props.data = data));
.then((data) => this.props.completeSetState(data));
}
handleRatingChange=(e)=>{
@ -484,7 +558,7 @@ handleRatingChange=(e)=>{
};
fetch('/api/order/' + '?order_id=' + this.props.data.id, requestOptions)
.then((response) => response.json())
.then((data) => (this.props.data = data));
.then((data) => this.props.completeSetState(data));
}
showFiatSentButton(){
@ -526,16 +600,42 @@ handleRatingChange=(e)=>{
)
}
showChat(sendFiatButton, receivedFiatButton, openDisputeButton){
showChat=()=>{
//In Chatroom - No fiat sent - showChat(showSendButton, showReveiceButton, showDisputeButton)
if(this.props.data.is_buyer & this.props.data.status == 9){
var showSendButton=true;
var showReveiceButton=false;
var showDisputeButton=true;
}
if(this.props.data.is_seller & this.props.data.status == 9){
var showSendButton=false;
var showReveiceButton=false;
var showDisputeButton=true;
}
//In Chatroom - Fiat sent - showChat(showSendButton, showReveiceButton, showDisputeButton)
if(this.props.data.is_buyer & this.props.data.status == 10){
var showSendButton=false;
var showReveiceButton=false;
var showDisputeButton=true;
}
if(this.props.data.is_seller & this.props.data.status == 10){
var showSendButton=false;
var showReveiceButton=true;
var showDisputeButton=true;
}
return(
<Grid container spacing={1}>
{/* Make confirmation sound for Chat Open. */}
<this.Sound soundFileName="chat-open"/>
<Grid item xs={12} align="center">
<Typography component="subtitle1" variant="subtitle1">
<b>Chatting with {this.props.data.isMaker ? this.props.data.takerNick : this.props.data.makerNick}</b>
<b>Chatting with {this.props.data.is_maker ? this.props.data.taker_nick : this.props.data.maker_nick}</b>
</Typography>
</Grid>
<Grid item xs={12} align="center">
{this.props.data.isSeller ?
{this.props.data.is_seller ?
<Typography component="body2" variant="body2" align="center">
Say hi! Be helpful and concise. Let them know how to send you {this.props.data.currencyCode}.
</Typography>
@ -547,12 +647,11 @@ handleRatingChange=(e)=>{
<Divider/>
</Grid>
<Chat orderId={this.props.data.id} urNick={this.props.data.urNick}/>
<Chat orderId={this.props.data.id} ur_nick={this.props.data.ur_nick}/>
<Grid item xs={12} align="center">
{openDisputeButton ? this.showOpenDisputeButton() : ""}
{sendFiatButton ? this.showFiatSentButton() : ""}
{receivedFiatButton ? this.showFiatReceivedButton() : ""}
{showDisputeButton ? this.showOpenDisputeButton() : ""}
{showSendButton ? this.showFiatSentButton() : ""}
{showReveiceButton ? this.showFiatReceivedButton() : ""}
</Grid>
{this.showBondIsLocked()}
</Grid>
@ -562,6 +661,8 @@ handleRatingChange=(e)=>{
showRateSelect(){
return(
<Grid container spacing={1}>
{/* Make confirmation sound for Chat Open. */}
<this.Sound soundFileName="successful"/>
<Grid item xs={12} align="center">
<Typography component="h6" variant="h6">
🎉Trade finished!🥳
@ -569,7 +670,7 @@ handleRatingChange=(e)=>{
</Grid>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2" align="center">
What do you think of <b>{this.props.data.isMaker ? this.props.data.takerNick : this.props.data.makerNick}</b>?
What do you think of <b>{this.props.data.is_maker ? this.props.data.taker_nick : this.props.data.maker_nick}</b>?
</Typography>
</Grid>
<Grid item xs={12} align="center">
@ -582,49 +683,132 @@ handleRatingChange=(e)=>{
)
}
showSendingPayment(){
return(
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography component="h6" variant="h6">
Attempting Lightning Payment
</Typography>
</Grid>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2" align="center">
RoboSats is trying to pay your lightning invoice. Remember that lightning nodes must
be online in order to receive payments.
</Typography>
</Grid>
</Grid>
)
}
showRoutingFailed=()=>{
// TODO If it has failed 3 times, ask for a new invoice.
if(this.props.data.invoice_expired){
return(
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography component="h6" variant="h6">
Lightning Routing Failed
</Typography>
</Grid>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2" align="center">
Your invoice has expires or more than 3 payments have been attempted.
</Typography>
</Grid>
<Grid item xs={12} align="center">
<Typography color="primary" component="subtitle1" variant="subtitle1">
<b> Submit a LN invoice for {pn(this.props.data.invoice_amount)} Sats </b>
</Typography>
</Grid>
<Grid item xs={12} align="center">
<TextField
error={this.state.badInvoice}
helperText={this.state.badInvoice ? this.state.badInvoice : "" }
label={"Payout Lightning Invoice"}
required
inputProps={{
style: {textAlign:"center"}
}}
multiline
onChange={this.handleInputInvoiceChanged}
/>
</Grid>
<Grid item xs={12} align="center">
<Button onClick={this.handleClickSubmitInvoiceButton} variant='contained' color='primary'>Submit</Button>
</Grid>
</Grid>
)
}else{
return(
<Grid container spacing={1}>
<Grid item xs={12} align="center">
<Typography component="h6" variant="h6">
Lightning Routing Failed
</Typography>
</Grid>
<Grid item xs={12} align="center">
<Typography component="body2" variant="body2" align="center">
RoboSats will try to pay your invoice 3 times every 5 minutes. If it keeps failing, you
will be able to submit a new invoice. Check whether you have enough inboud liquidity.
Remember that lightning nodes must be online in order to receive payments.
</Typography>
<List>
<Divider/>
<ListItemText secondary="Next attempt in">
<Countdown date={new Date(this.props.data.next_retry_time)} renderer={this.countdownRenderer} />
</ListItemText>
</List>
</Grid>
</Grid>
)}
}
render() {
return (
<Grid container spacing={1} style={{ width:330}}>
<Grid container spacing={1} style={{ width:this.props.width}}>
<this.ConfirmDisputeDialog/>
<this.ConfirmFiatReceivedDialog/>
<Grid item xs={12} align="center">
<MediaQuery minWidth={920}>
<Typography component="h5" variant="h5">
Contract Box
</Typography>
</MediaQuery>
<Paper elevation={12} style={{ padding: 8,}}>
{/* Maker and taker Bond request */}
{this.props.data.bondInvoice ? this.showQRInvoice() : ""}
{this.props.data.is_maker & this.props.data.status == 0 ? this.showQRInvoice() : ""}
{this.props.data.is_taker & this.props.data.status == 3 ? this.showQRInvoice() : ""}
{/* Waiting for taker and taker bond request */}
{this.props.data.isMaker & this.props.data.statusCode == 1 ? this.showMakerWait() : ""}
{this.props.data.isMaker & this.props.data.statusCode == 3 ? this.showTakerFound() : ""}
{this.props.data.is_maker & this.props.data.status == 1 ? this.showMakerWait() : ""}
{this.props.data.is_maker & this.props.data.status == 3 ? this.showTakerFound() : ""}
{/* Send Invoice (buyer) and deposit collateral (seller) */}
{this.props.data.isSeller & this.props.data.escrowInvoice != null ? this.showEscrowQRInvoice() : ""}
{this.props.data.isBuyer & this.props.data.invoiceAmount != null ? this.showInputInvoice() : ""}
{this.props.data.isBuyer & this.props.data.statusCode == 7 ? this.showWaitingForEscrow() : ""}
{this.props.data.isSeller & this.props.data.statusCode == 8 ? this.showWaitingForBuyerInvoice() : ""}
{this.props.data.is_seller & (this.props.data.status == 6 || this.props.data.status == 7 ) ? this.showEscrowQRInvoice() : ""}
{this.props.data.is_buyer & (this.props.data.status == 6 || this.props.data.status == 8 )? this.showInputInvoice() : ""}
{this.props.data.is_buyer & this.props.data.status == 7 ? this.showWaitingForEscrow() : ""}
{this.props.data.is_seller & this.props.data.status == 8 ? this.showWaitingForBuyerInvoice() : ""}
{/* In Chatroom - No fiat sent - showChat(showSendButton, showReveiceButton, showDisputeButton) */}
{this.props.data.isBuyer & this.props.data.statusCode == 9 ? this.showChat(true,false,true) : ""}
{this.props.data.isSeller & this.props.data.statusCode == 9 ? this.showChat(false,false,true) : ""}
{/* In Chatroom - Fiat sent - showChat(showSendButton, showReveiceButton, showDisputeButton) */}
{this.props.data.isBuyer & this.props.data.statusCode == 10 ? this.showChat(false,false,true) : ""}
{this.props.data.isSeller & this.props.data.statusCode == 10 ? this.showChat(false,true,true) : ""}
{/* In Chatroom */}
{this.props.data.status == 9 || this.props.data.status == 10 ? this.showChat(): ""}
{/* Trade Finished */}
{(this.props.data.isSeller & this.props.data.statusCode > 12 & this.props.data.statusCode < 15) ? this.showRateSelect() : ""}
{(this.props.data.isBuyer & this.props.data.statusCode == 14) ? this.showRateSelect() : ""}
{(this.props.data.is_seller & [13,14,15].includes(this.props.data.status)) ? this.showRateSelect() : ""}
{(this.props.data.is_buyer & this.props.data.status == 14) ? this.showRateSelect() : ""}
{/* Trade Finished - Payment Routing Failed */}
{this.props.data.isBuyer & this.props.data.statusCode == 15 ? this.showUpdateInvoice() : ""}
{this.props.data.is_buyer & this.props.data.status == 13 ? this.showSendingPayment() : ""}
{/* Trade Finished - Payment Routing Failed */}
{this.props.data.is_buyer & this.props.data.status == 15 ? this.showRoutingFailed() : ""}
{/* Trade Finished - TODO Needs more planning */}
{this.props.data.statusCode == 11 ? this.showInDisputeStatement() : ""}
{this.props.data.status == 11 ? this.showInDisputeStatement() : ""}
{this.props.data.status == 16 ? this.showWaitForDisputeResolution() : ""}
{/* Order has expired */}
{this.props.data.statusCode == 5 ? this.showOrderExpired() : ""}
{this.props.data.status == 5 ? this.showOrderExpired() : ""}
{/* TODO */}
{/* */}
{/* */}

View File

@ -3,7 +3,8 @@ import { Button , Dialog, Grid, Typography, TextField, ButtonGroup, CircularProg
import { Link } from 'react-router-dom'
import Image from 'material-ui-image'
import InfoDialog from './InfoDialog'
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import CasinoIcon from '@mui/icons-material/Casino';
import ContentCopy from "@mui/icons-material/ContentCopy";
function getCookie(name) {
@ -29,7 +30,8 @@ export default class UserGenPage extends Component {
this.state = {
token: this.genBase62Token(34),
openInfo: false,
showRobosat: true,
loadingRobot: true,
tokenHasChanged: false,
};
this.getGeneratedUser(this.state.token);
}
@ -45,7 +47,7 @@ export default class UserGenPage extends Component {
.substring(0, length);
}
getGeneratedUser(token) {
getGeneratedUser=(token)=>{
fetch('/api/user' + '?token=' + token)
.then((response) => response.json())
.then((data) => {
@ -56,8 +58,18 @@ export default class UserGenPage extends Component {
shannon_entropy: data.token_shannon_entropy,
bad_request: data.bad_request,
found: data.found,
showRobosat:true,
});
loadingRobot:false,
})
&
// Add nick and token to App state (token only if not a bad request)
(data.bad_request ? this.props.setAppState({
nickname: data.nickname,
})
:
this.props.setAppState({
nickname: data.nickname,
token: this.state.token,
}));
});
}
@ -71,23 +83,24 @@ export default class UserGenPage extends Component {
.then((data) => console.log(data));
}
handleAnotherButtonPressed=(e)=>{
this.delGeneratedUser()
// this.setState({
// showRobosat: false,
// token: this.genBase62Token(34),
// });
// this.getGeneratedUser(this.state.token);
window.location.reload();
handleClickNewRandomToken=()=>{
this.setState({
token: this.genBase62Token(34),
tokenHasChanged: true,
});
}
handleChangeToken=(e)=>{
this.delGeneratedUser()
this.setState({
token: e.target.value,
tokenHasChanged: true,
})
this.getGeneratedUser(e.target.value);
this.setState({showRobosat: false})
}
handleClickSubmitToken=()=>{
this.delGeneratedUser()
this.getGeneratedUser(this.state.token);
this.setState({loadingRobot: true, tokenHasChanged: false})
}
handleClickOpenInfo = () => {
@ -107,17 +120,16 @@ export default class UserGenPage extends Component {
aria-describedby="info-dialog-description"
scroll="paper"
>
<InfoDialog/>
<InfoDialog handleCloseInfo = {this.handleCloseInfo}/>
</Dialog>
)
}
render() {
return (
<Grid container spacing={1}>
<Grid item xs={12} align="center">
{this.state.showRobosat ?
<Grid item xs={12} align="center" sx={{width:370, height:260}}>
{!this.state.loadingRobot ?
<div>
<Grid item xs={12} align="center">
<Typography component="h5" variant="h5">
@ -135,7 +147,7 @@ export default class UserGenPage extends Component {
</div><br/>
</Grid>
</div>
: <CircularProgress />}
: <CircularProgress sx={{position: 'relative', top: 100, }}/>}
</Grid>
{
this.state.found ?
@ -149,14 +161,11 @@ export default class UserGenPage extends Component {
}
<Grid container align="center">
<Grid item xs={12} align="center">
<IconButton onClick= {()=>navigator.clipboard.writeText(this.state.token)}>
<ContentCopy/>
</IconButton>
<TextField
<TextField sx={{maxWidth: 280}}
//sx={{ input: { color: 'purple' } }}
InputLabelProps={{
style: { color: 'green' },
}}
// InputLabelProps={{
// style: { color: 'green' },
// }}
error={this.state.bad_request}
label='Store your token safely'
required='true'
@ -166,18 +175,34 @@ export default class UserGenPage extends Component {
size='small'
// multiline = {true}
onChange={this.handleChangeToken}
onKeyPress={(e) => {
if (e.key === 'Enter') {
this.handleClickSubmitToken();
}
}}
InputProps={{
startAdornment:
<IconButton onClick= {()=>navigator.clipboard.writeText(this.state.token)}>
<ContentCopy color={this.state.tokenHasChanged ? 'inherit' : 'primary' } sx={{width:18, height:18}} />
</IconButton>,
endAdornment:
<IconButton onClick={this.handleClickNewRandomToken}><CasinoIcon/></IconButton>,
}}
/>
</Grid>
</Grid>
<Grid item xs={12} align="center">
<Button size='small' onClick={this.handleAnotherButtonPressed}>Generate Another Robosat</Button>
<Button disabled={!this.state.tokenHasChanged} type="submit" size='small' onClick= {this.handleClickSubmitToken}>
<SmartToyIcon sx={{width:18, height:18}} />
<span> Generate Robot</span>
</Button>
</Grid>
<Grid item xs={12} align="center">
<ButtonGroup variant="contained" aria-label="outlined primary button group">
<Button color='primary' to='/make/' component={Link}>Make Order</Button>
<Button disabled={this.state.loadingRobot} color='primary' to='/make/' component={Link}>Make Order</Button>
<Button color='inherit' onClick={this.handleClickOpenInfo}>Info</Button>
<this.InfoDialog/>
<Button color='secondary' to='/book/' component={Link}>View Book</Button>
<Button disabled={this.state.loadingRobot} color='secondary' to='/book/' component={Link}>View Book</Button>
</ButtonGroup>
</Grid>
<Grid item xs={12} align="center">

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -37,6 +37,11 @@ body {
top: -14px;
}
.profileNickname {
margin: 0;
left: -16px;
}
.newAvatar {
background-color:white;
border-radius: 50%;
@ -45,3 +50,20 @@ body {
height: 200px;
width: 200px;
}
.profileAvatar {
border: 0.5px solid #555;
filter: drop-shadow(0.5px 0.5px 0.5px #000000);
left: 35px;
}
.smallAvatar {
border: 0.5px solid #555;
filter: drop-shadow(0.5px 0.5px 0.5px #000000);
}
.flippedSmallAvatar {
transform: scaleX(-1);
border: 0.3px solid #555;
filter: drop-shadow(0.5px 0.5px 0.5px #000000);
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
/*!****************************************!*\
!*** ./node_modules/jsqr/dist/jsQR.js ***!
\****************************************/
/*!*************************************************************!*\
!*** ./node_modules/react-webcam-qr-scanner/dist/Worker.js ***!
\*************************************************************/

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
/*!****************************************!*\
!*** ./node_modules/jsqr/dist/jsQR.js ***!
\****************************************/

View File

@ -31,9 +31,9 @@ app.conf.beat_scheduler = 'django_celery_beat.schedulers:DatabaseScheduler'
# Configure the periodic tasks
app.conf.beat_schedule = {
'users-cleansing': { # Cleans abandoned users every hour
'users-cleansing': { # Cleans abandoned users every 6 hours
'task': 'users_cleansing',
'schedule': timedelta(hours=1),
'schedule': timedelta(hours=6),
},
'cache-market-prices': { # Cache market prices every minutes for now.
'task': 'cache_external_market_prices',

View File

@ -126,9 +126,86 @@ npm install websocket
npm install react-countdown
npm install @mui/icons-material
npm install @mui/x-data-grid
npm install react-responsive
npm install react-qr-reader
```
Note we are using mostly MaterialUI V5 (@mui/material) but Image loading from V4 (@material-ui/core) extentions (so both V4 and V5 are needed)
### Launch the React render
from frontend/ directory
`npm run dev`
## Robosats background threads.
There is 3 processes that run asynchronously: two admin commands and a celery beat scheduler.
The celery worker will run the task of caching external API market prices and cleaning(deleting) the generated robots that were never used.
`celery -A robosats worker --beat -l debug -S django`
The admin commands are used to keep an eye on the state of LND hold invoices and check whether orders have expired
```
python3 manage.py follow_invoices
python3 manage.py clean_order
```
It might be best to set up system services to continuously run these background processes.
### Follow invoices admin command as system service
Create `/etc/systemd/system/follow_invoices.service` and edit with:
```
[Unit]
Description=RoboSats Follow LND Invoices
After=lnd.service
StartLimitIntervalSec=0
[Service]
WorkingDirectory=/home/<USER>/robosats/
StandardOutput=file:/home/<USER>/robosats/follow_invoices.log
StandardError=file:/home/<USER>/robosats/follow_invoices.log
Type=simple
Restart=always
RestartSec=1
User=<USER>
ExecStart=python3 manage.py follow_invoices
[Install]
WantedBy=multi-user.target
```
Then launch it with
```
systemctl start follow_invoices
systemctl enable follow_invoices
```
### Clean orders admin command as system service
Create `/etc/systemd/system/clean_orders.service` and edit with (replace <USER> for your username):
```
[Unit]
Description=RoboSats Clean Orders
After=lnd.service
StartLimitIntervalSec=0
[Service]
WorkingDirectory=/home/<USER>/robosats/
StandardOutput=file:/home/<USER>/robosats/clean_orders.log
StandardError=file:/home/<USER>/robosats/clean_orders.log
Type=simple
Restart=always
RestartSec=1
User=<USER>
ExecStart=python3 manage.py clean_orders
[Install]
WantedBy=multi-user.target
```
Then launch it with
```
systemctl start clean_orders
systemctl enable clean_orders
```