From 4064cb1364e9ce3a20465fe5afd4edfbdc41ca49 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Mon, 19 Dec 2022 11:52:04 -0600 Subject: [PATCH 1/3] handle underscores in receive --- frontend/src/routes/Receive.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/Receive.tsx b/frontend/src/routes/Receive.tsx index 54170e008..5e0a707bf 100644 --- a/frontend/src/routes/Receive.tsx +++ b/frontend/src/routes/Receive.tsx @@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; import ActionButton from "@components/ActionButton"; import MutinyToaster from "@components/MutinyToaster"; +import prettyPrintAmount from "@util/prettyPrintAmount"; function Receive() { let navigate = useNavigate(); @@ -19,7 +20,7 @@ function Receive() { const queryClient = useQueryClient() async function handleContinue() { - const amount = receiveAmount.replace(/,/g, "") + const amount = receiveAmount.replace(/_/g, "") if (amount.match(/\D/)) { setAmount('') toast("That doesn't look right") @@ -33,6 +34,22 @@ function Receive() { } } + function setAmountFormatted(value: string) { + if (value.length === 0) { + setAmount('') + return + } + //Use a regex to replace all commas and underscores with empty string + const amount = value.replace(/[_,]/g, ""); + let parsedAmount = parseInt(amount) + if (typeof parsedAmount === "number" && parsedAmount !== 0) { + setAmount(prettyPrintAmount(parseInt(amount))) + } else { + setAmount('') + } + } + + return ( <>
@@ -43,7 +60,7 @@ function Receive() {

Want some sats?

- setAmount(e.target.value)} value={receiveAmount} className={`w-full ${inputStyle({ accent: "blue" })}`} type="text" inputMode="numeric" placeholder='How much? (optional)' /> + setAmountFormatted(e.target.value)} value={receiveAmount} className={`w-full ${inputStyle({ accent: "blue" })}`} type="text" inputMode="numeric" placeholder='How much? (optional)' /> setDescription(e.target.value)} className={`w-full ${inputStyle({ accent: "blue" })}`} type="text" placeholder='What for? (optional)' />
handleContinue()}> From 145438257626f4707a133e4da897f7a9861edd2e Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Mon, 19 Dec 2022 17:55:10 -0600 Subject: [PATCH 2/3] amountinput component with switchable currency --- frontend/src/components/AmountInput.tsx | 113 ++++++++++++++++++++++++ frontend/src/index.css | 21 +++++ frontend/src/routes/Receive.tsx | 20 +---- frontend/src/routes/SendAmount.tsx | 13 +-- frontend/src/styles/index.ts | 12 ++- 5 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/AmountInput.tsx diff --git a/frontend/src/components/AmountInput.tsx b/frontend/src/components/AmountInput.tsx new file mode 100644 index 000000000..52ac4bf5a --- /dev/null +++ b/frontend/src/components/AmountInput.tsx @@ -0,0 +1,113 @@ +import { useQuery } from "@tanstack/react-query"; +import prettyPrintAmount from "@util/prettyPrintAmount"; +import { Dispatch, SetStateAction, useContext, useState } from "react"; +import { inputStyle, selectStyle } from "../styles"; +import { NodeManagerContext } from "./GlobalStateProvider"; + +type Props = { + amountSats: string, + setAmount: Dispatch>, + accent: "green" | "blue" | "red", + placeholder: string, +} + +export enum Currency { + USD = "USD", + SATS = "SATS" +} + +function satsToUsd(amount: number, price: number): string { + let btc = amount / 100000000; + let usd = btc * price; + return usd.toFixed(2); +} + +function usdToSats(amount: number, price: number): string { + let btc = amount / price; + let sats = btc * 100000000; + return sats.toFixed(0); +} + +export default function AmountInput({ amountSats, setAmount, accent, placeholder }: Props) { + const [currency, setCurrency] = useState(Currency.SATS) + + // amountSats will be our source of truth, this is just for showing to the user + const [localDisplayAmount, setLocalDisplayAmount] = useState(amountSats) + + const { nodeManager } = useContext(NodeManagerContext); + + const { data: price } = useQuery({ + queryKey: ['price'], + queryFn: async () => { + console.log("Checking bitcoin price...") + return await nodeManager?.get_bitcoin_price() + }, + enabled: !!nodeManager, + }) + + function setAmountFormatted(value: string) { + if (value.length === 0 || value === "0") { + setAmount('') + setLocalDisplayAmount('') + return + } + //Use a regex to replace all commas and underscores with empty string + const amount = value.replace(/[_,]/g, ""); + let parsedAmount = parseInt(amount) + if (typeof parsedAmount === "number" && parsedAmount !== 0 && price) { + // If the currency is set to usd, we need to convert the input amount to sats and store that in setAmount + if (currency === Currency.USD) { + setAmount(usdToSats(parsedAmount, price)) + // Then we set the usd amount to localDisplayAmount + setLocalDisplayAmount(prettyPrintAmount(parsedAmount)) + } else { + // Otherwise we can just set the amount to the parsed amount + setAmount(prettyPrintAmount(parsedAmount)) + // And the localDisplayAmount to the same thing + setLocalDisplayAmount(prettyPrintAmount(parsedAmount)) + } + } else { + setAmount('') + setLocalDisplayAmount('') + } + } + + function handleCurrencyChange(value: Currency) { + setCurrency(value) + const amount = amountSats.replace(/[_,]/g, ""); + let parsedAmount = parseInt(amount) + if (typeof parsedAmount === "number" && price) { + if (value === Currency.USD) { + // Then we set the usd amount to localDisplayAmount + let dollars = satsToUsd(parsedAmount, price) + let parsedDollars = Number(dollars); + if (parsedDollars === 0) { + // 0 looks lame + setLocalDisplayAmount(""); + } else { + setLocalDisplayAmount(prettyPrintAmount(parsedDollars)) + } + } else if (value === Currency.SATS) { + // And the localDisplayAmount to the same thing + setLocalDisplayAmount(prettyPrintAmount(parsedAmount)) + } + } + } + + return ( + <> + {/* HANDY DEBUGGER FOR UNDERSTANDING */} + {/*
{JSON.stringify({ currency, amountSats, localDisplayAmount }, null, 2)}
*/} +
+ setAmountFormatted(e.target.value)} value={localDisplayAmount} className={inputStyle({ accent, width: "wide" })} type="text" inputMode={currency === Currency.SATS ? "numeric" : "decimal"} placeholder={placeholder} /> +
+ +
+
+ + ) + +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 9d16d39e6..4e30d6e41 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -136,3 +136,24 @@ dd { .fileInputButton:hover { cursor: pointer; } + +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; +} + +.select-wrapper { + @apply relative; +} + +.select-wrapper::after { + /* A down arrow because the platform sucks */ + content: 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"); + right: 0.5rem; + top: 1.2rem; + width: 1.5rem; + @apply absolute; +} diff --git a/frontend/src/routes/Receive.tsx b/frontend/src/routes/Receive.tsx index 5e0a707bf..65106c909 100644 --- a/frontend/src/routes/Receive.tsx +++ b/frontend/src/routes/Receive.tsx @@ -10,7 +10,7 @@ import { useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; import ActionButton from "@components/ActionButton"; import MutinyToaster from "@components/MutinyToaster"; -import prettyPrintAmount from "@util/prettyPrintAmount"; +import AmountInput from "@components/AmountInput"; function Receive() { let navigate = useNavigate(); @@ -34,22 +34,6 @@ function Receive() { } } - function setAmountFormatted(value: string) { - if (value.length === 0) { - setAmount('') - return - } - //Use a regex to replace all commas and underscores with empty string - const amount = value.replace(/[_,]/g, ""); - let parsedAmount = parseInt(amount) - if (typeof parsedAmount === "number" && parsedAmount !== 0) { - setAmount(prettyPrintAmount(parseInt(amount))) - } else { - setAmount('') - } - } - - return ( <>
@@ -60,7 +44,7 @@ function Receive() {

Want some sats?

- setAmountFormatted(e.target.value)} value={receiveAmount} className={`w-full ${inputStyle({ accent: "blue" })}`} type="text" inputMode="numeric" placeholder='How much? (optional)' /> + setDescription(e.target.value)} className={`w-full ${inputStyle({ accent: "blue" })}`} type="text" placeholder='What for? (optional)' />
handleContinue()}> diff --git a/frontend/src/routes/SendAmount.tsx b/frontend/src/routes/SendAmount.tsx index 4b18f11b4..61b82eaeb 100644 --- a/frontend/src/routes/SendAmount.tsx +++ b/frontend/src/routes/SendAmount.tsx @@ -3,11 +3,11 @@ import { useNavigate } from "react-router"; import Close from "../components/Close"; import PageTitle from "../components/PageTitle"; import ScreenMain from "../components/ScreenMain"; -import { inputStyle } from "../styles"; import toast from "react-hot-toast" import MutinyToaster from "../components/MutinyToaster"; import { useSearchParams } from "react-router-dom"; import ActionButton from "@components/ActionButton"; +import AmountInput from "@components/AmountInput"; export default function SendAmount() { let navigate = useNavigate(); @@ -15,11 +15,12 @@ export default function SendAmount() { const [searchParams] = useSearchParams(); const destination = searchParams.get("destination") - const [receiveAmount, setAmount] = useState("") + const [sendAmount, setAmount] = useState("") function handleContinue() { - const amount = receiveAmount.replace(/,/g, "") - if (!amount || amount.match(/\D/)) { + const amount = sendAmount.replace(/_/g, "") + const parsedAmount = parseInt(amount); + if (!parsedAmount) { setAmount('') toast("That doesn't look right") return @@ -29,7 +30,7 @@ export default function SendAmount() { return } - if (destination && amount.match(/^[\d.]+$/)) { + if (destination && typeof parsedAmount === "number") { navigate(`/send/confirm?destination=${destination}&amount=${amount}`) } } @@ -43,7 +44,7 @@ export default function SendAmount() {

How much would you like to send?

- setAmount(e.target.value)} value={receiveAmount} className={`w-full ${inputStyle({ accent: "green" })}`} type="text" inputMode="numeric" placeholder='sats' /> +
Continue diff --git a/frontend/src/styles/index.ts b/frontend/src/styles/index.ts index c6b2bbd2e..e5d56c7cf 100644 --- a/frontend/src/styles/index.ts +++ b/frontend/src/styles/index.ts @@ -18,4 +18,14 @@ const inputStyle = cva("", { } }); -export { inputStyle } \ No newline at end of file +const selectStyle = cva("", { + variants: { + accent: { + green: "bg-green focus:ring-green", + blue: "bg-blue focus:ring-blue", + red: "bg-red focus:ring-red", + }, + } +}); + +export { inputStyle, selectStyle } \ No newline at end of file From 850d32a79c86387c0c0481d457a84650942a1c2d Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Mon, 19 Dec 2022 20:15:36 -0600 Subject: [PATCH 3/3] use rust for conversions, refactor dupe query --- frontend/src/components/AmountInput.tsx | 32 +++++++------------------ frontend/src/components/MainBalance.tsx | 19 ++++++--------- frontend/src/utility/conversions.ts | 29 ++++++++++++++++++++++ frontend/src/utility/queries.ts | 12 ++++++++++ node-manager/src/nodemanager.rs | 8 +++++-- 5 files changed, 63 insertions(+), 37 deletions(-) create mode 100644 frontend/src/utility/conversions.ts create mode 100644 frontend/src/utility/queries.ts diff --git a/frontend/src/components/AmountInput.tsx b/frontend/src/components/AmountInput.tsx index 52ac4bf5a..733657b58 100644 --- a/frontend/src/components/AmountInput.tsx +++ b/frontend/src/components/AmountInput.tsx @@ -1,5 +1,6 @@ -import { useQuery } from "@tanstack/react-query"; +import { satsToUsd, usdToSats } from "@util/conversions"; import prettyPrintAmount from "@util/prettyPrintAmount"; +import { usePriceQuery } from "@util/queries"; import { Dispatch, SetStateAction, useContext, useState } from "react"; import { inputStyle, selectStyle } from "../styles"; import { NodeManagerContext } from "./GlobalStateProvider"; @@ -16,18 +17,6 @@ export enum Currency { SATS = "SATS" } -function satsToUsd(amount: number, price: number): string { - let btc = amount / 100000000; - let usd = btc * price; - return usd.toFixed(2); -} - -function usdToSats(amount: number, price: number): string { - let btc = amount / price; - let sats = btc * 100000000; - return sats.toFixed(0); -} - export default function AmountInput({ amountSats, setAmount, accent, placeholder }: Props) { const [currency, setCurrency] = useState(Currency.SATS) @@ -36,14 +25,7 @@ export default function AmountInput({ amountSats, setAmount, accent, placeholder const { nodeManager } = useContext(NodeManagerContext); - const { data: price } = useQuery({ - queryKey: ['price'], - queryFn: async () => { - console.log("Checking bitcoin price...") - return await nodeManager?.get_bitcoin_price() - }, - enabled: !!nodeManager, - }) + const { data: price } = usePriceQuery(nodeManager); function setAmountFormatted(value: string) { if (value.length === 0 || value === "0") { @@ -88,8 +70,12 @@ export default function AmountInput({ amountSats, setAmount, accent, placeholder setLocalDisplayAmount(prettyPrintAmount(parsedDollars)) } } else if (value === Currency.SATS) { - // And the localDisplayAmount to the same thing - setLocalDisplayAmount(prettyPrintAmount(parsedAmount)) + if (!parsedAmount) { + // 0 looks lame + setLocalDisplayAmount("") + } else { + setLocalDisplayAmount(prettyPrintAmount(parsedAmount)) + } } } } diff --git a/frontend/src/components/MainBalance.tsx b/frontend/src/components/MainBalance.tsx index 34c211687..4aef33251 100644 --- a/frontend/src/components/MainBalance.tsx +++ b/frontend/src/components/MainBalance.tsx @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import { satsToUsd } from "@util/conversions"; import prettyPrintAmount from "@util/prettyPrintAmount"; +import { usePriceQuery } from "@util/queries"; import { MutinyBalance } from "node-manager"; import { useContext, useState } from "react"; import { NodeManagerContext } from "./GlobalStateProvider"; @@ -22,9 +24,8 @@ function prettyPrintUnconfirmedUsd(b: MutinyBalance, price: number): string { } function prettyPrintUSD(amount: number, price: number): string { - let btc = amount / 100000000; - let usd = btc * price; - return prettyPrintAmount(Number(usd.toFixed(2))) + let usd = satsToUsd(amount, price); + return prettyPrintAmount(Number(usd)) } export default function MainBalance() { @@ -40,14 +41,8 @@ export default function MainBalance() { }, enabled: !!nodeManager, }) - const { data: price } = useQuery({ - queryKey: ['price'], - queryFn: async () => { - console.log("Checking bitcoin price...") - return await nodeManager?.get_bitcoin_price() - }, - enabled: !!nodeManager, - }) + + const { data: price } = usePriceQuery(nodeManager); return (
setShowFiat(!showFiat)}> {showFiat && price && @@ -57,7 +52,7 @@ export default function MainBalance() { } {(!showFiat || !price) &&

- {balance && prettyPrintBalance(balance)} sats + {balance && prettyPrintBalance(balance)} sats

} {(balance && balance.unconfirmed?.valueOf() > 0) && showFiat && price && diff --git a/frontend/src/utility/conversions.ts b/frontend/src/utility/conversions.ts new file mode 100644 index 000000000..5920307ec --- /dev/null +++ b/frontend/src/utility/conversions.ts @@ -0,0 +1,29 @@ +import { NodeManager } from "node-manager"; + +export function satsToUsd(amount: number, price: number): string { + if (typeof amount !== "number" || isNaN(amount)) { + return "" + } + try { + let btc = NodeManager.convert_sats_to_btc(BigInt(Math.floor(amount))); + let usd = btc * price; + return usd.toFixed(2); + } catch (e) { + console.error(e); + return "" + } +} + +export function usdToSats(amount: number, price: number): string { + if (typeof amount !== "number" || isNaN(amount)) { + return "" + } + try { + let btc = amount / price; + let sats = NodeManager.convert_btc_to_sats(btc); + return sats.toString(); + } catch (e) { + console.error(e); + return "" + } +} \ No newline at end of file diff --git a/frontend/src/utility/queries.ts b/frontend/src/utility/queries.ts new file mode 100644 index 000000000..d47acfcee --- /dev/null +++ b/frontend/src/utility/queries.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query" +import { NodeManager } from "node-manager" + +export const usePriceQuery = (nodeManager: NodeManager | undefined) => + useQuery({ + queryKey: ['price'], + queryFn: async () => { + console.log("Checking bitcoin price...") + return await nodeManager?.get_bitcoin_price() + }, + enabled: !!nodeManager, + }) \ No newline at end of file diff --git a/node-manager/src/nodemanager.rs b/node-manager/src/nodemanager.rs index 6819842ef..d27d92624 100644 --- a/node-manager/src/nodemanager.rs +++ b/node-manager/src/nodemanager.rs @@ -921,7 +921,12 @@ impl NodeManager { #[wasm_bindgen] pub fn convert_btc_to_sats(btc: f64) -> Result { - if let Ok(amount) = bitcoin::Amount::from_btc(btc) { + // rust bitcoin doesn't like extra precision in the float + // so we round to the nearest satoshi + // explained here: https://stackoverflow.com/questions/28655362/how-does-one-round-a-floating-point-number-to-a-specified-number-of-digits + let truncated = 10i32.pow(8) as f64; + let btc = (btc * truncated).round() / truncated; + if let Ok(amount) = bitcoin::Amount::from_btc(btc as f64) { Ok(amount.to_sat()) } else { Err(MutinyJsError::BadAmountError) @@ -929,7 +934,6 @@ impl NodeManager { } #[wasm_bindgen] - // Hopefully we only use this when preparing bip21 strings pub fn convert_sats_to_btc(sats: u64) -> f64 { bitcoin::Amount::from_sat(sats).to_btc() }