UMA Playground Quickstart

Welcome to the UMA Playground Quickstart Guide. UMA lets anyone send and receive fiat and crypto with real time settlement over the Lightning Network. This guide will walk you through an example UMA payment experience and corresponding backend API requests.
For each step in the UMA experience, we'll provide a quick overview (e.g. how to create a UMA in the Playground UI) and a simplified technical explanation with code snippets. We'll also link to additional documentation for those looking for in depth details.
The UMA Playground is written in TypeScript and hosted on Replit (a cloud-based IDE), you can fork, modify and tweak it without downloading any files. We encourage you to copy our code for your own integration! We've built SDKs in Python, Rust, Go, Java, and Kotlin to speed up your implementation. To simplify sending and receiving Lightning Network payments, the UMA Playground integrates Lightspark APIs. The UMA protocol is implementation-agnostic, meaning you can use your preferred Lightning Network implementation, such as LND, LDK, or C-Lightning, when building your own integration.
  1. Fork this repo on Replit
  2. Follow the instructions in the README file
  3. You should see the UMA Playground UI in the webview tab
Picture of Playground home UI
Success! You've setup the UMA Playground.
Before we create a UMA, lets understand its structure$gemma@somevasp.com:
  1. The domain somevasp.com identifies the VASP where Gemma's account resides. $gemma is the user identifier at the VASP.
  2. The sending VASP can lookup the receiver by sending a LNURLP request to: https://{domain}/.well-known/lnurlp/{user identifier} in this case https://somevasp.com/.well-known/lnurlp/$gemma.
More details can be found in the UMA Addresses documentation. Now that we understand UMA structure, let's set up your test UMA on the playground. This creates a test account in memory that you can use to send and receive payments. In a real implementation, you'll tie the UMA to the customer record identifying the customer and associated account.
Screenshot of creating UMA home UI
The UMA Playground leverages both the UMA Typescript SDK to create and handle UMA requests and the Lightspark SDK to interact with the Lightning Network. The Lightspark SDK client is instantiated in startServer.ts passing Lightspark API credentials.
import {
  AccountTokenAuthProvider,
  LightsparkClient,
} from "@lightsparkdev/lightspark-sdk";

const client = new LightsparkClient(
  new AccountTokenAuthProvider(CLIENT_ID, CLIENT_SECRET),
);
You can easily distinguish which SDK is being used in the code snippets: methods prefixed with uma belong to the UMA SDK, while those prefixed with client are from the Lightspark SDK.
On the playground, we provide a fake fiat balance that's stored in memory. You can use this test balance to send payments to other UMAs on Lightspark's REGTEST network.
You can also fund your REGTEST account by calling fundNode.
const fundNodeOutput = await client.fundNode(NODE_ID, 200000);
if (!fundNodeOutput) {
  throw new Error("Unable to fund node");
}
In your production application, you'll leverage your ledger balance. In the playground UI, you can add funds to the user balance by clicking the "add funds" button.
Screenshot of "add funds" UI
Click send to initiate the send payment flow, then select a recipient from a list of mock recipients. UMA enables payment in any currency supported by the receiver.
Once a recipient has been selected, enter the amount you intend to send. You can lock the amount to send or the amount that your user will receive. The UI displays a conversion based on mock exchange rates between fiat and BTC at the sending and receiving VASPS.
Screenshot of "send payment" UI
Finally, you will see a confirmation page that includes useful details about the transaction like the conversion rate and transaction fee before confirming payment. Once you click continue, the payment is sent. You can view the sequence of UMA requests in the activity log on the home screen.
The "Send Payment" flow involves several steps and interactions between the sending VASP, receiving VASP, compliance providers, and the Lightning Network. Here's a look at what happens behind the scenes sequence diagram.
Once you select the recipient, the transaction process begins with an LNURLP request from the sending VASP to the receiving VASP. This initial step serves several purposes:
  1. Verify the recipient's existence
  2. Determine the exchange rate
  3. Establish transaction limits
  4. Identify compliance requirements
The LNURLP request contains data such as UMA version, signature, sending VASP domain and if Travel Rule applies. You can find additional details in the Initial LNURLP Request documentation. In the playground, this request is initiated from sendingVasp.ts with:
lnurlpRequestUrl = await uma.getSignedLnurlpRequestUrl({
  isSubjectToTravelRule: true,
  receiverAddress: receiverUmaAddress,
  signingPrivateKey: this.config.umaSigningPrivKey(),  
  senderVaspDomain: this.getSendingVaspDomain(requestUrl),
});

