Migrating from UMA v0.x to UMA v1

UMA v1 is a major upgrade to the UMA protocol, which builds upon feedback from VASPs throughout the initial UMA pilot. This guide will help you migrate your UMA v0.x server implementations to UMA v1. Major changes include:
  • Security/Crypto
    • Add a signature to the payreq response
    • Add a signature to post-tx hook callback requests
    • Use X.509 Certificates for PKI
  • Features
    • Use the new Currency LUD-21 spec
    • Add Payee Data (LUD-22)
  • SDK Improvements
    • Version fallback support
    • Better LNURL interoperability
The following list shows example diffs of migrating a VASP from UMA v0.x to UMA v1 in various languages:
In UMA v1, the payreq response must include a signature from the receiving VASP. On the receiving VASP side, this is a simple change to pass the private key to the function which creates the payreq response:
response = create_pay_req_response(
    # ... other params ...
    signing_private_key=self.config.get_signing_privkey(),
)
On the sending VASP side, you can verify the signature like with other signed UMA messages:
# You can pull the uma_version from the lnurlp response.
if uma_major_version > 0:
    try:
        verify_pay_req_response_signature(
            sender_address=sending_user.get_uma_address(),
            receiver_address=initial_request_data.receiver_uma,
            response=payreq_response,
            other_vasp_pubkeys=receiver_vasp_pubkey_response,
            nonce_cache=self.nonce_cache,
        )
    except InvalidSignatureException as e:
        _abort_with_error(
            424, f"Error verifying payreq response signature: {e}"
        )
Signatures were also added to the post-tx hook callback requests. They are created and verified similarly to the payreq response:
# Note that in V1, you need to create the post-tx callback object using this
# function rather than manually constructing and serializing only the utxos.
post_tx_callback = create_post_transaction_callback(
    utxos_with_amounts,
    self.config.get_uma_domain(),
    self.config.get_signing_privkey()
)
res = requests.post(
    utxo_callback,
    json=post_tx_callback.to_dict(),
    timeout=10,
)
... and to verify the signature:
try:
    tx_callback = PostTransactionCallback.from_json(json.dumps(request.json))
except Exception as e:
    raise UmaException(
        status_code=400, message=f"Error parsing UTXO callback: {e}"
    )

if uma_major_version > 0:
    other_vasp_pubkeys = fetch_public_key_for_vasp(
        vasp_domain=tx_callback.vasp_domain,
        cache=pubkey_cache,
    )
    try:
        verify_post_transaction_callback_signature(
            tx_callback, other_vasp_pubkeys, nonce_cache
        )
    except InvalidSignatureException as e:
        raise UmaException(
            f"Error verifying post-tx callback signature: {e}", 424
        )
UMA v1 uses X.509 certificates for PKI rather than directly exposing raw public keys in the pubkey response. This allows for more secure and flexible key management, and opens the door for VASP Identity Authorities to issue certs that can then be verified by VASPs as a method of trust. The pubkey response now includes a certificate chain:
{
  // Used to verify signatures from VASP1. List of certificates (hex-encoded
  // X.509 DER) ordered from leaf to root.
  "signingCertChain": string[],
  // Used to encrypt TR info sent to VASP1. List of certificates (hex-encoded
  // X.509 DER) ordered from leaf to root.
  "encryptionCertChain": string[]
}
See Keys, Auth, and Encryption for details on how to generate these certificates. To create a pubkey response, handle the /.well-known/lnurlpubkey route as before and create the response object:
@app.route("/.well-known/lnurlpubkey")
def handle_public_key_request():
    # Note: The `to_dict()` at the end here is important to correctly serialize
    # the response.
    return create_pubkey_response(
        config.signing_cert_chain, config.encryption_cert_chain
    ).to_dict()
When receiving a pubkey response, you can just use the existing fetch_public_key_for_vasp utility function. A key difference from v1, however, is that most of the signature verification functions now take a PubkeyResponse object instead of a raw pubkey. For example:
other_vasp_pubkeys = fetch_public_key_for_vasp(
    vasp_domain=tx_callback.vasp_domain,
    cache=pubkey_cache,
)

try:
    verify_uma_lnurlp_query_signature(
        request=lnurlp_request,
        other_vasp_pubkeys=other_vasp_pubkeys,
        nonce_cache=self.nonce_cache,
    )
except Exception as e:
    raise UmaException(
        f"Invalid signature: {e}",
        status_code=400,
    )
