mirror of
https://github.com/RoboSats/robosats.git
synced 2024-12-13 19:06:26 +00:00
379 lines
16 KiB
Python
379 lines
16 KiB
Python
import grpc, os, hashlib, secrets, ring
|
|
|
|
|
|
from . import lightning_pb2 as lnrpc, lightning_pb2_grpc as lightningstub
|
|
from . import invoices_pb2 as invoicesrpc, invoices_pb2_grpc as invoicesstub
|
|
from . import router_pb2 as routerrpc, router_pb2_grpc as routerstub
|
|
|
|
from decouple import config
|
|
from base64 import b64decode
|
|
|
|
from datetime import timedelta, datetime
|
|
from django.utils import timezone
|
|
|
|
|
|
#######
|
|
# Should work with LND (c-lightning in the future if there are features that deserve the work)
|
|
#######
|
|
|
|
# Read tls.cert from file or .env variable string encoded as base64
|
|
try:
|
|
CERT = open(os.path.join(config("LND_DIR"), "tls.cert"), "rb").read()
|
|
except:
|
|
CERT = b64decode(config("LND_CERT_BASE64"))
|
|
|
|
# Read macaroon from file or .env variable string encoded as base64
|
|
try:
|
|
MACAROON = open(os.path.join(config("LND_DIR"), config("MACAROON_path")),
|
|
"rb").read()
|
|
except:
|
|
MACAROON = b64decode(config("LND_MACAROON_BASE64"))
|
|
|
|
LND_GRPC_HOST = config("LND_GRPC_HOST")
|
|
|
|
|
|
class LNNode:
|
|
|
|
os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA"
|
|
|
|
creds = grpc.ssl_channel_credentials(CERT)
|
|
channel = grpc.secure_channel(LND_GRPC_HOST, creds)
|
|
|
|
lightningstub = lightningstub.LightningStub(channel)
|
|
invoicesstub = invoicesstub.InvoicesStub(channel)
|
|
routerstub = routerstub.RouterStub(channel)
|
|
|
|
lnrpc = lnrpc
|
|
invoicesrpc = invoicesrpc
|
|
routerrpc = routerrpc
|
|
|
|
payment_failure_context = {
|
|
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.",
|
|
}
|
|
|
|
@classmethod
|
|
def decode_payreq(cls, invoice):
|
|
"""Decodes a lightning payment request (invoice)"""
|
|
request = lnrpc.PayReqString(pay_req=invoice)
|
|
response = cls.lightningstub.DecodePayReq(request,
|
|
metadata=[("macaroon",
|
|
MACAROON.hex())])
|
|
return response
|
|
|
|
@classmethod
|
|
def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1):
|
|
"""Returns estimated fee for onchain payouts"""
|
|
|
|
# We assume segwit. Use robosats donation address as shortcut so there is no need of user inputs
|
|
request = lnrpc.EstimateFeeRequest(AddrToAmount={'bc1q3cpp7ww92n6zp04hv40kd3eyy5avgughx6xqnx':amount_sats},
|
|
target_conf=target_conf,
|
|
min_confs=min_confs,
|
|
spend_unconfirmed=False)
|
|
|
|
response = cls.lightningstub.EstimateFee(request,
|
|
metadata=[("macaroon",
|
|
MACAROON.hex())])
|
|
|
|
return {'mining_fee_sats': response.fee_sat, 'mining_fee_rate': response.sat_per_vbyte}
|
|
|
|
wallet_balance_cache = {}
|
|
@ring.dict(wallet_balance_cache, expire=10) # keeps in cache for 10 seconds
|
|
@classmethod
|
|
def wallet_balance(cls):
|
|
"""Returns onchain balance"""
|
|
request = lnrpc.WalletBalanceRequest()
|
|
response = cls.lightningstub.WalletBalance(request,
|
|
metadata=[("macaroon",
|
|
MACAROON.hex())])
|
|
|
|
return {'total_balance': response.total_balance,
|
|
'confirmed_balance': response.confirmed_balance,
|
|
'unconfirmed_balance': response.unconfirmed_balance}
|
|
|
|
channel_balance_cache = {}
|
|
@ring.dict(channel_balance_cache, expire=10) # keeps in cache for 10 seconds
|
|
@classmethod
|
|
def channel_balance(cls):
|
|
"""Returns channels balance"""
|
|
request = lnrpc.ChannelBalanceRequest()
|
|
response = cls.lightningstub.ChannelBalance(request,
|
|
metadata=[("macaroon",
|
|
MACAROON.hex())])
|
|
|
|
|
|
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}
|
|
|
|
@classmethod
|
|
def pay_onchain(cls, onchainpayment):
|
|
"""Send onchain transaction for buyer payouts"""
|
|
|
|
if config("DISABLE_ONCHAIN", cast=bool):
|
|
return False
|
|
|
|
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)
|
|
|
|
response = cls.lightningstub.SendCoins(request,
|
|
metadata=[("macaroon",
|
|
MACAROON.hex())])
|
|
|
|
onchainpayment.txid = response.txid
|
|
onchainpayment.save()
|
|
|
|
return True
|
|
|
|
@classmethod
|
|
def cancel_return_hold_invoice(cls, payment_hash):
|
|
"""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
|
|
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 None response. TODO
|
|
return str(response) == "" # True if no response, false otherwise.
|
|
|
|
@classmethod
|
|
def gen_hold_invoice(cls, num_satoshis, description, invoice_expiry,
|
|
cltv_expiry_secs):
|
|
"""Generates hold invoice"""
|
|
|
|
hold_payment = {}
|
|
# The preimage is a random hash of 256 bits entropy
|
|
preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest()
|
|
|
|
# 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=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
|
|
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(
|
|
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, lnpayment):
|
|
"""Checks if hold invoice is locked"""
|
|
from api.models import LNPayment
|
|
|
|
request = invoicesrpc.LookupInvoiceMsg(
|
|
payment_hash=bytes.fromhex(lnpayment.payment_hash))
|
|
response = cls.invoicesstub.LookupInvoiceV2(request,
|
|
metadata=[("macaroon",
|
|
MACAROON.hex())
|
|
])
|
|
|
|
# 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
|
|
pass
|
|
if response.state == 1: # SETTLED
|
|
pass
|
|
if response.state == 2: # CANCELLED
|
|
pass
|
|
if response.state == 3: # ACCEPTED (LOCKED)
|
|
lnpayment.expiry_height = response.htlcs[0].expiry_height
|
|
lnpayment.status = LNPayment.Status.LOCKED
|
|
lnpayment.save()
|
|
return True
|
|
|
|
@classmethod
|
|
def resetmc(cls):
|
|
request = routerrpc.ResetMissionControlRequest()
|
|
response = cls.routerstub.ResetMissionControl(request,
|
|
metadata=[
|
|
("macaroon",
|
|
MACAROON.hex())
|
|
])
|
|
return True
|
|
|
|
@classmethod
|
|
def validate_ln_invoice(cls, invoice, num_satoshis):
|
|
"""Checks if the submited LN invoice comforms to expectations"""
|
|
|
|
payout = {
|
|
"valid": False,
|
|
"context": None,
|
|
"description": None,
|
|
"payment_hash": None,
|
|
"created_at": None,
|
|
"expires_at": None,
|
|
}
|
|
|
|
try:
|
|
payreq_decoded = cls.decode_payreq(invoice)
|
|
except:
|
|
payout["context"] = {
|
|
"bad_invoice": "Does not look like a valid lightning invoice"
|
|
}
|
|
return payout
|
|
|
|
## 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
|
|
max_routing_fee_sats = max(num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")))
|
|
|
|
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
|
|
route_cost += hop_hint.fee_proportional_millionths * num_satoshis / 1000000
|
|
|
|
# ...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
|
|
if min(routes_cost) >= max_routing_fee_sats :
|
|
payout["context"] = {
|
|
"bad_invoice": "The invoice submitted only has expensive routing hints, you are using an incompatible wallet (probably Muun?). Check the wallet compatibility guide at wallets.robosats.com"
|
|
}
|
|
return payout
|
|
|
|
if payreq_decoded.num_satoshis == 0:
|
|
payout["context"] = {
|
|
"bad_invoice": "The invoice provided has no explicit amount"
|
|
}
|
|
return payout
|
|
|
|
if not payreq_decoded.num_satoshis == num_satoshis:
|
|
payout["context"] = {
|
|
"bad_invoice":
|
|
"The invoice provided is not for " +
|
|
"{:,}".format(num_satoshis) + " Sats"
|
|
}
|
|
return payout
|
|
|
|
payout["created_at"] = timezone.make_aware(
|
|
datetime.fromtimestamp(payreq_decoded.timestamp))
|
|
payout["expires_at"] = payout["created_at"] + timedelta(
|
|
seconds=payreq_decoded.expiry)
|
|
|
|
if payout["expires_at"] < timezone.now():
|
|
payout["context"] = {
|
|
"bad_invoice": "The invoice provided has already expired"
|
|
}
|
|
return payout
|
|
|
|
payout["valid"] = True
|
|
payout["description"] = payreq_decoded.description
|
|
payout["payment_hash"] = payreq_decoded.payment_hash
|
|
|
|
return payout
|
|
|
|
@classmethod
|
|
def pay_invoice(cls, lnpayment):
|
|
"""Sends sats. Used for rewards payouts"""
|
|
from api.models import LNPayment
|
|
|
|
fee_limit_sat = int(
|
|
max(
|
|
lnpayment.num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
|
|
float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")),
|
|
)) # 200 ppm or 10 sats
|
|
timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS"))
|
|
request = routerrpc.SendPaymentRequest(payment_request=lnpayment.invoice,
|
|
fee_limit_sat=fee_limit_sat,
|
|
timeout_seconds=timeout_seconds)
|
|
|
|
for response in cls.routerstub.SendPaymentV2(request,
|
|
metadata=[("macaroon",
|
|
MACAROON.hex())
|
|
]):
|
|
|
|
if response.status == 0: # Status 0 'UNKNOWN'
|
|
# Not sure when this status happens
|
|
pass
|
|
|
|
if response.status == 1: # Status 1 'IN_FLIGHT'
|
|
pass
|
|
|
|
if response.status == 3: # Status 3 'FAILED'
|
|
"""0 Payment isn't failed (yet).
|
|
1 There are more routes to try, but the payment timeout was exceeded.
|
|
2 All possible routes were tried and failed permanently. Or were no routes to the destination at all.
|
|
3 A non-recoverable error has occured.
|
|
4 Payment details incorrect (unknown hash, invalid amt or invalid final cltv delta)
|
|
5 Insufficient local balance.
|
|
"""
|
|
failure_reason = cls.payment_failure_context[response.failure_reason]
|
|
lnpayment.failure_reason = response.failure_reason
|
|
lnpayment.status = LNPayment.Status.FAILRO
|
|
lnpayment.save()
|
|
return False, failure_reason
|
|
|
|
if response.status == 2: # STATUS 'SUCCEEDED'
|
|
lnpayment.status = LNPayment.Status.SUCCED
|
|
lnpayment.fee = float(response.fee_msat)/1000
|
|
lnpayment.preimage = response.payment_preimage
|
|
lnpayment.save()
|
|
return True, None
|
|
|
|
return False
|
|
|
|
@classmethod
|
|
def double_check_htlc_is_settled(cls, payment_hash):
|
|
"""Just as it sounds. Better safe than sorry!"""
|
|
request = invoicesrpc.LookupInvoiceMsg(
|
|
payment_hash=bytes.fromhex(payment_hash))
|
|
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
|