diff --git a/lightspark/__tests__/test_uma_utils.py b/lightspark/__tests__/test_uma_utils.py new file mode 100644 index 0000000..a4a6307 --- /dev/null +++ b/lightspark/__tests__/test_uma_utils.py @@ -0,0 +1,40 @@ +import datetime +import logging +from unittest.mock import patch +from lightspark import LightsparkSyncClient + +logger = logging.getLogger("lightspark") +logger.setLevel(logging.DEBUG) + + +class TestUmaUtils: + @patch("lightspark.lightspark_client.datetime") + def test_hash_uma_identifier_same_month(self, mock_datetime): + client = LightsparkSyncClient("", "") + priv_key_bytes = b"xyz" + mock_datetime.now.return_value = datetime.datetime(2021, 1, 1, 0, 0, 0) + + hashed_uma = client.hash_uma_identifier("user@domain.com", priv_key_bytes) + hashed_uma_same_month = client.hash_uma_identifier( + "user@domain.com", priv_key_bytes + ) + + logger.debug(hashed_uma) + assert hashed_uma_same_month == hashed_uma + + @patch("lightspark.lightspark_client.datetime") + def test_hash_uma_identifier_different_month(self, mock_datetime): + client = LightsparkSyncClient("", "") + priv_key_bytes = b"xyz" + + mock_datetime.now.return_value = datetime.datetime(2021, 1, 1, 0, 0, 0) + hashed_uma = client.hash_uma_identifier("user@domain.com", priv_key_bytes) + + mock_datetime.now.return_value = datetime.datetime(2021, 2, 1, 0, 0, 0) + hashed_uma_diff_month = client.hash_uma_identifier( + "user@domain.com", priv_key_bytes + ) + + logger.debug(hashed_uma) + logger.debug(hashed_uma_diff_month) + assert hashed_uma_diff_month != hashed_uma diff --git a/lightspark/lightspark_client.py b/lightspark/lightspark_client.py index de711a2..bf2bdef 100644 --- a/lightspark/lightspark_client.py +++ b/lightspark/lightspark_client.py @@ -303,16 +303,47 @@ def create_uma_invoice( amount_msats: int, metadata: str, expiry_secs: Optional[int] = None, + signing_private_key: Optional[bytes] = None, + receiver_identifier: Optional[str] = None, ) -> Invoice: + """Creates a new invoice for the UMA protocol. The metadata is hashed and included in the invoice. This API + generates a Lightning Invoice (follows the Bolt 11 specification) to request a payment from another Lightning Node. + This should only be used for generating invoices for UMA, with `create_invoice` preferred in the general case. + + Args: + node_id: The node ID for which to create an invoice. + amount_msats: The amount of the invoice in msats. You can create a zero-amount invoice to accept any payment amount. + metadata: The LNURL metadata payload field in the initial payreq response. This wil be hashed and present in the + h-tag (SHA256 purpose of payment) of the resulting Bolt 11 invoice. See + [this spec](https://github.com/lnurl/luds/blob/luds/06.md#pay-to-static-qrnfclink) for details. + expiry_secs: The number of seconds until the invoice expires. Defaults to 600. + signing_private_key: The receiver's signing private key. Used to hash the receiver identifier. + receiver_identifier: Optional identifier of the receiver. If provided, this will be hashed using a monthly-rotated + seed and used for anonymized analysis. + """ + receiver_hash = None + if receiver_identifier is not None: + if signing_private_key is None: + raise LightsparkException( + "CreateUmaInvoiceError", + "Receiver identifier provided without signing private key", + ) + receiver_hash = self.hash_uma_identifier( + receiver_identifier, signing_private_key + ) + + variables = { + "amount_msats": amount_msats, + "node_id": node_id, + "metadata_hash": sha256(metadata.encode("utf-8")).hexdigest(), + "expiry_secs": expiry_secs if expiry_secs is not None else 600, + } + if receiver_hash is not None: + variables["receiver_hash"] = receiver_hash logger.info("Creating an uma invoice for node %s.", node_id) json = self._requester.execute_graphql( CREATE_UMA_INVOICE_MUTATION, - { - "amount_msats": amount_msats, - "node_id": node_id, - "metadata_hash": sha256(metadata.encode("utf-8")).hexdigest(), - "expiry_secs": expiry_secs if expiry_secs is not None else 600, - }, + variables, ) return Invoice_from_json(self._requester, json["create_uma_invoice"]["invoice"]) @@ -530,7 +561,36 @@ def pay_uma_invoice( maximum_fees_msats: int, amount_msats: Optional[int] = None, idempotency_key: Optional[str] = None, + signing_private_key: Optional[bytes] = None, + sender_identifier: Optional[str] = None, ) -> OutgoingPayment: + """Sends an UMA payment to a node on the Lightning Network, based on the invoice (as defined by the BOLT11 + specification) that you provide. This should only be used for paying UMA invoices, with `pay_invoice` preferred + in the general case. + + Args: + node_id: The ID of the node that will pay the invoice. + encoded_invoice: The encoded invoice to pay. + timeout_secs: A timeout for the payment in seconds. + maximum_fees_msats: Maximum fees (in msats) to pay for the payment. + amount_msats: The amount to pay in msats for a zero-amount invoice. Defaults to the full amount of the + invoice. Note, this parameter can only be passed for a zero-amount invoice. Otherwise, the call will fail. + idempotency_key: An optional key to ensure idempotency of the payment. + signing_private_key: The sender's signing private key. Used to hash the sender identifier. + sender_identifier: Optional identifier of the sender. If provided, this will be hashed using a monthly-rotated + seed and used for anonymized analysis. + """ + sender_hash = None + if sender_identifier is not None: + if signing_private_key is None: + raise LightsparkException( + "PayUmaInvoiceError", + "Sender identifier provided without signing private key", + ) + sender_hash = self.hash_uma_identifier( + sender_identifier, signing_private_key + ) + variables = { "node_id": node_id, "encoded_invoice": encoded_invoice, @@ -541,6 +601,8 @@ def pay_uma_invoice( variables["amount_msats"] = amount_msats if idempotency_key is not None: variables["idempotency_key"] = idempotency_key + if sender_hash is not None: + variables["sender_hash"] = sender_hash json = self._requester.execute_graphql( PAY_UMA_INVOICE_MUTATION, variables, @@ -933,6 +995,11 @@ def _hash_phone_number(self, phone_number_e164_format: str) -> str: ) return sha256(phone_number_e164_format.encode()).hexdigest() + def hash_uma_identifier(self, identifier: str, signing_private_key: bytes) -> str: + now = datetime.now(timezone.utc) + input_data = identifier + f"{now.month}-{now.year}" + signing_private_key.hex() + return sha256(input_data.encode()).hexdigest() + def fail_htlcs(self, invoice_id: str, cancel_invoice: bool = True) -> str: """ Fails all pending HTLCs associated with an invoice. diff --git a/lightspark/scripts/create_uma_invoice.py b/lightspark/scripts/create_uma_invoice.py index 62d0af5..315e374 100644 --- a/lightspark/scripts/create_uma_invoice.py +++ b/lightspark/scripts/create_uma_invoice.py @@ -8,12 +8,14 @@ $amount_msats: Long! $metadata_hash: String! $expiry_secs: Int + $receiver_hash: String = null ) {{ create_uma_invoice(input: {{ node_id: $node_id amount_msats: $amount_msats metadata_hash: $metadata_hash expiry_secs: $expiry_secs + receiver_hash: $receiver_hash }}) {{ invoice {{ ...InvoiceFragment diff --git a/lightspark/scripts/pay_uma_invoice.py b/lightspark/scripts/pay_uma_invoice.py index f2eb86f..8972c28 100644 --- a/lightspark/scripts/pay_uma_invoice.py +++ b/lightspark/scripts/pay_uma_invoice.py @@ -10,6 +10,7 @@ $maximum_fees_msats: Long! $amount_msats: Long $idempotency_key: String + $sender_hash: String = null ) {{ pay_uma_invoice(input: {{ node_id: $node_id @@ -18,6 +19,7 @@ maximum_fees_msats: $maximum_fees_msats amount_msats: $amount_msats idempotency_key: $idempotency_key + sender_hash: $sender_hash }}) {{ payment {{ ...OutgoingPaymentFragment