swap to ln

This commit is contained in:
Paul Miller
2023-05-23 09:12:31 -05:00
parent fc36103892
commit 15ce9db8a7
12 changed files with 1006 additions and 432 deletions

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 17h2v-6h-2v6Zm1-8c.2833 0 .521-.096.713-.288.192-.192.2877-.42933.287-.712 0-.28333-.096-.521-.288-.713-.192-.192-.4293-.28767-.712-.287-.2833 0-.521.096-.713.288-.192.192-.2877.42933-.287.712 0 .28333.096.521.288.713.192.192.4293.28767.712.287Zm0 13c-1.3833 0-2.68333-.2627-3.9-.788-1.21667-.5253-2.275-1.2377-3.175-2.137-.9-.9-1.61233-1.9583-2.137-3.175S2.00067 13.3833 2 12c0-1.3833.26267-2.68333.788-3.9.52533-1.21667 1.23767-2.275 2.137-3.175.9-.9 1.95833-1.61233 3.175-2.137S10.6167 2.00067 12 2c1.3833 0 2.6833.26267 3.9.788 1.2167.52533 2.275 1.23767 3.175 2.137.9.9 1.6127 1.95833 2.138 3.175.5253 1.21667.7877 2.5167.787 3.9 0 1.3833-.2627 2.6833-.788 3.9-.5253 1.2167-1.2377 2.275-2.137 3.175-.9.9-1.9583 1.6127-3.175 2.138-1.2167.5253-2.5167.7877-3.9.787Zm0-2c2.2333 0 4.125-.775 5.675-2.325C19.225 16.125 20 14.2333 20 12c0-2.23333-.775-4.125-2.325-5.675C16.125 4.775 14.2333 4 12 4c-2.23333 0-4.125.775-5.675 2.325C4.775 7.875 4 9.76667 4 12c0 2.2333.775 4.125 2.325 5.675C7.875 19.225 9.76667 20 12 20Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 8.99985h3.5c.736 0 1.393.391 1.851 1.00095.32529-.60198.7255-1.16042 1.191-1.66195-.803-.823-1.866-1.339-3.042-1.339H4c-.26522 0-.51957.10536-.70711.29289C3.10536 7.48028 3 7.73463 3 7.99985s.10536.51957.29289.70711c.18754.18753.44189.29289.70711.29289Zm7.685 3.11095c.551-1.657 2.256-3.11095 3.649-3.11095h1.838l-1.293 1.29295c-.0928.0929-.1665.2031-.2167.3244-.0503.1213-.0761.2513-.0761.3826 0 .1314.0258.2614.0761.3827.0502.1213.1239.2315.2167.3243.0928.0929.2031.1665.3244.2168.1213.0502.2513.0761.3826.0761.1313 0 .2613-.0259.3826-.0761.1213-.0503.2316-.1239.3244-.2168L21 7.99985l-3.707-3.707c-.0928-.09285-.2031-.16649-.3244-.21674C16.8473 4.02586 16.7173 4 16.586 4c-.1313 0-.2613.02586-.3826.07611-.1213.05025-.2316.12389-.3244.21674-.0928.09284-.1665.20307-.2167.32437-.0503.12131-.0761.25133-.0761.38263 0 .1313.0258.26132.0761.38262.0502.12131.1239.23153.2167.32438l1.293 1.293h-1.838c-2.274 0-4.711 1.967-5.547 4.47895l-.472 1.411c-.641 1.926-2.072 3.11-2.815 3.11H4c-.26522 0-.51957.1054-.70711.2929-.18753.1876-.29289.4419-.29289.7071 0 .2653.10536.5196.29289.7072.18754.1875.44189.2928.70711.2928h2.5c1.837 0 3.863-1.925 4.713-4.479l.472-1.41Zm4.194 1.182c-.0929.0928-.1667.203-.217.3244-.0503.1213-.0762.2513-.0762.3826 0 .1314.0259.2614.0762.3827.0503.1214.1241.2316.217.3243l1.293 1.293h-2.338c-1.268 0-2.33-.891-2.691-2.108-.2661.773-.6326 1.5076-1.09 2.185.886 1.162 2.243 1.923 3.781 1.923h2.338l-1.293 1.293c-.0928.0929-.1665.2031-.2167.3244-.0503.1213-.0761.2513-.0761.3826 0 .1314.0258.2614.0761.3827.0502.1213.1239.2315.2167.3244.0928.0928.2031.1664.3244.2167.1213.0502.2513.0761.3826.0761.1313 0 .2613-.0259.3826-.0761.1213-.0503.2316-.1239.3244-.2167L21 16.9998l-3.707-3.707c-.0928-.0929-.203-.1666-.3243-.2169-.1213-.0504-.2514-.0763-.3827-.0763-.1313 0-.2614.0259-.3827.0763-.1213.0503-.2315.124-.3243.2169Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -5,87 +5,136 @@ import { satsToUsd } from "~/utils/conversions";
import { AmountEditable } from "./AmountEditable"; import { AmountEditable } from "./AmountEditable";
const noop = () => { const noop = () => {
// do nothing // do nothing
} };
const KeyValue: ParentComponent<{ key: string, gray?: boolean }> = (props) => { const KeyValue: ParentComponent<{ key: string; gray?: boolean }> = (props) => {
return ( return (
<div class="flex justify-between items-center" classList={{ "text-neutral-400": props.gray }}> <div class="flex justify-between items-center" classList={{ "text-neutral-400": props.gray }}>
<div class="font-semibold uppercase">{props.key}</div> <div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div> <div class="font-light">{props.children}</div>
</div>
);
};
export const InlineAmount: ParentComponent<{ amount: string; sign?: string; fiat?: boolean }> = (
props
) => {
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString();
}
});
return (
<div class="inline-block text-lg">
{props.sign ? `${props.sign} ` : ""}
{props.fiat ? "$" : ""}
{prettyPrint()} <span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</div>
);
};
function USDShower(props: { amountSats: string; fee?: string }) {
const [state, _] = useMegaStore();
const amountInUsd = () => satsToUsd(state.price, add(props.amountSats, props.fee), true);
return (
<Show when={!(props.amountSats === "0")}>
<KeyValue gray key="">
<div class="self-end">
&#8776; {amountInUsd()}&nbsp;<span class="text-sm">USD</span>
</div> </div>
) </KeyValue>
} </Show>
);
export const InlineAmount: ParentComponent<{ amount: string, sign?: string, fiat?: boolean }> = (props) => {
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
return props.amount;
} else {
return parsed.toLocaleString();
}
})
return (<div class="inline-block text-lg">{props.sign ? `${props.sign} ` : ""}{props.fiat ? "$" : ""}{prettyPrint()} <span class="text-sm">{props.fiat ? "USD" : "SATS"}</span></div>)
}
function USDShower(props: { amountSats: string, fee?: string }) {
const [state, _] = useMegaStore()
const amountInUsd = () => satsToUsd(state.price, add(props.amountSats, props.fee), true)
return (
<Show when={!(props.amountSats === "0")}>
<KeyValue gray key="">
<div class="self-end">&#8776; {amountInUsd()}&nbsp;<span class="text-sm">USD</span></div>
</KeyValue>
</Show>
)
} }
function add(a: string, b?: string) { function add(a: string, b?: string) {
return Number(a || 0) + Number(b || 0) return Number(a || 0) + Number(b || 0);
} }
export function AmountCard(props: { amountSats: string, fee?: string, initialOpen?: boolean, isAmountEditable?: boolean, setAmountSats?: (amount: bigint) => void }) { function subtract(a: string, b?: string) {
return ( return Number(a || 0) - Number(b || 0);
<Card> }
<VStack>
<Switch> export function AmountCard(props: {
<Match when={props.fee}> amountSats: string;
<div class="flex flex-col gap-1"> fee?: string;
<KeyValue key="Amount"> reserve?: string;
<Show when={props.isAmountEditable} fallback={<InlineAmount amount={props.amountSats} /> initialOpen?: boolean;
}> isAmountEditable?: boolean;
<AmountEditable initialOpen={props.initialOpen ?? false} initialAmountSats={props.amountSats.toString()} setAmountSats={props.setAmountSats ? props.setAmountSats : noop} /> setAmountSats?: (amount: bigint) => void;
</Show> }) {
</KeyValue> return (
<KeyValue gray key="+ Fee"> <Card>
<InlineAmount amount={props.fee || "0"} /> <VStack>
</KeyValue> <Switch>
</div> <Match when={props.fee}>
<hr class="border-white/20" /> <div class="flex flex-col gap-1">
<div class="flex flex-col gap-1"> <KeyValue key="Amount">
<KeyValue key="Total"> <Show
<InlineAmount amount={add(props.amountSats, props.fee).toString()} /> when={props.isAmountEditable}
</KeyValue> fallback={<InlineAmount amount={props.amountSats} />}
<USDShower amountSats={props.amountSats} fee={props.fee} /> >
</div> <AmountEditable
</Match> initialOpen={props.initialOpen ?? false}
<Match when={!props.fee}> initialAmountSats={props.amountSats.toString()}
<div class="flex flex-col gap-1"> setAmountSats={props.setAmountSats ? props.setAmountSats : noop}
<KeyValue key="Amount"> />
<Show when={props.isAmountEditable} fallback={<InlineAmount amount={props.amountSats} /> </Show>
}> </KeyValue>
<AmountEditable initialOpen={props.initialOpen ?? false} initialAmountSats={props.amountSats.toString()} setAmountSats={props.setAmountSats ? props.setAmountSats : noop} /> <KeyValue gray key="+ Fee">
</Show> <InlineAmount amount={props.fee || "0"} />
</KeyValue> </KeyValue>
<USDShower amountSats={props.amountSats} /> </div>
</div> <hr class="border-white/20" />
</Match> <div class="flex flex-col gap-1">
</Switch> <KeyValue key="Total">
</VStack> <InlineAmount amount={add(props.amountSats, props.fee).toString()} />
</Card> </KeyValue>
) <USDShower amountSats={props.amountSats} fee={props.fee} />
} </div>
</Match>
<Match when={props.reserve}>
<div class="flex flex-col gap-1">
<KeyValue key="Channel size">
<InlineAmount amount={add(props.amountSats, props.reserve).toString()} />
</KeyValue>
<KeyValue gray key="- Channel Reserve">
<InlineAmount amount={props.reserve || "0"} />
</KeyValue>
</div>
<hr class="border-white/20" />
<div class="flex flex-col gap-1">
<KeyValue key="Spendable">
<InlineAmount amount={props.amountSats} />
</KeyValue>
<USDShower amountSats={props.amountSats} fee={props.reserve} />
</div>
</Match>
<Match when={!props.fee && !props.reserve}>
<div class="flex flex-col gap-1">
<KeyValue key="Amount">
<Show
when={props.isAmountEditable}
fallback={<InlineAmount amount={props.amountSats} />}
>
<AmountEditable
initialOpen={props.initialOpen ?? false}
initialAmountSats={props.amountSats.toString()}
setAmountSats={props.setAmountSats ? props.setAmountSats : noop}
/>
</Show>
</KeyValue>
<USDShower amountSats={props.amountSats} />
</div>
</Match>
</Switch>
</VStack>
</Card>
);
}

