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

@@ -6,18 +6,20 @@ import { AmountEditable } from "./AmountEditable";
const noop = () => {
// do nothing
}
};
const KeyValue: ParentComponent<{ key: string, gray?: boolean }> = (props) => {
const KeyValue: ParentComponent<{ key: string; gray?: boolean }> = (props) => {
return (
<div class="flex justify-between items-center" classList={{ "text-neutral-400": props.gray }}>
<div class="font-semibold uppercase">{props.key}</div>
<div class="font-light">{props.children}</div>
</div>
)
}
);
};
export const InlineAmount: ParentComponent<{ amount: string, sign?: string, fiat?: boolean }> = (props) => {
export const InlineAmount: ParentComponent<{ amount: string; sign?: string; fiat?: boolean }> = (
props
) => {
const prettyPrint = createMemo(() => {
const parsed = Number(props.amount);
if (isNaN(parsed)) {
@@ -25,30 +27,48 @@ export const InlineAmount: ParentComponent<{ amount: string, sign?: string, fiat
} 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>)
}
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)
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 class="self-end">
&#8776; {amountInUsd()}&nbsp;<span class="text-sm">USD</span>
</div>
</KeyValue>
</Show>
)
);
}
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 Number(a || 0) - Number(b || 0);
}
export function AmountCard(props: {
amountSats: string;
fee?: string;
reserve?: string;
initialOpen?: boolean;
isAmountEditable?: boolean;
setAmountSats?: (amount: bigint) => void;
}) {
return (
<Card>
<VStack>
@@ -56,9 +76,15 @@ export function AmountCard(props: { amountSats: string, fee?: string, initialOpe
<Match when={props.fee}>
<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
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>
<KeyValue gray key="+ Fee">
@@ -73,12 +99,35 @@ export function AmountCard(props: { amountSats: string, fee?: string, initialOpe
<USDShower amountSats={props.amountSats} fee={props.fee} />
</div>
</Match>
<Match when={!props.fee}>
<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
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} />
@@ -87,5 +136,5 @@ export function AmountCard(props: { amountSats: string, fee?: string, initialOpe
</Switch>
</VStack>
</Card>
)
);
}

View File

@@ -2,7 +2,8 @@ import { Show, Suspense } from "solid-js";
import { Button, ButtonLink, FancyCard, Indicator } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
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 {
if (!n || n.valueOf() === 0) {
@@ -22,6 +23,8 @@ export function LoadingShimmer() {
</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 }) {
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>}>
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<div onClick={actions.sync}>
<div class="flex justify-between">
<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>
</Show>
<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
value={value()}
onChange={setValue}
validationState={(value() == "" || value().startsWith("mutiny:")) ? "valid" : "invalid"}
validationState={(value() == "") ? "valid" : "invalid"}
class="flex flex-col gap-4"
>
<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 =>
<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`}
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"}>
<RadioGroup.ItemInput />

View File

@@ -39,3 +39,15 @@ a {
#video-container .scan-region-highlight-svg {
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 { Amount } from "~/components/Amount";
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 { Scan } from "~/assets/svg/Scan";
import { useMegaStore } from "~/state/megaStore";
@@ -11,7 +22,7 @@ import { ParsedParams, toParsedParams } from "./Scanner";
import { showToast } from "~/components/Toaster";
import eify from "~/utils/eify";
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 mempoolTxUrl from "~/utils/mempoolTxUrl";
import { BackLink } from "~/components/layout/BackLink";
@@ -22,50 +33,92 @@ import { AmountCard } from "~/components/AmountCard";
import { MutinyTagItem } from "~/utils/tags";
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_ADDRESS = "tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe"
// 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: {
source: SendSource;
setSource: (source: string) => void;
both?: boolean;
}) {
const [store, _actions] = useMegaStore();
const methods = createMemo(() => {
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`
: "No balance"
},
{
value: "onchain",
label: "On-chain Balance",
caption: store.balance?.confirmed
? `${store.balance?.confirmed.toLocaleString()} SATS`
: "No balance"
}
];
});
return (
<Switch>
<Match when={props.both}>
<StyledRadioGroup accent="white" value={props.source} onValueChange={props.setSource} choices={methods()} />
<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]]} />
<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]]} />
<StyledRadioGroup
accent="white"
value={props.source}
onValueChange={props.setSource}
choices={[methods()[1]]}
/>
</Match>
</Switch>
)
);
}
function DestinationInput(props: {
fieldDestination: string,
setFieldDestination: (destination: string) => void,
handleDecode: () => void,
handlePaste: () => void,
fieldDestination: string;
setFieldDestination: (destination: string) => void;
handleDecode: () => void;
handlePaste: () => void;
}) {
return (
<VStack>
<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" />
<Button disabled={!props.fieldDestination} intent="blue" onClick={props.handleDecode}>Decode</Button>
<textarea
value={props.fieldDestination}
onInput={(e) => props.setFieldDestination(e.currentTarget.value)}
placeholder="bitcoin:..."
class="p-2 rounded-lg bg-white/10 placeholder-neutral-400"
/>
<Button disabled={!props.fieldDestination} intent="blue" onClick={props.handleDecode}>
Decode
</Button>
<HStack>
<Button onClick={props.handlePaste}>
<div class="flex flex-col gap-2 items-center">
@@ -81,17 +134,17 @@ function DestinationInput(props: {
</ButtonLink>
</HStack>
</VStack>
)
);
}
function DestinationShower(props: {
source: SendSource,
description?: string,
address?: string,
invoice?: MutinyInvoice,
nodePubkey?: string,
lnurl?: string,
clearAll: () => void,
source: SendSource;
description?: string;
address?: string;
invoice?: MutinyInvoice;
nodePubkey?: string;
lnurl?: string;
clearAll: () => void;
}) {
return (
<Switch>
@@ -108,13 +161,12 @@ function DestinationShower(props: {
<StringShower text={props.lnurl || ""} />
</Match>
</Switch>
)
);
}
export default function Send() {
const [state, actions] = useMegaStore();
const navigate = useNavigate()
const navigate = useNavigate();
// These can only be set by the user
const [fieldDestination, setFieldDestination] = createSignal("");
@@ -157,53 +209,53 @@ export default function Send() {
return state.mutiny_wallet?.estimate_tx_fee(address()!, amountSats(), undefined);
}
return undefined
}
return undefined;
};
onMount(() => {
if (state.scan_result) {
setDestination(state.scan_result);
actions.setScanResult(undefined);
}
})
});
// Rerun every time the destination changes
createEffect(() => {
const source = destination();
if (!source) return undefined;
try {
if (source.address) setAddress(source.address)
if (source.address) setAddress(source.address);
if (source.memo) setDescription(source.memo);
if (source.invoice) {
state.mutiny_wallet?.decode_invoice(source.invoice).then(invoice => {
state.mutiny_wallet?.decode_invoice(source.invoice).then((invoice) => {
if (invoice?.amount_sats) setAmountSats(invoice.amount_sats);
setInvoice(invoice)
setSource("lightning")
setInvoice(invoice);
setSource("lightning");
});
} else if (source.node_pubkey) {
setAmountSats(source.amount_sats || 0n);
setNodePubkey(source.node_pubkey);
setSource("lightning")
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")
setSource("lightning");
}
})
});
} else {
setAmountSats(source.amount_sats || 0n);
setSource("onchain")
setSource("onchain");
}
// Return the source just to trigger `decodedDestination` as not undefined
return source
return source;
} catch (e) {
console.error("error", e)
console.error("error", e);
clearAll();
}
})
});
function parsePaste(text: string) {
if (text) {
@@ -213,7 +265,12 @@ export default function Send() {
showToast(result.error);
return;
} else {
if (result.value?.address || result.value?.invoice || result.value?.node_pubkey || result.value?.lnurl) {
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);
@@ -230,27 +287,30 @@ export default function Send() {
function handlePaste() {
if (!navigator.clipboard.readText) return showToast(new Error("Clipboard not supported"));
navigator.clipboard.readText().then(text => {
navigator.clipboard
.readText()
.then((text) => {
setFieldDestination(text);
parsePaste(text);
}).catch((e) => {
showToast(new Error("Failed to read clipboard: " + e.message))
})
.catch((e) => {
showToast(new Error("Failed to read clipboard: " + e.message));
});
}
async function processContacts(contacts: Partial<MutinyTagItem>[]): Promise<string[]> {
console.log("Processing contacts", contacts)
console.log("Processing contacts", contacts);
if (contacts.length) {
const first = contacts![0];
if (!first.name) {
console.error("Something went wrong with contact creation, proceeding anyway")
return []
console.error("Something went wrong with contact creation, proceeding anyway");
return [];
}
if (!first.id && first.name) {
console.error("Creating new contact", first.name)
console.error("Creating new contact", first.name);
const c = new Contact(first.name, undefined, undefined, undefined);
const newContactId = await state.mutiny_wallet?.create_new_contact(c);
if (newContactId) {
@@ -259,15 +319,13 @@ export default function Send() {
}
if (first.id) {
console.error("Using existing contact", first.name, 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 []
console.error("Something went wrong with contact creation, proceeding anyway");
return [];
}
async function handleSend() {
@@ -280,7 +338,7 @@ export default function Send() {
if (source() === "lightning" && invoice() && bolt11) {
const nodes = await state.mutiny_wallet?.list_nodes();
const firstNode = nodes[0] as string || ""
const firstNode = (nodes[0] as string) || "";
sentDetails.destination = bolt11;
// If the invoice has sats use that, otherwise we pass the user-defined amount
if (invoice()?.amount_sats) {
@@ -292,22 +350,32 @@ export default function Send() {
}
} 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);
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")
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);
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")
throw new Error("Lnurl Pay failed");
} else {
sentDetails.amount = amountSats();
}
@@ -323,7 +391,7 @@ export default function Send() {
setSentDetails(sentDetails as SentDetails);
clearAll();
} catch (e) {
const error = eify(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))
@@ -335,7 +403,7 @@ export default function Send() {
const sendButtonDisabled = createMemo(() => {
return !destination() || sending() || amountSats() === 0n;
})
});
return (
<MutinyWalletGuard>
@@ -349,20 +417,31 @@ export default function Send() {
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("/"); }}
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>
<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">
<a
href={mempoolTxUrl(sentDetails()?.txid, state.mutiny_wallet?.get_network())}
target="_blank"
rel="noreferrer"
>
Mempool Link
</a>
</Show>
@@ -373,28 +452,61 @@ export default function Send() {
<VStack biggap>
<Switch>
<Match when={address() || invoice() || nodePubkey() || lnurlp()}>
<MethodChooser source={source()} setSource={setSource} both={!!address() && !!invoice()} />
<MethodChooser
source={source()}
setSource={setSource}
both={!!address() && !!invoice()}
/>
<Card>
<VStack>
<DestinationShower source={source()} description={description()} invoice={invoice()} address={address()} nodePubkey={nodePubkey()} lnurl={lnurlp()} clearAll={clearAll} />
<DestinationShower
source={source()}
description={description()}
invoice={invoice()}
address={address()}
nodePubkey={nodePubkey()}
lnurl={lnurlp()}
clearAll={clearAll}
/>
<SmallHeader>Private tags</SmallHeader>
<TagEditor selectedValues={selectedContacts()} setSelectedValues={setSelectedContacts} placeholder="Add the receiver for your records" />
<TagEditor
selectedValues={selectedContacts()}
setSelectedValues={setSelectedContacts}
placeholder="Add the receiver for your records"
/>
</VStack>
</Card>
<AmountCard amountSats={amountSats().toString()} setAmountSats={setAmountSats} fee={feeEstimate()?.toString()} isAmountEditable={!(invoice()?.amount_sats)} />
<AmountCard
amountSats={amountSats().toString()}
setAmountSats={setAmountSats}
fee={feeEstimate()?.toString()}
isAmountEditable={!invoice()?.amount_sats}
/>
</Match>
<Match when={true}>
<DestinationInput fieldDestination={fieldDestination()} setFieldDestination={setFieldDestination} handleDecode={handleDecode} handlePaste={handlePaste} />
<DestinationInput
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>
<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,12 +1,32 @@
import { Match, Show, Switch } from "solid-js";
import { ActivityItem } from "~/components/ActivityItem";
import { Amount } from "~/components/Amount";
import { AmountCard } from "~/components/AmountCard";
import NavBar from "~/components/NavBar";
import { OnboardWarning } from "~/components/OnboardWarning";
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() {
const channelOpenResult = () => {
return {
channel: {
balance: 100000n,
reserve: 1000n,
outpoint: "123:0"
},
failure_reason: undefined
};
};
const setChannelOpenResult = (result: any) => {};
return (
<SafeArea>
<DefaultMain>
@@ -15,15 +35,64 @@ export default function Admin() {
<VStack>
<AmountCard amountSats={"100000"} fee={"69"} />
<ShareCard text={SAMPLE} />
<Card title="Activity">
{/* <Card title="Activity">
<ActivityItem kind="lightning" labels={["benthecarman"]} amount={100000} 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} />
</Card>
</Card> */}
<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={"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>
);
}