Skip to content

Commit

Permalink
Add support for sending to Lightning Addresses (LUD-16)
Browse files Browse the repository at this point in the history
  • Loading branch information
hsjoberg committed Aug 20, 2021
1 parent 7a04403 commit 2843ed1
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 38 deletions.
93 changes: 55 additions & 38 deletions src/state/LNURL.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
//
// Sorry for the condition in this file.
// Reach out to me on Telegram @hsjoberg or lnurl mafia group https://t.me/lnurl if you have any questions.
//
import { Action, action, Thunk, thunk } from "easy-peasy";
import * as Bech32 from "bech32";
import { Hash as sha256Hash, HMAC as sha256HMAC } from "fast-sha256";
Expand All @@ -7,7 +11,6 @@ import { IStoreModel } from "./index";
import { IStoreInjections } from "./store";
import { timeout, bytesToString, getDomainFromURL, stringToUint8Array, hexToUint8Array, bytesToHexString } from "../utils/index";

import Long from "long";
import { lnrpc } from "../../proto/proto";
import { LndMobileEventEmitter } from "../utils/event-listener";

Expand All @@ -16,6 +19,8 @@ const log = logger("LNURL");

export type LNURLType = "channelRequest" | "login" | "withdrawRequest" | "payRequest" | "unknown" | "error" | "unsupported";

export type LightningAddress = string;

