From a3375df6e581bc7043cfcc0f382cac732021eb74 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Tue, 11 Jan 2022 16:02:17 -0800 Subject: [PATCH] Work on lightning functionality: locks, settles, cancels, validates. Trading almost working on testnet --- .env-sample | 12 ++- api/lightning/node.py | 35 ++----- api/logics.py | 228 ++++++++++++++++++++++++------------------ api/models.py | 8 +- 4 files changed, 154 insertions(+), 129 deletions(-) diff --git a/.env-sample b/.env-sample index 6054e345..cf3d5977 100644 --- a/.env-sample +++ b/.env-sample @@ -19,13 +19,19 @@ MIN_TRADE = 10000 MAX_TRADE = 500000 # Expiration time for HODL invoices and returning collateral in HOURS -BOND_EXPIRY = 8 +BOND_EXPIRY = 14 ESCROW_EXPIRY = 8 -# Expiration time for locking collateral in MINUTES +# Expiration time for locking collateral in SECONDS EXP_MAKER_BOND_INVOICE = 300 EXP_TAKER_BOND_INVOICE = 200 -EXP_TRADE_ESCR_INVOICE = 200 + +# Time a order is public in the book HOURS +PUBLIC_ORDER_DURATION = 6 +# Time to provide a valid invoice and the trade escrow MINUTES +INVOICE_AND_ESCROW_DURATION = 30 +# Time to confim chat and confirm fiat (time to Fiat Sent confirmation) HOURS +FIAT_EXCHANGE_DURATION = 4 # Username for HTLCs escrows ESCROW_USERNAME = 'admin' \ No newline at end of file diff --git a/api/lightning/node.py b/api/lightning/node.py index a78c72a3..fd2fdb49 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -40,23 +40,16 @@ class LNNode(): '''Cancels or returns a hold invoice''' request = invoicesrpc.CancelInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) response = cls.invoicesstub.CancelInvoice(request, metadata=[('macaroon', MACAROON.hex())]) - # Fix this: tricky because canceling sucessfully an invoice has no response. TODO - if response == None: - return True - else: - return False + return str(response) == "" # True if no response, false otherwise. @classmethod def settle_hold_invoice(cls, preimage): '''settles a hold invoice''' request = invoicesrpc.SettleInvoiceMsg(preimage=bytes.fromhex(preimage)) response = cls.invoicesstub.SettleInvoice(request, metadata=[('macaroon', MACAROON.hex())]) - # Fix this: tricky because settling sucessfully an invoice has no response. TODO - if response == None: - return True - else: - return False + # Fix this: tricky because settling sucessfully an invoice has None response. TODO + return str(response)=="" # True if no response, false otherwise. @classmethod def gen_hold_invoice(cls, num_satoshis, description, expiry): @@ -90,8 +83,6 @@ class LNNode(): '''Checks if hold invoice is locked''' request = invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) response = cls.invoicesstub.LookupInvoiceV2(request, metadata=[('macaroon', MACAROON.hex())]) - print('status here') - print(response.state) # LND states: 0 OPEN, 1 SETTLED, 3 ACCEPTED, GRPC_ERROR status 5 when cancelled return response.state == 3 # True if hold invoice is accepted. @classmethod @@ -99,10 +90,10 @@ class LNNode(): '''Checks until hold invoice is locked. When invoice is locked, returns true. If time expires, return False.''' - # Experimental, needs asyncio + # 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 = cls.invoicesrpc.SubscribeSingleInvoiceRequest(r_hash=payment_hash) + request = invoicesrpc.SubscribeSingleInvoiceRequest(r_hash=payment_hash) for invoice in cls.invoicesstub.SubscribeSingleInvoice(request): print(invoice) if timezone.now > expiration: @@ -165,13 +156,9 @@ class LNNode(): payment_request=invoice, amt_msat=num_satoshis, fee_limit_sat=fee_limit_sat, - timeout_seconds=60, - ) - - for response in routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]): - print(response) - print(response.status) + timeout_seconds=60) + for response in cls.routerstub.SendPaymentV2(request, metadata=[('macaroon', MACAROON.hex())]): if response.status == True: return True @@ -181,12 +168,10 @@ class LNNode(): def double_check_htlc_is_settled(cls, payment_hash): ''' Just as it sounds. Better safe than sorry!''' request = invoicesrpc.LookupInvoiceMsg(payment_hash=payment_hash) - response = invoicesstub.LookupInvoiceV2(request, metadata=[('macaroon', MACAROON.hex())]) + response = cls.invoicesstub.LookupInvoiceV2(request, metadata=[('macaroon', MACAROON.hex())]) + + return response.state == 1 # LND states: 0 OPEN, 1 SETTLED, 3 ACCEPTED, GRPC_ERROR status 5 when cancelled/returned - if response.state == 'SETTLED': - return True - else: - return False diff --git a/api/logics.py b/api/logics.py index bf3e8851..ed88b2b5 100644 --- a/api/logics.py +++ b/api/logics.py @@ -19,11 +19,13 @@ MAX_TRADE = int(config('MAX_TRADE')) EXP_MAKER_BOND_INVOICE = int(config('EXP_MAKER_BOND_INVOICE')) EXP_TAKER_BOND_INVOICE = int(config('EXP_TAKER_BOND_INVOICE')) -EXP_TRADE_ESCR_INVOICE = int(config('EXP_TRADE_ESCR_INVOICE')) BOND_EXPIRY = int(config('BOND_EXPIRY')) ESCROW_EXPIRY = int(config('ESCROW_EXPIRY')) +PUBLIC_ORDER_DURATION = int(config('PUBLIC_ORDER_DURATION')) +INVOICE_AND_ESCROW_DURATION = int(config('INVOICE_AND_ESCROW_DURATION')) +FIAT_EXCHANGE_DURATION = int(config('FIAT_EXCHANGE_DURATION')) class Logics(): @@ -53,6 +55,7 @@ class Logics(): else: order.taker = user order.status = Order.Status.TAK + order.expires_at = timezone.now() + timedelta(minutes=EXP_TAKER_BOND_INVOICE) order.save() return True, None @@ -96,11 +99,29 @@ class Logics(): return price, premium def order_expires(order): + ''' General case when time runs out. Only + used when the maker does not lock a publishing bond''' order.status = Order.Status.EXP order.maker = None order.taker = None order.save() + def kick_taker(order): + ''' The taker did not lock the taker_bond. Now he has to go''' + # Add a time out to the taker + profile = order.taker.profile + profile.penalty_expiration = timezone.now() + timedelta(seconds=PENALTY_TIMEOUT) + profile.save() + + # Delete the taker_bond payment request, and make order public again + if LNNode.cancel_return_hold_invoice(order.taker_bond.payment_hash): + order.status = Order.Status.PUB + order.taker = None + order.taker_bond = None + order.expires_at = timezone.now() + timedelta(hours=PUBLIC_ORDER_DURATION) ## TO FIX. Restore the remaining order durantion, not all of it! + order.save() + return True + @classmethod def buyer_invoice_amount(cls, order, user): ''' Computes buyer invoice amount. Uses order.last_satoshis, @@ -124,7 +145,7 @@ class Logics(): num_satoshis = cls.buyer_invoice_amount(order, user)[1]['invoice_amount'] buyer_invoice = LNNode.validate_ln_invoice(invoice, num_satoshis) - + if not buyer_invoice['valid']: return False, buyer_invoice['context'] @@ -144,8 +165,8 @@ class Logics(): 'expires_at' : buyer_invoice['expires_at']} ) - # If the order status is 'Waiting for escrow'. Move forward to 'chat' - if order.status == Order.Status.WFE: order.status = Order.Status.CHA + # If the order status is 'Waiting for invoice'. Move forward to 'chat' + if order.status == Order.Status.WFI: order.status = Order.Status.CHA # If the order status is 'Waiting for both'. Move forward to 'waiting for escrow' if order.status == Order.Status.WF2: @@ -174,22 +195,6 @@ class Logics(): profile.save() - @classmethod - def rate_counterparty(cls, order, user, rating): - - # If the trade is finished - if order.status > Order.Status.PAY: - # if maker, rates taker - if order.maker == user: - cls.add_profile_rating(order.taker.profile, rating) - # if taker, rates maker - if order.taker == user: - cls.add_profile_rating(order.maker.profile, rating) - else: - return False, {'bad_request':'You cannot rate your counterparty yet.'} - - return True, None - def is_penalized(user): ''' Checks if a user that is not participant of orders has a limit on taking or making a order''' @@ -212,23 +217,18 @@ class Logics(): order.maker = None order.status = Order.Status.UCA order.save() - return True, {} + 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. 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)''' elif order.status == Order.Status.PUB and order.maker == user: - #Settle the maker bond (Maker loses the bond for a public order) + #Settle the maker bond (Maker loses the bond for cancelling public order) if cls.settle_maker_bond(order): - order.maker_bond.status = LNPayment.Status.SETLED - order.maker_bond.save() - order.maker = None order.status = Order.Status.UCA order.save() - return True, {} + return True, None # 3) When taker cancels before bond ''' The order goes back to the book as public. @@ -242,7 +242,7 @@ class Logics(): order.status = Order.Status.PUB order.save() - return True, {} + return True, None # 4) When taker or maker cancel after bond (before escrow) '''The order goes into cancelled status if maker cancels. @@ -258,7 +258,7 @@ class Logics(): order.maker = None order.status = Order.Status.UCA order.save() - return True, {} + return True, None # 4.b) When taker cancel after bond (before escrow) '''The order into cancelled status if maker cancels.''' @@ -270,7 +270,7 @@ class Logics(): order.status = Order.Status.PUB # order.taker_bond = None # TODO fix this, it overrides the information about the settled taker bond. Might make admin tasks hard. order.save() - return True, {} + return True, None # 5) When trade collateral has been posted (after escrow) '''Always goes to cancelled status. Collaboration is needed. @@ -281,22 +281,34 @@ class Logics(): else: return False, {'bad_request':'You cannot cancel this order'} + @classmethod + def is_maker_bond_locked(cls, order): + if LNNode.validate_hold_invoice_locked(order.maker_bond.payment_hash): + order.maker_bond.status = LNPayment.Status.LOCKED + order.maker_bond.save() + order.status = Order.Status.PUB + # With the bond confirmation the order is extended 'public_order_duration' hours + order.expires_at = timezone.now() + timedelta(hours=PUBLIC_ORDER_DURATION) + order.save() + return True + return False + @classmethod def gen_maker_hold_invoice(cls, order, user): - # Do not gen and cancel if order is more than 5 minutes old + # Do not gen and cancel if order is older than expiry time if order.expires_at < timezone.now(): cls.order_expires(order) return False, {'bad_request':'Invoice expired. You did not confirm publishing the order in time. Make a new order.'} # Return the previous invoice if there was one and is still unpaid if order.maker_bond: - cls.check_maker_bond_locked(order) - if order.maker_bond.status == LNPayment.Status.INVGEN: - return True, {'bond_invoice':order.maker_bond.invoice,'bond_satoshis':order.maker_bond.num_satoshis} - else: + if cls.is_maker_bond_locked(order): return False, None + elif order.maker_bond.status == LNPayment.Status.INVGEN: + return True, {'bond_invoice':order.maker_bond.invoice,'bond_satoshis':order.maker_bond.num_satoshis} + # If there was no maker_bond object yet, generates one order.last_satoshis = cls.satoshis_now(order) bond_satoshis = int(order.last_satoshis * BOND_SIZE) @@ -323,24 +335,16 @@ class Logics(): return True, {'bond_invoice':hold_payment['invoice'], 'bond_satoshis':bond_satoshis} @classmethod - def check_until_maker_bond_locked(cls, order): - expiration = order.maker_bond.created_at + timedelta(seconds=EXP_MAKER_BOND_INVOICE) - is_locked = LNNode.check_until_invoice_locked(order.payment_hash, expiration) - - if is_locked: - order.maker_bond.status = LNPayment.Status.LOCKED - order.maker_bond.save() - order.status = Order.Status.PUB - - order.save() - return is_locked - - @classmethod - def check_maker_bond_locked(cls, order): - if LNNode.validate_hold_invoice_locked(order.maker_bond.payment_hash): - order.maker_bond.status = LNPayment.Status.LOCKED - order.maker_bond.save() - order.status = Order.Status.PUB + def is_taker_bond_locked(cls, order): + if LNNode.validate_hold_invoice_locked(order.taker_bond.payment_hash): + # THE TRADE AMOUNT IS FINAL WITH THE CONFIRMATION OF THE TAKER BOND! + # (This is the last update to "last_satoshis", it becomes the escrow amount next!) + order.last_satoshis = cls.satoshis_now(order) + order.taker_bond.status = LNPayment.Status.LOCKED + order.taker_bond.save() + # With the bond confirmation the order is extended 'public_order_duration' hours + order.expires_at = timezone.now() + timedelta(minutes=INVOICE_AND_ESCROW_DURATION) + order.status = Order.Status.WF2 order.save() return True return False @@ -348,21 +352,21 @@ class Logics(): @classmethod def gen_taker_hold_invoice(cls, order, user): - # Do not gen and cancel if a taker invoice is there and older than X minutes and unpaid still + # Do not gen and kick out the taker if order is older than expiry time + if order.expires_at < timezone.now(): + cls.kick_taker(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. if order.taker_bond: # Check if status is INVGEN and still not expired - if order.taker_bond.status == LNPayment.Status.INVGEN: - if order.taker_bond.created_at > (timezone.now()+timedelta(minutes=EXP_TAKER_BOND_INVOICE)): - cls.cancel_order(order, user, 3) # State 3, cancel order before taker bond - return False, {'bad_request':'Invoice expired. You did not confirm taking the order in time.'} - # Return the previous invoice there was with INVGEN status - else: - return True, {'bond_invoice':order.taker_bond.invoice,'bond_satoshis':order.taker_bond.num_satoshis} - # Invoice exists, but was already locked or settled - else: + if cls.is_taker_bond_locked(order): return False, None + elif order.taker_bond.status == LNPayment.Status.INVGEN: + return True, {'bond_invoice':order.taker_bond.invoice,'bond_satoshis':order.taker_bond.num_satoshis} - order.last_satoshis = cls.satoshis_now(order) # LOCKS THE AMOUNT OF SATOSHIS FOR THE TRADE + # If there was no taker_bond object yet, generates one + order.last_satoshis = cls.satoshis_now(order) bond_satoshis = int(order.last_satoshis * BOND_SIZE) description = f"RoboSats - Taking '{str(order)}' - This is a taker bond, it will freeze in your wallet. It automatically returns. It will be charged if you cheat or cancel." @@ -383,30 +387,45 @@ class Logics(): created_at = hold_payment['created_at'], expires_at = hold_payment['expires_at']) - # Extend expiry time to allow for escrow deposit - ## Not here, on func for confirming taker collar. order.expires_at = timezone.now() + timedelta(minutes=EXP_TRADE_ESCR_INVOICE) - order.save() return True, {'bond_invoice': hold_payment['invoice'], 'bond_satoshis': bond_satoshis} + + @classmethod + def is_trade_escrow_locked(cls, order): + if LNNode.validate_hold_invoice_locked(order.trade_escrow.payment_hash): + order.trade_escrow.status = LNPayment.Status.LOCKED + order.trade_escrow.save() + # If status is 'Waiting for both' move to Waiting for invoice + if order.status == Order.Status.WF2: + order.status = Order.Status.WFI + # If status is 'Waiting for invoice' move to Chat + elif order.status == Order.Status.WFI: + order.status = Order.Status.CHA + order.expires_at = timezone.now() + timedelta(hours=FIAT_EXCHANGE_DURATION) + order.save() + return True + return False + @classmethod def gen_escrow_hold_invoice(cls, order, user): - # Do not generate and cancel if an invoice is there and older than X minutes and unpaid still + + # Do not generate if escrow deposit time has expired + if order.expires_at < timezone.now(): + cls.cancel_order(order,user) + 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. if order.trade_escrow: # Check if status is INVGEN and still not expired - if order.trade_escrow.status == LNPayment.Status.INVGEN: - if order.trade_escrow.created_at > (timezone.now()+timedelta(minutes=EXP_TRADE_ESCR_INVOICE)): # Expired - cls.cancel_order(order, user, 4) # State 4, cancel order before trade escrow locked - return False, {'bad_request':'Invoice expired. You did not lock the trade escrow in time.'} - # Return the previous invoice there was with INVGEN status - else: - return True, {'escrow_invoice': order.trade_escrow.invoice, 'escrow_satoshis':order.trade_escrow.num_satoshis} - # Invoice exists, but was already locked or settled - else: - return False, None # Does not return any context of a healthy locked escrow + if cls.is_trade_escrow_locked(order): + return False, None + elif order.trade_escrow.status == LNPayment.Status.INVGEN: + return True, {'escrow_invoice':order.trade_escrow.invoice, 'escrow_satoshis':order.trade_escrow.num_satoshis} - escrow_satoshis = order.last_satoshis # Trade sats amount was fixed at the time of taker bond generation (order.last_satoshis) - 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 pay." + # If there was no taker_bond object yet, generates one + escrow_satoshis = order.last_satoshis # Amount was fixed when taker bond was locked + 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) @@ -431,37 +450,32 @@ class Logics(): def settle_escrow(order): ''' Settles the trade escrow hold invoice''' # TODO ERROR HANDLING - valid = LNNode.settle_hold_invoice(order.trade_escrow.preimage) - if valid: + if LNNode.settle_hold_invoice(order.trade_escrow.preimage): order.trade_escrow.status = LNPayment.Status.SETLED - order.save() - - return valid + order.trade_escrow.save() + return True def settle_maker_bond(order): ''' Settles the maker bond hold invoice''' # TODO ERROR HANDLING if LNNode.settle_hold_invoice(order.maker_bond.preimage): order.maker_bond.status = LNPayment.Status.SETLED - order.save() + order.maker_bond.save() return True def settle_taker_bond(order): ''' Settles the taker bond hold invoice''' # TODO ERROR HANDLING - valid = LNNode.settle_hold_invoice(order.taker_bond.preimage) - if valid: + if LNNode.settle_hold_invoice(order.taker_bond.preimage): order.taker_bond.status = LNPayment.Status.SETLED - order.save() - - return valid + order.taker_bond.save() + return True def pay_buyer_invoice(order): ''' Pay buyer invoice''' # TODO ERROR HANDLING - - valid = LNNode.pay_invoice(order.buyer_invoice.invoice) - return valid + if LNNode.pay_invoice(order.buyer_invoice.invoice): + return True @classmethod def confirm_fiat(cls, order, user): @@ -492,8 +506,28 @@ class Logics(): if cls.pay_buyer_invoice(order): ##### !!! KEY LINE - PAYS THE BUYER INVOICE !!! order.status = Order.Status.PAY order.buyer_invoice.status = LNPayment.Status.PAYING + + # RETURN THE BONDS + LNNode.cancel_return_hold_invoice(order.taker_bond.payment_hash) + LNNode.cancel_return_hold_invoice(order.maker_bond.payment_hash) else: return False, {'bad_request':'You cannot confirm the fiat payment at this stage'} order.save() + return True, None + + @classmethod + def rate_counterparty(cls, order, user, rating): + + # If the trade is finished + if order.status > Order.Status.PAY: + # if maker, rates taker + if order.maker == user: + cls.add_profile_rating(order.taker.profile, rating) + # if taker, rates maker + if order.taker == user: + cls.add_profile_rating(order.maker.profile, rating) + else: + return False, {'bad_request':'You cannot rate your counterparty yet.'} + return True, None \ No newline at end of file diff --git a/api/models.py b/api/models.py index b5b5bb28..fcd0190d 100644 --- a/api/models.py +++ b/api/models.py @@ -32,7 +32,7 @@ class LNPayment(models.Model): LOCKED = 1, 'Locked' SETLED = 2, 'Settled' RETNED = 3, 'Returned' - MISSNG = 4, 'Missing' + EXPIRE = 4, 'Expired' VALIDI = 5, 'Valid' FLIGHT = 6, 'On flight' FAILRO = 7, 'Routing failed' @@ -44,10 +44,10 @@ class LNPayment(models.Model): routing_retries = models.PositiveSmallIntegerField(null=False, default=0) # payment info - invoice = models.CharField(max_length=500, unique=True, null=True, default=None, blank=True) + invoice = models.CharField(max_length=1000, unique=True, null=True, default=None, blank=True) payment_hash = models.CharField(max_length=100, unique=True, null=True, default=None, blank=True) preimage = models.CharField(max_length=64, unique=True, null=True, default=None, blank=True) - description = models.CharField(max_length=150, unique=False, null=True, default=None, blank=True) + description = models.CharField(max_length=200, unique=False, null=True, default=None, blank=True) 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() @@ -80,7 +80,7 @@ class Order(models.Model): DIS = 11, 'In dispute' CCA = 12, 'Collaboratively cancelled' PAY = 13, 'Sending satoshis to buyer' - SUC = 14, 'Sucessfully settled' + SUC = 14, 'Sucessful trade' FAI = 15, 'Failed lightning network routing' MLD = 16, 'Maker lost dispute' TLD = 17, 'Taker lost dispute'