let response = await fetch(lnurlpRequestUrl);
Next, the receiving VASP responds with details for the transaction, including the recipient's preferred currency, estimated exchange rates, compliance requirements, and any applicable transaction limits. In this playground, this request is parsed in receivingVasp.ts like the following:
const lnurlpResponse = uma.parseLnurlpResponse(await response.text());
Each VASP needs to verify and authenticate the source of the inbound request. UMA uses signing key pairs for this process. Once the requester is verified, the flow can proceed.
As detailed in our Fetching Keys and Verifying Signatures documentation, you can generate the encryption and signing keys using any method you prefer, as long as they follow the secp256k1 elliptic curve standard.
The UmaConfig class (defined in the vasp-server folder) handles the configuration for the UMA setup, including storing and providing access to the encryption and signing keys. It also manages other essential settings, such as the Lightspark API credentials and node ID. Remember to keep your private keys secure.
Upon receiving the LNURLP request, the receiving VASP needs to verify the request came from the sending VASP. To do this, the receiving VASP fetches the public key of the sending VASP to verify the signature of the LNURL request. The simplest version happens in two parts: Use the UMA SDK to fetch the public key, then verify the signature. You can use a cache to avoid unnecessary network calls. You can find more details in the Fetching and Verifying documentation.
if (!isLnurlpRequestForUma(request)) {
  return;
}
let pubKeys: uma.PubKeyResponse;
try {
  pubKeys = await uma.fetchPublicKeyForVasp({
    cache: this.pubKeyCache,
    vaspDomain: umaQuery.vaspDomain,
  });
} catch (e) {
  console.error(e);
  return {
    httpStatus: 500,
    data: new Error("Failed to fetch public key.", { cause: e }),
  };
}
After fetching the public key, the receiving VASP verifies the signature like the following:
try {
  const isSignatureValid = await uma.verifyUmaLnurlpResponseSignature(
    lnurlpResponse,
    pubKeys,
    this.nonceCache,
  );
  if (!isSignatureValid) {
    return { httpStatus: 424, data: "Invalid UMA response signature." };
  }
} catch (e) {
  console.error("Error verifying UMA response signature.", e);
  return {
    httpStatus: 424,
    data: new Error("Error verifying UMA response signature.", {
      cause: e,
    }),
  };
}
Upon receiving the LNURL-pay response, the sender can specify the desired payment amount. The UMA Playground then sends a Payreq request to the receiving VASP.
The UMA Playground uses uma.getPayRequest method to create a payment request object. The object includes the amount, currency, requested sender compliance data, and can request receiver compliance data. The request object is defined in the Payreq Request documentation. The request is sent to the receiving VASP's callback URL defined in the LNURLP response. The below snippet generates a PayRequest:
let payReq: uma.PayRequest;
try {
  payReq = await uma.getPayRequest({
    receiverEncryptionPubKey: pubKeys.getEncryptionPubKey(),
    sendingVaspPrivateKey: this.config.umaSigningPrivKey(),
    receivingCurrencyCode: receivingCurrencyCode,
    isAmountInReceivingCurrency: !isAmountInMsats,
    amount: amount,
    payerIdentifier: payerProfile.identifier!,
    payerKycStatus: user.kycStatus,
    utxoCallback,
    trInfo,
    payerUtxos: node.umaPrescreeningUtxos,
    payerNodePubKey: node.publicKey ?? "",
    payerName: payerProfile.name,
    payerEmail: payerProfile.email,
    requestedPayeeData: {
      // Compliance and Identifier are mandatory fields added automatically.
      name: { mandatory: false },
      email: { mandatory: false },
    },
    umaMajorVersion: initialRequestData.lnurlpResponse.umaVersion
      ? uma.getMajorVersion(initialRequestData.lnurlpResponse.umaVersion)
      : 1,
  });
} catch (e) {
  console.error("Error generating payreq.", e);
  return { httpStatus: 500, data: "Error generating payreq." };
}
The receiving VASP uses the information provided in the PayReqRequest to construct a Lightning invoice and PayReqResponse. You can use the UmaInvoiceCreatorfrom our SDK to create the invoice. Once that is done, you need to create the PayReqResponse. The response object is defined by this schema. On the playground, the payreq response with the following snippet:
// 1 minute invoice expiration to avoid big fluctuations in exchange rate.
const expirationTimeSec = 60;
// In a real implementation, this would be the txId for your own internal
// tracking in post-transaction hooks.
const txId = "1234";
const umaInvoiceCreator = {
  createUmaInvoice: async (amountMsats: number, metadata: string) => {
    const invoice = await this.lightsparkClient.createUmaInvoice(
      this.config.nodeID,
      Math.round(amountMsats),
      metadata,
      expirationTimeSec,
    );
    return invoice?.data.encodedPaymentRequest;
  },
};

