diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts index 8ec4bf1..63446fe 100644 --- a/e2e/routes.spec.ts +++ b/e2e/routes.spec.ts @@ -22,7 +22,6 @@ const settingsRoutes = [ "/emergencykit", "/encrypt", "/gift", - "/lnurlauth", "/plus", "/restore", "/servers", @@ -116,10 +115,6 @@ test("visit each route", async ({ page }) => { ); await page.goBack(); - // LNURL Auth - await checkRoute(page, "/settings/lnurlauth", "LNURL Auth", checklist); - await page.goBack(); - // Sync Nostr Contacts await checkRoute( page, diff --git a/src/components/HomePrompt.tsx b/src/components/HomePrompt.tsx new file mode 100644 index 0000000..563c4f8 --- /dev/null +++ b/src/components/HomePrompt.tsx @@ -0,0 +1,147 @@ +import { useSearchParams } from "@solidjs/router"; +import { createEffect, createSignal, Show } from "solid-js"; + +import { Button, InfoBox, NiceP, SimpleDialog, VStack } from "~/components"; +import { useI18n } from "~/i18n/context"; +import { useMegaStore } from "~/state/megaStore"; +import { bech32, bech32WordsToUrl, eify } from "~/utils"; + +const ImageWithFallback = (props: { src: string; alt: string }) => { + const [hasError, setHasError] = createSignal(false); + + const handleError = () => { + setHasError(true); + }; + + return ( + + ); +}; + +export function HomePrompt() { + const [state, _actions] = useMegaStore(); + const i18n = useI18n(); + + const [params, setParams] = useSearchParams(); + + // When we have a nice result we can head over to the send screen + createEffect(() => { + if (params.lnurlauth) { + const lnurlauth = params.lnurlauth; + setParams({ lnurlauth: undefined }); + setLnurlauthResult(lnurlauth); + try { + const decodedLnurl = bech32.decode(lnurlauth, 1023); + const url = bech32WordsToUrl(decodedLnurl.words); + const domain = new URL(url).hostname; + setLnurlDomain(domain); + } catch (e) { + // We care about this error the domain is just to make it pretty + console.error(e); + } + return; + } + + if (params.fedimint_invite) { + const fedimint_invite = params.fedimint_invite; + setParams({ fedimint_invite: undefined }); + setFedi(fedimint_invite); + return; + } + }); + + // Fedi stuff + // fed11qgqzc2nhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8garhduhx6at5d9h8jmn9wshxxmmd9uqqzgxg6s3evnr6m9zdxr6hxkdkukexpcs3mn7mj3g5pc5dfh63l4tj6g9zk4er + const [fedi, setFedi] = createSignal(); + + // Lnurl Auth stuff + const [lnurlauthResult, setLnurlauthResult] = createSignal(); + const [authLoading, setAuthLoading] = createSignal(false); + const [isAuthenticated, setIsAuthenticated] = createSignal(false); + const [authError, setAuthError] = createSignal( + undefined + ); + const [lnurlDomain, setLnurlDomain] = createSignal(""); + + async function handleLnurlAuth() { + setAuthLoading(true); + try { + await state.mutiny_wallet?.lnurl_auth(lnurlauthResult()!); + setIsAuthenticated(true); + } catch (e) { + // lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9ashq6f0d3hxzat5dqlhgct884kx7emfdcnxkvfavvurwdtrvgmkyd3489skgcfexqckxd3svg6xgwr98q6nsd3c893kzcfkvc6nsdr9xpjxvc3jvejrxwpevyurqvfev3nxvvnxx5ergdc8g6gzl + console.error(e); + setAuthError(eify(e)); + } finally { + setAuthLoading(false); + } + } + + return ( + <> + setFedi(undefined)} + > + {i18n.t("modals.fedi_invite.description")} + + { + if (!open) setLnurlauthResult(undefined); + }} + > + + + + {lnurlDomain()} + + + + + + {i18n.t("modals.lnurl_auth.login")} + + setLnurlauthResult(undefined)} + > + {i18n.t("modals.lnurl_auth.decline")} + + + + + + + + {i18n.t("modals.lnurl_auth.authenticated")} + + + + + + + {i18n.t("modals.lnurl_auth.error")} + + + + > + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 0323d92..481925c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -46,3 +46,4 @@ export * from "./GiftLink"; export * from "./MutinyPlusCta"; export * from "./ToggleHodl"; export * from "./IOSbanner"; +export * from "./HomePrompt"; diff --git a/src/i18n/en/translations.ts b/src/i18n/en/translations.ts index b1f4b85..df90f5a 100644 --- a/src/i18n/en/translations.ts +++ b/src/i18n/en/translations.ts @@ -703,6 +703,17 @@ export default { are_you_sure: "Are you sure?", cancel: "Cancel", confirm: "Confirm" + }, + lnurl_auth: { + auth_request: "Authentication Request", + login: "Login", + decline: "Decline", + error: "That didn't work for some reason.", + authenticated: "Authenticated!" + }, + fedi_invite: { + title: "Fedimint Invite", + description: "Fedimint support coming soon!" } } }; diff --git a/src/logic/waila.ts b/src/logic/waila.ts index d89658f..814bbd4 100644 --- a/src/logic/waila.ts +++ b/src/logic/waila.ts @@ -16,6 +16,8 @@ export type ParsedParams = { lnurl?: string; lightning_address?: string; nostr_wallet_auth?: string; + fedimint_invite?: string; + is_lnurl_auth?: boolean; }; export function toParsedParams( @@ -58,7 +60,9 @@ export function toParsedParams( node_pubkey: params.node_pubkey, lnurl: params.lnurl, lightning_address: params.lightning_address, - nostr_wallet_auth: params.nostr_wallet_auth + nostr_wallet_auth: params.nostr_wallet_auth, + is_lnurl_auth: params.is_lnurl_auth, + fedimint_invite: params.fedimint_invite_code } }; } diff --git a/src/router.tsx b/src/router.tsx index 6111c5f..36b1d0e 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -26,7 +26,6 @@ import { EmergencyKit, Encrypt, Gift, - LnUrlAuth, Plus, Restore, Servers, @@ -112,7 +111,6 @@ export function Router() { - diff --git a/src/routes/Main.tsx b/src/routes/Main.tsx index 16b4647..010b89f 100644 --- a/src/routes/Main.tsx +++ b/src/routes/Main.tsx @@ -10,6 +10,7 @@ import { CombinedActivity, DecryptDialog, DefaultMain, + HomePrompt, IOSbanner, LoadingIndicator, LoadingShimmer, @@ -106,6 +107,7 @@ export function Main() { + ); diff --git a/src/routes/settings/LnUrlAuth.tsx b/src/routes/settings/LnUrlAuth.tsx deleted file mode 100644 index dc505dc..0000000 --- a/src/routes/settings/LnUrlAuth.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { TextField } from "@kobalte/core"; -import { createSignal } from "solid-js"; - -import { - BackLink, - Button, - DefaultMain, - InnerCard, - LargeHeader, - MutinyWalletGuard, - NavBar, - SafeArea -} from "~/components"; -import { useI18n } from "~/i18n/context"; -import { useMegaStore } from "~/state/megaStore"; - -export function LnUrlAuth() { - const i18n = useI18n(); - const [state, _] = useMegaStore(); - - const [value, setValue] = createSignal(""); - - const onSubmit = async (e: SubmitEvent) => { - e.preventDefault(); - - const lnurl = value().trim(); - await state.mutiny_wallet?.lnurl_auth(lnurl); - - setValue(""); - }; - - return ( - - - - - - {i18n.t("settings.lnurl_auth.title")} - - - - - - {i18n.t("settings.lnurl_auth.title")} - - - - {i18n.t("settings.lnurl_auth.expected")} - - - - {i18n.t("settings.lnurl_auth.auth")} - - - - - - - - ); -} diff --git a/src/routes/settings/Root.tsx b/src/routes/settings/Root.tsx index 54b6216..ef34fa8 100644 --- a/src/routes/settings/Root.tsx +++ b/src/routes/settings/Root.tsx @@ -148,10 +148,6 @@ export function Settings() { ? i18n.t("settings.gift.no_plus_caption") : undefined }, - { - href: "/settings/lnurlauth", - text: i18n.t("settings.lnurl_auth.title") - }, { href: "/settings/syncnostrcontacts", text: "Sync Nostr Contacts" diff --git a/src/routes/settings/index.ts b/src/routes/settings/index.ts index 35c5ff2..7430800 100644 --- a/src/routes/settings/index.ts +++ b/src/routes/settings/index.ts @@ -7,7 +7,6 @@ export * from "./Currency"; export * from "./EmergencyKit"; export * from "./Encrypt"; export * from "./Gift"; -export * from "./LnUrlAuth"; export * from "./Plus"; export * from "./Restore"; export * from "./Servers"; diff --git a/src/state/megaStore.tsx b/src/state/megaStore.tsx index d4cd376..bbc206c 100644 --- a/src/state/megaStore.tsx +++ b/src/state/megaStore.tsx @@ -299,7 +299,7 @@ export const Provider: ParentComponent = (props) => { } } }, - setScanResult(scan_result: ParsedParams) { + setScanResult(scan_result: ParsedParams | undefined) { setState({ scan_result }); }, setHasBackedUp() { @@ -361,12 +361,25 @@ export const Provider: ParentComponent = (props) => { result.value?.address || result.value?.invoice || result.value?.node_pubkey || - result.value?.lnurl + (result.value?.lnurl && !result.value.is_lnurl_auth) ) { if (onSuccess) { onSuccess(result.value); } } + if (result.value?.lnurl && result.value?.is_lnurl_auth) { + navigate( + "/?lnurlauth=" + encodeURIComponent(result.value?.lnurl) + ); + actions.setScanResult(undefined); + } + if (result.value?.fedimint_invite) { + navigate( + "/?fedimint_invite=" + + encodeURIComponent(result.value?.fedimint_invite) + ); + actions.setScanResult(undefined); + } if (result.value?.nostr_wallet_auth) { console.log( "nostr_wallet_auth", diff --git a/src/utils/bech32.ts b/src/utils/bech32.ts new file mode 100644 index 0000000..45983f5 --- /dev/null +++ b/src/utils/bech32.ts @@ -0,0 +1,243 @@ +// pasted from https://github.com/bitcoinjs/bech32/ +const ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +const ALPHABET_MAP: { [key: string]: number } = {}; +for (let z = 0; z < ALPHABET.length; z++) { + const x = ALPHABET.charAt(z); + ALPHABET_MAP[x] = z; +} + +function polymodStep(pre: number): number { + const b = pre >> 25; + return ( + ((pre & 0x1ffffff) << 5) ^ + (-((b >> 0) & 1) & 0x3b6a57b2) ^ + (-((b >> 1) & 1) & 0x26508e6d) ^ + (-((b >> 2) & 1) & 0x1ea119fa) ^ + (-((b >> 3) & 1) & 0x3d4233dd) ^ + (-((b >> 4) & 1) & 0x2a1462b3) + ); +} + +function prefixChk(prefix: string): number | string { + let chk = 1; + for (let i = 0; i < prefix.length; ++i) { + const c = prefix.charCodeAt(i); + if (c < 33 || c > 126) return "Invalid prefix (" + prefix + ")"; + + chk = polymodStep(chk) ^ (c >> 5); + } + chk = polymodStep(chk); + + for (let i = 0; i < prefix.length; ++i) { + const v = prefix.charCodeAt(i); + chk = polymodStep(chk) ^ (v & 0x1f); + } + return chk; +} + +function convert( + data: ArrayLike, + inBits: number, + outBits: number, + pad: true +): number[]; +function convert( + data: ArrayLike, + inBits: number, + outBits: number, + pad: false +): number[] | string; +function convert( + data: ArrayLike, + inBits: number, + outBits: number, + pad: boolean +): number[] | string { + let value = 0; + let bits = 0; + const maxV = (1 << outBits) - 1; + + const result: number[] = []; + for (let i = 0; i < data.length; ++i) { + value = (value << inBits) | data[i]; + bits += inBits; + + while (bits >= outBits) { + bits -= outBits; + result.push((value >> bits) & maxV); + } + } + + if (pad) { + if (bits > 0) { + result.push((value << (outBits - bits)) & maxV); + } + } else { + if (bits >= inBits) return "Excess padding"; + if ((value << (outBits - bits)) & maxV) return "Non-zero padding"; + } + + return result; +} + +function toWords(bytes: ArrayLike): number[] { + return convert(bytes, 8, 5, true); +} + +function fromWordsUnsafe(words: ArrayLike): number[] | undefined { + const res = convert(words, 5, 8, false); + if (Array.isArray(res)) return res; +} + +function fromWords(words: ArrayLike): number[] { + const res = convert(words, 5, 8, false); + if (Array.isArray(res)) return res; + + throw new Error(res); +} + +function getLibraryFromEncoding(encoding: "bech32" | "bech32m"): BechLib { + let ENCODING_CONST: number; + if (encoding === "bech32") { + ENCODING_CONST = 1; + } else { + ENCODING_CONST = 0x2bc830a3; + } + + function encode( + prefix: string, + words: ArrayLike, + LIMIT?: number + ): string { + LIMIT = LIMIT || 90; + if (prefix.length + 7 + words.length > LIMIT) + throw new TypeError("Exceeds length limit"); + + prefix = prefix.toLowerCase(); + + // determine chk mod + let chk = prefixChk(prefix); + if (typeof chk === "string") throw new Error(chk); + + let result = prefix + "1"; + for (let i = 0; i < words.length; ++i) { + const x = words[i]; + if (x >> 5 !== 0) throw new Error("Non 5-bit word"); + + chk = polymodStep(chk) ^ x; + result += ALPHABET.charAt(x); + } + + for (let i = 0; i < 6; ++i) { + chk = polymodStep(chk); + } + chk ^= ENCODING_CONST; + + for (let i = 0; i < 6; ++i) { + const v = (chk >> ((5 - i) * 5)) & 0x1f; + result += ALPHABET.charAt(v); + } + + return result; + } + + function __decode(str: string, LIMIT?: number): Decoded | string { + LIMIT = LIMIT || 90; + if (str.length < 8) return str + " too short"; + if (str.length > LIMIT) return "Exceeds length limit"; + + // don't allow mixed case + const lowered = str.toLowerCase(); + const uppered = str.toUpperCase(); + if (str !== lowered && str !== uppered) + return "Mixed-case string " + str; + str = lowered; + + const split = str.lastIndexOf("1"); + if (split === -1) return "No separator character for " + str; + if (split === 0) return "Missing prefix for " + str; + + const prefix = str.slice(0, split); + const wordChars = str.slice(split + 1); + if (wordChars.length < 6) return "Data too short"; + + let chk = prefixChk(prefix); + if (typeof chk === "string") return chk; + + const words = []; + for (let i = 0; i < wordChars.length; ++i) { + const c = wordChars.charAt(i); + const v = ALPHABET_MAP[c]; + if (v === undefined) return "Unknown character " + c; + chk = polymodStep(chk) ^ v; + + // not in the checksum? + if (i + 6 >= wordChars.length) continue; + words.push(v); + } + + if (chk !== ENCODING_CONST) return "Invalid checksum for " + str; + return { prefix, words }; + } + + function decodeUnsafe(str: string, LIMIT?: number): Decoded | undefined { + const res = __decode(str, LIMIT); + if (typeof res === "object") return res; + } + + function decode(str: string, LIMIT?: number): Decoded { + const res = __decode(str, LIMIT); + if (typeof res === "object") return res; + + throw new Error(res); + } + + return { + decodeUnsafe, + decode, + encode, + toWords, + fromWordsUnsafe, + fromWords + }; +} + +export function bech32WordsToUrl(words: number[]) { + // Step 1: Convert words to bytes + const bits = words + .map((word) => word.toString(2).padStart(5, "0")) + .join(""); + const bytes = []; + for (let i = 0; i < bits.length; i += 8) { + bytes.push(parseInt(bits.substring(i, i + 8), 2)); + } + + // Step 2: Convert bytes to characters + const urlChars = bytes.map((byte) => String.fromCharCode(byte)); + + // Step 3: Concatenate characters to form URL string + return urlChars.join(""); +} + +export const bech32 = getLibraryFromEncoding("bech32"); +export const bech32m = getLibraryFromEncoding("bech32m"); +export interface Decoded { + prefix: string; + words: number[]; +} +export interface BechLib { + decodeUnsafe: ( + str: string, + LIMIT?: number | undefined + ) => Decoded | undefined; + decode: (str: string, LIMIT?: number | undefined) => Decoded; + encode: ( + prefix: string, + words: ArrayLike, + LIMIT?: number | undefined + ) => string; + toWords: typeof toWords; + fromWordsUnsafe: typeof fromWordsUnsafe; + fromWords: typeof fromWords; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 3da0de7..5c2c39b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -19,3 +19,4 @@ export * from "./vibrate"; export * from "./openLinkProgrammatically"; export * from "./nostr"; export * from "./currencies"; +export * from "./bech32";
{lnurlDomain()}