2022-06-05 21:16:03 +00:00
import grpc , os , hashlib , secrets , ring
2022-06-16 15:31:30 +00:00
2022-06-16 20:01:10 +00:00
2022-01-11 01:02:06 +00:00
from . import lightning_pb2 as lnrpc , lightning_pb2_grpc as lightningstub
from . import invoices_pb2 as invoicesrpc , invoices_pb2_grpc as invoicesstub
2022-01-11 20:49:53 +00:00
from . import router_pb2 as routerrpc , router_pb2_grpc as routerstub
2022-01-09 20:05:19 +00:00
2022-01-10 23:27:48 +00:00
from decouple import config
from base64 import b64decode
2022-01-06 12:32:17 +00:00
2022-01-10 23:27:48 +00:00
from datetime import timedelta , datetime
from django . utils import timezone
2022-06-06 17:57:04 +00:00
2022-02-17 19:50:10 +00:00
2022-01-05 10:30:38 +00:00
#######
2022-01-10 23:27:48 +00:00
# Should work with LND (c-lightning in the future if there are features that deserve the work)
#######
2022-02-09 19:45:11 +00:00
# Read tls.cert from file or .env variable string encoded as base64
try :
2022-02-17 19:50:10 +00:00
CERT = open ( os . path . join ( config ( " LND_DIR " ) , " tls.cert " ) , " rb " ) . read ( )
2022-02-09 19:45:11 +00:00
except :
2022-02-17 19:50:10 +00:00
CERT = b64decode ( config ( " LND_CERT_BASE64 " ) )
2022-02-09 19:45:11 +00:00
# Read macaroon from file or .env variable string encoded as base64
try :
2022-10-20 09:56:10 +00:00
MACAROON = open (
os . path . join ( config ( " LND_DIR " ) , config ( " MACAROON_path " ) ) , " rb "
) . read ( )
2022-02-09 19:45:11 +00:00
except :
2022-02-17 19:50:10 +00:00
MACAROON = b64decode ( config ( " LND_MACAROON_BASE64 " ) )
LND_GRPC_HOST = config ( " LND_GRPC_HOST " )
2022-02-09 19:45:11 +00:00
2022-01-05 10:30:38 +00:00
2022-02-17 19:50:10 +00:00
class LNNode :
2022-01-06 13:55:47 +00:00
2022-02-17 19:50:10 +00:00
os . environ [ " GRPC_SSL_CIPHER_SUITES " ] = " HIGH+ECDSA "
2022-01-11 20:49:53 +00:00
2022-01-10 23:27:48 +00:00
creds = grpc . ssl_channel_credentials ( CERT )
channel = grpc . secure_channel ( LND_GRPC_HOST , creds )
2022-01-11 20:49:53 +00:00
2022-01-10 23:27:48 +00:00
lightningstub = lightningstub . LightningStub ( channel )
invoicesstub = invoicesstub . InvoicesStub ( channel )
2022-01-11 20:49:53 +00:00
routerstub = routerstub . RouterStub ( channel )
2022-01-10 23:27:48 +00:00
2022-01-17 16:41:55 +00:00
lnrpc = lnrpc
invoicesrpc = invoicesrpc
routerrpc = routerrpc
2022-01-12 14:26:26 +00:00
payment_failure_context = {
2022-02-17 19:50:10 +00:00
0 : " Payment isn ' t failed (yet) " ,
2022-10-20 09:56:10 +00:00
1 : " There are more routes to try, but the payment timeout was exceeded. " ,
2 : " All possible routes were tried and failed permanently. Or were no routes to the destination at all. " ,
2022-02-17 19:50:10 +00:00
3 : " A non-recoverable error has occured. " ,
2022-10-20 09:56:10 +00:00
4 : " Payment details incorrect (unknown hash, invalid amt or invalid final cltv delta) " ,
2022-02-17 19:50:10 +00:00
5 : " Insufficient local balance. " ,
}
2022-01-12 14:26:26 +00:00
2022-01-11 01:02:06 +00:00
@classmethod
def decode_payreq ( cls , invoice ) :
2022-02-17 19:50:10 +00:00
""" Decodes a lightning payment request (invoice) """
2022-01-10 23:27:48 +00:00
request = lnrpc . PayReqString ( pay_req = invoice )
2022-10-20 09:56:10 +00:00
response = cls . lightningstub . DecodePayReq (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-01-10 23:27:48 +00:00
return response
2022-06-05 21:16:03 +00:00
@classmethod
def estimate_fee ( cls , amount_sats , target_conf = 2 , min_confs = 1 ) :
""" Returns estimated fee for onchain payouts """
2022-06-16 15:31:30 +00:00
# We assume segwit. Use robosats donation address as shortcut so there is no need of user inputs
2022-10-20 09:56:10 +00:00
request = lnrpc . EstimateFeeRequest (
AddrToAmount = { " bc1q3cpp7ww92n6zp04hv40kd3eyy5avgughx6xqnx " : amount_sats } ,
target_conf = target_conf ,
min_confs = min_confs ,
spend_unconfirmed = False ,
)
2022-06-05 21:16:03 +00:00
2022-10-20 09:56:10 +00:00
response = cls . lightningstub . EstimateFee (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-06-05 21:16:03 +00:00
2022-10-20 09:56:10 +00:00
return {
" mining_fee_sats " : response . fee_sat ,
" mining_fee_rate " : response . sat_per_vbyte ,
}
2022-06-05 21:16:03 +00:00
wallet_balance_cache = { }
2022-10-20 09:56:10 +00:00
2022-06-05 21:16:03 +00:00
@ring.dict ( wallet_balance_cache , expire = 10 ) # keeps in cache for 10 seconds
@classmethod
def wallet_balance ( cls ) :
""" Returns onchain balance """
request = lnrpc . WalletBalanceRequest ( )
2022-10-20 09:56:10 +00:00
response = cls . lightningstub . WalletBalance (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-06-16 22:45:36 +00:00
2022-10-20 09:56:10 +00:00
return {
" total_balance " : response . total_balance ,
" confirmed_balance " : response . confirmed_balance ,
" unconfirmed_balance " : response . unconfirmed_balance ,
}
2022-06-05 21:16:03 +00:00
channel_balance_cache = { }
2022-10-20 09:56:10 +00:00
2022-06-05 21:16:03 +00:00
@ring.dict ( channel_balance_cache , expire = 10 ) # keeps in cache for 10 seconds
@classmethod
def channel_balance ( cls ) :
""" Returns channels balance """
request = lnrpc . ChannelBalanceRequest ( )
2022-10-20 09:56:10 +00:00
response = cls . lightningstub . ChannelBalance (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-06-16 22:45:36 +00:00
2022-10-20 09:56:10 +00:00
return {
" local_balance " : response . local_balance . sat ,
" remote_balance " : response . remote_balance . sat ,
" unsettled_local_balance " : response . unsettled_local_balance . sat ,
" unsettled_remote_balance " : response . unsettled_remote_balance . sat ,
}
2022-06-05 21:16:03 +00:00
2022-06-16 15:31:30 +00:00
@classmethod
def pay_onchain ( cls , onchainpayment ) :
""" Send onchain transaction for buyer payouts """
2022-06-16 20:01:10 +00:00
if config ( " DISABLE_ONCHAIN " , cast = bool ) :
2022-06-16 15:31:30 +00:00
return False
2022-10-20 09:56:10 +00:00
request = lnrpc . SendCoinsRequest (
addr = onchainpayment . address ,
amount = int ( onchainpayment . sent_satoshis ) ,
sat_per_vbyte = int ( onchainpayment . mining_fee_rate ) ,
label = str ( " Payout order # " + str ( onchainpayment . order_paid_TX . id ) ) ,
spend_unconfirmed = True ,
)
2022-06-16 20:01:10 +00:00
2022-10-20 09:56:10 +00:00
response = cls . lightningstub . SendCoins (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-06-16 15:31:30 +00:00
onchainpayment . txid = response . txid
onchainpayment . save ( )
return True
2022-01-11 01:02:06 +00:00
@classmethod
2022-02-17 19:50:10 +00:00
def cancel_return_hold_invoice ( cls , payment_hash ) :
""" Cancels or returns a hold invoice """
2022-10-20 09:56:10 +00:00
request = invoicesrpc . CancelInvoiceMsg ( payment_hash = bytes . fromhex ( payment_hash ) )
response = cls . invoicesstub . CancelInvoice (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-01-10 23:27:48 +00:00
# Fix this: tricky because canceling sucessfully an invoice has no response. TODO
2022-02-17 19:50:10 +00:00
return str ( response ) == " " # True if no response, false otherwise.
2022-01-10 23:27:48 +00:00
2022-01-11 01:02:06 +00:00
@classmethod
def settle_hold_invoice ( cls , preimage ) :
2022-02-17 19:50:10 +00:00
""" settles a hold invoice """
2022-10-20 09:56:10 +00:00
request = invoicesrpc . SettleInvoiceMsg ( preimage = bytes . fromhex ( preimage ) )
response = cls . invoicesstub . SettleInvoice (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-01-12 00:02:17 +00:00
# Fix this: tricky because settling sucessfully an invoice has None response. TODO
2022-02-17 19:50:10 +00:00
return str ( response ) == " " # True if no response, false otherwise.
2022-01-10 23:27:48 +00:00
@classmethod
2022-10-20 09:56:10 +00:00
def gen_hold_invoice (
cls , num_satoshis , description , invoice_expiry , cltv_expiry_blocks
) :
2022-02-17 19:50:10 +00:00
""" Generates hold invoice """
2022-01-10 23:27:48 +00:00
2022-01-11 14:36:43 +00:00
hold_payment = { }
2022-01-11 01:02:06 +00:00
# The preimage is a random hash of 256 bits entropy
2022-02-17 19:50:10 +00:00
preimage = hashlib . sha256 ( secrets . token_bytes ( nbytes = 32 ) ) . digest ( )
2022-01-10 23:27:48 +00:00
# Its hash is used to generate the hold invoice
2022-01-11 14:36:43 +00:00
r_hash = hashlib . sha256 ( preimage ) . digest ( )
2022-02-17 19:50:10 +00:00
2022-01-10 23:27:48 +00:00
request = invoicesrpc . AddHoldInvoiceRequest (
2022-02-17 19:50:10 +00:00
memo = description ,
value = num_satoshis ,
hash = r_hash ,
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 ,
)
2022-10-20 09:56:10 +00:00
response = cls . invoicesstub . AddHoldInvoice (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-02-17 19:50:10 +00:00
hold_payment [ " invoice " ] = response . payment_request
payreq_decoded = cls . decode_payreq ( hold_payment [ " invoice " ] )
hold_payment [ " preimage " ] = preimage . hex ( )
hold_payment [ " payment_hash " ] = payreq_decoded . payment_hash
hold_payment [ " created_at " ] = timezone . make_aware (
2022-10-20 09:56:10 +00:00
datetime . fromtimestamp ( payreq_decoded . timestamp )
)
2022-02-17 19:50:10 +00:00
hold_payment [ " expires_at " ] = hold_payment [ " created_at " ] + timedelta (
2022-10-20 09:56:10 +00:00
seconds = payreq_decoded . expiry
)
2022-02-17 19:50:10 +00:00
hold_payment [ " cltv_expiry " ] = cltv_expiry_blocks
2022-01-10 23:27:48 +00:00
2022-01-11 14:36:43 +00:00
return hold_payment
2022-01-05 10:30:38 +00:00
2022-01-11 01:02:06 +00:00
@classmethod
2022-01-25 14:46:02 +00:00
def validate_hold_invoice_locked ( cls , lnpayment ) :
2022-02-17 19:50:10 +00:00
""" Checks if hold invoice is locked """
2022-06-06 17:57:04 +00:00
from api . models import LNPayment
2022-02-17 19:50:10 +00:00
request = invoicesrpc . LookupInvoiceMsg (
2022-10-20 09:56:10 +00:00
payment_hash = bytes . fromhex ( lnpayment . payment_hash )
)
response = cls . invoicesstub . LookupInvoiceV2 (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-01-12 14:26:26 +00:00
2022-02-17 19:50:10 +00:00
# Will fail if 'unable to locate invoice'. Happens if invoice expiry
2022-01-24 18:34:52 +00:00
# time has passed (but these are 15% padded at the moment). Should catch it
# and report back that the invoice has expired (better robustness)
2022-02-17 19:50:10 +00:00
if response . state == 0 : # OPEN
2022-01-12 14:26:26 +00:00
pass
2022-02-17 19:50:10 +00:00
if response . state == 1 : # SETTLED
2022-01-12 14:26:26 +00:00
pass
2022-02-17 19:50:10 +00:00
if response . state == 2 : # CANCELLED
2022-01-12 14:26:26 +00:00
pass
2022-02-17 19:50:10 +00:00
if response . state == 3 : # ACCEPTED (LOCKED)
2022-01-25 14:46:02 +00:00
lnpayment . expiry_height = response . htlcs [ 0 ] . expiry_height
2022-01-25 15:20:56 +00:00
lnpayment . status = LNPayment . Status . LOCKED
2022-01-25 14:46:02 +00:00
lnpayment . save ( )
2022-01-12 14:26:26 +00:00
return True
2022-01-11 01:02:06 +00:00
2022-02-06 14:50:42 +00:00
@classmethod
def resetmc ( cls ) :
request = routerrpc . ResetMissionControlRequest ( )
2022-10-20 09:56:10 +00:00
response = cls . routerstub . ResetMissionControl (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-02-06 14:50:42 +00:00
return True
2022-01-05 10:30:38 +00:00
2022-01-10 23:27:48 +00:00
@classmethod
def validate_ln_invoice ( cls , invoice , num_satoshis ) :
2022-02-17 19:50:10 +00:00
""" Checks if the submited LN invoice comforms to expectations """
2022-01-10 23:27:48 +00:00
2022-01-25 14:46:02 +00:00
payout = {
2022-02-17 19:50:10 +00:00
" valid " : False ,
" context " : None ,
" description " : None ,
" payment_hash " : None ,
" created_at " : None ,
" expires_at " : None ,
}
2022-01-11 14:36:43 +00:00
2022-01-10 23:27:48 +00:00
try :
payreq_decoded = cls . decode_payreq ( invoice )
except :
2022-02-17 19:50:10 +00:00
payout [ " context " ] = {
" bad_invoice " : " Does not look like a valid lightning invoice "
}
2022-01-25 14:46:02 +00:00
return payout
2022-01-10 23:27:48 +00:00
2022-05-26 23:35:45 +00:00
## Some wallet providers (e.g. Muun) force routing through a private channel with high fees >1500ppm
## These payments will fail. So it is best to let the user know in advance this invoice is not valid.
route_hints = payreq_decoded . route_hints
# Max amount RoboSats will pay for routing
2022-10-20 09:56:10 +00:00
max_routing_fee_sats = max (
num_satoshis * float ( config ( " PROPORTIONAL_ROUTING_FEE_LIMIT " ) ) ,
float ( config ( " MIN_FLAT_ROUTING_FEE_LIMIT_REWARD " ) ) ,
)
2022-05-26 23:35:45 +00:00
if route_hints :
routes_cost = [ ]
# For every hinted route...
for hinted_route in route_hints :
route_cost = 0
# ...add up the cost of every hinted hop...
for hop_hint in hinted_route . hop_hints :
route_cost + = hop_hint . fee_base_msat / 1000
2022-10-20 09:56:10 +00:00
route_cost + = (
hop_hint . fee_proportional_millionths * num_satoshis / 1000000
)
2022-05-26 23:35:45 +00:00
# ...and store the cost of the route to the array
routes_cost . append ( route_cost )
# If the cheapest possible private route is more expensive than what RoboSats is willing to pay
2022-10-20 09:56:10 +00:00
if min ( routes_cost ) > = max_routing_fee_sats :
2022-05-26 23:35:45 +00:00
payout [ " context " ] = {
2022-10-20 09:56:10 +00:00
" bad_invoice " : " The invoice submitted only has a trick on the routing hints, you might be using an incompatible wallet (probably Muun? Use an onchain address instead!). Check the wallet compatibility guide at wallets.robosats.com "
2022-05-26 23:35:45 +00:00
}
return payout
2022-01-11 20:49:53 +00:00
if payreq_decoded . num_satoshis == 0 :
2022-02-17 19:50:10 +00:00
payout [ " context " ] = {
" bad_invoice " : " The invoice provided has no explicit amount "
}
2022-01-25 14:46:02 +00:00
return payout
2022-01-11 20:49:53 +00:00
2022-01-10 23:27:48 +00:00
if not payreq_decoded . num_satoshis == num_satoshis :
2022-02-17 19:50:10 +00:00
payout [ " context " ] = {
2022-10-20 09:56:10 +00:00
" bad_invoice " : " The invoice provided is not for "
+ " {:,} " . format ( num_satoshis )
+ " Sats "
2022-02-17 19:50:10 +00:00
}
2022-01-25 14:46:02 +00:00
return payout
2022-01-10 23:27:48 +00:00
2022-02-17 19:50:10 +00:00
payout [ " created_at " ] = timezone . make_aware (
2022-10-20 09:56:10 +00:00
datetime . fromtimestamp ( payreq_decoded . timestamp )
)
2022-02-17 19:50:10 +00:00
payout [ " expires_at " ] = payout [ " created_at " ] + timedelta (
2022-10-20 09:56:10 +00:00
seconds = payreq_decoded . expiry
)
2022-01-10 23:27:48 +00:00
2022-02-17 19:50:10 +00:00
if payout [ " expires_at " ] < timezone . now ( ) :
payout [ " context " ] = {
2022-06-05 16:15:40 +00:00
" bad_invoice " : " The invoice provided has already expired "
2022-02-17 19:50:10 +00:00
}
2022-01-25 14:46:02 +00:00
return payout
2022-01-10 23:27:48 +00:00
2022-02-17 19:50:10 +00:00
payout [ " valid " ] = True
payout [ " description " ] = payreq_decoded . description
payout [ " payment_hash " ] = payreq_decoded . payment_hash
2022-01-10 23:27:48 +00:00
2022-01-25 14:46:02 +00:00
return payout
2022-01-05 10:30:38 +00:00
2022-01-11 01:02:06 +00:00
@classmethod
2022-03-06 16:08:28 +00:00
def pay_invoice ( cls , lnpayment ) :
""" Sends sats. Used for rewards payouts """
2022-06-06 17:57:04 +00:00
from api . models import LNPayment
2022-10-20 09:56:10 +00:00
2022-02-17 19:50:10 +00:00
fee_limit_sat = int (
max (
2022-10-20 09:56:10 +00:00
lnpayment . num_satoshis
* float ( config ( " PROPORTIONAL_ROUTING_FEE_LIMIT " ) ) ,
2022-03-06 16:08:28 +00:00
float ( config ( " MIN_FLAT_ROUTING_FEE_LIMIT_REWARD " ) ) ,
2022-10-20 09:56:10 +00:00
)
) # 200 ppm or 10 sats
2022-06-06 20:37:51 +00:00
timeout_seconds = int ( config ( " REWARDS_TIMEOUT_SECONDS " ) )
2022-10-20 09:56:10 +00:00
request = routerrpc . SendPaymentRequest (
payment_request = lnpayment . invoice ,
fee_limit_sat = fee_limit_sat ,
timeout_seconds = timeout_seconds ,
)
2022-02-17 19:50:10 +00:00
2022-10-20 09:56:10 +00:00
for response in cls . routerstub . SendPaymentV2 (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
) :
2022-01-12 14:26:26 +00:00
2022-02-17 19:50:10 +00:00
if response . status == 0 : # Status 0 'UNKNOWN'
2022-03-06 16:08:28 +00:00
# Not sure when this status happens
2022-02-17 19:50:10 +00:00
pass
2022-03-06 16:08:28 +00:00
2022-02-17 19:50:10 +00:00
if response . status == 1 : # Status 1 'IN_FLIGHT'
2022-03-09 11:35:50 +00:00
pass
2022-03-06 16:08:28 +00:00
if response . status == 3 : # Status 3 'FAILED'
2022-02-17 19:50:10 +00:00
""" 0 Payment isn ' t failed (yet).
1 There are more routes to try , but the payment timeout was exceeded .
2 All possible routes were tried and failed permanently . Or were no routes to the destination at all .
3 A non - recoverable error has occured .
4 Payment details incorrect ( unknown hash , invalid amt or invalid final cltv delta )
5 Insufficient local balance .
"""
2022-03-09 11:35:50 +00:00
failure_reason = cls . payment_failure_context [ response . failure_reason ]
2022-05-19 14:00:55 +00:00
lnpayment . failure_reason = response . failure_reason
2022-03-09 11:35:50 +00:00
lnpayment . status = LNPayment . Status . FAILRO
lnpayment . save ( )
return False , failure_reason
2022-03-06 16:08:28 +00:00
2022-02-17 19:50:10 +00:00
if response . status == 2 : # STATUS 'SUCCEEDED'
2022-03-09 11:35:50 +00:00
lnpayment . status = LNPayment . Status . SUCCED
2022-10-20 09:56:10 +00:00
lnpayment . fee = float ( response . fee_msat ) / 1000
2022-05-19 14:00:55 +00:00
lnpayment . preimage = response . payment_preimage
2022-03-09 11:35:50 +00:00
lnpayment . save ( )
2022-01-12 21:22:16 +00:00
return True , None
2022-01-12 14:26:26 +00:00
2022-01-11 20:49:53 +00:00
return False
2022-01-09 21:24:48 +00:00
2022-01-11 01:02:06 +00:00
@classmethod
def double_check_htlc_is_settled ( cls , payment_hash ) :
2022-02-17 19:50:10 +00:00
""" Just as it sounds. Better safe than sorry! """
2022-10-20 09:56:10 +00:00
request = invoicesrpc . LookupInvoiceMsg ( payment_hash = bytes . fromhex ( payment_hash ) )
response = cls . invoicesstub . LookupInvoiceV2 (
request , metadata = [ ( " macaroon " , MACAROON . hex ( ) ) ]
)
2022-02-17 19:50:10 +00:00
return (
response . state == 1
) # LND states: 0 OPEN, 1 SETTLED, 3 ACCEPTED, GRPC_ERROR status 5 when cancelled/returned