mirror of
https://github.com/RoboSats/robosats.git
synced 2024-12-13 19:06:26 +00:00
62e6258f0f
* update cln holdinvoice plugin
DecodeBolt11 was removed in:
d51fd03390
* update CLN to v24.05 and holdinvoice to v3.0.0
* reactivate CLN integration tests
---------
Co-authored-by: jerryfletcher21 <jerryfletcher@cock.email>
875 lines
35 KiB
Python
Executable File
875 lines
35 KiB
Python
Executable File
import hashlib
|
|
import os
|
|
import secrets
|
|
import struct
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
import grpc
|
|
import ring
|
|
from decouple import config
|
|
from django.utils import timezone
|
|
|
|
from . import hold_pb2, hold_pb2_grpc, node_pb2, node_pb2_grpc
|
|
from . import primitives_pb2 as primitives__pb2
|
|
|
|
#######
|
|
# Works with CLN
|
|
#######
|
|
|
|
# Load the client's certificate and key
|
|
CLN_DIR = config("CLN_DIR", cast=str, default="/cln/testnet/")
|
|
with open(os.path.join(CLN_DIR, "client.pem"), "rb") as f:
|
|
client_cert = f.read()
|
|
with open(os.path.join(CLN_DIR, "client-key.pem"), "rb") as f:
|
|
client_key = f.read()
|
|
|
|
# Load the server's certificate
|
|
with open(os.path.join(CLN_DIR, "server.pem"), "rb") as f:
|
|
server_cert = f.read()
|
|
|
|
|
|
CLN_GRPC_HOST = config("CLN_GRPC_HOST", cast=str, default="localhost:9999")
|
|
CLN_GRPC_HOLD_HOST = config("CLN_GRPC_HOLD_HOST", cast=str, default="localhost:9998")
|
|
DISABLE_ONCHAIN = config("DISABLE_ONCHAIN", cast=bool, default=True)
|
|
MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500000)
|
|
|
|
|
|
class CLNNode:
|
|
os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA"
|
|
|
|
# Create the SSL credentials object
|
|
creds = grpc.ssl_channel_credentials(
|
|
root_certificates=server_cert,
|
|
private_key=client_key,
|
|
certificate_chain=client_cert,
|
|
)
|
|
# Create the gRPC channel using the SSL credentials
|
|
hold_channel = grpc.secure_channel(CLN_GRPC_HOLD_HOST, creds)
|
|
node_channel = grpc.secure_channel(CLN_GRPC_HOST, creds)
|
|
|
|
payment_failure_context = {
|
|
-1: "Catchall nonspecific error.",
|
|
201: "Already paid with this hash using different amount or destination.",
|
|
203: "Permanent failure at destination.",
|
|
205: "Unable to find a route.",
|
|
206: "Route too expensive.",
|
|
207: "Invoice expired.",
|
|
210: "Payment timed out without a payment in progress.",
|
|
}
|
|
|
|
@classmethod
|
|
def get_version(cls):
|
|
try:
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
request = node_pb2.GetinfoRequest()
|
|
response = nodestub.Getinfo(request)
|
|
return response.version
|
|
except Exception as e:
|
|
print(f"Cannot get CLN version: {e}")
|
|
return "Not installed"
|
|
|
|
@classmethod
|
|
def get_info(cls):
|
|
try:
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
request = node_pb2.GetinfoRequest()
|
|
response = nodestub.Getinfo(request)
|
|
return response
|
|
except Exception as e:
|
|
print(f"Cannot get CLN node id: {e}")
|
|
|
|
@classmethod
|
|
def newaddress(cls):
|
|
"""Only used on tests to fund the regtest node"""
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
request = node_pb2.NewaddrRequest()
|
|
response = nodestub.NewAddr(request)
|
|
return response.bech32
|
|
|
|
@classmethod
|
|
def decode_payreq(cls, invoice):
|
|
"""Decodes a lightning payment request (invoice)"""
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
request = node_pb2.DecodeRequest(string=invoice)
|
|
response = nodestub.Decode(request)
|
|
return response
|
|
|
|
@classmethod
|
|
def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1):
|
|
"""Returns estimated fee for onchain payouts"""
|
|
# feerate estimaes work a bit differently in cln see https://lightning.readthedocs.io/lightning-feerates.7.html
|
|
request = node_pb2.FeeratesRequest(style="PERKB")
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response = nodestub.Feerates(request)
|
|
|
|
# "opening" -> ~12 block target
|
|
return {
|
|
"mining_fee_sats": response.onchain_fee_estimates.opening_channel_satoshis,
|
|
"mining_fee_rate": response.perkb.opening / 1000,
|
|
}
|
|
|
|
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 = node_pb2.ListfundsRequest()
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response = nodestub.ListFunds(request)
|
|
|
|
unconfirmed_balance = 0
|
|
confirmed_balance = 0
|
|
total_balance = 0
|
|
for utxo in response.outputs:
|
|
if not utxo.reserved:
|
|
if (
|
|
utxo.status
|
|
== node_pb2.ListfundsOutputs.ListfundsOutputsStatus.UNCONFIRMED
|
|
):
|
|
unconfirmed_balance += utxo.amount_msat.msat // 1_000
|
|
total_balance += utxo.amount_msat.msat // 1_000
|
|
elif (
|
|
utxo.status
|
|
== node_pb2.ListfundsOutputs.ListfundsOutputsStatus.CONFIRMED
|
|
):
|
|
confirmed_balance += utxo.amount_msat.msat // 1_000
|
|
total_balance += utxo.amount_msat.msat // 1_000
|
|
|
|
return {
|
|
"total_balance": total_balance,
|
|
"confirmed_balance": confirmed_balance,
|
|
"unconfirmed_balance": 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 = node_pb2.ListpeerchannelsRequest()
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response = nodestub.ListPeerChannels(request)
|
|
|
|
local_balance_sat = 0
|
|
remote_balance_sat = 0
|
|
unsettled_local_balance = 0
|
|
unsettled_remote_balance = 0
|
|
for channel in response.channels:
|
|
if (
|
|
channel.state
|
|
== node_pb2.ListpeerchannelsChannels.ListpeerchannelsChannelsState.CHANNELD_NORMAL
|
|
):
|
|
local_balance_sat += channel.to_us_msat.msat // 1_000
|
|
remote_balance_sat += (
|
|
channel.total_msat.msat - channel.to_us_msat.msat
|
|
) // 1_000
|
|
for htlc in channel.htlcs:
|
|
if (
|
|
htlc.direction
|
|
== node_pb2.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.IN
|
|
):
|
|
unsettled_local_balance += htlc.amount_msat.msat // 1_000
|
|
elif (
|
|
htlc.direction
|
|
== node_pb2.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.OUT
|
|
):
|
|
unsettled_remote_balance += htlc.amount_msat.msat // 1_000
|
|
|
|
return {
|
|
"local_balance": local_balance_sat,
|
|
"remote_balance": remote_balance_sat,
|
|
"unsettled_local_balance": unsettled_local_balance,
|
|
"unsettled_remote_balance": unsettled_remote_balance,
|
|
}
|
|
|
|
@classmethod
|
|
def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2):
|
|
"""Send onchain transaction for buyer payouts"""
|
|
|
|
if DISABLE_ONCHAIN or onchainpayment.sent_satoshis > MAX_SWAP_AMOUNT:
|
|
return False
|
|
|
|
request = node_pb2.WithdrawRequest(
|
|
destination=onchainpayment.address,
|
|
satoshi=primitives__pb2.AmountOrAll(
|
|
amount=primitives__pb2.Amount(msat=onchainpayment.sent_satoshis * 1_000)
|
|
),
|
|
feerate=primitives__pb2.Feerate(
|
|
perkb=int(onchainpayment.mining_fee_rate) * 1_000
|
|
),
|
|
minconf=int(not config("SPEND_UNCONFIRMED", default=False, cast=bool)),
|
|
)
|
|
|
|
# Cheap security measure to ensure there has been some non-deterministic time between request and DB check
|
|
delay = (
|
|
secrets.randbelow(2**256) / (2**256) * 10
|
|
) # Random uniform 0 to 5 secs with good entropy
|
|
if not config("TESTING", cast=bool, default=False):
|
|
time.sleep(3 + delay)
|
|
|
|
if onchainpayment.status == queue_code:
|
|
# Changing the state to "MEMPO" should be atomic with SendCoins.
|
|
onchainpayment.status = on_mempool_code
|
|
onchainpayment.save(update_fields=["status"])
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response = nodestub.Withdraw(request)
|
|
|
|
if response.txid:
|
|
onchainpayment.txid = response.txid.hex()
|
|
onchainpayment.broadcasted = True
|
|
onchainpayment.save(update_fields=["txid", "broadcasted"])
|
|
return True
|
|
|
|
elif onchainpayment.status == on_mempool_code:
|
|
# Bug, double payment attempted
|
|
return True
|
|
|
|
@classmethod
|
|
def cancel_return_hold_invoice(cls, payment_hash):
|
|
"""Cancels or returns a hold invoice"""
|
|
request = hold_pb2.HoldInvoiceCancelRequest(
|
|
payment_hash=bytes.fromhex(payment_hash)
|
|
)
|
|
holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel)
|
|
response = holdstub.HoldInvoiceCancel(request)
|
|
|
|
return response.state == hold_pb2.Holdstate.CANCELED
|
|
|
|
@classmethod
|
|
def settle_hold_invoice(cls, preimage):
|
|
"""settles a hold invoice"""
|
|
request = hold_pb2.HoldInvoiceSettleRequest(
|
|
payment_hash=hashlib.sha256(bytes.fromhex(preimage)).digest()
|
|
)
|
|
holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel)
|
|
response = holdstub.HoldInvoiceSettle(request)
|
|
|
|
return response.state == hold_pb2.Holdstate.SETTLED
|
|
|
|
@classmethod
|
|
def gen_hold_invoice(
|
|
cls,
|
|
num_satoshis,
|
|
description,
|
|
invoice_expiry,
|
|
cltv_expiry_blocks,
|
|
order_id,
|
|
lnpayment_concept,
|
|
time,
|
|
):
|
|
"""Generates hold invoice"""
|
|
|
|
# constant 100h invoice expiry because cln has to cancel htlcs if invoice expires
|
|
# or it can't associate them anymore
|
|
invoice_expiry = cltv_expiry_blocks * 10 * 60
|
|
|
|
hold_payment = {}
|
|
# The preimage is a random hash of 256 bits entropy
|
|
preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest()
|
|
|
|
request = hold_pb2.HoldInvoiceRequest(
|
|
description=description,
|
|
amount_msat=hold_pb2.Amount(msat=num_satoshis * 1_000),
|
|
label=f"Order:{order_id}-{lnpayment_concept}-{time}",
|
|
expiry=invoice_expiry,
|
|
cltv=cltv_expiry_blocks,
|
|
preimage=preimage, # preimage is actually optional in cln, as cln would generate one by default
|
|
)
|
|
holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel)
|
|
response = holdstub.HoldInvoice(request)
|
|
|
|
hold_payment["invoice"] = response.bolt11
|
|
payreq_decoded = cls.decode_payreq(hold_payment["invoice"])
|
|
hold_payment["preimage"] = preimage.hex()
|
|
hold_payment["payment_hash"] = response.payment_hash.hex()
|
|
hold_payment["created_at"] = timezone.make_aware(
|
|
datetime.fromtimestamp(payreq_decoded.created_at)
|
|
)
|
|
hold_payment["expires_at"] = timezone.make_aware(
|
|
datetime.fromtimestamp(response.expires_at)
|
|
)
|
|
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 = hold_pb2.HoldInvoiceLookupRequest(
|
|
payment_hash=bytes.fromhex(lnpayment.payment_hash)
|
|
)
|
|
holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel)
|
|
response = holdstub.HoldInvoiceLookup(request)
|
|
|
|
# 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 == hold_pb2.Holdstate.OPEN:
|
|
pass
|
|
if response.state == hold_pb2.Holdstate.SETTLED:
|
|
pass
|
|
if response.state == hold_pb2.Holdstate.CANCELED:
|
|
pass
|
|
if response.state == hold_pb2.Holdstate.ACCEPTED:
|
|
lnpayment.expiry_height = response.htlc_expiry
|
|
lnpayment.status = LNPayment.Status.LOCKED
|
|
lnpayment.save(update_fields=["expiry_height", "status"])
|
|
return True
|
|
|
|
@classmethod
|
|
def lookup_invoice_status(cls, lnpayment):
|
|
"""
|
|
Returns the status (as LNpayment.Status) of the given payment_hash
|
|
If unchanged, returns the previous status
|
|
"""
|
|
from api.models import LNPayment
|
|
|
|
status = lnpayment.status
|
|
expiry_height = 0
|
|
|
|
cln_response_state_to_lnpayment_status = {
|
|
0: LNPayment.Status.INVGEN, # OPEN
|
|
1: LNPayment.Status.SETLED, # SETTLED
|
|
2: LNPayment.Status.CANCEL, # CANCELLED
|
|
3: LNPayment.Status.LOCKED, # ACCEPTED
|
|
}
|
|
|
|
try:
|
|
# this is similar to LNNnode.validate_hold_invoice_locked
|
|
request = hold_pb2.HoldInvoiceLookupRequest(
|
|
payment_hash=bytes.fromhex(lnpayment.payment_hash)
|
|
)
|
|
holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel)
|
|
response = holdstub.HoldInvoiceLookup(request)
|
|
|
|
status = cln_response_state_to_lnpayment_status[response.state]
|
|
|
|
# try saving expiry height
|
|
if hasattr(response, "htlc_expiry"):
|
|
try:
|
|
expiry_height = response.htlc_expiry
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
# If it fails at finding the invoice: it has been expired for more than an hour (and could be paid or just expired).
|
|
# In RoboSats DB we make a distinction between cancelled and returned
|
|
# (holdinvoice plugin has separate state for hodl-invoices, which it forgets after an invoice expired more than an hour ago)
|
|
if "empty result for listdatastore_state" in str(e):
|
|
print(str(e))
|
|
request2 = node_pb2.ListinvoicesRequest(
|
|
payment_hash=bytes.fromhex(lnpayment.payment_hash)
|
|
)
|
|
try:
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response2 = nodestub.ListInvoices(request2).invoices
|
|
except Exception as e:
|
|
print(str(e))
|
|
|
|
if (
|
|
response2[0].status
|
|
== node_pb2.ListinvoicesInvoices.ListinvoicesInvoicesStatus.PAID
|
|
):
|
|
status = LNPayment.Status.SETLED
|
|
elif (
|
|
response2[0].status
|
|
== node_pb2.ListinvoicesInvoices.ListinvoicesInvoicesStatus.EXPIRED
|
|
):
|
|
status = LNPayment.Status.CANCEL
|
|
else:
|
|
print(str(e))
|
|
|
|
# Other write to logs
|
|
else:
|
|
print(str(e))
|
|
|
|
return status, expiry_height
|
|
|
|
@classmethod
|
|
def resetmc(cls):
|
|
# don't think an equivalent exists for cln, maybe deleting gossip_store file?
|
|
return False
|
|
|
|
@classmethod
|
|
def validate_ln_invoice(cls, invoice, num_satoshis, routing_budget_ppm):
|
|
"""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 Exception:
|
|
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.routes.hints
|
|
|
|
# Max amount RoboSats will pay for routing
|
|
if routing_budget_ppm == 0:
|
|
max_routing_fee_sats = max(
|
|
num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
|
|
float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")),
|
|
)
|
|
else:
|
|
max_routing_fee_sats = int(
|
|
float(num_satoshis) * float(routing_budget_ppm) / 1000000
|
|
)
|
|
|
|
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.hops:
|
|
route_cost += hop_hint.fee_base_msat.msat / 1_000
|
|
route_cost += (
|
|
hop_hint.fee_proportional_millionths * num_satoshis / 1_000_000
|
|
)
|
|
|
|
# ...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 hinted private routes are not payable within the submitted routing budget."
|
|
}
|
|
return payout
|
|
|
|
if payreq_decoded.amount_msat.msat == 0:
|
|
payout["context"] = {
|
|
"bad_invoice": "The invoice provided has no explicit amount"
|
|
}
|
|
return payout
|
|
|
|
if not payreq_decoded.amount_msat.msat // 1_000 == 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.created_at)
|
|
)
|
|
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.hex()
|
|
|
|
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 = node_pb2.PayRequest(
|
|
bolt11=lnpayment.invoice,
|
|
maxfee=primitives__pb2.Amount(msat=fee_limit_sat * 1_000),
|
|
retry_for=timeout_seconds,
|
|
)
|
|
|
|
try:
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response = nodestub.Pay(request)
|
|
|
|
if response.status == node_pb2.PayResponse.PayStatus.COMPLETE:
|
|
lnpayment.status = LNPayment.Status.SUCCED
|
|
lnpayment.fee = (
|
|
float(response.amount_sent_msat.msat - response.amount_msat.msat)
|
|
/ 1000
|
|
)
|
|
lnpayment.preimage = response.payment_preimage.hex()
|
|
lnpayment.save(update_fields=["fee", "status", "preimage"])
|
|
return True, None
|
|
elif response.status == node_pb2.PayResponse.PayStatus.PENDING:
|
|
failure_reason = "Payment isn't failed (yet)"
|
|
lnpayment.failure_reason = LNPayment.FailureReason.NOTYETF
|
|
lnpayment.status = LNPayment.Status.FLIGHT
|
|
lnpayment.save(update_fields=["failure_reason", "status"])
|
|
return False, failure_reason
|
|
else: # response.status == node_pb2.PayResponse.PayStatus.FAILED
|
|
failure_reason = "All possible routes were tried and failed permanently. Or were no routes to the destination at all."
|
|
lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE
|
|
lnpayment.status = LNPayment.Status.FAILRO
|
|
lnpayment.save(update_fields=["failure_reason", "status"])
|
|
return False, failure_reason
|
|
except grpc._channel._InactiveRpcError as e:
|
|
status_code = int(e.details().split("code: Some(")[1].split(")")[0])
|
|
failure_reason = cls.payment_failure_context[status_code]
|
|
lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE
|
|
lnpayment.status = LNPayment.Status.FAILRO
|
|
lnpayment.save(update_fields=["failure_reason", "status"])
|
|
return False, failure_reason
|
|
|
|
@classmethod
|
|
def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds):
|
|
"""Sends sats to buyer, continuous update"""
|
|
|
|
from api.models import LNPayment, Order
|
|
|
|
hash = lnpayment.payment_hash
|
|
|
|
# retry_for is not quite the same as a timeout. Pay can still take SIGNIFICANTLY longer to return if htlcs are stuck!
|
|
# allow_self_payment=True, No such thing in pay command and self_payments do not work with pay!
|
|
request = node_pb2.PayRequest(
|
|
bolt11=lnpayment.invoice,
|
|
maxfee=primitives__pb2.Amount(msat=fee_limit_sat * 1_000),
|
|
retry_for=timeout_seconds,
|
|
)
|
|
|
|
order = lnpayment.order_paid_LN
|
|
if order.trade_escrow.num_satoshis < lnpayment.num_satoshis:
|
|
print(f"Order: {order.id} Payout is larger than collateral !?")
|
|
return
|
|
|
|
def watchpayment():
|
|
request_listpays = node_pb2.ListpaysRequest(
|
|
payment_hash=bytes.fromhex(hash)
|
|
)
|
|
while True:
|
|
try:
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response_listpays = nodestub.ListPays(request_listpays)
|
|
except Exception as e:
|
|
print(str(e))
|
|
time.sleep(2)
|
|
continue
|
|
|
|
if (
|
|
len(response_listpays.pays) == 0
|
|
or response_listpays.pays[0].status
|
|
!= node_pb2.ListpaysPays.ListpaysPaysStatus.PENDING
|
|
):
|
|
return response_listpays
|
|
else:
|
|
time.sleep(2)
|
|
|
|
def handle_response():
|
|
try:
|
|
lnpayment.status = LNPayment.Status.FLIGHT
|
|
lnpayment.in_flight = True
|
|
lnpayment.save(update_fields=["in_flight", "status"])
|
|
|
|
order.update_status(Order.Status.PAY)
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response = nodestub.Pay(request)
|
|
|
|
if response.status == node_pb2.PayResponse.PayStatus.PENDING:
|
|
print(f"Order: {order.id} IN_FLIGHT. Hash {hash}")
|
|
|
|
watchpayment()
|
|
|
|
handle_response()
|
|
|
|
if response.status == node_pb2.PayResponse.PayStatus.FAILED:
|
|
lnpayment.status = LNPayment.Status.FAILRO
|
|
lnpayment.last_routing_time = timezone.now()
|
|
lnpayment.routing_attempts += 1
|
|
lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE
|
|
lnpayment.in_flight = False
|
|
if lnpayment.routing_attempts > 2:
|
|
lnpayment.status = LNPayment.Status.EXPIRE
|
|
lnpayment.routing_attempts = 0
|
|
lnpayment.save(
|
|
update_fields=[
|
|
"status",
|
|
"last_routing_time",
|
|
"routing_attempts",
|
|
"failure_reason",
|
|
"in_flight",
|
|
]
|
|
)
|
|
|
|
order.update_status(Order.Status.FAI)
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.FAI)
|
|
)
|
|
order.save(update_fields=["expires_at"])
|
|
|
|
print(
|
|
f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[-1]}"
|
|
)
|
|
order.log(
|
|
f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) failed. Failure reason: {cls.payment_failure_context[-1]})"
|
|
)
|
|
|
|
return {
|
|
"succeded": False,
|
|
"context": f"payment failure reason: {cls.payment_failure_context[-1]}",
|
|
}
|
|
|
|
if response.status == node_pb2.PayResponse.PayStatus.COMPLETE:
|
|
print(f"Order: {order.id} SUCCEEDED. Hash: {hash}")
|
|
lnpayment.status = LNPayment.Status.SUCCED
|
|
lnpayment.fee = (
|
|
float(
|
|
response.amount_sent_msat.msat - response.amount_msat.msat
|
|
)
|
|
/ 1000
|
|
)
|
|
lnpayment.preimage = response.payment_preimage.hex()
|
|
lnpayment.save(update_fields=["status", "fee", "preimage"])
|
|
order.update_status(Order.Status.SUC)
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.SUC)
|
|
)
|
|
order.save(update_fields=["expires_at"])
|
|
|
|
order.log(
|
|
f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) <b>succeeded</b>"
|
|
)
|
|
|
|
results = {"succeded": True}
|
|
return results
|
|
|
|
except grpc._channel._InactiveRpcError as e:
|
|
if "code: Some" in str(e):
|
|
status_code = int(e.details().split("code: Some(")[1].split(")")[0])
|
|
if (
|
|
status_code == 201
|
|
): # Already paid with this hash using different amount or destination
|
|
# i don't think this can happen really, since we don't use the amount_msat in request
|
|
# and if you just try 'pay' 2x where the first time it succeeds you get the same
|
|
# non-error result the 2nd time.
|
|
print(
|
|
f"Order: {order.id} ALREADY PAID using different amount or destination THIS SHOULD NEVER HAPPEN! Hash: {hash}."
|
|
)
|
|
|
|
# Permanent failure at destination. or Unable to find a route. or Route too expensive.
|
|
elif (
|
|
status_code == 203
|
|
or status_code == 205
|
|
or status_code == 206
|
|
or status_code == 210
|
|
):
|
|
lnpayment.status = LNPayment.Status.FAILRO
|
|
lnpayment.last_routing_time = timezone.now()
|
|
lnpayment.routing_attempts += 1
|
|
lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE
|
|
lnpayment.in_flight = False
|
|
if lnpayment.routing_attempts > 2:
|
|
lnpayment.status = LNPayment.Status.EXPIRE
|
|
lnpayment.routing_attempts = 0
|
|
lnpayment.save(
|
|
update_fields=[
|
|
"status",
|
|
"last_routing_time",
|
|
"routing_attempts",
|
|
"in_flight",
|
|
"failure_reason",
|
|
]
|
|
)
|
|
|
|
order.update_status(Order.Status.FAI)
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.FAI)
|
|
)
|
|
order.save(update_fields=["expires_at"])
|
|
|
|
print(
|
|
f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[status_code]}"
|
|
)
|
|
order.log(
|
|
f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) <b>failed</b>. Failure reason: {cls.payment_failure_context[status_code]}"
|
|
)
|
|
|
|
return {
|
|
"succeded": False,
|
|
"context": f"payment failure reason: {cls.payment_failure_context[status_code]}",
|
|
}
|
|
elif status_code == 207: # invoice expired
|
|
print(f"Order: {order.id}. INVOICE EXPIRED. Hash: {hash}")
|
|
|
|
last_payresponse = watchpayment()
|
|
|
|
# check if succeeded while pending and expired
|
|
if (
|
|
len(last_payresponse.pays) > 0
|
|
and last_payresponse.pays[0].status
|
|
== node_pb2.ListpaysPays.ListpaysPaysStatus.COMPLETE
|
|
):
|
|
handle_response()
|
|
else:
|
|
lnpayment.status = LNPayment.Status.EXPIRE
|
|
lnpayment.last_routing_time = timezone.now()
|
|
lnpayment.in_flight = False
|
|
lnpayment.save(
|
|
update_fields=[
|
|
"status",
|
|
"last_routing_time",
|
|
"in_flight",
|
|
]
|
|
)
|
|
order.update_status(Order.Status.FAI)
|
|
order.expires_at = timezone.now() + timedelta(
|
|
seconds=order.t_to_expire(Order.Status.FAI)
|
|
)
|
|
order.save(update_fields=["expires_at"])
|
|
|
|
order.log(
|
|
f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) <b>had expired</b>"
|
|
)
|
|
|
|
results = {
|
|
"succeded": False,
|
|
"context": "The payout invoice has expired",
|
|
}
|
|
return results
|
|
else: # -1 (general error)
|
|
print(str(e))
|
|
else:
|
|
print(str(e))
|
|
|
|
handle_response()
|
|
|
|
@classmethod
|
|
def send_keysend(
|
|
cls, target_pubkey, message, num_satoshis, routing_budget_sats, timeout, sign
|
|
):
|
|
# keysends for dev donations
|
|
from api.models import LNPayment
|
|
|
|
# Cannot perform selfpayments
|
|
# config("ALLOW_SELF_KEYSEND", cast=bool, default=False)
|
|
|
|
keysend_payment = {}
|
|
keysend_payment["created_at"] = timezone.now()
|
|
keysend_payment["expires_at"] = timezone.now()
|
|
try:
|
|
custom_records = []
|
|
|
|
msg = str(message)
|
|
|
|
if len(msg) > 0:
|
|
custom_records.append(
|
|
primitives__pb2.TlvEntry(
|
|
type=34349334, value=bytes.fromhex(msg.encode("utf-8").hex())
|
|
)
|
|
)
|
|
if sign:
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
self_pubkey = nodestub.Getinfo(node_pb2.GetinfoRequest()).id
|
|
timestamp = struct.pack(">i", int(time.time()))
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
signature = nodestub.SignMessage(
|
|
node_pb2.SignmessageRequest(
|
|
message=(
|
|
bytes.fromhex(self_pubkey)
|
|
+ bytes.fromhex(target_pubkey)
|
|
+ timestamp
|
|
+ bytes.fromhex(msg.encode("utf-8").hex())
|
|
),
|
|
)
|
|
).zbase
|
|
custom_records.append(
|
|
primitives__pb2.TlvEntry(type=34349337, value=signature)
|
|
)
|
|
custom_records.append(
|
|
primitives__pb2.TlvEntry(
|
|
type=34349339, value=bytes.fromhex(self_pubkey)
|
|
)
|
|
)
|
|
custom_records.append(
|
|
primitives__pb2.TlvEntry(type=34349343, value=timestamp)
|
|
)
|
|
|
|
# no maxfee for Keysend
|
|
maxfeepercent = (routing_budget_sats / num_satoshis) * 100
|
|
request = node_pb2.KeysendRequest(
|
|
destination=bytes.fromhex(target_pubkey),
|
|
extratlvs=primitives__pb2.TlvStream(entries=custom_records),
|
|
maxfeepercent=maxfeepercent,
|
|
retry_for=timeout,
|
|
amount_msat=primitives__pb2.Amount(msat=num_satoshis * 1000),
|
|
)
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
response = nodestub.KeySend(request)
|
|
|
|
keysend_payment["preimage"] = response.payment_preimage.hex()
|
|
keysend_payment["payment_hash"] = response.payment_hash.hex()
|
|
|
|
waitreq = node_pb2.WaitsendpayRequest(
|
|
payment_hash=response.payment_hash, timeout=timeout
|
|
)
|
|
try:
|
|
nodestub = node_pb2_grpc.NodeStub(cls.node_channel)
|
|
waitresp = nodestub.WaitSendPay(waitreq)
|
|
keysend_payment["fee"] = (
|
|
float(waitresp.amount_sent_msat.msat - waitresp.amount_msat.msat)
|
|
/ 1000
|
|
)
|
|
keysend_payment["status"] = LNPayment.Status.SUCCED
|
|
except grpc._channel._InactiveRpcError as e:
|
|
if "code: Some" in str(e):
|
|
status_code = int(e.details().split("code: Some(")[1].split(")")[0])
|
|
if status_code == 200: # Timed out before the payment could complete.
|
|
keysend_payment["status"] = LNPayment.Status.FLIGHT
|
|
elif status_code == 208:
|
|
print(
|
|
f"A payment for {response.payment_hash.hex()} was never made and there is nothing to wait for"
|
|
)
|
|
else:
|
|
keysend_payment["status"] = LNPayment.Status.FAILRO
|
|
keysend_payment["failure_reason"] = response.failure_reason
|
|
except Exception as e:
|
|
print("Error while sending keysend payment! Error: " + str(e))
|
|
|
|
except Exception as e:
|
|
print("Error while sending keysend payment! Error: " + str(e))
|
|
|
|
return True, keysend_payment
|
|
|
|
@classmethod
|
|
def double_check_htlc_is_settled(cls, payment_hash):
|
|
"""Just as it sounds. Better safe than sorry!"""
|
|
request = hold_pb2.HoldInvoiceLookupRequest(
|
|
payment_hash=bytes.fromhex(payment_hash)
|
|
)
|
|
try:
|
|
holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel)
|
|
response = holdstub.HoldInvoiceLookup(request)
|
|
except Exception as e:
|
|
if "Timed out" in str(e):
|
|
return False
|
|
else:
|
|
raise e
|
|
|
|
return response.state == hold_pb2.Holdstate.SETTLED
|