// Construct Payreq Response
let response: uma.PayReqResponse;
try {
  response = await uma.getPayReqResponse({
    request: payreq,
    conversionRate: receivingCurrency.multiplier,
    receivingCurrencyCode: receivingCurrency.code,
    receivingCurrencyDecimals: receivingCurrency.decimals,
    invoiceCreator: umaInvoiceCreator,
    metadata: this.getEncodedMetadata(requestUrl, user),
    receiverChannelUtxos: [],
    receiverFeesMillisats: receiverFeesMillisats,
    receiverNodePubKey: isUmaRequest
      ? await this.getReceiverNodePubKey()
      : undefined,
    utxoCallback: isUmaRequest
      ? this.getUtxoCallback(requestUrl, txId)
      : undefined,
    payeeData: payeeData,
    receivingVaspPrivateKey: isUmaRequest
      ? this.config.umaSigningPrivKey()
      : undefined,
    payeeIdentifier: `$${user.umaUserName}@${hostNameWithPort(requestUrl)}`,
  });
  return { httpStatus: 200, data: response.toJsonSchemaObject() };
} catch (e) {
  console.log(`Failed to generate UMA response: ${e}`);
  console.error(e);
  return {
    httpStatus: 500,
    data: new Error("Failed to generate UMA response.", { cause: e }),
  };
}
Before processing a payment, internal and external compliance checks must pass:
  1. Internal Compliance: Verify that the transaction details and user data meet your organization's compliance requirements as defined by your compliance team.
  2. External Screening: Utilize node public keys and UTXOs to perform additional screening with external compliance providers.
Ensure that your compliance checks are comprehensive and align with both your internal policies and regulatory requirements. The specific checks and their implementation will depend on your organization's needs and the regulatory landscape in which you operate. The UMA Playground mocks the compliance check like the following:
const shouldTransact = await this.complianceService.preScreenTransaction(
  payerProfile.identifier,
  `${initialRequestData.receiverId}@${initialRequestData.receivingVaspDomain}`,
  amountValueMillisats,
  payResponse.compliance.nodePubKey,
  payResponse.compliance.utxos,
);
if (!shouldTransact) {
  return {
    httpStatus: 424,
    data: "Transaction not allowed due to risk rating.",
  };
}
Next, the UMA Playground pays the Lightning invoice. This step converts the sender's funding currency to Bitcoin to transfer over Lightning. The UMA Playground uses the Lightspark SDK to interact with Lightning and pays the invoice pr with the following snippet:
let paymentResult;
try {
  paymentResult = await client.payUmaInvoice(
    config.nodeID,
    payReq.pr,
    /* maximumFeesMsats */ 1000000,
  );
  if (!paymentResult) {
    throw new Error("Payment request failed.");
  }
} catch (e) {
  console.error("Error paying invoice.", e);
  return { httpStatus: 500, data: "Error paying invoice." };
}
If you're not using Lightspark as your Lightning implementation, you can find more information here.
Once the payment is completed on the Lightning Network, the receiving VASP calls the sending VASP post transaction callback url to pass any post-transaction compliance data. This data is registered for transaction monitoring and other compliance purposes. Again, this is a natural place to integrate your own compliance provider methods.
await this.complianceService.registerTransactionMonitoring(
  payment.id,
  nodePubKey,
  PaymentDirection.SENT,
  payment.umaPostTransactionData ?? [],
);

// Confirm payment success
return {
  httpStatus: 200,
  data: {
    paymentId: payment.id,
    didSucceed: payment.status === TransactionStatus.SUCCESS,
  },
};
In this quickstart guide, we walked you through setting up an UMA, creating an account, depositing funds, and sending UMA payments. By leveraging theUMA SDKs, you can simplify cross-border payments, ensuring compliance and security while providing a seamless experience for your users. Our developer playground demonstrates the core functionality and can be easily customized to fit your use cases.