good enough send to send

This commit is contained in:
Paul Miller
2023-04-12 18:46:30 -05:00
parent 3b92e34309
commit be9e1c2cad
14 changed files with 363 additions and 80 deletions

14
pnpm-lock.yaml generated
View File

@@ -2326,7 +2326,7 @@ packages:
postcss: ^8.1.0
dependencies:
browserslist: 4.21.5
caniuse-lite: 1.0.30001477
caniuse-lite: 1.0.30001478
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
@@ -2427,8 +2427,8 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
caniuse-lite: 1.0.30001477
electron-to-chromium: 1.4.359
caniuse-lite: 1.0.30001478
electron-to-chromium: 1.4.360
node-releases: 2.0.10
update-browserslist-db: 1.0.10(browserslist@4.21.5)
@@ -2472,8 +2472,8 @@ packages:
engines: {node: '>= 6'}
dev: true
/caniuse-lite@1.0.30001477:
resolution: {integrity: sha512-lZim4iUHhGcy5p+Ri/G7m84hJwncj+Kz7S5aD4hoQfslKZJgt0tHc/hafVbqHC5bbhHb+mrW2JOUHkI5KH7toQ==}
/caniuse-lite@1.0.30001478:
resolution: {integrity: sha512-gMhDyXGItTHipJj2ApIvR+iVB5hd0KP3svMWWXDvZOmjzJJassGLMfxRkQCSYgGd2gtdL/ReeiyvMSFD1Ss6Mw==}
/chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
@@ -2735,8 +2735,8 @@ packages:
jake: 10.8.5
dev: true
/electron-to-chromium@1.4.359:
resolution: {integrity: sha512-OoVcngKCIuNXtZnsYoqlCvr0Cf3NIPzDIgwUfI9bdTFjXCrr79lI0kwQstLPZ7WhCezLlGksZk/BFAzoXC7GDw==}
/electron-to-chromium@1.4.360:
resolution: {integrity: sha512-EP/jdF15S+l3iSSzgUpUqeazvkbVFXNuVxwwLMVUSie3lUeH1HH70gKe0IS7TASB/0h5QPG2bLMzv2jelSztIQ==}
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}

View File

@@ -0,0 +1,5 @@
<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z" fill="#000"/>
<path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="#000"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

8
src/assets/svg/Paste.tsx Normal file
View File

@@ -0,0 +1,8 @@
export function Paste() {
return (<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z" fill="currentColor" />
<path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z" fill="currentColor" />
</svg>)
}

6
src/assets/svg/Scan.tsx Normal file
View File

@@ -0,0 +1,6 @@
export function Scan() {
return (<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 3H30.5C32.1569 3 33.5 4.34315 33.5 6V10.5H36.5V6C36.5 2.68629 33.8137 0 30.5 0H26V3ZM11 3V0H6.5C3.18629 0 0.5 2.68629 0.5 6V10.5H3.5V6C3.5 4.34315 4.84315 3 6.5 3H11ZM3.5 25.5H0.5V30C0.5 33.3137 3.18629 36 6.5 36H11V33H6.5C4.84315 33 3.5 31.6569 3.5 30V25.5ZM26 33V36H30.5C33.8137 36 36.5 33.3137 36.5 30V25.5H33.5V30C33.5 31.6569 32.1569 33 30.5 33H26Z" fill="currentColor" />
</svg>)
}

35
src/components/Amount.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Show, createResource } from "solid-js"
import { useMegaStore } from "~/state/megaStore"
import { satsToUsd } from "~/utils/conversions"
function prettyPrintAmount(n?: number | bigint): string {
if (!n || n.valueOf() === 0) {
return "0"
}
return n.toLocaleString()
}
export function Amount(props: { amountSats: bigint | number | undefined, showFiat?: boolean }) {
const [state, _] = useMegaStore()
async function getPrice() {
return await state.node_manager?.get_bitcoin_price()
}
const [price] = createResource(getPrice)
const amountInUsd = () => satsToUsd(price(), Number(props.amountSats) || 0, true)
return (
<div class="flex flex-col gap-2">
<h1 class="text-4xl font-light">
{prettyPrintAmount(props.amountSats)} <span class='text-xl'>SAT</span>
</h1>
<Show when={props.showFiat}>
<h2 class="text-xl font-light text-white/70" >
&#8776; {amountInUsd()} <span class="text-sm">USD</span>
</h2>
</Show>
</div>
)
}

View File

