diff --git a/e2e/roundtrip.spec.ts b/e2e/roundtrip.spec.ts index e922e755..32aecb31 100644 --- a/e2e/roundtrip.spec.ts +++ b/e2e/roundtrip.spec.ts @@ -54,21 +54,23 @@ test("rountrip receive and send", async ({ page }) => { // The SVG's value property includes "lightning:l" expect(value).toContain("lightning:l"); - const lightningInvoice = value?.split("lightning:")[1]; - // Post the lightning invoice to the server - const _response = await fetch( - "https://faucet.mutinynet.com/api/lightning", - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - bolt11: lightningInvoice - }) - } - ); + const response = await fetch("https://faucet.mutinynet.com/api/lightning", { + method: "POST", + mode: "cors", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + bolt11: value + }) + }); + + if (!response.ok) { + response.text().then((text) => { + throw new Error("failed to post invoice to faucet: " + text); + }); + } // Wait for an h1 to appear in the dom that says "Payment Received" await page.waitForSelector("text=Payment Received", { timeout: 30000 }); diff --git a/public/i18n/en.json b/public/i18n/en.json index bb0d2611..bd1d3600 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -263,7 +263,8 @@ "back_home": "back home" }, "start_a_chat": "Start a chat?", - "start_a_chat_are_you_sure": "This user isn't in your contact list." + "start_a_chat_are_you_sure": "This user isn't in your contact list.", + "federation_message": "Federation Message" }, "scanner": { "paste": "Paste Something", @@ -561,7 +562,9 @@ "descriptionpart2": "Each one is run by a group of different inviduals or companies. Discover one that you or your friends might trust below.", "join_me": "Join me", "recommend": "Recommend federation", - "recommended_by_you": "Recommended by you" + "recommended_by_you": "Recommended by you", + "transfer_funds": "Transfer funds", + "transfer_funds_message": "Add a second federation to enable transfers." }, "gift": { "give_sats_link": "Give sats as a gift", @@ -782,5 +785,11 @@ "nowish": "Nowish", "seconds_future": "Seconds from now", "seconds_past": "Just now" + }, + "transfer": { + "completed": "Transfer Completed", + "sats_moved": "+{{amount}} sats have been moved to {{federation_name}}", + "confirm": "Confirm Transfer", + "title": "Transfer funds" } } diff --git a/src/components/Activity.tsx b/src/components/Activity.tsx index 5bd5dcd0..a86d7cf2 100644 --- a/src/components/Activity.tsx +++ b/src/components/Activity.tsx @@ -18,6 +18,7 @@ import { Button, ButtonCard, ContactButton, + FederationPopup, LoadingShimmer, NiceP, SimpleDialog @@ -424,6 +425,9 @@ export function CombinedActivity() { /> }> + + + { + if (!open) { + setShowFederationExpirationWarning(false); + actions.clearExpirationWarning(); + } + }} + > + {state.expiration_warning?.expiresMessage} + { + actions.clearExpirationWarning(); + setShowFederationExpirationWarning(false); + navigate("/settings/federations"); + }} + > +
+ + {i18n.t("profile.manage_federation")} +
+
+ + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index d3b21602..5706dba8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -56,3 +56,4 @@ export * from "./EditProfileForm"; export * from "./ImportNsecForm"; export * from "./LightningAddressShower"; export * from "./FederationInviteShower"; +export * from "./FederationPopup"; diff --git a/src/router.tsx b/src/router.tsx index 474dffb9..0ad459fc 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -33,7 +33,8 @@ import { Search, Send, Swap, - SwapLightning + SwapLightning, + Transfer } from "~/routes"; import { Admin, @@ -179,6 +180,7 @@ export function Router() { + diff --git a/src/routes/Send.tsx b/src/routes/Send.tsx index 39f24ab4..55d97e23 100644 --- a/src/routes/Send.tsx +++ b/src/routes/Send.tsx @@ -1,10 +1,5 @@ import { MutinyInvoice, TagItem } from "@mutinywallet/mutiny-wasm"; -import { - createAsync, - useLocation, - useNavigate, - useSearchParams -} from "@solidjs/router"; +import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"; import { Eye, EyeOff, Link, X, Zap } from "lucide-solid"; import { createEffect, diff --git a/src/routes/Swap.tsx b/src/routes/Swap.tsx index 157543bd..c48a9a8f 100644 --- a/src/routes/Swap.tsx +++ b/src/routes/Swap.tsx @@ -1,6 +1,6 @@ import { createForm, required } from "@modular-forms/solid"; import { MutinyChannel } from "@mutinywallet/mutiny-wasm"; -import { createAsync, useNavigate } from "@solidjs/router"; +import { useNavigate } from "@solidjs/router"; import { createEffect, createMemo, diff --git a/src/routes/Transfer.tsx b/src/routes/Transfer.tsx new file mode 100644 index 00000000..66fda217 --- /dev/null +++ b/src/routes/Transfer.tsx @@ -0,0 +1,220 @@ +import { FedimintSweepResult } from "@mutinywallet/mutiny-wasm"; +import { createAsync, useNavigate, useSearchParams } from "@solidjs/router"; +import { ArrowDown, Users } from "lucide-solid"; +import { createMemo, createSignal, Match, Suspense, Switch } from "solid-js"; + +import { + AmountEditable, + AmountFiat, + AmountSats, + BackLink, + Button, + DefaultMain, + Failure, + Fee, + LargeHeader, + MegaCheck, + MutinyWalletGuard, + SharpButton, + SuccessModal, + VStack +} from "~/components"; +import { useI18n } from "~/i18n/context"; +import { useMegaStore } from "~/state/megaStore"; +import { eify, vibrateSuccess } from "~/utils"; + +type TransferResultDetails = { + result?: FedimintSweepResult; + failure_reason?: string; +}; + +export function Transfer() { + const [state, _actions, sw] = useMegaStore(); + const i18n = useI18n(); + const navigate = useNavigate(); + const [amountSats, setAmountSats] = createSignal(0n); + const [loading, setLoading] = createSignal(false); + const [params] = useSearchParams(); + + const [transferResult, setTransferResult] = + createSignal(); + + const fromFed = () => { + return state.federations?.find((f) => f.federation_id === params.from); + }; + + const toFed = () => { + return state.federations?.find((f) => f.federation_id !== params.from); + }; + + const federationBalances = createAsync(async () => { + try { + const balances = await sw.get_federation_balances(); + return balances?.balances || []; + } catch (e) { + console.error(e); + return []; + } + }); + + const calculateMaxFederation = createAsync(async () => { + const balance = federationBalances()?.find( + (f) => f.identity_federation_id === fromFed()?.federation_id + )?.balance; + return balance || 0n; + }); + + const toBalance = createAsync(async () => { + return federationBalances()?.find( + (f) => f.identity_federation_id === toFed()?.federation_id + )?.balance; + }); + + const isMax = createMemo(() => { + return amountSats() === calculateMaxFederation(); + }); + + const canTransfer = createMemo(() => { + if (!calculateMaxFederation()) return false; + return amountSats() > 0n && amountSats() <= calculateMaxFederation()!; + }); + + async function handleTransfer() { + try { + setLoading(true); + if (!fromFed()) throw new Error("No from federation"); + if (!toFed()) throw new Error("No to federation"); + + if (isMax()) { + const result = await sw.sweep_federation_balance( + undefined, + fromFed()?.federation_id, + toFed()?.federation_id + ); + + setTransferResult({ result: result }); + } else { + const result = await sw.sweep_federation_balance( + amountSats(), + fromFed()?.federation_id, + toFed()?.federation_id + ); + + setTransferResult({ result: result }); + } + + await vibrateSuccess(); + } catch (e) { + const error = eify(e); + setTransferResult({ failure_reason: error.message }); + console.error(e); + } finally { + setLoading(false); + } + } + + return ( + + + { + if (!open) setTransferResult(undefined); + }} + onConfirm={() => { + setTransferResult(undefined); + navigate("/"); + }} + > + + + + + + +
+

+ {i18n.t("transfer.completed")} +

+

+ {i18n.t("transfer.sats_moved", { + amount: Number( + transferResult()?.result?.amount + ).toLocaleString(), + federation_name: + toFed()?.federation_name + })} +

+
+ + + +
+
+
+ +
+
+
+ + {i18n.t("transfer.title")} +
+
+
+ + {}}> + + {fromFed()?.federation_name} + + + + {}}> + + {toFed()?.federation_name} + + +
+
+ + + +
+ + + ); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index cd7fa32f..6d6ccb25 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -12,3 +12,4 @@ export * from "./Request"; export * from "./EditProfile"; export * from "./Swap"; export * from "./SwapLightning"; +export * from "./Transfer"; diff --git a/src/routes/settings/ManageFederations.tsx b/src/routes/settings/ManageFederations.tsx index fe339615..2b8bf97c 100644 --- a/src/routes/settings/ManageFederations.tsx +++ b/src/routes/settings/ManageFederations.tsx @@ -7,8 +7,9 @@ import { } from "@modular-forms/solid"; import { FederationBalance, TagItem } from "@mutinywallet/mutiny-wasm"; import { A, useNavigate, useSearchParams } from "@solidjs/router"; -import { BadgeCheck, LogOut, Scan, Trash } from "lucide-solid"; +import { ArrowLeftRight, BadgeCheck, LogOut, Scan, Trash } from "lucide-solid"; import { + createMemo, createResource, createSignal, For, @@ -28,6 +29,7 @@ import { ExternalLink, FancyCard, FederationInviteShower, + FederationPopup, InfoBox, KeyValue, LabelCircle, @@ -39,6 +41,7 @@ import { NavBar, NiceP, showToast, + SimpleDialog, SubtleButton, TextField, VStack @@ -57,6 +60,9 @@ export type MutinyFederationIdentity = { welcome_message: string; federation_expiry_timestamp: number; invite_code: string; + meta_external_url?: string; + popup_end_timestamp?: number; + popup_countdown_message?: string; }; export type Metadata = { @@ -89,7 +95,7 @@ export function AddFederationForm(props: { browseOnly?: boolean; }) { const i18n = useI18n(); - const [_state, actions, sw] = useMegaStore(); + const [state, actions, sw] = useMegaStore(); const navigate = useNavigate(); const [error, setError] = createSignal(); const [success, setSuccess] = createSignal(""); @@ -179,6 +185,9 @@ export function AddFederationForm(props: { return (
+ + + {i18n.t("settings.manage_federations.manual")} @@ -240,99 +249,12 @@ export function AddFederationForm(props: { {(fed) => ( - - -
- -
-
- {fed.metadata?.name} -
- -

{fed.metadata?.about}

-
-
-
- - - - - - - - - - - 0 - } - > - -
- - {(contact) => ( - - )} - -
-
-
- - - -
-
+ )}
@@ -342,6 +264,94 @@ export function AddFederationForm(props: { ); } +function FederationFormItem(props: { + fed: DiscoveredFederation; + onSelect: (invite_codes: string[]) => void; + loadingFederation: string; + setup: boolean; +}) { + const [state, _actions, _sw] = useMegaStore(); + const i18n = useI18n(); + + const alreadyAdded = createMemo(() => { + const matches = state.federations?.find((f) => + props.fed.invite_codes.includes(f.invite_code) + ); + return matches !== undefined; + }); + return ( + + +
+ +
+
+ {props.fed.metadata?.name} +
+ +

{props.fed.metadata?.about}

+
+
+
+ + + + + + + + + + + 0}> + +
+ + {(contact) => ( + + )} + +
+
+
+ + + +
+
+ ); +} + function RecommendButton(props: { fed: MutinyFederationIdentity }) { const [_state, _actions, sw] = useMegaStore(); const i18n = useI18n(); @@ -425,7 +435,8 @@ function FederationListItem(props: { balance?: bigint; }) { const i18n = useI18n(); - const [_state, actions, sw] = useMegaStore(); + const [state, actions, sw] = useMegaStore(); + const navigate = useNavigate(); async function removeFederation() { setConfirmLoading(true); @@ -442,6 +453,17 @@ function FederationListItem(props: { setConfirmOpen(true); } + const [transferDialogOpen, setTransferDialogOpen] = createSignal(false); + + async function transferFunds() { + // If there's only one federation we need to let them know to add another + if (state.federations?.length && state.federations.length < 2) { + setTransferDialogOpen(true); + } else { + navigate("/transfer?from=" + props.fed.federation_id); + } + } + const [confirmOpen, setConfirmOpen] = createSignal(false); const [confirmLoading, setConfirmLoading] = createSignal(false); @@ -457,6 +479,19 @@ function FederationListItem(props: {

{props.fed.welcome_message}

+ + + {i18n.t( + "settings.manage_federations.transfer_funds_message" + )} + + + + + {i18n.t("settings.manage_federations.transfer_funds")} + @@ -606,7 +645,7 @@ export function ManageFederations() { - + diff --git a/src/state/megaStore.tsx b/src/state/megaStore.tsx index 8bd49230..a6b7b234 100644 --- a/src/state/megaStore.tsx +++ b/src/state/megaStore.tsx @@ -87,7 +87,15 @@ export const makeMegaStoreContext = () => { testflightPromptDismissed: localStorage.getItem("testflightPromptDismissed") === "true", federations: undefined as MutinyFederationIdentity[] | undefined, - balanceView: localStorage.getItem("balanceView") || "sats" + balanceView: localStorage.getItem("balanceView") || "sats", + expiration_warning: undefined as + | { + expiresTimestamp: number; + expiresMessage: string; + federationName: string; + } + | undefined, + expiration_warning_seen: false }); const actions = { @@ -226,15 +234,33 @@ export const makeMegaStoreContext = () => { const balance = await sw.get_balance(); // Get federations - const federations = - (await sw.list_federations()) as MutinyFederationIdentity[]; + const federations = await sw.list_federations(); + + let expiration_warning: + | { + expiresTimestamp: number; + expiresMessage: string; + federationName: string; + } + | undefined = undefined; + + federations.forEach((f) => { + if (f.popup_countdown_message && f.popup_end_timestamp) { + expiration_warning = { + expiresTimestamp: f.popup_end_timestamp, + expiresMessage: f.popup_countdown_message, + federationName: f.federation_name + }; + } + }); setState({ wallet_loading: false, load_stage: "done", balance, federations, - network: network as Network + network: network as Network, + expiration_warning }); // Timestamp our initialization for double init defense @@ -466,7 +492,26 @@ export const makeMegaStoreContext = () => { }, async refreshFederations() { const federations = await sw.list_federations(); - setState({ federations }); + + let expiration_warning: + | { + expiresTimestamp: number; + expiresMessage: string; + federationName: string; + } + | undefined = undefined; + + federations.forEach((f) => { + if (f.popup_countdown_message && f.popup_end_timestamp) { + expiration_warning = { + expiresTimestamp: f.popup_end_timestamp, + expiresMessage: f.popup_countdown_message, + federationName: f.federation_name + }; + } + }); + + setState({ federations, expiration_warning }); }, cycleBalanceView() { if (state.balanceView === "sats") { @@ -506,6 +551,10 @@ export const makeMegaStoreContext = () => { channel.postMessage({ type: "EXISTING_TAB" }); } }; + }, + // Only show the expiration warning once per session + clearExpirationWarning() { + setState({ expiration_warning_seen: true }); } }; diff --git a/src/workers/walletWorker.ts b/src/workers/walletWorker.ts index 8f5d54b5..6814d030 100644 --- a/src/workers/walletWorker.ts +++ b/src/workers/walletWorker.ts @@ -1381,6 +1381,7 @@ export async function delete_federation_recommendation( */ export async function get_federation_balances(): Promise { const balances = await wallet!.get_federation_balances(); + if (!balances) return { balances: [] } as unknown as FederationBalances; // PAIN // Have to rebuild the balances from the raw data, which is a bit of a pain const newBalances: FederationBalance[] = []; @@ -1548,9 +1549,15 @@ export async function estimate_sweep_channel_open_fee( * @returns {Promise} */ export async function sweep_federation_balance( - amount?: bigint + amount?: bigint, + from_federation_id?: string, + to_federation_id?: string ): Promise { - const result = await wallet!.sweep_federation_balance(amount); + const result = await wallet!.sweep_federation_balance( + amount, + from_federation_id, + to_federation_id + ); return { ...result.value } as FedimintSweepResult; }