export interface ILNUrlChannelRequest {
uri: string;
callback: string;
Expand Down Expand Up @@ -129,6 +134,8 @@ export interface ILNUrlModel {
lnUrlObject: LNUrlRequest | undefined;

clear: Action<ILNUrlModel>;

resolveLightningAddress: Thunk<ILNUrlModel, LightningAddress>;
};

export const lnUrl: ILNUrlModel = {
Expand Down Expand Up @@ -260,42 +267,6 @@ export const lnUrl: ILNUrlModel = {
const signedMessage = secp256k1.ecdsaSign(hexToUint8Array(lnUrlObject.k1), linkingKeyPriv);
const signedMessageDER = secp256k1.signatureExport(signedMessage.signature)

/*
// FOLLOWS THE LNURL-AUTH SPECIFICATION
// https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-auth.md
// Key derivation for Bitcoin wallets:
// 1. There exists a private hashingKey which is derived by user LN WALLET using m/138'/0 path.
const hashingKey = await injections.lndMobile.wallet.derivePrivateKey(138, 0);
// const hashingKeyPub = await injections.lndMobile.wallet.deriveKey(138, 0);
// 2. LN SERVICE domain name is extracted from login LNURL and then hashed using hmacSha256(hashingKey, service domain name).
const domain = getDomainFromURL(lnUrlStr);
const hmac = new sha256HMAC(hashingKey.rawKeyBytes);
const derivationMaterial = hmac.update(stringToUint8Array(domain)).digest();
// 3. 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-specific linkingKey using m/138'/<long1>/<long2>/<long3>/<long4> path
// ?? walletrpc.DerviceKey does not allow deriving from such specific path
// 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.
// We cannot derive the correct key in lnd so we are taking the 4 first bytes from the derivationMaterial
// and using that instead
const first4 = derivationMaterial.slice(0, 4);
const keyIndex = Long.fromBytesBE(first4 as unknown as number[], true).toNumber();
const linkingKey = await injections.lndMobile.wallet.derivePrivateKey(138, keyIndex);
log.d("key derived from family and index", [linkingKey.rawKeyBytes, 138, keyIndex]);
// Wallet to service interaction flow:
// 1, 2 omitted
// 3. Once accepted, user LN WALLET signs k1 on secp256k1 using linkingPrivKey and DER-encodes the signature.
const signedMessage = secp256k1.ecdsaSign(hexToUint8Array(lnUrlObject.k1), linkingKey.rawKeyBytes);
const signedMessageDER = secp256k1.signatureExport(signedMessage.signature);
const linkingKeyPub = secp256k1.publicKeyCreate(linkingKey.rawKeyBytes, true);
// const signedMessageDER = (await signMessage(138, keyIndex, hexToUint8Array(lnUrlObject.k1))).signature;
// const linkingKeyPub = (await deriveKey(138, keyIndex)).rawKeyBytes;
// log.d("signedMessageDER", [signedMessageDER]);
// log.d("linkingKeyPub", [linkingKeyPub]);
*/

// LN WALLET Then issues a GET to LN SERVICE using
// <LNURL_hostname_and_path>?<LNURL_existing_query_parameters>&sig=<hex(sign(k1.toByteArray, linkingPrivKey))>&key=<hex(linkingKey)>
const url = (
Expand All @@ -319,7 +290,7 @@ export const lnUrl: ILNUrlModel = {
if (response.status === "OK") {
return true;
}
throw new Error(response.reason!);
throw new Error(response.reason! ?? "Invalid response: " + JSON.stringify(response));
}
else {
throw new Error("Requirements not satisfied, type must be login and lnUrlObject must be set");
Expand Down Expand Up @@ -476,6 +447,52 @@ export const lnUrl: ILNUrlModel = {
state.lnUrlObject = undefined;
}),

resolveLightningAddress: thunk(async (actions, lightningAddress) => {
actions.clear();
// https://github.com/fiatjaf/lnurl-rfc/blob/luds/16.md
// The idea here is that a SERVICE can offer human-readable addresses for users or specific internal endpoints
// that use the format <username>@<domainname>, e.g. [email protected]. A user can then type these on a WALLET.
//
// Upon seeing such an address, WALLET makes a GET request to
// https://<domain>/.well-known/lnurlp/<username> endpoint if domain is clearnet or http://<domain>/.well-known/lnurlp/<name> if domain is onion.
// For example, if the address is [email protected], the request is to be made to https://bitcoin.org/.well-known/lnurlp/satoshi.
const [username, domain] = lightningAddress.toLowerCase().split("@");
if (domain == undefined) {
throw new Error("Invalid Lightning Address");
}

// Normal LNURL fetch request follows:
const lnurlPayUrl = `http://${domain}/.well-known/lnurlp/${username}`;
actions.setLNUrlStr(lnurlPayUrl);
const result = await fetch(lnurlPayUrl);

if (!result.ok) {
let error;
try {
error = await result.json();
} catch {
log.i("error", [result]);
throw new Error("Could not pay");
}
throw new Error(error.reason ?? "Could not pay");
}

const lnurlObject: LNUrlRequest | ILNUrlPayResponseError = await result.json();

if (isLNUrlPayResponseError(lnurlObject)) {
log.e("Got error")
throw new Error(lnurlObject.reason);
}

log.v(JSON.stringify(lnurlObject));
if (lnurlObject.tag === "payRequest") {
actions.setType("payRequest");
actions.setLNUrlObject(lnurlObject);
return true;
}
return false;
}),

lnUrlStr: undefined,
type: undefined,
lnUrlObject: undefined,
Expand Down
5 changes: 5 additions & 0 deletions src/windows/Send/SendCamera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function SendCamera({ navigation, route }: ISendCameraProps) {
const [cameraType, setCameraType] = useState<"front" | "back">("back");
const [scanning, setScanning] = useState(true);
const setLNURL = useStoreActions((store) => store.lnUrl.setLNUrl)
const resolveLightningAddress = useStoreActions((store) => store.lnUrl.resolveLightningAddress)
const lnurlClear = useStoreActions((store) => store.lnUrl.clear);
const [cameraActive, setCameraActive] = useState(route.params?.viaSwipe ?? true);

Expand Down Expand Up @@ -124,6 +125,10 @@ export default function SendCamera({ navigation, route }: ISendCameraProps) {
lnurlClear();
}
} catch (e) { }
} else if (paymentRequest.includes("@")) {
if (await resolveLightningAddress(paymentRequest)) {
gotoNextScreen("LNURL", { screen: "PayRequest" }, false);
}
}
else {
try {
Expand Down
37 changes: 37 additions & 0 deletions src/windows/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,36 @@ Do you wish to proceed?`;
navigation.navigate("LndMobileHelpCenter");
}

// Pay to Lightning Address
const resolveLightningAddress = useStoreActions((store) => store.lnUrl.resolveLightningAddress);
const onPressPayToLightningAddress = () => {
Alert.prompt(
"Lightning Address",
"Enter Lightning Address ([email protected])",
[{
text: "Cancel",
style: "cancel",
onPress: () => {},
}, {
text: "Ok",
onPress: async (text) => {
console.log("hello")
try {
if (await resolveLightningAddress(text ?? "")) {
navigation.push("LNURL", { screen: "PayRequest" }, false);
}
} catch (error) {
console.log("catch")
Alert.alert("Cannot resolve Lightning Address", error.message);
}
},
}],
"plain-text",
undefined,
"email-address",
);
};

// Setup demo environment
const setupDemo = useStoreActions((store) => store.setupDemo);

Expand Down Expand Up @@ -830,6 +860,13 @@ Do you wish to proceed?`;
</Body>
</ListItem>
}
<ListItem style={style.listItem} icon={true} onPress={onPressPayToLightningAddress}>
<Left><Icon style={style.icon} type="Feather" name="send" /></Left>
<Body>
<Text>Pay to a Lightning Address</Text>
</Body>
</ListItem>


<ListItem style={style.itemHeader} itemHeader={true} first={true}>
<Text>Wallet</Text>
Expand Down

1 comment on commit 2843ed1

@vercel
Copy link

@vercel vercel bot commented on 2843ed1 Aug 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.