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:
let response: uma.PayReqResponse;
try {
    response = await uma.getPayReqResponse({
        // ... other params ...
        receivingVaspPrivateKey: isUmaRequest
            ? this.config.umaSigningPrivKey()
            : undefined,
    });
} catch (e) {
    // handle error
}
On the sending VASP side, you can verify the signature like with other signed UMA messages:
try {
    if (payReq.umaMajorVersion !== 0) {
        const isSignatureValid = await uma.verifyPayReqResponseSignature(
            payResponse,
            payerIdentifier,
            payeeIdentifier,
            pubKeys,
            this.nonceCache,
        );
        if (!isSignatureValid) {
            return {
                httpStatus: 424,
                data: "Invalid payreq response signature.",
            };
        }
    }
} catch (e) {
    return {
        httpStatus: 424,
        data: new Error("Invalid payreq response signature.", { cause: e }),
    };
}
Signatures were also added to the post-tx hook callback requests. They are created and verified similarly to the payreq response:
let postTransactionCallback = await uma.getPostTransactionCallback({
    utxos: utxos,
    vaspDomain: vaspDomain,
    signingPrivateKey: this.config.umaSigningPrivKey(),
});
let response: globalThis.Response;
try {
    response = await fetch(utxoCallback, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(postTransactionCallback),
    });
} catch (e) {
    return { httpStatus: 500, data: "Error post transaction callback." };
}
... and to verify the signature:
let postTransactionCallback = uma.parsePostTransactionCallback(responseJson);
try {
    if (payReq.umaMajorVersion !== 0) {
        const isSignatureValid = await uma.verifyPostTransactionCallbackSignature(
            postTransactionCallback,
            pubKeys,
            this.nonceCache,
        );
        if (!isSignatureValid) {
            return {
                httpStatus: 424,
                data: "Invalid post transaction callback signature.",
            };
        }
    }
} catch (e) {
    return {
        httpStatus: 424,
        data: new Error("Invalid post transaction callback signature.", { cause: e }),
    };
}
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.get("/.well-known/lnurlpubkey", (req, res) => {
    res.send(getPubKeyResponse({
        signingCertChainPem: config.umaSigningCertChain,
        encryptionCertChainPem: config.umaEncryptionCertChain,
    }).toJsonString());
});
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:
let pubKeys: uma.PubKeyResponse;
try {
    pubKeys = await uma.fetchPublicKeyForVasp({
        cache: this.pubKeyCache,
        vaspDomain: umaQuery.vaspDomain!,
    });
} catch (e) {
    // handle error
}
try {
    const isSignatureValid = await uma.verifyUmaLnurlpQuerySignature(umaQuery, pubKeys, this.nonceCache);
    if (!isSignatureValid) {
        return {
            httpStatus: 500,
            data: "Invalid UMA query signature.",
        };
    }
} catch (e) {
    // handle error
}
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 isAmountInReceivingCurrency to false and specify the amount
// in msats. If you're sending a specific amount in the receiving
// currency, set isAmountInReceivingCurrency 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.
let payReq: uma.PayRequest;
try {
    payReq = await uma.getPayRequest({
        receivingCurrencyCode: receivingCurrencyCode,
        isAmountInReceivingCurrency: isAmountInReceivingCurrency,
        amount: amount,
        // ... other params ... 
    });
} catch (e) {
    console.error("Error generating payreq.", e);
    return { httpStatus: 500, data: "Error generating payreq." };
}
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
// `currencyCode` field is now `receivingCurrencyCode` and there's a new
// `sendingCurrencyCode` field. When creating the payreq response:
try {
    response = await uma.getPayReqResponse({
        request: payreq,
        receivingCurrencyCode: payreq.receivingCurrencyCode,
        // ... other params ...
    });
    return { httpStatus: 200, data: response.toJsonSchemaObject() };
} catch (e) {
    // handle error
}
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:
let payReq: uma.PayRequest;
try {
    payReq = await uma.getPayRequest({
        // ... other params ...
        requestedPayeeData: {
          // Compliance and Identifier are mandatory fields added automatically.
          name: { mandatory: false },
          email: { mandatory: false },
        },
    });
} catch (e) {
    console.error("Error generating payreq.", e);
    return { httpStatus: 500, data: "Error generating payreq." };
}
On the receiving side, you can include the requested payee data in the payreq response:
const payeeData = {
    identifier: "$satoshi@nakomoto.com",
    name:       "Satoshi Nakamoto",
    email:      "satoshi@nakomoto.com",
}
let response: uma.PayReqResponse;
try {
    response = await uma.getPayReqResponse({
        // ... other params ...
        payeeData: payeeData,
    });
    return { httpStatus: 200, data: response.toJsonSchemaObject() };
} catch (e) {
    return {
        httpStatus: 500,
        data: new Error("Failed to generate UMA response.", { cause: e }),
    };
}
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 JS SDK will automatically detect the version of the messages being parsed.
// The main change required in Typescript for backwards-compatibility is passing
// the UMA major version to the getPayRequest function. You can get this version
// by remembering the lnurlp response you received from the counterparty, which
// will include the UMA major version:
try {
    payReq = await uma.getPayRequest({
        // ... other params ...
        umaMajorVersion: initialRequestData.lnurlpResponse.umaVersion
            ? getMajorVersion(initialRequestData.lnurlpResponse.umaVersion)
            : 1,
    });
} catch (e) {
    console.error("Error generating payreq.", e);
    return { httpStatus: 500, data: "Error generating payreq." };
}