View File

@@ -2,7 +2,8 @@ import { Show, Suspense } from "solid-js";
import { Button, ButtonLink, FancyCard, Indicator } from "~/components/layout"; import { Button, ButtonLink, FancyCard, Indicator } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { Amount } from "./Amount"; import { Amount } from "./Amount";
import { useNavigate } from "solid-start"; import { A, useNavigate } from "solid-start";
import shuffle from "~/assets/icons/shuffle.svg"
function prettyPrintAmount(n?: number | bigint): string { function prettyPrintAmount(n?: number | bigint): string {
if (!n || n.valueOf() === 0) { if (!n || n.valueOf() === 0) {
@@ -22,6 +23,8 @@ export function LoadingShimmer() {
</div>) </div>)
} }
const STYLE = "px-2 py-1 rounded-xl border border-neutral-400 text-sm flex gap-2 items-center font-semibold"
export default function BalanceBox(props: { loading?: boolean }) { export default function BalanceBox(props: { loading?: boolean }) {
const [state, actions] = useMegaStore(); const [state, actions] = useMegaStore();
@@ -39,8 +42,13 @@ export default function BalanceBox(props: { loading?: boolean }) {
<FancyCard title="On-Chain" tag={state.is_syncing && <Indicator>Syncing</Indicator>}> <FancyCard title="On-Chain" tag={state.is_syncing && <Indicator>Syncing</Indicator>}>
<Show when={!props.loading} fallback={<LoadingShimmer />}> <Show when={!props.loading} fallback={<LoadingShimmer />}>
<div onClick={actions.sync}> <div class="flex justify-between">
<Amount amountSats={state.balance?.confirmed} showFiat /> <Amount amountSats={state.balance?.confirmed} showFiat />
<div class="self-end justify-self-end">
<A href="/swap" class={STYLE}>
<img src={shuffle} alt="swap" />
</A>
</div>
</div> </div>
</Show> </Show>
<Suspense> <Suspense>

View File

@@ -0,0 +1,19 @@
import { ParentComponent } from "solid-js";
import { ButtonLink, SmallHeader } from "~/components/layout"
import info from "~/assets/icons/info.svg"
export const InfoBox: ParentComponent<{ accent: "red" | "blue" | "green" | "white" }> = (props) => {
return (
<div class="grid grid-cols-[auto_minmax(0,_1fr)] rounded-xl p-4 gap-4 bg-neutral-950/50 border"
classList={{"border-m-red": props.accent === "red", "border-m-blue": props.accent === "blue", "border-m-green": props.accent === "green", "border-white": props.accent === "white"}}>
<div class="self-center">
<img src={info} alt="info" class="w-8 h-8" />
</div>
<div class="flex items-center">
<p class="text-base font-light">
{props.children}
</p>
</div>
</div>
)
}

View File

@@ -112,7 +112,7 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
<TextField.Root <TextField.Root
value={value()} value={value()}
onChange={setValue} onChange={setValue}
validationState={(value() == "" || value().startsWith("mutiny:")) ? "valid" : "invalid"} validationState={(value() == "") ? "valid" : "invalid"}
class="flex flex-col gap-4" class="flex flex-col gap-4"
> >
<TextField.Label class="text-sm font-semibold uppercase" >Connect Peer</TextField.Label> <TextField.Label class="text-sm font-semibold uppercase" >Connect Peer</TextField.Label>

View File

View File

@@ -15,7 +15,7 @@ export function StyledRadioGroup(props: { value: string, choices: Choices, onVal
{choice => {choice =>
<RadioGroup.Item value={choice.value} <RadioGroup.Item value={choice.value}
class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2`} class={`ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2`}
classList={{ "ui-checked:outline-m-red": props.accent === "red", "ui-checked:outline-white": props.accent === "white" }} classList={{ "ui-checked:outline-m-red": props.accent === "red", "ui-checked:outline-white": props.accent === "white", "ui-checked:outline-black/50 ui-checked:bg-white/10": props.choices.length === 1 }}
> >
<div class={props.small ? "py-2 px-2" : "py-3 px-4"}> <div class={props.small ? "py-2 px-2" : "py-3 px-4"}>
<RadioGroup.ItemInput /> <RadioGroup.ItemInput />

View File

@@ -39,3 +39,15 @@ a {
#video-container .scan-region-highlight-svg { #video-container .scan-region-highlight-svg {
display: none; display: none;
} }
select {
@apply appearance-none;
@apply block;
@apply border-[2px] focus:outline-none focus:ring-2 focus:ring-offset-2 ring-offset-black;
@apply font-light text-lg;
@apply py-4 pl-4 pr-8;
background-image: url("data:image/svg+xml,%3Csvg aria-hidden='true' class='w-4 h-4 ml-1' fill='white' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414z' clip-rule='evenodd'/%3E%3C/svg%3E");
background-position: right 0.75rem center;
background-size: 20px 20px;
background-repeat: no-repeat;
}

View File

@@ -1,7 +1,18 @@
import { Match, Show, Switch, createEffect, createMemo, createSignal, onMount } from "solid-js"; import { Match, Show, Switch, createEffect, createMemo, createSignal, onMount } from "solid-js";
import { Amount } from "~/components/Amount"; import { Amount } from "~/components/Amount";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { Button, ButtonLink, Card, DefaultMain, HStack, LargeHeader, MutinyWalletGuard, SafeArea, SmallHeader, VStack } from "~/components/layout"; import {
Button,
ButtonLink,
Card,
DefaultMain,
HStack,
LargeHeader,
MutinyWalletGuard,
SafeArea,
SmallHeader,
VStack
} from "~/components/layout";
import { Paste } from "~/assets/svg/Paste"; import { Paste } from "~/assets/svg/Paste";
import { Scan } from "~/assets/svg/Scan"; import { Scan } from "~/assets/svg/Scan";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
@@ -11,7 +22,7 @@ import { ParsedParams, toParsedParams } from "./Scanner";
import { showToast } from "~/components/Toaster"; import { showToast } from "~/components/Toaster";
import eify from "~/utils/eify"; import eify from "~/utils/eify";
import { FullscreenModal } from "~/components/layout/FullscreenModal"; import { FullscreenModal } from "~/components/layout/FullscreenModal";
import megacheck from "~/assets/icons/megacheck.png" import megacheck from "~/assets/icons/megacheck.png";
import megaex from "~/assets/icons/megaex.png"; import megaex from "~/assets/icons/megaex.png";
import mempoolTxUrl from "~/utils/mempoolTxUrl"; import mempoolTxUrl from "~/utils/mempoolTxUrl";
import { BackLink } from "~/components/layout/BackLink"; import { BackLink } from "~/components/layout/BackLink";
@@ -22,379 +33,480 @@ import { AmountCard } from "~/components/AmountCard";
import { MutinyTagItem } from "~/utils/tags"; import { MutinyTagItem } from "~/utils/tags";
import { BackButton } from "~/components/layout/BackButton"; import { BackButton } from "~/components/layout/BackButton";
type SendSource = "lightning" | "onchain"; export type SendSource = "lightning" | "onchain";
// const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl" // const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl"
// const TEST_DEST_ADDRESS = "tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe" // const TEST_DEST_ADDRESS = "tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe"
// TODO: better success / fail type // TODO: better success / fail type
type SentDetails = { amount?: bigint, destination?: string, txid?: string, failure_reason?: string } type SentDetails = {
amount?: bigint;
destination?: string;
txid?: string;
failure_reason?: string;
};
function MethodChooser(props: { source: SendSource, setSource: (source: string) => void, both?: boolean }) { export function MethodChooser(props: {
const [store, _actions] = useMegaStore(); source: SendSource;
setSource: (source: string) => void;
both?: boolean;
}) {
const [store, _actions] = useMegaStore();
const methods = createMemo(() => { const methods = createMemo(() => {
return [ return [
{ value: "lightning", label: "Lightning Balance", caption: store.balance?.lightning ? `${store.balance?.lightning.toLocaleString()} SATS` : "No balance" }, {
{ value: "onchain", label: "On-chain Balance", caption: store.balance?.confirmed ? `${store.balance?.confirmed.toLocaleString()} SATS` : "No balance" } value: "lightning",
] label: "Lightning Balance",
caption: store.balance?.lightning
}) ? `${store.balance?.lightning.toLocaleString()} SATS`
return ( : "No balance"
<Switch> },
<Match when={props.both}> {
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={methods()} /> value: "onchain",
</Match> label: "On-chain Balance",
<Match when={props.source === "lightning"}> caption: store.balance?.confirmed
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={[methods()[0]]} /> ? `${store.balance?.confirmed.toLocaleString()} SATS`
</Match> : "No balance"
<Match when={props.source === "onchain"}> }
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={[methods()[1]]} /> ];
</Match> });
</Switch> return (
) <Switch>
<Match when={props.both}>
<StyledRadioGroup
accent="white"
value={props.source}
onValueChange={props.setSource}
choices={methods()}
/>
</Match>
<Match when={props.source === "lightning"}>
<StyledRadioGroup
accent="white"
value={props.source}
onValueChange={props.setSource}
choices={[methods()[0]]}
/>
</Match>
<Match when={props.source === "onchain"}>
<StyledRadioGroup
accent="white"
value={props.source}
onValueChange={props.setSource}
choices={[methods()[1]]}
/>
</Match>
</Switch>
);
} }
function DestinationInput(props: { function DestinationInput(props: {
fieldDestination: string, fieldDestination: string;
setFieldDestination: (destination: string) => void, setFieldDestination: (destination: string) => void;
handleDecode: () => void, handleDecode: () => void;
handlePaste: () => void, handlePaste: () => void;
}) { }) {
return ( return (
<VStack> <VStack>
<SmallHeader>Destination</SmallHeader> <SmallHeader>Destination</SmallHeader>
<textarea value={props.fieldDestination} onInput={(e) => props.setFieldDestination(e.currentTarget.value)} placeholder="bitcoin:..." class="p-2 rounded-lg bg-white/10 placeholder-neutral-400" /> <textarea
<Button disabled={!props.fieldDestination} intent="blue" onClick={props.handleDecode}>Decode</Button> value={props.fieldDestination}
<HStack> onInput={(e) => props.setFieldDestination(e.currentTarget.value)}
<Button onClick={props.handlePaste}> placeholder="bitcoin:..."
<div class="flex flex-col gap-2 items-center"> class="p-2 rounded-lg bg-white/10 placeholder-neutral-400"
<Paste /> />
<span>Paste</span> <Button disabled={!props.fieldDestination} intent="blue" onClick={props.handleDecode}>
</div> Decode
</Button> </Button>
<ButtonLink href="/scanner"> <HStack>
<div class="flex flex-col gap-2 items-center"> <Button onClick={props.handlePaste}>
<Scan /> <div class="flex flex-col gap-2 items-center">
<span>Scan QR</span> <Paste />
</div> <span>Paste</span>
</ButtonLink> </div>
</HStack> </Button>
</VStack> <ButtonLink href="/scanner">
) <div class="flex flex-col gap-2 items-center">
<Scan />
<span>Scan QR</span>
</div>
</ButtonLink>
</HStack>
</VStack>
);
} }
function DestinationShower(props: { function DestinationShower(props: {
source: SendSource, source: SendSource;
description?: string, description?: string;
address?: string, address?: string;
invoice?: MutinyInvoice, invoice?: MutinyInvoice;
nodePubkey?: string, nodePubkey?: string;
lnurl?: string, lnurl?: string;
clearAll: () => void, clearAll: () => void;
}) { }) {
return ( return (
<Switch> <Switch>
<Match when={props.address && props.source === "onchain"}> <Match when={props.address && props.source === "onchain"}>
<StringShower text={props.address || ""} /> <StringShower text={props.address || ""} />
</Match> </Match>
<Match when={props.invoice && props.source === "lightning"}> <Match when={props.invoice && props.source === "lightning"}>
<StringShower text={props.invoice?.bolt11 || ""} /> <StringShower text={props.invoice?.bolt11 || ""} />
</Match> </Match>
<Match when={props.nodePubkey && props.source === "lightning"}> <Match when={props.nodePubkey && props.source === "lightning"}>
<StringShower text={props.nodePubkey || ""} /> <StringShower text={props.nodePubkey || ""} />
</Match> </Match>
<Match when={props.lnurl && props.source === "lightning"}> <Match when={props.lnurl && props.source === "lightning"}>
<StringShower text={props.lnurl || ""} /> <StringShower text={props.lnurl || ""} />
</Match> </Match>
</Switch> </Switch>
);
)
} }
export default function Send() { export default function Send() {
const [state, actions] = useMegaStore(); const [state, actions] = useMegaStore();
const navigate = useNavigate() const navigate = useNavigate();
// These can only be set by the user // These can only be set by the user
const [fieldDestination, setFieldDestination] = createSignal(""); const [fieldDestination, setFieldDestination] = createSignal("");
const [destination, setDestination] = createSignal<ParsedParams>(); const [destination, setDestination] = createSignal<ParsedParams>();
// These can be derived from the "destination" signal or set by the user // These can be derived from the "destination" signal or set by the user
const [amountSats, setAmountSats] = createSignal(0n); const [amountSats, setAmountSats] = createSignal(0n);
const [source, setSource] = createSignal<SendSource>("lightning"); const [source, setSource] = createSignal<SendSource>("lightning");
// These can only be derived from the "destination" signal // These can only be derived from the "destination" signal
const [invoice, setInvoice] = createSignal<MutinyInvoice>(); const [invoice, setInvoice] = createSignal<MutinyInvoice>();
const [nodePubkey, setNodePubkey] = createSignal<string>(); const [nodePubkey, setNodePubkey] = createSignal<string>();
const [lnurlp, setLnurlp] = createSignal<string>(); const [lnurlp, setLnurlp] = createSignal<string>();
const [address, setAddress] = createSignal<string>(); const [address, setAddress] = createSignal<string>();
const [description, setDescription] = createSignal<string>(); const [description, setDescription] = createSignal<string>();
// Is sending / sent // Is sending / sent
const [sending, setSending] = createSignal(false); const [sending, setSending] = createSignal(false);
const [sentDetails, setSentDetails] = createSignal<SentDetails>(); const [sentDetails, setSentDetails] = createSignal<SentDetails>();
// Tagging stuff // Tagging stuff
const [selectedContacts, setSelectedContacts] = createSignal<Partial<MutinyTagItem>[]>([]); const [selectedContacts, setSelectedContacts] = createSignal<Partial<MutinyTagItem>[]>([]);
function clearAll() { function clearAll() {
setDestination(undefined); setDestination(undefined);
setAmountSats(0n); setAmountSats(0n);
setSource("lightning"); setSource("lightning");
setInvoice(undefined); setInvoice(undefined);
setAddress(undefined); setAddress(undefined);
setDescription(undefined); setDescription(undefined);
setNodePubkey(undefined); setNodePubkey(undefined);
setLnurlp(undefined); setLnurlp(undefined);
setFieldDestination(""); setFieldDestination("");
}
const feeEstimate = () => {
if (source() === "lightning") return undefined;
if (source() === "onchain" && amountSats() && amountSats() > 0n && address()) {
return state.mutiny_wallet?.estimate_tx_fee(address()!, amountSats(), undefined);
} }
const feeEstimate = () => { return undefined;
if (source() === "lightning") return undefined; };
if (source() === "onchain" && amountSats() && amountSats() > 0n && address()) { onMount(() => {
return state.mutiny_wallet?.estimate_tx_fee(address()!, amountSats(), undefined); if (state.scan_result) {
} setDestination(state.scan_result);
actions.setScanResult(undefined);
return undefined
} }
});
onMount(() => { // Rerun every time the destination changes
if (state.scan_result) { createEffect(() => {
setDestination(state.scan_result); const source = destination();
actions.setScanResult(undefined); if (!source) return undefined;
} try {
}) if (source.address) setAddress(source.address);
if (source.memo) setDescription(source.memo);
// Rerun every time the destination changes if (source.invoice) {
createEffect(() => { state.mutiny_wallet?.decode_invoice(source.invoice).then((invoice) => {
const source = destination(); if (invoice?.amount_sats) setAmountSats(invoice.amount_sats);
if (!source) return undefined; setInvoice(invoice);
try { setSource("lightning");
if (source.address) setAddress(source.address)
if (source.memo) setDescription(source.memo);
if (source.invoice) {
state.mutiny_wallet?.decode_invoice(source.invoice).then(invoice => {
if (invoice?.amount_sats) setAmountSats(invoice.amount_sats);
setInvoice(invoice)
setSource("lightning")
});
} else if (source.node_pubkey) {
setAmountSats(source.amount_sats || 0n);
setNodePubkey(source.node_pubkey);
setSource("lightning")
} else if (source.lnurl) {
state.mutiny_wallet?.decode_lnurl(source.lnurl).then((lnurlParams) => {
if (lnurlParams.tag === "payRequest") {
setAmountSats(source.amount_sats || 0n);
setLnurlp(source.lnurl);
setSource("lightning")
}
})
} else {
setAmountSats(source.amount_sats || 0n);
setSource("onchain")
}
// Return the source just to trigger `decodedDestination` as not undefined
return source
} catch (e) {
console.error("error", e)
clearAll();
}
})
function parsePaste(text: string) {
if (text) {
const network = state.mutiny_wallet?.get_network() || "signet";
const result = toParsedParams(text || "", network);
if (!result.ok) {
showToast(result.error);
return;
} else {
if (result.value?.address || result.value?.invoice || result.value?.node_pubkey || result.value?.lnurl) {
setDestination(result.value);
// Important! we need to clear the scan result once we've used it
actions.setScanResult(undefined);
}
}
}
}
function handleDecode() {
const text = fieldDestination();
parsePaste(text);
}
function handlePaste() {
if (!navigator.clipboard.readText) return showToast(new Error("Clipboard not supported"));
navigator.clipboard.readText().then(text => {
setFieldDestination(text);
parsePaste(text);
}).catch((e) => {
showToast(new Error("Failed to read clipboard: " + e.message))
}); });
} else if (source.node_pubkey) {
setAmountSats(source.amount_sats || 0n);
setNodePubkey(source.node_pubkey);
setSource("lightning");
} else if (source.lnurl) {
state.mutiny_wallet?.decode_lnurl(source.lnurl).then((lnurlParams) => {
if (lnurlParams.tag === "payRequest") {
setAmountSats(source.amount_sats || 0n);
setLnurlp(source.lnurl);
setSource("lightning");
}
});
} else {
setAmountSats(source.amount_sats || 0n);
setSource("onchain");
}
// Return the source just to trigger `decodedDestination` as not undefined
return source;
} catch (e) {
console.error("error", e);
clearAll();
} }
});
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> { function parsePaste(text: string) {
console.log("Processing contacts", contacts) if (text) {
const network = state.mutiny_wallet?.get_network() || "signet";
if (contacts.length) { const result = toParsedParams(text || "", network);
const first = contacts![0]; if (!result.ok) {
showToast(result.error);
if (!first.name) { return;
console.error("Something went wrong with contact creation, proceeding anyway") } else {
return [] if (
} result.value?.address ||
result.value?.invoice ||
if (!first.id && first.name) { result.value?.node_pubkey ||
console.error("Creating new contact", first.name) result.value?.lnurl
const c = new Contact(first.name, undefined, undefined, undefined); ) {
const newContactId = await state.mutiny_wallet?.create_new_contact(c); setDestination(result.value);
if (newContactId) { // Important! we need to clear the scan result once we've used it
return [newContactId]; actions.setScanResult(undefined);
}
}
if (first.id) {
console.error("Using existing contact", first.name, first.id)
return [first.id];
}
} }
}
console.error("Something went wrong with contact creation, proceeding anyway")
return []
} }
}
async function handleSend() { function handleDecode() {
try { const text = fieldDestination();
setSending(true); parsePaste(text);
const bolt11 = invoice()?.bolt11; }
const sentDetails: Partial<SentDetails> = {};
const tags = await processContacts(selectedContacts()); function handlePaste() {
if (!navigator.clipboard.readText) return showToast(new Error("Clipboard not supported"));
if (source() === "lightning" && invoice() && bolt11) { navigator.clipboard
const nodes = await state.mutiny_wallet?.list_nodes(); .readText()
const firstNode = nodes[0] as string || "" .then((text) => {
sentDetails.destination = bolt11; setFieldDestination(text);
// If the invoice has sats use that, otherwise we pass the user-defined amount parsePaste(text);
if (invoice()?.amount_sats) { })
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tags); .catch((e) => {
sentDetails.amount = invoice()?.amount_sats; showToast(new Error("Failed to read clipboard: " + e.message));
} else { });
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tags); }
sentDetails.amount = amountSats();
}
} else if (source() === "lightning" && nodePubkey()) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || ""
const payment = await state.mutiny_wallet?.keysend(firstNode, nodePubkey()!, amountSats(), tags);
// TODO: handle timeouts async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> {
if (!payment?.paid) { console.log("Processing contacts", contacts);
throw new Error("Keysend failed")
} else {
sentDetails.amount = amountSats();
}
} else if (source() === "lightning" && lnurlp()) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || ""
const payment = await state.mutiny_wallet?.lnurl_pay(firstNode, lnurlp()!, amountSats(), tags);
if (!payment?.paid) { if (contacts.length) {
throw new Error("Lnurl Pay failed") const first = contacts![0];
} else {
sentDetails.amount = amountSats(); if (!first.name) {
} console.error("Something went wrong with contact creation, proceeding anyway");
} else if (source() === "onchain" && address()) { return [];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion }
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tags);
sentDetails.amount = amountSats(); if (!first.id && first.name) {
sentDetails.destination = address(); console.error("Creating new contact", first.name);
// TODO: figure out if this is necessary, it takes forever const c = new Contact(first.name, undefined, undefined, undefined);
await actions.sync(); const newContactId = await state.mutiny_wallet?.create_new_contact(c);
sentDetails.txid = txid; if (newContactId) {
} return [newContactId];
setSentDetails(sentDetails as SentDetails);
clearAll();
} catch (e) {
const error = eify(e)
setSentDetails({ failure_reason: error.message });
// TODO: figure out ux of when we want to show toast vs error screen
// showToast(eify(e))
console.error(e);
} finally {
setSending(false);
} }
}
if (first.id) {
console.error("Using existing contact", first.name, first.id);
return [first.id];
}
} }
const sendButtonDisabled = createMemo(() => { console.error("Something went wrong with contact creation, proceeding anyway");
return !destination() || sending() || amountSats() === 0n; return [];
}) }
return ( async function handleSend() {
<MutinyWalletGuard> try {
<SafeArea> setSending(true);
<DefaultMain> const bolt11 = invoice()?.bolt11;
<Show when={address() || invoice() || nodePubkey() || lnurlp()} fallback={<BackLink />}> const sentDetails: Partial<SentDetails> = {};
<BackButton onClick={() => clearAll()} title="Start Over" />
</Show> const tags = await processContacts(selectedContacts());
<LargeHeader>Send Bitcoin</LargeHeader>
<FullscreenModal if (source() === "lightning" && invoice() && bolt11) {
title={sentDetails()?.amount ? "Sent" : "Payment Failed"} const nodes = await state.mutiny_wallet?.list_nodes();
confirmText={sentDetails()?.amount ? "Nice" : "Too Bad"} const firstNode = (nodes[0] as string) || "";
open={!!sentDetails()} sentDetails.destination = bolt11;
setOpen={(open: boolean) => { if (!open) setSentDetails(undefined) }} // If the invoice has sats use that, otherwise we pass the user-defined amount
onConfirm={() => { setSentDetails(undefined); navigate("/"); }} if (invoice()?.amount_sats) {
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, undefined, tags);
sentDetails.amount = invoice()?.amount_sats;
} else {
await state.mutiny_wallet?.pay_invoice(firstNode, bolt11, amountSats(), tags);
sentDetails.amount = amountSats();
}
} else if (source() === "lightning" && nodePubkey()) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
const payment = await state.mutiny_wallet?.keysend(
firstNode,
nodePubkey()!,
amountSats(),
tags
);
// TODO: handle timeouts
if (!payment?.paid) {
throw new Error("Keysend failed");
} else {
sentDetails.amount = amountSats();
}
} else if (source() === "lightning" && lnurlp()) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
const payment = await state.mutiny_wallet?.lnurl_pay(
firstNode,
lnurlp()!,
amountSats(),
tags
);
if (!payment?.paid) {
throw new Error("Lnurl Pay failed");
} else {
sentDetails.amount = amountSats();
}
} else if (source() === "onchain" && address()) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const txid = await state.mutiny_wallet?.send_to_address(address()!, amountSats(), tags);
sentDetails.amount = amountSats();
sentDetails.destination = address();
// TODO: figure out if this is necessary, it takes forever
await actions.sync();
sentDetails.txid = txid;
}
setSentDetails(sentDetails as SentDetails);
clearAll();
} catch (e) {
const error = eify(e);
setSentDetails({ failure_reason: error.message });
// TODO: figure out ux of when we want to show toast vs error screen
// showToast(eify(e))
console.error(e);
} finally {
setSending(false);
}
}
const sendButtonDisabled = createMemo(() => {
return !destination() || sending() || amountSats() === 0n;
});
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<Show when={address() || invoice() || nodePubkey() || lnurlp()} fallback={<BackLink />}>
<BackButton onClick={() => clearAll()} title="Start Over" />
</Show>
<LargeHeader>Send Bitcoin</LargeHeader>
<FullscreenModal
title={sentDetails()?.amount ? "Sent" : "Payment Failed"}
confirmText={sentDetails()?.amount ? "Nice" : "Too Bad"}
open={!!sentDetails()}
setOpen={(open: boolean) => {
if (!open) setSentDetails(undefined);
}}
onConfirm={() => {
setSentDetails(undefined);
navigate("/");
}}
>
<div class="flex flex-col items-center gap-8 h-full">
<Switch>
<Match when={sentDetails()?.failure_reason}>
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[50vh]" />
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
{sentDetails()?.failure_reason}
</p>
</Match>
<Match when={true}>
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh]" />
<Amount amountSats={sentDetails()?.amount} showFiat />
<Show when={sentDetails()?.txid}>
<a
href={mempoolTxUrl(sentDetails()?.txid, state.mutiny_wallet?.get_network())}
target="_blank"
rel="noreferrer"
> >
<div class="flex flex-col items-center gap-8 h-full"> Mempool Link
<Switch> </a>
<Match when={sentDetails()?.failure_reason}> </Show>
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[50vh]" /> </Match>
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">{sentDetails()?.failure_reason}</p> </Switch>
</Match> </div>
<Match when={true}> </FullscreenModal>
<img src={megacheck} alt="success" class="w-1/2 mx-auto max-w-[50vh]" /> <VStack biggap>
<Amount amountSats={sentDetails()?.amount} showFiat /> <Switch>
<Show when={sentDetails()?.txid}> <Match when={address() || invoice() || nodePubkey() || lnurlp()}>
<a href={mempoolTxUrl(sentDetails()?.txid, state.mutiny_wallet?.get_network())} target="_blank" rel="noreferrer"> <MethodChooser
Mempool Link source={source()}
</a> setSource={setSource}
</Show> both={!!address() && !!invoice()}
</Match> />
</Switch> <Card>
</div> <VStack>
</FullscreenModal> <DestinationShower
<VStack biggap> source={source()}
<Switch> description={description()}
<Match when={address() || invoice() || nodePubkey() || lnurlp()}> invoice={invoice()}
address={address()}
<MethodChooser source={source()} setSource={setSource} both={!!address() && !!invoice()} /> nodePubkey={nodePubkey()}
<Card> lnurl={lnurlp()}
<VStack> clearAll={clearAll}
<DestinationShower source={source()} description={description()} invoice={invoice()} address={address()} nodePubkey={nodePubkey()} lnurl={lnurlp()} clearAll={clearAll} /> />
<SmallHeader>Private tags</SmallHeader> <SmallHeader>Private tags</SmallHeader>
<TagEditor selectedValues={selectedContacts()} setSelectedValues={setSelectedContacts} placeholder="Add the receiver for your records" /> <TagEditor
</VStack> selectedValues={selectedContacts()}
</Card> setSelectedValues={setSelectedContacts}
<AmountCard amountSats={amountSats().toString()} setAmountSats={setAmountSats} fee={feeEstimate()?.toString()} isAmountEditable={!(invoice()?.amount_sats)} /> placeholder="Add the receiver for your records"
</Match> />
<Match when={true}> </VStack>
<DestinationInput fieldDestination={fieldDestination()} setFieldDestination={setFieldDestination} handleDecode={handleDecode} handlePaste={handlePaste} /> </Card>
</Match> <AmountCard
</Switch> amountSats={amountSats().toString()}
<Show when={destination()}> setAmountSats={setAmountSats}
<Button class="w-full flex-grow-0" disabled={sendButtonDisabled()} intent="blue" onClick={handleSend} loading={sending()}>{sending() ? "Sending..." : "Confirm Send"}</Button> fee={feeEstimate()?.toString()}
</Show> isAmountEditable={!invoice()?.amount_sats}
</VStack> />
</DefaultMain> </Match>
<NavBar activeTab="send" /> <Match when={true}>
</SafeArea > <DestinationInput
</MutinyWalletGuard > fieldDestination={fieldDestination()}
) setFieldDestination={setFieldDestination}
} handleDecode={handleDecode}
handlePaste={handlePaste}
/>
</Match>
</Switch>
<Show when={destination()}>
<Button
class="w-full flex-grow-0"
disabled={sendButtonDisabled()}
intent="blue"
onClick={handleSend}
loading={sending()}
>
{sending() ? "Sending..." : "Confirm Send"}
</Button>
</Show>
</VStack>
</DefaultMain>
<NavBar activeTab="send" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -1,29 +1,98 @@
import { Match, Show, Switch } from "solid-js";
import { ActivityItem } from "~/components/ActivityItem"; import { ActivityItem } from "~/components/ActivityItem";
import { Amount } from "~/components/Amount";
import { AmountCard } from "~/components/AmountCard"; import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar"; import NavBar from "~/components/NavBar";
import { OnboardWarning } from "~/components/OnboardWarning"; import { OnboardWarning } from "~/components/OnboardWarning";
import { ShareCard } from "~/components/ShareCard"; import { ShareCard } from "~/components/ShareCard";
import { Card, DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout"; import { Card, DefaultMain, LargeHeader, SafeArea, SmallHeader, VStack } from "~/components/layout";
import { FullscreenModal } from "~/components/layout/FullscreenModal";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
import megaex from "~/assets/icons/megaex.png";
import megacheck from "~/assets/icons/megacheck.png";
const SAMPLE = "bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6" const SAMPLE =
"bitcoin:tb1prqm8xtlgme0vmw5s30lgf0a4f5g4mkgsqundwmpu6thrg8zr6uvq2qrhzq?amount=0.001&lightning=lntbs1m1pj9n9xjsp5xgdrmvprtm67p7nq4neparalexlhlmtxx87zx6xeqthsplu842zspp546d6zd2seyaxpapaxx62m88yz3xueqtjmn9v6wj8y56np8weqsxqdqqnp4qdn2hj8tfknpuvdg6tz9yrf3e27ltrx9y58c24jh89lnm43yjwfc5xqrpwjcqpj9qrsgq5sdgh0m3ur5mu5hrmmag4mx9yvy86f83pd0x9ww80kgck6tac3thuzkj0mrtltaxwnlfea95h2re7tj4qsnwzxlvrdmyq2h9mgapnycpppz6k6";
export default function Admin() { export default function Admin() {
return ( const channelOpenResult = () => {
<SafeArea> return {
<DefaultMain> channel: {
<LargeHeader>Storybook</LargeHeader> balance: 100000n,
<OnboardWarning /> reserve: 1000n,
<VStack> outpoint: "123:0"
<AmountCard amountSats={"100000"} fee={"69"} /> },
<ShareCard text={SAMPLE} /> failure_reason: undefined
<Card title="Activity"> };
};
const setChannelOpenResult = (result: any) => {};
return (
<SafeArea>
<DefaultMain>
<LargeHeader>Storybook</LargeHeader>
<OnboardWarning />
<VStack>
<AmountCard amountSats={"100000"} fee={"69"} />
<ShareCard text={SAMPLE} />
{/* <Card title="Activity">
<ActivityItem kind="lightning" labels={["benthecarman"]} amount={100000} date={1683664966} /> <ActivityItem kind="lightning" labels={["benthecarman"]} amount={100000} date={1683664966} />
<ActivityItem kind="onchain" labels={["tony"]} amount={42000000} positive date={1683664966} /> <ActivityItem kind="onchain" labels={["tony"]} amount={42000000} positive date={1683664966} />
<ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} /> <ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} />
<ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} /> <ActivityItem kind="onchain" labels={["a fake name thati is too long"]} amount={42000000} date={1683664966} />
</Card> </Card> */}
</VStack> <FullscreenModal
</DefaultMain> title={channelOpenResult()?.channel ? "Channel Opened" : "Channel Open Failed"}
<NavBar activeTab="none" /> confirmText={channelOpenResult()?.channel ? "Nice" : "Too Bad"}
</SafeArea> open={!!channelOpenResult()}
) setOpen={(open: boolean) => {
} if (!open) setChannelOpenResult(undefined);
}}
onConfirm={() => {
setChannelOpenResult(undefined);
// navigate("/");
}}
>
<div class="flex flex-col items-center gap-8 pb-8">
<Switch>
<Match when={channelOpenResult()?.failure_reason}>
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" />
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
{channelOpenResult()?.failure_reason?.message}
</p>
</Match>
<Match when={true}>
<img
src={megacheck}
alt="success"
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
/>
<AmountCard
amountSats={channelOpenResult()?.channel?.balance.toString()}
reserve={"1000"}
/>
<Show when={channelOpenResult()?.channel?.outpoint}>
<a
class=""
href={mempoolTxUrl(
channelOpenResult()?.channel?.outpoint?.split(":")[0],
"signet"
)}
target="_blank"
rel="noreferrer"
>
Mempool Link
</a>
</Show>
{/* <pre>{JSON.stringify(channelOpenResult()?.channel?.value, null, 2)}</pre> */}
</Match>
</Switch>
</div>
</FullscreenModal>
</VStack>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
);
}

299
src/routes/Swap.tsx Normal file
View File

@@ -0,0 +1,299 @@
import { createForm, required } from "@modular-forms/solid";
import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm";
import { For, Match, Show, Switch, createResource, createSignal } from "solid-js";
import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar";
import { showToast } from "~/components/Toaster";
import {
Button,
Card,
Checkbox,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
SafeArea,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink";
import { TextField } from "~/components/layout/TextField";
import { MethodChooser, SendSource } from "~/routes/Send";
import { useMegaStore } from "~/state/megaStore";
import eify from "~/utils/eify";
import megaex from "~/assets/icons/megaex.png";
import megacheck from "~/assets/icons/megacheck.png";
import { InfoBox } from "~/components/InfoBox";
import { FullscreenModal } from "~/components/layout/FullscreenModal";
import { useNavigate } from "solid-start";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
import { Network } from "~/logic/mutinyWalletSetup";
const CHANNEL_FEE_ESTIMATE_ADDRESS =
"bc1qf7546vg73ddsjznzq57z3e8jdn6gtw6au576j07kt6d9j7nz8mzsyn6lgf";
type PeerConnectForm = {
peer: string;
};
type ChannelOpenDetails = {
channel?: MutinyChannel;
failure_reason?: Error;
};
export default function Swap() {
const [state, actions] = useMegaStore();
const navigate = useNavigate();
const [source, setSource] = createSignal<SendSource>("onchain");
const [amountSats, setAmountSats] = createSignal(0n);
const [useLsp, setUseLsp] = createSignal(true);
const [isConnecting, setIsConnecting] = createSignal(false);
const [selectedPeer, setSelectedPeer] = createSignal<string>("");
const [channelOpenResult, setChannelOpenResult] = createSignal<ChannelOpenDetails>();
const feeEstimate = () => {
if (amountSats()) {
try {
return state.mutiny_wallet?.estimate_tx_fee(
CHANNEL_FEE_ESTIMATE_ADDRESS,
amountSats(),
undefined
);
} catch (e) {
console.error(e);
// showToast(eify(new Error("Unsufficient funds")))
return undefined;
}
}
return undefined;
};
const hasLsp = () => {
return !!localStorage.getItem("MUTINY_SETTINGS_lsp") || !!import.meta.env.VITE_LSP;
};
const getPeers = async () => {
return (await state.mutiny_wallet?.list_peers()) as Promise<MutinyPeer[]>;
};
const [peers, { refetch }] = createResource(getPeers);
const [_peerForm, { Form, Field }] = createForm<PeerConnectForm>();
const onSubmit = async (values: PeerConnectForm) => {
setIsConnecting(true);
try {
const peerConnectString = values.peer.trim();
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
await state.mutiny_wallet?.connect_to_peer(firstNode, peerConnectString);
await refetch();
// If peers list contains the peer we just connected to, select it
const peer = peers()?.find((p) => p.pubkey === peerConnectString.split("@")[0]);
if (peer) {
setSelectedPeer(peer.pubkey);
} else {
showToast(new Error("Peer not found"));
}
} catch (e) {
showToast(eify(e));
} finally {
setIsConnecting(false);
}
};
const handlePeerSelect = (
e: Event & {
currentTarget: HTMLSelectElement;
target: HTMLSelectElement;
}
) => {
setSelectedPeer(e.currentTarget.value);
};
const handleSwap = async () => {
if (canSwap()) {
try {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = (nodes[0] as string) || "";
if (useLsp()) {
const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
undefined,
amountSats()
);
setChannelOpenResult({ channel: new_channel });
} else {
const new_channel = await state.mutiny_wallet?.open_channel(
firstNode,
selectedPeer(),
amountSats()
);
setChannelOpenResult({ channel: new_channel });
}
} catch (e) {
setChannelOpenResult({ failure_reason: eify(e) });
// showToast(eify(e))
}
}
};
const canSwap = () => {
const balance = (state.balance?.confirmed || 0n) + (state.balance?.unconfirmed || 0n);
return (!!selectedPeer() || !!useLsp()) && amountSats() >= 10000n && amountSats() <= balance;
};
const amountWarning = () => {
if (amountSats() < 10000n) {
return "It's just silly to make a channel smaller than 10,000 sats";
}
if (amountSats() > (state.balance?.confirmed ?? 0n) || !feeEstimate()) {
return "You don't have enough funds to make this channel";
}
return undefined;
};
const network = state.mutiny_wallet?.get_network() as Network;
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink />
<LargeHeader>Swap to Lightning</LargeHeader>
<FullscreenModal
title={channelOpenResult()?.channel ? "Channel Opened" : "Channel Open Failed"}
confirmText={channelOpenResult()?.channel ? "Nice" : "Too Bad"}
open={!!channelOpenResult()}
setOpen={(open: boolean) => {
if (!open) setChannelOpenResult(undefined);
}}
onConfirm={() => {
setChannelOpenResult(undefined);
navigate("/");
}}
>
<div class="flex flex-col items-center gap-8 pb-8">
<Switch>
<Match when={channelOpenResult()?.failure_reason}>
<img src={megaex} alt="fail" class="w-1/2 mx-auto max-w-[30vh] flex-shrink" />
<p class="text-xl font-light py-2 px-4 rounded-xl bg-white/10">
{channelOpenResult()?.failure_reason?.message}
</p>
</Match>
<Match when={true}>
<img
src={megacheck}
alt="success"
class="w-1/2 mx-auto max-w-[30vh] flex-shrink"
/>
<AmountCard
amountSats={channelOpenResult()?.channel?.balance?.toString() || ""}
reserve={channelOpenResult()?.channel?.reserve?.toString() || ""}
/>
<Show when={channelOpenResult()?.channel?.outpoint}>
<a
class=""
href={mempoolTxUrl(
channelOpenResult()?.channel?.outpoint?.split(":")[0],
"signet"
)}
target="_blank"
rel="noreferrer"
>
Mempool Link
</a>
</Show>
{/* <pre>{JSON.stringify(channelOpenResult()?.channel?.value, null, 2)}</pre> */}
</Match>
</Switch>
</div>
</FullscreenModal>
<VStack biggap>
<MethodChooser source={source()} setSource={setSource} both={false} />
<VStack>
<Show when={hasLsp()}>
<Checkbox checked={useLsp()} onChange={setUseLsp} label="Use LSP" />
</Show>
<Show when={!useLsp()}>
<Card>
<VStack>
<div class="w-full flex flex-col gap-2">
<label for="peerselect" class="uppercase font-semibold text-sm">
Use existing peer
</label>
<select
name="peerselect"
class="bg-black px-4 py-2 rounded truncate w-full"
onChange={handlePeerSelect}
value={selectedPeer()}
>
<option value="" class="" selected>
Choose a peer
</option>
<For each={peers()}>
{(peer) => (
<option value={peer.pubkey}>{peer.alias ?? peer.pubkey}</option>
)}
</For>
</select>
</div>
<Show when={!selectedPeer()}>
<Form onSubmit={onSubmit} class="flex flex-col gap-4">
<Field name="peer" validate={[required("")]}>
{(field, props) => (
<TextField
{...props}
value={field.value}
error={field.error}
label="Connect to new peer"
placeholder="Peer connect string"
/>
)}
</Field>
<Button layout="small" type="submit" disabled={isConnecting()}>
{isConnecting() ? "Connecting..." : "Connect"}
</Button>
</Form>
</Show>
</VStack>
</Card>
</Show>
</VStack>
<AmountCard
amountSats={amountSats().toString()}
setAmountSats={setAmountSats}
fee={feeEstimate()?.toString()}
isAmountEditable={true}
/>
<Show when={amountWarning() && amountSats() > 0n}>
<InfoBox accent={"red"}>{amountWarning()}</InfoBox>
</Show>
</VStack>
<div class="flex-1" />
<Button
class="w-full flex-grow-0"
disabled={!canSwap()}
intent="blue"
onClick={handleSwap}
loading={false}
>
{"Confirm Swap"}
</Button>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
</MutinyWalletGuard>
);
}