UMA v1 switches to the new Currency LUD-21 spec, which allows for more flexible and extensible currency support. In particular, it allows locking the sending amount, whereas UMA v0 only allowed locking the receiving amount. This is useful for cases where the sender wants to send exactly some amount in their own local currency, and the receiver can receive whatever that translates to after exchange rates and fees.
There are some UX considerations on the sending side around locking to the sending amount vs. the receiving amount. You should make it clear to your user which amount will be fixed when they enter the amount to send.
On the sending side, there are new parameters to support this functionality when creating the pay request:
# If you're sending a specific amount in the sending currency,
# set is_amount_in_receiving_currency to False and specify the amount
# in msats. If you're sending a specific amount in the receiving
# currency, set is_amount_in_receiving_currency to True, which will
# lock to the receiving amount like in UMA v0.x. In that case, you'll
# need to specify amount in the smallest unit of the receiving currency
# like in UMA v0.x.
payreq = create_pay_request(
    receiving_currency_code=receiving_currency_code,
    is_amount_in_receiving_currency=is_amount_in_receiving_currency,
    amount=amount,
    # ... other params ...
)
On the receiving side, the SDK will help ensure that you're creating an invoice with the right amount based on the PayRequest object:
# Note that the PayRequest object's fields changed slightly in v1. The
# `currency_code` field is now `receiving_currency_code` and there's a new
# `sending_currency_code` field. When creating the payreq response:
response = create_pay_req_response(
    request=pay_request,
    receiving_currency_code=pay_request.receiving_currency_code,
    # ... other params ...
)
Your InvoiceCreator object will always be provided the right amount of msats for which to create the BOLT-11 invoice.
UMA v1 introduces the Payee Data LUD-22 spec which allows the sending VASP to ask for data about the payee. The major implication for the UMA SDKs (in addition to the new optional information exchange) is That the payreq response compliance object has been moved into the payee data object.
On the sending side, you can request payee data in the pay request:
requested_payee_data = create_counterparty_data_options(
    {
        "compliance": True,
        "identifier": True,
        # This boolean is just whether the field is mandatory or not.
        "email": False,
        "name": False,
        # ... any fields can be requested here ...
    }
)
payreq = create_pay_request(
    # ... other params ...
    requested_payee_data=requested_payee_data,
)
On the receiving side, you can include the requested payee data in the payreq response:
payee_data = {
    "name": "Satoshi Nakamoto",
    "email": "satoshi@nakomoto.com",
    # ... other fields if desired ...
    # Note that the compliance and identifier fields are added automatically for
    # UMA by the SDK.
}
response = create_pay_req_response(
    # ... other params ...
    payee_data=payee_data,
)
The UMA v1 SDKs added better fallback support so that you can continue to transact with v0 counterparties after updating to the v1 SDK. This is important for a smooth transition period where not all VASPs have upgraded to v1 yet. Where possible, the SDKs will automatically detect the version of the counterparty and use the appropriate version when serializing and deserilizing messages.
Some programming languages have bigger changes than others as a result of this due to the way they handle json serialization. For example, in JS, many types had to be changed to classes and you'll need to be sure to use the appropriate toJsonString() and fromJson() methods rather than directly calling JSON.stringify() and JSON.parse(). Language-specific changes are described below:
# The migration effort for Python is minimal. The SDK will automatically detect the
# version of the messages being parsed and in some cases, will create an
# uma_major_version field on the parsed object for visibility. For example, the
# PayRequest and PayreqResponse objects now have `uma_major_version` fields if you
# want to check the version of the message.

# The only change required in Python for backwards-compatibility is passing the UMA
# major version to the create_pay_request function. You can get this version by
# remembering the lnurlp response you received from the counterparty, which will
# include the UMA major version:
uma_version = initial_request_data.lnurlp_response.uma_version
if uma_version is not None:
    uma_major_version = ParsedVersion.load(uma_version).major
payreq = create_pay_request(
    # ... other params ...
    uma_major_version=uma_major_version if uma_version is not None else 1,
)
UMA v1 SDKs have improved interoperability with raw LNURL and have added additional support for the following LNURL features:
  • Handle LUD-12 compatibility for comments in payments
  • Pass disposable: false by default for LUD-11 compatibility with uma transactions
  • Add LUD-09 successAction support.
  • nostr zaps support (NIP-57) - allow the allowsNostr and nostrPubkey fields to be set on the lnurlp response.
These features are exposed via some new optional fields on various protocol messages. In general, VASPs don't need to support these features, but they're nice to have for better interoperability with other LNURL wallets and services.
As a result of the LNURL interoperability, the message objects in the SDKs have been updated to be more flexible, which requires some changes for VASPs:
  • Allow arbitrary fields in payerdata (LUD-18) and payeedata rather than only UMA-specific ones.
  • Make several UMA-specific fields optional in most messages and expose an isUma() function to check if a message is valid for UMA.
For example:
# On the receiving side, when handling an lnurlp request:
lnurlp_request: LnurlpRequest
try:
    lnurlp_request = parse_lnurlp_request(flask_request.url)
except UnsupportedVersionException as e:
    raise e
except Exception as e:
    print(f"Invalid UMA lnurlp request: {e}")
    raise UmaException(
        f"Invalid UMA lnurlp request: {e}",
        status_code=400,
    )

if not lnurlp_request.is_uma_request():
    # If you only support UMA, but not LNURL, you can return an error here.
    return self._handle_non_uma_lnurlp_request(lnurlp_request)

# Now you can handle the UMA-specific fields in the request like before.

# ... On the sending side, when you get back the lnurlp response:

lnurlp_response: LnurlpResponse
try:
    lnurlp_response = parse_lnurlp_response(response.text)
except Exception as e:
    _abort_with_error(424, f"Error parsing LNURLP response: {e}")

if not lnurlp_response.is_uma_response():
    # If you only support UMA, but not LNURL, you can return an error here.
    return self._handle_as_non_uma_lnurl_response(lnurlp_response, receiver_uma)

# There are similar functions available on the other message objects like
# PayRequest, PayreqResponse, etc.
The important part to remember here is to avoid trying to do things like verify signatures or exchange compliance data for non-UMA messages. The SDKs will help you ensure that you're only doing UMA-specific operations on UMA messages.