@@ -1,8 +1,9 @@
import { Motion, Presence } from "@motionone/solid";
import { createResource, Show, Suspense } from "solid-js";
import { ButtonLink } from "~/components/layout";
import { ButtonLink, SmallHeader } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { Amount } from "./Amount";
function prettyPrintAmount(n?: number | bigint): string {
if (!n || n.valueOf() === 0) {
@@ -32,17 +33,18 @@ export default function BalanceBox() {
transition={{ duration: 0.5, easing: [0.87, 0, 0.13, 1] }}
>
<div class='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
<header class='text-sm font-semibold uppercase'>
Balance
</header>
<SmallHeader>
Lightning Balance
</SmallHeader>
<div onClick={refetchBalance}>
<h1 class='text-4xl font-light'>
<Suspense fallback={"..."}>
<Show when={balance()}>
<div class="flex flex-col gap-4">
<div>
{prettyPrintAmount(balance()?.confirmed)} <span class='text-xl'>SAT</span>
</div>
<Amount amountSats={balance()?.lightning} showFiat />
<SmallHeader>
On-Chain Balance
</SmallHeader>
<Amount amountSats={balance()?.confirmed} showFiat />
<Show when={balance()?.unconfirmed}>
<div class="flex flex-col gap-2">
<header class='text-sm font-semibold uppercase text-white/50'>
@@ -56,7 +58,6 @@ export default function BalanceBox() {
</div>
</Show>
</Suspense>
</h1>
</div>
<div class="flex gap-2 py-4">
<ButtonLink href="/send" intent="green">Send</ButtonLink>

View File

@@ -1,5 +1,5 @@
import { useMegaStore } from "~/state/megaStore";
import { Card, Hr, SmallHeader, Button } from "~/components/layout";
import { Card, Hr, SmallHeader, Button, InnerCard } from "~/components/layout";
import PeerConnectModal from "~/components/PeerConnectModal";
import { For, Show, Suspense, createResource, createSignal } from "solid-js";
import { MutinyChannel, MutinyPeer } from "@mutinywallet/node-manager";
@@ -60,7 +60,8 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
};
return (
<form class="border border-white/20 rounded-xl p-4 flex flex-col gap-4" onSubmit={onSubmit} >
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
<TextField.Root
value={value()}
onValueChange={setValue}
@@ -73,6 +74,7 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
</TextField.Root>
<Button layout="small" type="submit">Connect</Button>
</form >
</InnerCard>
)
}
@@ -156,7 +158,8 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
return (
<>
<form class="border border-white/20 rounded-xl p-2 flex flex-col gap-4" onSubmit={onSubmit} >
<InnerCard>
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
<TextField.Root
value={peerPubkey()}
onValueChange={setPeerPubkey}
@@ -177,6 +180,7 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
</TextField.Root>
<Button layout="small" type="submit">Open Channel</Button>
</form >
</InnerCard>
<Show when={newChannel()}>
<pre class="overflow-x-auto whitespace-pre-line break-all">
{JSON.stringify(newChannel()?.outpoint, null, 2)}

View File

@@ -3,19 +3,20 @@ import { children, JSX, ParentComponent, splitProps } from "solid-js";
import { Dynamic } from "solid-js/web";
import { A } from "solid-start";
const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold", "disabled:opacity-50", "transition"], {
const button = cva("p-4 rounded-xl text-xl font-semibold disabled:opacity-50 disabled:grayscale transition", {
variants: {
intent: {
active: "bg-white text-black",
inactive: "bg-black text-white border border-white hover:enabled:text-[#3B6CCC]",
blue: "bg-m-blue text-white shadow-inner-button hover:bg-m-blue-dark text-shadow-button",
red: "bg-m-red text-white shadow-inner-button hover:bg-m-red-dark text-shadow-button",
green: "bg-m-green text-white shadow-inner-button hover:bg-m-green-dark text-shadow-button",
active: "bg-white text-black border border-white enabled:hover:text-[#3B6CCC]",
inactive: "bg-black text-white border border-white enabled:hover:text-[#3B6CCC]",
// TODO: not sure what breaks these hover states, they work in tailwind playground
blue: "bg-m-blue text-white shadow-inner-button enabled:hover:bg-m-blue-dark text-shadow-button",
red: "bg-m-red text-white shadow-inner-button enabled:bg-m-red-dark text-shadow-button",
green: "bg-m-green text-white shadow-inner-button enabled:bg-m-green-dark text-shadow-button",
},
layout: {
flex: "flex-1",
pad: "px-8",
small: "p-1 w-auto",
small: "px-4 py-2 w-auto text-lg",
},
},

View File

@@ -12,7 +12,15 @@ const Card: ParentComponent<{ title?: string }> = (props) => {
{props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.children}
</div>
)
}
const InnerCard: ParentComponent<{ title?: string }> = (props) => {
return (
<div class='rounded-xl p-4 flex flex-col gap-2 border border-white/10 bg-[rgba(255,255,255,0.05)]'>
{props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.children}
</div>
)
}
@@ -61,4 +69,4 @@ const LoadingSpinner = () => {
const Hr = () => <Separator.Root class="my-4 border-white/20" />
export { SmallHeader, Card, SafeArea, LoadingSpinner, Button, ButtonLink, Linkify, Hr, NodeManagerGuard, FullscreenLoader }
export { SmallHeader, Card, SafeArea, LoadingSpinner, Button, ButtonLink, Linkify, Hr, NodeManagerGuard, FullscreenLoader, InnerCard }

View File

@@ -40,3 +40,8 @@ a {
#video-container .scan-region-highlight-svg {
display: none;
}
/* Missing you sveltekit */
dd {
@apply mb-8 mt-4;
}

View File

@@ -1,11 +1,205 @@
import { TextField } from "@kobalte/core";
import { Show, createResource, createSignal } from "solid-js";
import { Amount } from "~/components/Amount";
import NavBar from "~/components/NavBar";
import { SafeArea } from "~/components/layout";
import { Button, InnerCard, SafeArea, SmallHeader } from "~/components/layout";
import { Paste } from "~/assets/svg/Paste";
import { Scan } from "~/assets/svg/Scan";
import { Motion, Presence } from "@motionone/solid";
import { useMegaStore } from "~/state/megaStore";
import { MutinyInvoice, NodeManager } from "@mutinywallet/node-manager";
import { bip21decode } from "~/utils/TEMPbip21";
type SendSource = "lightning" | "onchain";
export default function Send() {
const [state, _] = useMegaStore();
// These can only be set by the user
const [destination, setDestination] = createSignal("");
const [privateLabel, setPrivateLabel] = createSignal("");
// These can be derived from the "destination" signal or set by the user
const [amountSats, setAmountSats] = createSignal(0n);
const [source, setSource] = createSignal<SendSource>("lightning");
// These can only be derived from the "destination" signal
const [invoice, setInvoice] = createSignal<MutinyInvoice>();
const [address, setAddress] = createSignal<string>();
const [description, setDescription] = createSignal<string>();
async function decode(source: string) {
if (!source) return;
try {
const { address, label, lightning, amount } = bip21decode(source);
setAddress(address)
if (lightning) {
const invoice = await state.node_manager?.decode_invoice(lightning);
if (invoice?.amount_sats) setAmountSats(invoice.amount_sats);
setInvoice(invoice)
// We can stick with default lightning because there's an invoice
setSource("lightning")
} else {
// If we can't use the lightning amount we have to use the float btc amount
const amt = NodeManager.convert_btc_to_sats(amount || 0);
setAmountSats(amt);
// We use onchain because there's no invoice
setSource("onchain")
}
if (label) setDescription(label);
setInvoice(invoice)
return invoice
} catch (e) {
console.error("error", e)
}
}
// IMPORTANT: pass the signal but don't "call" the signal (`destination`, not `destination()`)
const [_decodedDestination] = createResource(destination, decode);
let labelInput!: HTMLInputElement;
function handlePaste() {
navigator.clipboard.readText().then(text => {
setDestination(text);
labelInput.focus();
});
}
async function handleSend() {
const bolt11 = invoice()?.bolt11;
if (source() === "lightning" && invoice() && bolt11) {
const nodes = await state.node_manager?.list_nodes();
const firstNode = nodes[0] as string || ""
// If the invoice has sats use that, otherwise we pass the user-defined amount
if (invoice()?.amount_sats) {
await state.node_manager?.pay_invoice(firstNode, bolt11);
} else {
await state.node_manager?.pay_invoice(firstNode, bolt11, amountSats());
}
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const txid = await state.node_manager?.send_to_address(address()!, amountSats());
console.error(txid)
}
console.error("SENT");
}
return (
<SafeArea main>
<div class="w-full max-w-[400px] flex flex-col gap-4">
<h1 class="text-2xl font-semibold uppercase border-b-2 border-b-white">Send Bitcoin</h1>
<dl>
<dt>
<SmallHeader>
Source
</SmallHeader>
</dt>
<dd>
<div class="flex gap-4 items-start">
<Button onClick={() => setSource("lightning")} intent={source() === "lightning" ? "active" : "inactive"} layout="small">Lightning</Button>
<Button onClick={() => setSource("onchain")} intent={source() === "onchain" ? "active" : "inactive"} layout="small">On-Chain</Button>
</div>
</dd>
<dt>
<SmallHeader>
How Much
</SmallHeader>
</dt>
<dd>
<Amount amountSats={amountSats() || 0} showFiat />
</dd>
<dt>
<SmallHeader>Destination</SmallHeader>
</dt>
<dd>
<InnerCard>
<Show when={destination()} fallback={<div class="flex flex-row gap-4">
<Button onClick={handlePaste}>
<div class="flex flex-col gap-2 items-center">
{/* <img src={scan} class="w-8 h-8 text-white" /> */}
<Paste />
<span>Paste</span>
</div>
</Button>
<Button>
<div class="flex flex-col gap-2 items-center">
{/* <img src={scan} class="w-8 h-8 text-white" /> */}
<Scan />
<span>Scan QR</span>
</div>
</Button>
</div>}>
<Presence>
<Motion
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<div class="flex flex-col gap-2">
<Show when={address()}>
<code class="line-clamp-3 text-sm break-all">{source() === "onchain" && "→"} {address()}</code>
</Show>
<Show when={invoice()}>
<code class="line-clamp-3 text-sm break-all">{source() === "lightning" && "→"} {invoice()?.bolt11}</code>
</Show>
</div>
{/* <code class="line-clamp-3 text-sm break-all mb-2">{destination()}</code> */}
</Motion>
</Presence>
</Show>
</InnerCard>
</dd>
<Show when={description()}>
<dt>
<SmallHeader>Description</SmallHeader>
</dt>
<dd>
<code class="line-clamp-3 text-sm break-all">{description()}</code>
</dd>
</Show>
<TextField.Root
value={privateLabel()}
onValueChange={setPrivateLabel}
class="flex flex-col gap-2"
>
<dt>
<SmallHeader>
<TextField.Label>Label (private)</TextField.Label>
</SmallHeader>
</dt>
<dd>
<TextField.Input
autofocus
ref={el => labelInput = el}
class="w-full p-2 rounded-lg text-black"
placeholder="A helpful reminder of why you spent bitcoin"
/>
</dd>
</TextField.Root>
</dl>
<Button disabled={!destination()} intent="blue" onClick={handleSend}>Confirm Send</Button>
<Show when={destination()}>
<Button intent="inactive" onClick={() => setDestination("")}>Clear</Button>
</Show>
</div>
<NavBar activeTab="send" />
</SafeArea >

View File

@@ -1,10 +1,10 @@
import App from "~/components/App";
import { Switch, Match, Suspense, Show } from "solid-js";
import { Switch, Match } from "solid-js";
import { WaitlistAlreadyIn } from "~/components/waitlist/WaitlistAlreadyIn";
import WaitlistForm from "~/components/waitlist/WaitlistForm";
import { useMegaStore } from "~/state/megaStore";
import { FullscreenLoader, LoadingSpinner } from "~/components/layout";
import { FullscreenLoader } from "~/components/layout";
export default function Home() {
const [state, _] = useMegaStore();
@@ -24,6 +24,5 @@ export default function Home() {
</Match>
</Switch>
</>
);
}

18
src/utils/TEMPbip21.ts Normal file
View File

@@ -0,0 +1,18 @@
// Take in a string that looks like this:
// bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl
// and return an object with this shape: { address: string, amount: number, label: string, lightning: string }
// using typescript type annotations
export function bip21decode(bip21: string): { address: string, amount?: number, label?: string, lightning?: string } {
const [scheme, data] = bip21.split(':')
if (scheme !== 'bitcoin') {
throw new Error('Not a bitcoin URI')
}
const [address, query] = data.split('?')
const params = new URLSearchParams(query)
return {
address,
amount: Number(params.get('amount')) || undefined,
label: params.get('label') || undefined,
lightning: params.get('lightning') || undefined
}
}

View File

@@ -28,7 +28,7 @@ module.exports = {
backgroundImage: {
'fade-to-blue': 'linear-gradient(1.63deg, #0B215B 32.05%, rgba(11, 33, 91, 0) 84.78%)',
'subtle-fade': 'linear-gradient(180deg, #060A13 0%, #131E39 100%)',
'richer-fade': 'linear-gradient(180deg, #050914 0%, #0A1329 100%)'
'richer-fade': 'linear-gradient(180deg, hsla(224, 20%, 8%, 1) 0%, hsla(224, 20%, 15%, 1) 100%)'
},
dropShadow: {
'blue-glow': '0px 0px 32px rgba(11, 33, 91, 0.5)',
@@ -80,7 +80,6 @@ module.exports = {
}
}
}
addUtilities(newUtilities);
}),
// Text shadow!