LNURL
is a bech32-encoded HTTPS/Onion query string which is supposed to help payer interact with payee and thus simplify a number of standard scenarios such as:
- Requesting incoming channels
- Logging in
- Withdrawing funds
- Paying for a service
An example LNURL
:
https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df
would be bech32-encoded as:
LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS
and presented as the following QR:
Once LNURL
is decoded:
- If
tag
query parameter is present then thisLNURL
has a special meaning, further actions will be based ontag
parameter value. - Otherwise a GET request should be executed which must return a Json object containing a
tag
field, further actions will be based ontag
field value.
LNURL
is acceptable in two forms: either an https://
clearnet link (no self-signed certificates allowed) or an http://
v2/v3 onion link.
LNURL
can be used as fallback inside of other URI schemes, with the key 'lightning' and the value equal to the bech32-encoding, an example: https://service.com/giftcard/redeem?id=123&lightning=LNURL1...
In Scala:
import fr.acinq.bitcoin.Bech32
val bech32lnurl: String = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS"
val (hrp, dataPart) = Bech32.decode(bech32lnurl)
val requestByteArray = Bech32.five2eight(dataPart)
new String(requestByteArray, "UTF-8") // https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df
If you have any questions about implementing LNURL as a wallet or service, join us in the BLW Telegram and get help from the creators and other LNURL implementors.
Suppose user has a balance on a certain service which he wishes to turn into an incoming channel and service supports such functionality. This would require many parameters so the resulting QR may be overly dense and cause scanning issues. Additionally, the user has to make sure that a connection to target LN node is established before an incoming channel is requested.
Wallet to service interaction flow:
-
User scans a LNURL QR code or accesses an
lightning:LNURL..
link withLN WALLET
andLN WALLET
decodes LNURL. -
LN WALLET
makes a GET request toLN SERVICE
using the decoded LNURL. -
LN WALLET
gets Json response fromLN SERVICE
of form:{ uri: String, // Remote node address of form node_key@ip_address:port_number callback: String, // a second-level URL which would initiate an OpenChannel message from target LN node k1: String, // random or non-random string to identify the user's LN WALLET when using the callback URL tag: "channelRequest" // type of LNURL }
or
{"status":"ERROR", "reason":"error details..."}
-
LN WALLET
opens a connection to the target node usinguri
field. -
LN WALLET
issues a GET request toLN SERVICE
using<callback>?k1=<k1>&remoteid=<Local LN node ID>&private=<1/0>
-
LN SERVICE
sends a{"status":"OK"}
or{"status":"ERROR", "reason":"error details..."}
Json response. -
LN WALLET
awaits for incomingOpenChannel
message from the target node which would initiate a channel opening.
Wallet to service interaction flow:
-
User scans a LNURL QR code or accesses an
lightning:LNURL..
link withLN WALLET
andLN WALLET
decodes LNURL. -
LN WALLET
makes a GET request toLN SERVICE
using the decoded LNURL. -
LN WALLET
gets Json response fromLN SERVICE
of form:{ uri: String, // Remote node address of form node_key@ip_address:port_number k1: String, // a second-level hex encoded secret byte array to be used by wallet in `InvokeHostedChannel` message, may be random if Host has no use for it alias: String, // Optional remote node alias tag: "hostedChannelRequest" // type of LNURL }
or
{"status":"ERROR", "reason":"error details..."}
-
LN WALLET
opens a connection to the target node usinguri
field. -
Once connected,
LN WALLET
sends anInvokeHostedChannel
message to the target node usingk1
converted to byte array. -
The rest is handled by hosted channel protocol.
A special linkingKey
can be used to login user to a service or authorise sensitive actions. This preferrably should be done without compromising user identity so plain LN node key can not be used here. Instead of asking for user credentials a service could display a "login" QR code which contains a specialized LNURL
.
Server-side signature verification:
Once LN SERVICE
receives a call at the specified LNURL-auth
handler, it should take k1
, key
and a DER-encoded sig
and verify the signature using secp256k1
, storing somehow key
as the user identifier, either in a session, database or however it sees fit.
Key derivation for Bitcoin wallets:
Once "login" QR code is scanned linkingKey
derivation in user's LN WALLET
should happen as follows:
- There exists a private
hashingKey
which is derived by userLN WALLET
usingm/138'/0
path. LN SERVICE
domain name is extracted from loginLNURL
and then hashed usinghmacSha256(hashingKey, service domain name)
.- First 16 bytes are taken from resulting hash and then turned into a sequence of 4
Long
values which are in turn used to derive a service-specificlinkingKey
usingm/138'/<long1>/<long2>/<long3>/<long4>
path, a Scala example:
import fr.acinq.bitcoin.crypto
import fr.acinq.bitcoin.Protocol
import java.io.ByteArrayInputStream
import fr.acinq.bitcoin.DeterministicWallet._
val domainName = "site.com"
val hashingPrivKey = derivePrivateKey(walletMasterKey, hardened(138L) :: 0L :: Nil)
val derivationMaterial = hmac256(key = hashingPrivKey.toBin, message = domainName)
val stream = new ByteArrayInputStream(derivationMaterial.slice(0, 16).toArray)
val pathSuffix = Vector.fill(4)(Protocol.uint32(stream, ByteOrder.BIG_ENDIAN)) // each uint32 call consumes next 4 bytes
val linkingPrivKey = derivePrivateKey(walletMasterKey, hardened(138L) +: pathSuffix)
val linkingKey = linkingPrivKey.publicKey
LN WALLET
may choose to use a different derivation scheme but doing so will make it unportable. That is, users won't be able to switch to a different wallet and keep using a service bound to existing linkingKey
.
Wallet to service interaction flow:
-
LN WALLET
scans a QR code and decodes an URL which must contain the following query parameters:tag
with value set tologin
which means no GET should be made yet.k1
(hex encoded 32 bytes of challenge) which is going to be signed by user'slinkingPrivKey
.
-
LN WALLET
displays a "Login" dialog which must include a domain name extracted fromLNURL
query string. -
Once accepted, user
LN WALLET
signsk1
onsecp256k1
usinglinkingPrivKey
and DER-encodes the signature.LN WALLET
Then issues a GET toLN SERVICE
using<LNURL_hostname_and_path>?<LNURL_existing_query_parameters>&sig=<hex(sign(k1.toByteArray, linkingPrivKey))>&key=<hex(linkingKey)>
-
LN SERVICE
responds with the following Json once client signature is verified:{ status: "OK", event: "REGISTERED | LOGGEDIN | LINKED | AUTHED" // An optional enum indication of which exact action has happened, 3 listed types are supported }
or
{"status":"ERROR", "reason":"error details..."}
linkingKey
should henceforth be used as user identifier by service.
event
enums meaning:
REGISTERED
: service has created a new account linked to user providedlinkingKey
.LOGGEDIN
: service has found a matching existing account linked to user providedlinkingKey
.LINKED
service has linked a user providedlinkingKey
to user's existing account (if account was not originally created usinglnurl-auth
).AUTHED
: user was requesting some stateless action which does not require logging in (or possibly even prior registration) and that request was granted.
Today users are asked to provide a withdrawal Lightning invoice to a service, this requires some effort and is especially painful when user tries to withdraw funds into mobile wallet while using a desktop website. Instead of asking for Lightning invoice a service could display a "withdraw" QR code which contains a specialized LNURL
.
Wallet to service interaction flow:
-
User scans a LNURL QR code or accesses an
lightning:LNURL..
link withLN WALLET
andLN WALLET
decodes LNURL. -
LN WALLET
makes a GET request toLN SERVICE
using the decoded LNURL. -
LN WALLET
gets Json response fromLN SERVICE
of form:{ callback: String, // the URL which LN SERVICE would accept a withdrawal Lightning invoice as query parameter k1: String, // random or non-random string to identify the user's LN WALLET when using the callback URL maxWithdrawable: MilliSatoshi, // max withdrawable amount for a given user on LN SERVICE defaultDescription: String, // A default withdrawal invoice description minWithdrawable: MilliSatoshi // An optional field, defaults to 1 MilliSatoshi if not present, can not be less than 1 or more than `maxWithdrawable` tag: "withdrawRequest" // type of LNURL }
or
{"status":"ERROR", "reason":"error details..."}
-
LN WALLET
Displays a withdraw dialog where user can specify an exact sum to be withdrawn which would be bounded by:max can receive = min(maxWithdrawable, local estimation of how much can be routed into wallet) min can receive = max(minWithdrawable, local minimal value allowed by wallet)
-
Once accepted by the user,
LN WALLET
sends a GET toLN SERVICE
in the form of<callback>?k1=<k1>&pr=<lightning invoice, ...>
-
LN SERVICE
sends a{"status":"OK"}
or{"status":"ERROR", "reason":"error details..."}
JSON response and then attempts to pay the invoices asynchronously. -
LN WALLET
awaits for incoming payment if response was successful.
Note that service will withdraw funds to anyone who can provide a valid ephemeral k1
. In order to harden this a service may require autorization (LNURL-auth, email link etc.) before displaying a withdraw QR.
Additionally, LN SERVICE
and LN WALLET
developers can also choose to implement a fast LNURL-withdraw. This implementation reduces the steps involved in the process by putting the data that would be sent by LN SERVICE
in step 3. as query parameters of the LN SERVICE
URL accessed in step 1., before it is bech32-encoded.
Eg:
https://LNserviceURL
?tag=withdrawRequest
&k1=String
&minWithdrawable=MilliSatoshi
&maxWithdrawable=MilliSatoshi
&defaultDescription=String
&callback=String
This fast LNURL-withdraw method is not to be confused as an alternative to the original LNURL-withdraw, and is designed to be only be used for lightning:
-type links that work between apps. It is not suitable for QR code implementations.
If a LN SERVICE
developer chooses to implement fast LNURL-withdraw in their app, the encoded URL with query params must still return a Json response containing data that would be sent in step 3. when a GET request is made to it. This is required so as to be backwards-compatible with LN WALLET
s which have only implemented the original LNURL-withdraw method.
If a LN WALLET
developer chooses to implement fast LNURL-withdraw in their app, they will need to handle for both fast and original LNURL-withdraw methods as LN SERVICE
s mostly use the original method.
Wallet to service interaction flow:
-
User scans a LNURL QR code or accesses an
lightning:LNURL..
link withLN WALLET
andLN WALLET
decodes LNURL. -
LN WALLET
makes a GET request toLN SERVICE
using the decoded LNURL. -
LN WALLET
gets Json response fromLN SERVICE
of form:{ callback: String, // the URL from LN SERVICE which will accept the pay request parameters maxSendable: MilliSatoshi, // max amount LN SERVICE is willing to receive minSendable: MilliSatoshi, // min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable` metadata: String, // metadata json which must be presented as raw string here, this is required to pass signature verification at a later step tag: "payRequest" // type of LNURL }
or
{"status":"ERROR", "reason":"error details..."}
metadata
json array must contain onetext/plain
entry, all other types of entries are optional:[ [ "text/plain", // must always be present content // actual metadata content ], [ "image/png;base64", // optional 512x512px PNG thumbnail which will represent this lnurl in a list or grid content // base64 string, up to 136536 characters (100Kb of image data in base-64 encoding) ], [ "image/jpeg;base64", // optional 512x512px JPG thumbnail which will represent this lnurl in a list or grid content // base64 string, up to 136536 characters (100Kb of image data in base-64 encoding) ], ... // more objects for future types ]
and be sent as a string:
"[[\"text/plain\", \"lorem ipsum blah blah\"]]"
-
LN WALLET
displays a payment dialog where user can specify an exact sum to be sent which would be bounded by:max can send = min(maxSendable, local estimation of how much can be sent from wallet) min can send = max(minSendable, local minimal value allowed by wallet)
Additionally, a payment dialog must include:
- Domain name extracted from
LNURL
query string. - A way to view the metadata sent of
text/plain
format.
- Domain name extracted from
-
LN WALLET
makes a GET request using<callback>?amount=<milliSatoshi>&fromnodes=<nodeId1,nodeId2,...>
where
amount
is user specified sum in MilliSatoshi andfromnodes
is an optional parameter with value set to comma separatednodeId
s if payer wishes a service to provide payment routes starting from specified LNnodeId
s. -
LN Service
takes the GET request and returns JSON response of form:{ pr: String, // bech32-serialized lightning invoice successAction: Object or null, // An optional action to be executed after successfully paying an invoice disposabe: Boolean or null, // An optional flag to let a wallet know whether to persist `lightning:LNURL..` code from step 1 routes: [ [ { nodeId: String, channelUpdate: String // hex-encoded serialized ChannelUpdate gossip message }, ... // next hop ], ... // next route ] // array with payment routes, should be left empty if no routes are to be provided }
or
{"status":"ERROR", "reason":"error details..."}
pr
must have theh
tag (description_hash
) set tosha256(utf8ByteArray(metadata))
.Currently supported tags for
successAction
object areurl
,message
, andaes
. If there is no action thensuccessAction
value must be set tonull
.{ tag: String, // action type ... rest of fields depends on tag value }
Examples of
successAction
:{ tag: 'url' description: 'Thank you for your purchase. Here is your order details' // Up to 144 characters url: 'https://www.ln-service.com/order/<orderId>' // url domain must be the same as `callback` domain at step 3 } { tag: 'message' message: 'Thank you for using bike-over-ln CO! Your rental bike is unlocked now' // Up to 144 characters } { tag: 'aes' description: 'Here is your redeem code' // Up to 144 characters ciphertext: <base64> // an AES-encrypted data where encryption key is payment preimage, up to 4kb of characters iv: <base64> // initialization vector, exactly 24 characters }
-
LN WALLET
Verifies thath
tag in provided invoice is a hash ofmetadata
string converted to byte array in UTF-8 encoding. -
LN WALLET
Verifies that amount in provided invoice equals an amount previously specified by user. -
If routes array is not empty: verifies signature for every provided
ChannelUpdate
, may use these routes if fee levels are acceptable. -
If
successAction
is not null:LN WALLET
makes sure thattag
value of is of supported type, aborts a payment otherwise. -
LN WALLET
pays the invoice, no additional user confirmation is required at this point. -
Once payment is fulfilled
LN WALLET
excecutes a non-nullsuccessAction
. Formessage
, a toaster or popup is sufficient. Forurl
, the wallet should give the user a popup which displaysdescription
,url
, and a 'open' button to open theurl
in a new browser tab. Foraes
,LN WALLET
must attempt to decrypt aciphertext
with payment preimage.LN WALLET
should also storesuccessAction
data on the transaction record.
When client makes a first call
Construct a metadata object, turn it into json, then include in into parent json as string.
When client makes a second call
- Make a hash as follows:
sha256(utf8ByteArray(unescaped_metadata_string))
. - Generate a payment request using an obtained hash.
Used encryption type is 256-bit AES in AES/CBC/PKCS5Padding
mode.
An encryption example in Scala:
val iv = Tools.random.getBytes(16) // exactly 16 bytes, unique for each secret
val key = Tools.random.getBytes(32) // payment preimage
val data = "Secret data".getBytes
val aesCipher = Cipher getInstance "AES/CBC/PKCS5Padding"
val ivParameterSpec = new IvParameterSpec(iv)
aesCipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), ivParameterSpec)
val cipherbytes = aesCipher.doFinal(data)
val ciphertext64 = ByteVector.view(cipherbytes).toBase64 // Base 64 alphabet as defined by http://tools.ietf.org/html/rfc4648#section-4 RF4648 section 4. Whitespace is ignored.
val iv64 = ByteVector.view(iv).toBase64 // 16 bytes results in exactly 24 characters