You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

283 lines
9.6 KiB

import time
from typing import TYPE_CHECKING, List, Optional, Union, Dict, Any
from decimal import Decimal
import attr
from .json_db import StoredObject
from .i18n import _
from .util import age, InvoiceError
from .lnaddr import lndecode, LnAddr
from . import constants
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from .bitcoin import address_to_script
from .transaction import PartialTxOutput
from .crypto import sha256d
if TYPE_CHECKING:
from .paymentrequest import PaymentRequest
# convention: 'invoices' = outgoing , 'request' = incoming
# status of payment requests
PR_UNPAID = 0 # if onchain: invoice amt not reached by txs in mempool+chain. if LN: invoice not paid.
PR_EXPIRED = 1 # invoice is unpaid and expiry time reached
PR_UNKNOWN = 2 # e.g. invoice not found
PR_PAID = 3 # if onchain: paid and mined (1 conf). if LN: invoice is paid.
PR_INFLIGHT = 4 # only for LN. payment attempt in progress
PR_FAILED = 5 # only for LN. we attempted to pay it, but all attempts failed
PR_ROUTING = 6 # only for LN. *unused* atm.
PR_UNCONFIRMED = 7 # only onchain. invoice is satisfied but tx is not mined yet.
pr_color = {
PR_UNPAID: (.7, .7, .7, 1),
PR_PAID: (.2, .9, .2, 1),
PR_UNKNOWN: (.7, .7, .7, 1),
PR_EXPIRED: (.9, .2, .2, 1),
PR_INFLIGHT: (.9, .6, .3, 1),
PR_FAILED: (.9, .2, .2, 1),
PR_ROUTING: (.9, .6, .3, 1),
PR_UNCONFIRMED: (.9, .6, .3, 1),
}
pr_tooltips = {
PR_UNPAID:_('Unpaid'),
PR_PAID:_('Paid'),
PR_UNKNOWN:_('Unknown'),
PR_EXPIRED:_('Expired'),
PR_INFLIGHT:_('In progress'),
PR_FAILED:_('Failed'),
PR_ROUTING: _('Computing route...'),
PR_UNCONFIRMED: _('Unconfirmed'),
}
PR_DEFAULT_EXPIRATION_WHEN_CREATING = 24*60*60 # 1 day
pr_expiration_values = {
0: _('Never'),
10*60: _('10 minutes'),
60*60: _('1 hour'),
24*60*60: _('1 day'),
7*24*60*60: _('1 week'),
}
assert PR_DEFAULT_EXPIRATION_WHEN_CREATING in pr_expiration_values
def _decode_outputs(outputs) -> Optional[List[PartialTxOutput]]:
if outputs is None:
return None
ret = []
for output in outputs:
if not isinstance(output, PartialTxOutput):
output = PartialTxOutput.from_legacy_tuple(*output)
ret.append(output)
return ret
# hack: BOLT-11 is not really clear on what an expiry of 0 means.
# It probably interprets it as 0 seconds, so already expired...
# Our higher level invoices code however uses 0 for "never".
# Hence set some high expiration here
LN_EXPIRY_NEVER = 100 * 365 * 24 * 60 * 60 # 100 years
@attr.s
class Invoice(StoredObject):
# mandatory fields
amount_msat = attr.ib(kw_only=True) # type: Optional[Union[int, str]] # can be '!' or None
message = attr.ib(type=str, kw_only=True)
time = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) # timestamp of the invoice
exp = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) # expiration delay (relative). 0 means never
# optional fields.
# an request (incoming) can be satisfied onchain, using lightning or using a swap
# an invoice (outgoing) is constructed from a source: bip21, bip70, lnaddr
# onchain only
outputs = attr.ib(kw_only=True, converter=_decode_outputs) # type: Optional[List[PartialTxOutput]]
height = attr.ib(type=int, kw_only=True, validator=attr.validators.instance_of(int)) # only for receiving
bip70 = attr.ib(type=str, kw_only=True) # type: Optional[str]
#bip70_requestor = attr.ib(type=str, kw_only=True) # type: Optional[str]
# lightning only
lightning_invoice = attr.ib(type=str, kw_only=True) # type: Optional[str]
__lnaddr = None
def is_lightning(self):
return self.lightning_invoice is not None
def get_status_str(self, status):
status_str = pr_tooltips[status]
if status == PR_UNPAID:
if self.exp > 0 and self.exp != LN_EXPIRY_NEVER:
expiration = self.get_expiration_date()
status_str = _('Expires') + ' ' + age(expiration, include_seconds=True)
return status_str
def get_address(self) -> Optional[str]:
"""returns the first address, to be displayed in GUI"""
address = None
if self.outputs:
address = self.outputs[0].address if len(self.outputs) > 0 else None
if not address:
if self.is_lightning():
address = self._lnaddr.get_fallback_address() or None
return address
def get_outputs(self):
if self.is_lightning():
address = self.get_address()
amount = self.get_amount_sat()
if address and amount is not None:
outputs = [PartialTxOutput.from_address_and_value(address, int(amount))]
else:
outputs = []
else:
outputs = self.outputs
return outputs
def can_be_paid_onchain(self) -> bool:
if self.is_lightning():
return bool(self._lnaddr.get_fallback_address())
else:
return True
def get_expiration_date(self):
# 0 means never
return self.exp + self.time if self.exp else 0
def has_expired(self) -> bool:
exp = self.get_expiration_date()
return bool(exp) and exp < time.time()
def get_amount_msat(self) -> Union[int, str, None]:
return self.amount_msat
def get_time(self):
return self.time
def get_message(self):
return self.message
def get_amount_sat(self) -> Union[int, str, None]:
"""
Returns an integer satoshi amount, or '!' or None.
Callers who need msat precision should call get_amount_msat()
"""
amount_msat = self.amount_msat
if amount_msat in [None, "!"]:
return amount_msat
return int(amount_msat // 1000)
def get_bip21_URI(self, *, include_lightning: bool = False) -> Optional[str]:
from electrum.util import create_bip21_uri
addr = self.get_address()
amount = self.get_amount_sat()
if amount is not None:
amount = int(amount)
message = self.message
extra = {}
if self.time and self.exp:
extra['time'] = str(int(self.time))
extra['exp'] = str(int(self.exp))
lightning = self.lightning_invoice if include_lightning else None
if lightning:
extra['lightning'] = lightning
if not addr and lightning:
return "bitcoin:?lightning="+lightning
if not addr and not lightning:
return None
uri = create_bip21_uri(addr, amount, message, extra_query_params=extra)
return str(uri)
@lightning_invoice.validator
def _validate_invoice_str(self, attribute, value):
if value is not None:
lndecode(value) # this checks the str can be decoded
@amount_msat.validator
def _validate_amount(self, attribute, value):
if value is None:
return
if isinstance(value, int):
if not (0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN * 1000):
raise InvoiceError(f"amount is out-of-bounds: {value!r} msat")
elif isinstance(value, str):
if value != '!':
raise InvoiceError(f"unexpected amount: {value!r}")
else:
raise InvoiceError(f"unexpected amount: {value!r}")
@property
def _lnaddr(self) -> LnAddr:
if self.__lnaddr is None:
self.__lnaddr = lndecode(self.lightning_invoice)
return self.__lnaddr
@property
def rhash(self) -> str:
return self._lnaddr.paymenthash.hex()
@classmethod
def from_bech32(cls, invoice: str) -> 'Invoice':
"""Constructs Invoice object from BOLT-11 string.
Might raise InvoiceError.
"""
try:
lnaddr = lndecode(invoice)
except Exception as e:
raise InvoiceError(e) from e
amount_msat = lnaddr.get_amount_msat()
timestamp = lnaddr.date
exp_delay = lnaddr.get_expiry()
message = lnaddr.get_description()
return Invoice(
message=message,
amount_msat=amount_msat,
time=timestamp,
exp=exp_delay,
outputs=None,
bip70=None,
height=0,
lightning_invoice=invoice,
)
@classmethod
def from_bip70_payreq(cls, pr: 'PaymentRequest', *, height: int = 0) -> 'Invoice':
return Invoice(
amount_msat=pr.get_amount()*1000,
message=pr.get_memo(),
time=pr.get_time(),
exp=pr.get_expiration_date() - pr.get_time(),
outputs=pr.get_outputs(),
bip70=pr.raw.hex(),
height=height,
lightning_invoice=None,
)
def to_debug_json(self) -> Dict[str, Any]:
d = self.to_json()
d.update({
'pubkey': self._lnaddr.pubkey.serialize().hex(),
'amount_BTC': str(self._lnaddr.amount),
'rhash': self._lnaddr.paymenthash.hex(),
'description': self._lnaddr.get_description(),
'exp': self._lnaddr.get_expiry(),
'time': self._lnaddr.date,
# 'tags': str(lnaddr.tags),
})
return d
def get_id(self) -> str:
if self.is_lightning():
return self.rhash
else: # on-chain
return get_id_from_onchain_outputs(outputs=self.get_outputs(), timestamp=self.time)
def get_id_from_onchain_outputs(outputs: List[PartialTxOutput], *, timestamp: int) -> str:
outputs_str = "\n".join(f"{txout.scriptpubkey.hex()}, {txout.value}" for txout in outputs)
return sha256d(outputs_str + "%d" % timestamp).hex()[0:10]