// In addition to specifying the major version, you will also need to update how
// you serialize classes to Json as mentioned above. For example:

// Use `toJsonString()` when serializing to a Json response body.
let response: globalThis.Response;
try {
    response = await fetch(initialRequestData.lnurlpResponse.callback, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: payReq.toJsonString(),
    });
} catch (e) {
    return { httpStatus: 500, data: "Error sending payreq." };
}

// Use `fromJson()` when deserializing from a Json response body.
let payreq: uma.PayRequest;
try {
    payreq = uma.PayRequest.fromJson(requestBody);
} catch (e) {
    return {
        httpStatus: 500,
        data: new Error("Invalid UMA pay request.", { cause: e }),
    };
}

// There are similar functions available on the other message objects like
// LnurlpResponse, PayReqResponse, etc.
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:
let lnurlpRequest: uma.LnurlpRequest;
try {
    lnurlpRequest = uma.parseLnurlpRequest(requestUrl);
} catch (e: any) {
    if (e instanceof uma.UnsupportedVersionError) {
        // For unsupported versions, return a 412 "Precondition Failed" as per
        // the spec.
        return {
            httpStatus: 412,
            data: {
                supportedMajorVersions: e.supportedMajorVersions,
                unsupportedVersion: e.unsupportedVersion,
            },
        };
    }
    return {
        httpStatus: 500,
        data: new Error("Invalid lnurlp Query", { cause: e }),
    };
}

if (uma.isLnurlpRequestForUma(lnurlpRequest)) {
    // Handle the UMA-specific fields in the request like before.
    return this.handleUmaLnurlp(requestUrl, lnurlpRequest, user);
} else {
    // If you only support UMA, but not LNURL, you can return an error here.
    // Fall back to normal LNURLp.
    const callback = this.getLnurlpCallback(requestUrl, false, user);
    const metadata = this.getEncodedMetadata(requestUrl, user);
    return {
      httpStatus: 200,
      data: {
        callback: callback,
        maxSendable: 10_000_000,
        minSendable: 1_000,
        metadata: metadata,
        tag: "payRequest",
      },
    };
}

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

try {
    lnurlpResponse = uma.LnurlpResponse.fromJson(responseJson);
} catch (e) {
    return { httpStatus: 424, data: `Error parsing Lnurlp response. ${e}` };
}

if (!lnurlpResponse.isUma()) {
    // If you only support UMA, but not LNURL, you can return an error here.
    return await this.handleAsNonUmaLnurlpResponse(
        lnurlpResponse,
        receiverId,
        receivingVaspDomain,
    );
}

// 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.