From 4f76ab42991922c16838ae3a37c410b6d99c9a2a Mon Sep 17 00:00:00 2001 From: Samer Buna <75209+samerbuna@users.noreply.github.com> Date: Wed, 12 Jan 2022 09:45:53 -0800 Subject: [PATCH] feat: implement send over lightning and intraledger (#20) * feat: add the generic parse payment destination logic under a galoy-client folder (which will be a package later) * feat: implement the send screen, ln-invoice works * feat: make a sendFixedAmount component and add reset button * feat: add action to handle no-amount invoices * feat: implement sending over intraledger Co-authored-by: Samer Buna --- .eslintrc.js | 11 +- .github/workflows/code-check.yml | 1 + .github/workflows/tests.yml | 1 + package.json | 2 + src/components/debounced-input.tsx | 49 ++++ src/components/debounced-textarea.tsx | 2 +- src/components/error-fallback.tsx | 2 +- src/components/formatted-number-input.tsx | 4 +- src/components/invoice.tsx | 7 + src/components/login.tsx | 2 +- src/components/receive.tsx | 6 +- src/components/root.tsx | 14 +- src/components/send-action.tsx | 171 ++++++++++++ src/components/send.tsx | 248 ++++++++++++++++- src/components/spinner.tsx | 2 +- src/galoy-client/package.json | 14 + src/galoy-client/src/bolt11.ts | 20 ++ src/galoy-client/src/index.ts | 1 + .../src/parse-payment-destination.ts | 180 +++++++++++++ src/galoy-client/yarn.lock | 252 ++++++++++++++++++ src/renderers/dom.tsx | 2 +- src/renderers/server.tsx | 15 +- src/server/graphql.ts | 2 +- src/server/server.ts | 2 +- src/{server => store}/config.test.ts | 0 src/{server => store}/config.ts | 12 + .../mutation.intra-ledger-paymest-send.ts | 13 + .../mutation.ln-invoice-payment-send.ts | 14 + ...tation.ln-noamount-invoice-payment-send.ts | 14 + .../graphql/mutation.onchain-payment-send.ts | 14 + src/store/graphql/query.main.ts | 3 + .../graphql/query.user-default-wallet-id.ts | 9 + src/store/history.ts | 2 +- src/store/index.ts | 4 +- src/store/reducer.ts | 2 + src/store/use-delayed-query.ts | 39 +++ src/store/use-main-query.ts | 1 + src/styles/main.css | 40 +-- src/types/index.d.ts | 51 ++-- views/index.ejs | 2 +- webpack.config.js | 13 +- yarn.lock | 30 ++- 42 files changed, 1188 insertions(+), 85 deletions(-) create mode 100644 src/components/debounced-input.tsx create mode 100644 src/components/send-action.tsx create mode 100644 src/galoy-client/package.json create mode 100644 src/galoy-client/src/bolt11.ts create mode 100644 src/galoy-client/src/index.ts create mode 100644 src/galoy-client/src/parse-payment-destination.ts create mode 100644 src/galoy-client/yarn.lock rename src/{server => store}/config.test.ts (100%) rename src/{server => store}/config.ts (73%) create mode 100644 src/store/graphql/mutation.intra-ledger-paymest-send.ts create mode 100644 src/store/graphql/mutation.ln-invoice-payment-send.ts create mode 100644 src/store/graphql/mutation.ln-noamount-invoice-payment-send.ts create mode 100644 src/store/graphql/mutation.onchain-payment-send.ts create mode 100644 src/store/graphql/query.user-default-wallet-id.ts create mode 100644 src/store/use-delayed-query.ts diff --git a/.eslintrc.js b/.eslintrc.js index 65bf4cf9..b858b692 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -53,9 +53,6 @@ module.exports = { "array-callback-return": "error", "block-scoped-var": "error", "camelcase": "error", - "capitalized-comments": "warn", - "complexity": "error", - "consistent-return": "error", "consistent-this": "error", "curly": "error", "default-case-last": "error", @@ -73,12 +70,12 @@ module.exports = { "jsx-quotes": "error", "linebreak-style": ["error", "unix"], "max-depth": ["error", { max: 3 }], - "max-lines-per-function": ["error", { max: 200 }], + "max-lines-per-function": ["error", { max: 250 }], "max-lines": ["error", { max: 300 }], "max-nested-callbacks": ["error", { max: 2 }], "max-params": ["error", { max: 2 }], "max-statements-per-line": ["error", { max: 1 }], - "max-statements": ["error", { max: 30 }], + "max-statements": ["error", { max: 100 }], "new-parens": "error", "newline-per-chained-call": "error", "no-alert": "error", @@ -145,10 +142,8 @@ module.exports = { "no-useless-return": "error", "no-var": "error", "no-void": "error", - "no-warning-comments": "error", "object-shorthand": "error", - "prefer-const": "error", - "prefer-exponentiation-operator": "error", + "prefer-const": ["error", { destructuring: "all" }], "prefer-numeric-literals": "error", "prefer-object-spread": "error", "prefer-promise-reject-errors": "error", diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml index b4538b89..edf78255 100644 --- a/.github/workflows/code-check.yml +++ b/.github/workflows/code-check.yml @@ -10,5 +10,6 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 - run: yarn install + - run: (cd src/galoy-client && yarn install) - name: Run check code run: yarn code:check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b70976c1..15d727f3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,5 +13,6 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 - run: yarn install + - run: (cd src/galoy-client && yarn install) - name: Run all tests run: yarn test diff --git a/package.json b/package.json index 42f03582..c40afaba 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,12 @@ "regenerator-runtime": "^0.13.9", "serialize-javascript": "^6.0.0", "source-map-loader": "^3.0.0", + "stream-browserify": "^3.0.0", "style-loader": "^3.3.1", "subscriptions-transport-ws": "^0.11.0", "ts-loader": "^9.2.6", "typescript": "^4.5.4", + "url": "^0.11.0", "use-debounce": "^7.0.1", "webpack": "^5.64.4", "webpack-cli": "^4.9.1" diff --git a/src/components/debounced-input.tsx b/src/components/debounced-input.tsx new file mode 100644 index 00000000..f6c33e22 --- /dev/null +++ b/src/components/debounced-input.tsx @@ -0,0 +1,49 @@ +import { ChangeEvent, useState, memo, useEffect } from "react" +import { useDebouncedCallback } from "use-debounce" + +export type OnInputValueChange = (value: string) => void + +type Props = { + onChange?: OnInputValueChange + onDebouncedChange?: OnInputValueChange + [prop: string]: unknown +} + +type InputObject = { + value: string + debouncedValue?: string + typing: boolean +} + +const DebouncedInput = ({ onChange, onDebouncedChange, ...inputProps }: Props) => { + const [input, setInput] = useState({ value: "", typing: false }) + + const setDebouncedInputValue = useDebouncedCallback((debouncedValue) => { + setInput((currInput) => ({ ...currInput, debouncedValue, typing: false })) + }, 1000) + + useEffect(() => { + if (input.typing) { + setDebouncedInputValue(input.value) + } + return () => setDebouncedInputValue.cancel() + }, [setDebouncedInputValue, input.typing, input.value]) + + useEffect(() => { + if (onDebouncedChange && input.debouncedValue !== undefined) { + onDebouncedChange(input.debouncedValue) + } + }, [onDebouncedChange, input.debouncedValue]) + + const handleOnChange = (event: ChangeEvent) => { + const newValue = event.target.value + if (onChange) { + onChange(newValue) + } + setInput({ value: newValue, typing: true }) + } + + return +} + +export default memo(DebouncedInput) diff --git a/src/components/debounced-textarea.tsx b/src/components/debounced-textarea.tsx index 9488b3d0..72acf775 100644 --- a/src/components/debounced-textarea.tsx +++ b/src/components/debounced-textarea.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useState, memo, useEffect } from "react" import { useDebouncedCallback } from "use-debounce" -export type OnTextValueChange = (numberValue: string) => void +export type OnTextValueChange = (value: string) => void type Props = { onChange?: OnTextValueChange diff --git a/src/components/error-fallback.tsx b/src/components/error-fallback.tsx index 4f2bcee4..3203e087 100644 --- a/src/components/error-fallback.tsx +++ b/src/components/error-fallback.tsx @@ -1,4 +1,4 @@ -import config from "server/config" +import config from "store/config" type Props = { error: string | Error } diff --git a/src/components/formatted-number-input.tsx b/src/components/formatted-number-input.tsx index 218c5fdd..2069df80 100644 --- a/src/components/formatted-number-input.tsx +++ b/src/components/formatted-number-input.tsx @@ -3,12 +3,12 @@ import { useDebouncedCallback } from "use-debounce" const formatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }) -export type ParsieInputValueFunction = (inputValue: string) => { +export type ParseInputValueFunction = (inputValue: string) => { numberValue: number | "" formattedValue: string } -const parseInputValue: ParsieInputValueFunction = (inputValue) => { +const parseInputValue: ParseInputValueFunction = (inputValue) => { if (inputValue === "") { return { numberValue: "", formattedValue: "" } } diff --git a/src/components/invoice.tsx b/src/components/invoice.tsx index 697bf61e..09299460 100644 --- a/src/components/invoice.tsx +++ b/src/components/invoice.tsx @@ -5,6 +5,7 @@ import copy from "copy-to-clipboard" import { useMyUpdates } from "store/use-my-updates" import { translate } from "translate" import SuccessCheckmark from "./sucess-checkmark" +import { useAppDispatcher } from "store" type Props = { invoice: GraphQL.LnInvoice | GraphQL.LnNoAmountInvoice @@ -12,6 +13,7 @@ type Props = { } const Invoice = ({ invoice, onPaymentSuccess }: Props) => { + const dispatch = useAppDispatcher() const { lnUpdate } = useMyUpdates() const [showCopied, setShowCopied] = useState(false) @@ -21,6 +23,10 @@ const Invoice = ({ invoice, onPaymentSuccess }: Props) => { setTimeout(() => setShowCopied(false), 3000) } + const resetReceiveScreen = () => { + dispatch({ type: "reset-current-screen" }) + } + const invoicePaid = lnUpdate?.paymentHash === invoice?.paymentHash && lnUpdate?.status === "PAID" @@ -32,6 +38,7 @@ const Invoice = ({ invoice, onPaymentSuccess }: Props) => { return (
+
) } diff --git a/src/components/login.tsx b/src/components/login.tsx index fa9c1c16..aa6760c5 100644 --- a/src/components/login.tsx +++ b/src/components/login.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef, useState } from "react" import intlTelInput from "intl-tel-input" -import config from "server/config" +import config from "store/config" import { translate } from "translate" import { history, useRequest } from "store" diff --git a/src/components/receive.tsx b/src/components/receive.tsx index d153b50c..3d0b89d1 100644 --- a/src/components/receive.tsx +++ b/src/components/receive.tsx @@ -158,7 +158,7 @@ const Receive = () => {
{translate("Receive Bitcoin")}
{" "} -
+
{input.currency === "SATS" ? : "$"}
@@ -175,7 +175,7 @@ const Receive = () => { ⇅
-
+
{
{conversionDisplay}
)} -
+
{showInvoiceSpinner && } {showInvoice && ( { +const Root = ({ GwwState }: RootProps) => { const handleError = useErrorHandler() - const [state, dispatch] = useReducer(mainReducer, initialState, (initState) => { + const [state, dispatch] = useReducer(mainReducer, GwwState, (initState) => { setLocale(initState.defaultLanguage) return initState }) @@ -58,7 +58,7 @@ const Root = ({ initialState }: RootProps) => { return ( - + ) @@ -66,11 +66,11 @@ const Root = ({ initialState }: RootProps) => { type SSRootProps = { client: ApolloClient - initialState: InitialState + GwwState: GwwState } -export const SSRRoot = ({ client, initialState }: SSRootProps) => { - const [state, dispatch] = useReducer(mainReducer, initialState, (initState) => { +export const SSRRoot = ({ client, GwwState }: SSRootProps) => { + const [state, dispatch] = useReducer(mainReducer, GwwState, (initState) => { setLocale(initState.defaultLanguage) return initState }) diff --git a/src/components/send-action.tsx b/src/components/send-action.tsx new file mode 100644 index 00000000..1c41e957 --- /dev/null +++ b/src/components/send-action.tsx @@ -0,0 +1,171 @@ +import { useMutation } from "@apollo/client" +import { MouseEvent } from "react" + +import useMainQuery from "store/use-main-query" +import MUTATION_LN_INVOICE_PAYMENT_SEND from "store/graphql/mutation.ln-invoice-payment-send" +import MUTATION_LN_NOAMOUNT_INVOICE_PAYMENT_SEND from "store/graphql/mutation.ln-noamount-invoice-payment-send" + +import Spinner from "./spinner" +import SuccessCheckmark from "./sucess-checkmark" +import MUTATION_INTRA_LEDGER_PAYMENT_SEND from "store/graphql/mutation.intra-ledger-paymest-send" + +type SendActionProps = InvoiceInput & { + reset: () => void +} + +const SendFixedAmount = (props: SendActionProps) => { + const { btcWalletId } = useMainQuery() + + const [payInvoice, { loading, error, data }] = useMutation<{ + lnInvoicePaymentSend: GraphQL.PaymentSendPayload + }>(MUTATION_LN_INVOICE_PAYMENT_SEND, { + onError: console.error, + }) + + const errorString = + error?.message ?? + data?.lnInvoicePaymentSend?.errors?.map((err) => err.message).join(", ") + const success = data?.lnInvoicePaymentSend?.status === "SUCCESS" + + const handleSend = (event: MouseEvent) => { + event.preventDefault() + payInvoice({ + variables: { + input: { + walletId: btcWalletId, + paymentRequest: props.paymentRequset, + memo: props.memo, + }, + }, + }) + } + + return ( + <> + {errorString &&
{errorString}
} + {success ? ( +
+ + +
+ ) : ( + + )} + + ) +} + +const SendAmount = (props: SendActionProps) => { + const { btcWalletId } = useMainQuery() + + const [payInvoice, { loading, error, data }] = useMutation<{ + lnNoAmountInvoicePaymentSend: GraphQL.PaymentSendPayload + }>(MUTATION_LN_NOAMOUNT_INVOICE_PAYMENT_SEND, { + onError: console.error, + }) + + const errorString = + error?.message ?? + data?.lnNoAmountInvoicePaymentSend?.errors?.map((err) => err.message).join(", ") + const success = data?.lnNoAmountInvoicePaymentSend?.status === "SUCCESS" + + const handleSend = (event: MouseEvent) => { + event.preventDefault() + payInvoice({ + variables: { + input: { + walletId: btcWalletId, + paymentRequest: props.paymentRequset, + amount: props.satAmount, + memo: props.memo, + }, + }, + }) + } + + return ( + <> + {errorString &&
{errorString}
} + {success ? ( +
+ + +
+ ) : ( + + )} + + ) +} + +const SendIntraLedger = (props: SendActionProps) => { + const { btcWalletId } = useMainQuery() + + const [payInvoice, { loading, error, data }] = useMutation<{ + intraLedgerPaymentSend: GraphQL.PaymentSendPayload + }>(MUTATION_INTRA_LEDGER_PAYMENT_SEND, { + onError: console.error, + }) + + const errorString = + error?.message ?? + data?.intraLedgerPaymentSend?.errors?.map((err) => err.message).join(", ") + const success = data?.intraLedgerPaymentSend?.status === "SUCCESS" + + const handleSend = (event: MouseEvent) => { + event.preventDefault() + payInvoice({ + variables: { + input: { + walletId: btcWalletId, + recipientWalletId: props.reciepientWalletId, + amount: props.satAmount, + memo: props.memo, + }, + }, + }) + } + + return ( + <> + {errorString &&
{errorString}
} + {success ? ( +
+ + +
+ ) : ( + + )} + + ) +} + +const SendAction = (props: SendActionProps) => { + const validInput = + props.valid && + (props.fixedAmount || typeof props.amount === "number") && + (props.paymentType !== "intraledger" || props.reciepientWalletId) + + if (!validInput) { + return + } + + if (props.paymentType === "intraledger") { + return + } + + if (props.fixedAmount) { + return + } + + return +} + +export default SendAction diff --git a/src/components/send.tsx b/src/components/send.tsx index 0770b9ee..009ce719 100644 --- a/src/components/send.tsx +++ b/src/components/send.tsx @@ -1,26 +1,256 @@ -import { useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" +import config from "store/config" +import { satsFormatter, usdFormatter, useAppDispatcher } from "store" +import { useMyUpdates } from "store/use-my-updates" import { translate } from "translate" import FormattedNumberInput, { OnNumberValueChange } from "./formatted-number-input" import Header from "./header" +import SatSymbol from "./sat-symbol" +import Spinner from "./spinner" +import DebouncedTextarea, { OnTextValueChange } from "./debounced-textarea" +import DebouncedInput from "./debounced-input" +import { parsePaymentDestination } from "galoy-client/src" +import useMainQuery from "store/use-main-query" +import SendAction from "./send-action" +import QUERY_USER_DEFAULT_WALLET_ID from "store/graphql/query.user-default-wallet-id" +import useDelayedQuery from "store/use-delayed-query" const Send = () => { - const [amount, setAmount] = useState("") - const handleAmountUpdate: OnNumberValueChange = (numberValue) => { - setAmount(numberValue) + const dispatch = useAppDispatcher() + const { pubKey } = useMainQuery() + const { satsToUsd, usdToSats } = useMyUpdates() + + const [input, setInput] = useState({ + currency: "USD", + amount: "", + destination: "", + memo: "", + }) + + const [userDefaultWalletIdQuery, { loading: userDefaultWalletIdLoading }] = + useDelayedQuery(QUERY_USER_DEFAULT_WALLET_ID) + + useEffect(() => { + if (usdToSats && input.currency === "USD" && typeof input.amount === "number") { + setInput((currInput) => ({ + ...currInput, + satAmount: Math.round(usdToSats(input.amount as number)), + })) + } + }, [input.amount, input.currency, usdToSats]) + + useEffect(() => { + if (input.currency === "SATS" && typeof input.amount === "number") { + setInput((currInput) => ({ + ...currInput, + satAmount: input.amount as number, + })) + } + }, [input.amount, input.currency]) + + useEffect(() => { + const parseInput = async () => { + if (input.destination !== undefined) { + const parsedDestination = parsePaymentDestination({ + destination: input.destination, + network: config.network, + pubKey, + }) + + const newInputState: Partial = { + valid: parsedDestination.valid, + paymentType: parsedDestination.paymentType, + fixedAmount: parsedDestination.amount !== undefined, + paymentRequset: parsedDestination.paymentRequest, + } + + if (parsedDestination.paymentType === "intraledger") { + // Validate username (and get their default wallet id) + const { data, error } = await userDefaultWalletIdQuery({ + username: parsedDestination.username, + }) + if (error) { + // TODO: show error in UI + console.error(error) + } else { + newInputState.reciepientWalletId = data?.userDefaultWalletId + } + } + + setInput((currInput) => ({ + ...currInput, + ...newInputState, + amount: newInputState.fixedAmount ? parsedDestination.amount : currInput.amount, + currency: newInputState.fixedAmount ? "SATS" : currInput.currency, + })) + } + } + parseInput() + }, [input.destination, pubKey, userDefaultWalletIdQuery]) + + const handleAmountUpdate: OnNumberValueChange = useCallback(() => { + setInput((currInput) => ({ ...currInput, amount: undefined })) + }, []) + + const handleDebouncedAmountUpdate: OnNumberValueChange = useCallback( + (debouncedAmount) => { + setInput((currInput) => ({ ...currInput, amount: debouncedAmount })) + }, + [], + ) + + const handleMemoUpdate: OnTextValueChange = useCallback(() => { + setInput((currInput) => ({ ...currInput, memo: undefined })) + }, []) + + const handleDebouncedMemoUpdate: OnTextValueChange = useCallback((debouncedMemo) => { + setInput((currInput) => ({ ...currInput, memo: debouncedMemo })) + }, []) + + const handleDestinationUpdate: OnTextValueChange = useCallback(() => { + setInput((currInput) => ({ ...currInput, destination: undefined })) + }, []) + + const handleDebouncedDestinationUpdate: OnTextValueChange = useCallback( + (debouncedDestination) => { + setInput((currInput) => ({ ...currInput, destination: debouncedDestination })) + }, + [], + ) + + const toggleCurrency = useCallback(() => { + if (!satsToUsd) { + // Handle Price Error + return + } + setInput((currInput) => { + const newCurrency = currInput.currency === "SATS" ? "USD" : "SATS" + let newAmount: number | "" = "" + + if (currInput.currency === "SATS" && currInput.amount) { + newAmount = satsToUsd(currInput.amount) + } + + if (currInput.currency === "USD" && currInput.satAmount) { + newAmount = currInput.satAmount + } + + return { + ...currInput, + currency: newCurrency, + amount: newAmount, + } + }) + }, [satsToUsd]) + + const convertedValues = useMemo(() => { + if (!usdToSats || !satsToUsd || !input.amount) { + return null + } + + if (input.currency === "SATS") { + return { + usd: satsToUsd(input.amount), + } + } + + const satsForConversion = input.satAmount || usdToSats(input.amount) + + return { + sats: satsForConversion, + usd: satsToUsd(satsForConversion), + } + }, [input.amount, input.currency, input.satAmount, satsToUsd, usdToSats]) + + const conversionDisplay = useMemo(() => { + if (!convertedValues) { + return null + } + + if (!convertedValues.sats) { + return ( +
+ ≈ {usdFormatter.format(convertedValues.usd)} +
+ ) + } + + return ( + <> +
+ + {satsFormatter.format(convertedValues.sats)} +
+
+ ≈ {usdFormatter.format(convertedValues.usd)} +
+ + ) + }, [convertedValues]) + + const resetSendScreen = () => { + dispatch({ type: "reset-current-screen" }) } + + const inputValue = input.amount === undefined ? "" : input.amount.toString() + const showSpinner = + input.amount === undefined || + input.destination === undefined || + input.memo === undefined || + userDefaultWalletIdLoading + return (
-
{translate("Send Bitcoin")}
- -
+
{translate("Send Bitcoin")}
{" "} +
+
+ {input.currency === "SATS" ? : "$"} +
+ {!input.fixedAmount && ( +
+ ⇅ +
+ )} +
+
+ +
+
+ +
+ {conversionDisplay &&
{conversionDisplay}
} +
+ {showSpinner ? ( + + ) : ( + + )}
) diff --git a/src/components/spinner.tsx b/src/components/spinner.tsx index 673c3acf..33316c48 100644 --- a/src/components/spinner.tsx +++ b/src/components/spinner.tsx @@ -12,7 +12,7 @@ const faSize = (size: SpinnerSize): string => { type Props = { size?: SpinnerSize } const Spinner = ({ size = "small" }: Props) => { - return + return } export default Spinner diff --git a/src/galoy-client/package.json b/src/galoy-client/package.json new file mode 100644 index 00000000..429b9b1f --- /dev/null +++ b/src/galoy-client/package.json @@ -0,0 +1,14 @@ +{ + "name": "galoy-client", + "version": "1.0.0", + "main": "src/index.ts", + "license": "MIT", + "repository": "https://github.com/galoymoney/galoy-client", + "dependencies": { + "bitcoinjs-lib": "^6.0.1", + "bolt11": "^1.3.4" + }, + "resolutions": { + "bech32": "^1.1.2" + } +} diff --git a/src/galoy-client/src/bolt11.ts b/src/galoy-client/src/bolt11.ts new file mode 100644 index 00000000..34d8778d --- /dev/null +++ b/src/galoy-client/src/bolt11.ts @@ -0,0 +1,20 @@ +import * as lightningPayReq from "bolt11" + +export const getDescription = (decoded: lightningPayReq.PaymentRequestObject) => { + const data = decoded.tags.find((value) => value.tagName === "description")?.data + if (data) { + return data as string + } +} + +export const getDestination = ( + decoded: lightningPayReq.PaymentRequestObject, +): string | undefined => decoded.payeeNodeKey + +export const getHashFromInvoice = (invoice: string): string | undefined => { + const decoded = lightningPayReq.decode(invoice) + const data = decoded.tags.find((value) => value.tagName === "payment_hash")?.data + if (data) { + return data as string + } +} diff --git a/src/galoy-client/src/index.ts b/src/galoy-client/src/index.ts new file mode 100644 index 00000000..f94f95fe --- /dev/null +++ b/src/galoy-client/src/index.ts @@ -0,0 +1 @@ +export * from "./parse-payment-destination" diff --git a/src/galoy-client/src/parse-payment-destination.ts b/src/galoy-client/src/parse-payment-destination.ts new file mode 100644 index 00000000..3c5ca8ff --- /dev/null +++ b/src/galoy-client/src/parse-payment-destination.ts @@ -0,0 +1,180 @@ +import bolt11 from "bolt11" +import url from "url" +import { networks, address } from "bitcoinjs-lib" + +import { getDescription, getDestination } from "./bolt11" + +export type Network = "bitcoin" | "testnet" | "regtest" +export type PaymentType = "lightning" | "onchain" | "intraledger" | "lnurl" +export interface ValidPaymentReponse { + valid: boolean + errorMessage?: string | undefined + paymentRequest?: string | undefined // for lightning + address?: string | undefined // for bitcoin + lnurl?: string | undefined // for lnurl + amount?: number | undefined + memo?: string | undefined + paymentType?: PaymentType + sameNode?: boolean | undefined + username?: string | undefined +} + +export const lightningInvoiceHasExpired = ( + payReq: bolt11.PaymentRequestObject, +): boolean => { + return Boolean(payReq?.timeExpireDate && payReq.timeExpireDate < Date.now() / 1000) +} + +// from https://github.com/bitcoin/bips/blob/master/bip-0020.mediawiki#Transfer%20amount/size +const reAmount = /^(([\d.]+)(X(\d+))?|x([\da-f]*)(\.([\da-f]*))?(X([\da-f]+))?)$/iu +const parseAmount = (txt: string): number => { + const match = txt.match(reAmount) + if (!match) { + return NaN + } + return Math.round( + match[5] + ? (parseInt(match[5], 16) + + (match[7] ? parseInt(match[7], 16) * Math.pow(16, -match[7].length) : 0)) * + (match[9] ? Math.pow(16, parseInt(match[9], 16)) : 0x10000) + : Number(match[2]) * (match[4] ? Math.pow(10, Number(match[4])) : 1e8), + ) +} + +type ParsePaymentDestinationArgs = { + destination: string + network: Network + pubKey: string +} + +export const parsePaymentDestination = ({ + destination, + network, + pubKey, +}: ParsePaymentDestinationArgs): ValidPaymentReponse => { + if (!destination) { + return { valid: false } + } + + // input might start with 'lightning:', 'bitcoin:' + const [protocol, data] = destination + .split(":") + .map((value) => value.toLocaleLowerCase()) + + if (protocol === "https" || protocol.match(/(?!^(1|3|bc1|lnbc1))^[0-9a-z_]{3,50}$/iu)) { + const username = protocol === "https" ? data.split("/").at(-1) : protocol + return { + valid: true, + paymentType: "intraledger", + username, + } + } + + const destinationText = data ?? protocol + + if (destinationText.startsWith("lnurl")) { + return { + valid: true, + paymentType: "lnurl", + lnurl: destinationText, + } + } + + if (protocol === "lightning" || destinationText.startsWith("ln")) { + if (network === "testnet" && protocol.startsWith("lnbc")) { + return { + valid: false, + paymentType: "lightning", + errorMessage: "This is a mainnet invoice. The wallet is on testnet", + } + } + + if (network === "bitcoin" && protocol.startsWith("lntb")) { + return { + valid: false, + paymentType: "lightning", + errorMessage: "This is a testnet invoice. The wallet is on mainnet", + } + } + + let payReq: bolt11.PaymentRequestObject | undefined = undefined + try { + payReq = bolt11.decode(destinationText) + } catch (err) { + console.error(err) + return { valid: false } + } + const sameNode = pubKey === getDestination(payReq) + + const amount = + payReq.satoshis || payReq.millisatoshis + ? payReq.satoshis ?? Number(payReq.millisatoshis) / 1000 + : undefined + + if (lightningInvoiceHasExpired(payReq)) { + return { + valid: false, + errorMessage: "invoice has expired", + paymentType: "lightning", + } + } + + const memo = getDescription(payReq) + return { + valid: true, + paymentRequest: destinationText, + amount, + memo, + paymentType: "lightning", + sameNode, + } + } + + // No payment type detected, assume a bitcoin onchain address + + try { + const decodedData = url.parse(destinationText, true) + let path = decodedData.pathname // using url node library. the address is exposed as the "host" here + if (!path) { + throw new Error("Missing pathname in decoded destination") + } + // some apps encode bech32 addresses in UPPERCASE + const lowerCasePath = path.toLowerCase() + if ( + lowerCasePath.startsWith("bc1") || + lowerCasePath.startsWith("tb1") || + lowerCasePath.startsWith("bcrt1") + ) { + path = lowerCasePath + } + + let amount: number | undefined = undefined + + try { + amount = decodedData?.query?.amount + ? parseAmount(decodedData.query.amount as string) + : undefined + } catch (err) { + console.error(`can't decode amount ${err}`) + return { + valid: false, + errorMessage: "Invalid amount in destination", + } + } + + // will throw if address is not valid + address.toOutputScript(path, networks[network]) + return { + valid: true, + paymentType: "onchain", + address: path, + amount, + } + } catch (err) { + console.error(`issue with payment ${err}`) + return { + valid: false, + errorMessage: "We are unable to detect an invoice or payment address", + } + } +} diff --git a/src/galoy-client/yarn.lock b/src/galoy-client/yarn.lock new file mode 100644 index 00000000..790b29e6 --- /dev/null +++ b/src/galoy-client/yarn.lock @@ -0,0 +1,252 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/bn.js@^4.11.3": + version "4.11.6" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c" + integrity sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg== + dependencies: + "@types/node" "*" + +"@types/node@*": + version "17.0.8" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.8.tgz#50d680c8a8a78fe30abe6906453b21ad8ab0ad7b" + integrity sha512-YofkM6fGv4gDJq78g4j0mMuGMkZVxZDgtU0JRdx6FgiJDG+0fY0GKVolOV8WqVmEhLCXkQRjwDdKyPxJp/uucg== + +base-x@^3.0.2: + version "3.0.9" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320" + integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ== + dependencies: + safe-buffer "^5.0.1" + +bech32@^1.1.2, bech32@^2.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9" + integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== + +bip174@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/bip174/-/bip174-2.0.1.tgz#39cf8ca99e50ce538fb762589832f4481d07c254" + integrity sha512-i3X26uKJOkDTAalYAp0Er+qGMDhrbbh2o93/xiPyAN2s25KrClSpe3VXo/7mNJoqA5qfko8rLS2l3RWZgYmjKQ== + +bitcoinjs-lib@^6.0.0, bitcoinjs-lib@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/bitcoinjs-lib/-/bitcoinjs-lib-6.0.1.tgz#4fa9438bb86a0449451ac58607e83d9b5a7732e6" + integrity sha512-x/7D4jDj/MMkmO6t3p2CSDXTqpwZ/jRsRiJDmaiXabrR9XRo7jwby8HRn7EyK1h24rKFFI7vI0ay4czl6bDOZQ== + dependencies: + bech32 "^2.0.0" + bip174 "^2.0.1" + bs58check "^2.1.2" + create-hash "^1.1.0" + typeforce "^1.11.3" + varuint-bitcoin "^1.1.2" + wif "^2.0.1" + +bn.js@^4.11.8, bn.js@^4.11.9: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== + +bolt11@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/bolt11/-/bolt11-1.3.4.tgz#4f2c2fa529f2dc1d60ee7c932aa47d90a0c17ed9" + integrity sha512-x4lHDv0oid13lGlZU7cl/5gx9nRwjB2vgK/uB3c50802Wh+9WjWQMwzD2PCETHylUijx2iBAqUQYbx3ZgwF06Q== + dependencies: + "@types/bn.js" "^4.11.3" + bech32 "^1.1.2" + bitcoinjs-lib "^6.0.0" + bn.js "^4.11.8" + create-hash "^1.2.0" + lodash "^4.17.11" + safe-buffer "^5.1.1" + secp256k1 "^4.0.2" + +brorand@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + +bs58@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha1-vhYedsNU9veIrkBx9j806MTwpCo= + dependencies: + base-x "^3.0.2" + +bs58check@<3.0.0, bs58check@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" + integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== + dependencies: + bs58 "^4.0.0" + create-hash "^1.1.0" + safe-buffer "^5.1.2" + +cipher-base@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +create-hash@^1.1.0, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +elliptic@^6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" + integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + +hash-base@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== + dependencies: + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hmac-drbg@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +lodash@^4.17.11: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + +node-addon-api@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" + integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== + +node-gyp-build@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" + integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== + +readable-stream@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +secp256k1@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.3.tgz#c4559ecd1b8d3c1827ed2d1b94190d69ce267303" + integrity sha512-NLZVf+ROMxwtEj3Xa562qgv2BK5e2WNmXPiOdVIPLgs6lyTzMvBq0aWTYMI5XCP9jZMVKOcqZLw/Wc4vDkuxhA== + dependencies: + elliptic "^6.5.4" + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + +sha.js@^2.4.0: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +typeforce@^1.11.3: + version "1.18.0" + resolved "https://registry.yarnpkg.com/typeforce/-/typeforce-1.18.0.tgz#d7416a2c5845e085034d70fcc5b6cc4a90edbfdc" + integrity sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g== + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +varuint-bitcoin@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz#e76c138249d06138b480d4c5b40ef53693e24e92" + integrity sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw== + dependencies: + safe-buffer "^5.1.1" + +wif@^2.0.1: + version "2.0.6" + resolved "https://registry.yarnpkg.com/wif/-/wif-2.0.6.tgz#08d3f52056c66679299726fade0d432ae74b4704" + integrity sha1-CNP1IFbGZnkplyb63g1DKudLRwQ= + dependencies: + bs58check "<3.0.0" diff --git a/src/renderers/dom.tsx b/src/renderers/dom.tsx index 24c992ae..de4a18fe 100644 --- a/src/renderers/dom.tsx +++ b/src/renderers/dom.tsx @@ -15,6 +15,6 @@ if (!container) { ReactDOM.hydrateRoot( container, - + , ) diff --git a/src/renderers/server.tsx b/src/renderers/server.tsx index b66bf18d..f10bbf01 100644 --- a/src/renderers/server.tsx +++ b/src/renderers/server.tsx @@ -1,11 +1,9 @@ -import { createHttpLink, ApolloClient, InMemoryCache } from "@apollo/client" - -import config from "server/config" - import { Request } from "express" +import { createHttpLink, ApolloClient, InMemoryCache } from "@apollo/client" +import { renderToStringWithData } from "@apollo/client/react/ssr" +import config from "store/config" import appRoutes from "server/routes" -import { renderToStringWithData } from "@apollo/client/react/ssr" import { SSRRoot } from "components/root" export const serverRenderer = @@ -13,8 +11,9 @@ export const serverRenderer = async ({ path }: { path: RoutePath }) => { const authToken = req.session?.authToken - const initialState: InitialState = { + const GwwState: GwwState = { path, + key: 0, authToken, defaultLanguage: req.acceptsLanguages()?.[0], } @@ -30,13 +29,13 @@ export const serverRenderer = cache: new InMemoryCache(), }) - const App = + const App = const initialMarkup = await renderToStringWithData(App) const ssrData = client.extract() return Promise.resolve({ - initialState, + GwwState, initialMarkup, ssrData, pageData: appRoutes[path], diff --git a/src/server/graphql.ts b/src/server/graphql.ts index 447cf9a1..cee9dd33 100644 --- a/src/server/graphql.ts +++ b/src/server/graphql.ts @@ -1,7 +1,7 @@ import { Request } from "express" import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client" -import config from "./config" +import config from "store/config" const client = (req: Request) => { const authToken = req.session?.authToken diff --git a/src/server/server.ts b/src/server/server.ts index 4acfd4b2..73f7139f 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -5,7 +5,7 @@ import helmet from "helmet" import morgan from "morgan" import serialize from "serialize-javascript" -import config from "./config" +import config from "store/config" import apiRouter from "./api-router" import ssrRouter from "./ssr-router" diff --git a/src/server/config.test.ts b/src/store/config.test.ts similarity index 100% rename from src/server/config.test.ts rename to src/store/config.test.ts diff --git a/src/server/config.ts b/src/store/config.ts similarity index 73% rename from src/server/config.ts rename to src/store/config.ts index 2a18aba3..c5ef7dcd 100644 --- a/src/server/config.ts +++ b/src/store/config.ts @@ -16,6 +16,17 @@ requiredEnvVars.forEach((envVar) => { } }) +const networkMap = (graphqlUri: string): "bitcoin" | "testnet" | "regtest" => { + if (graphqlUri.match("mainnet")) { + return "bitcoin" + } + if (graphqlUri.match("testnet")) { + return "testnet" + } + + return "regtest" +} + export default { isDev: process.env.NODE_ENV !== "production", isBrowser: typeof window !== "undefined", @@ -27,6 +38,7 @@ export default { supportEmail: process.env.SUPPORT_EMAIL as string, + network: networkMap(process.env.GRAPHQL_URI as string), graphqlUri: process.env.GRAPHQL_URI as string, graphqlSubscriptionUri: process.env.GRAPHQL_SUBSCRIPTION_URI as string, diff --git a/src/store/graphql/mutation.intra-ledger-paymest-send.ts b/src/store/graphql/mutation.intra-ledger-paymest-send.ts new file mode 100644 index 00000000..38418e76 --- /dev/null +++ b/src/store/graphql/mutation.intra-ledger-paymest-send.ts @@ -0,0 +1,13 @@ +import { gql } from "@apollo/client" + +export const MUTATION_INTRA_LEDGER_PAYMENT_SEND = gql` + mutation intraLedgerPaymentSend($input: IntraLedgerPaymentSendInput!) { + intraLedgerPaymentSend(input: $input) { + errors { + message + } + status + } + } +` +export default MUTATION_INTRA_LEDGER_PAYMENT_SEND diff --git a/src/store/graphql/mutation.ln-invoice-payment-send.ts b/src/store/graphql/mutation.ln-invoice-payment-send.ts new file mode 100644 index 00000000..21defe3c --- /dev/null +++ b/src/store/graphql/mutation.ln-invoice-payment-send.ts @@ -0,0 +1,14 @@ +import { gql } from "@apollo/client" + +export const MUTATION_LN_INVOICE_PAYMENT_SEND = gql` + mutation lnInvoicePaymentSend($input: LnInvoicePaymentInput!) { + lnInvoicePaymentSend(input: $input) { + errors { + message + } + status + } + } +` + +export default MUTATION_LN_INVOICE_PAYMENT_SEND diff --git a/src/store/graphql/mutation.ln-noamount-invoice-payment-send.ts b/src/store/graphql/mutation.ln-noamount-invoice-payment-send.ts new file mode 100644 index 00000000..865aa29d --- /dev/null +++ b/src/store/graphql/mutation.ln-noamount-invoice-payment-send.ts @@ -0,0 +1,14 @@ +import { gql } from "@apollo/client" + +const MUTATION_LN_NOAMOUNT_INVOICE_PAYMENT_SEND = gql` + mutation lnNoAmountInvoicePaymentSend($input: LnNoAmountInvoicePaymentInput!) { + lnNoAmountInvoicePaymentSend(input: $input) { + errors { + message + } + status + } + } +` + +export default MUTATION_LN_NOAMOUNT_INVOICE_PAYMENT_SEND diff --git a/src/store/graphql/mutation.onchain-payment-send.ts b/src/store/graphql/mutation.onchain-payment-send.ts new file mode 100644 index 00000000..5a5f0880 --- /dev/null +++ b/src/store/graphql/mutation.onchain-payment-send.ts @@ -0,0 +1,14 @@ +import { gql } from "@apollo/client" + +const MUTATION_ONCHAIN_PAYMENT_SEND = gql` + mutation onChainPaymentSend($input: OnChainPaymentSendInput!) { + onChainPaymentSend(input: $input) { + errors { + message + } + status + } + } +` + +export default MUTATION_ONCHAIN_PAYMENT_SEND diff --git a/src/store/graphql/query.main.ts b/src/store/graphql/query.main.ts index 29b30c6c..9d97815f 100644 --- a/src/store/graphql/query.main.ts +++ b/src/store/graphql/query.main.ts @@ -2,6 +2,9 @@ import { gql } from "@apollo/client" const QUERY_MAIN = gql` query me($hasToken: Boolean!) { + globals { + nodesIds + } btcPrice { base offset diff --git a/src/store/graphql/query.user-default-wallet-id.ts b/src/store/graphql/query.user-default-wallet-id.ts new file mode 100644 index 00000000..dd7ecd44 --- /dev/null +++ b/src/store/graphql/query.user-default-wallet-id.ts @@ -0,0 +1,9 @@ +import { gql } from "@apollo/client" + +const QUERY_USER_DEFAULT_WALLET_ID = gql` + query userDefaultWalletId($username: Username!) { + userDefaultWalletId(username: $username) + } +` + +export default QUERY_USER_DEFAULT_WALLET_ID diff --git a/src/store/history.ts b/src/store/history.ts index 5554b0b6..72d209c5 100644 --- a/src/store/history.ts +++ b/src/store/history.ts @@ -1,4 +1,4 @@ -import config from "server/config" +import config from "./config" import { createBrowserHistory, createMemoryHistory } from "history" export const history = config.isBrowser ? createBrowserHistory() : createMemoryHistory() diff --git a/src/store/index.ts b/src/store/index.ts index 49055833..05a8d7b5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -13,11 +13,11 @@ import { WebSocketLink } from "@apollo/client/link/ws" import { getMainDefinition } from "@apollo/client/utilities" import { createContext, useContext } from "react" -import config from "server/config" +import config from "./config" import useAuthToken from "./use-auth-token" export const GwwContext = createContext({ - state: { path: "/" }, + state: { path: "/", key: 0 }, dispatch: (_action: GwwAction) => { // Do nothing }, diff --git a/src/store/reducer.ts b/src/store/reducer.ts index 9352e34a..62701653 100644 --- a/src/store/reducer.ts +++ b/src/store/reducer.ts @@ -4,6 +4,8 @@ const mainReducer = (state: GwwState, action: GwwAction): GwwState => { switch (type) { case "navigate": return { ...state, ...newState } + case "reset-current-screen": + return { ...state, key: Math.random() } default: throw new Error("UNSUPPORTED_ACTION") } diff --git a/src/store/use-delayed-query.ts b/src/store/use-delayed-query.ts new file mode 100644 index 00000000..a565338c --- /dev/null +++ b/src/store/use-delayed-query.ts @@ -0,0 +1,39 @@ +import { DocumentNode } from "graphql" + +import { ApolloQueryResult, OperationVariables, useApolloClient } from "@apollo/client" +import { useCallback, useState } from "react" + +type SendQuery = ( + variables: OperationVariables, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => Promise | { data: undefined; error: unknown }> + +const useDelayedQuery = ( + query: DocumentNode, +): [SendQuery, { loading: boolean | undefined }] => { + const client = useApolloClient() + + const [loading, setLoading] = useState() + + const sendQuery: SendQuery = useCallback( + async (variables) => { + setLoading(true) + try { + const result = await client.query({ + query, + variables, + }) + setLoading(false) + return result + } catch (err) { + setLoading(false) + return { data: undefined, error: err } + } + }, + [client, query], + ) + + return [sendQuery, { loading }] +} + +export default useDelayedQuery diff --git a/src/store/use-main-query.ts b/src/store/use-main-query.ts index 4841e849..15b14ca8 100644 --- a/src/store/use-main-query.ts +++ b/src/store/use-main-query.ts @@ -22,6 +22,7 @@ const useMainQuery = () => { return { btcPrice: data?.btcPrice, + pubKey: data?.globals?.nodesIds?.[0] ?? "", btcWalletId: btcWallet?.id, btcWalletBalance, } diff --git a/src/styles/main.css b/src/styles/main.css index b811a24a..07fe2e7f 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -59,10 +59,18 @@ input[type="tel"] { justify-content: space-around; } -.amount-input { +.center-display { display: flex; + flex-direction: column; + justify-content: center; + align-items: center; max-width: 375px; margin: 0 auto; + text-align: center; +} + +.amount-input { + flex-direction: row; .currency-label { margin-right: 0.5rem; font-size: 1.5rem; @@ -77,10 +85,16 @@ input[type="tel"] { } } +.destination-input { + margin-top: 1rem; + input { + border: thin solid var(--color-gray); + width: 100%; + } +} + .note-input { - display: flex; - max-width: 375px; - margin: 1rem auto; + margin-top: 1rem; textarea { border: thin solid var(--color-gray); width: 100%; @@ -88,27 +102,23 @@ input[type="tel"] { } } -.invoice-container { - text-align: center; - width: 375px; - margin: 0 auto; - i { - margin-top: 2rem; - } +.action-container { + padding-top: 1rem; } .invoice-paid { - margin: 3rem; + margin: 1.5rem; } .amount-converted { - margin: 1rem auto 0 auto; + max-width: 375px; + margin: 0 auto; + margin-top: 1rem; text-align: center; - width: 375px; .converted-sats { - margin-bottom: 0.3rem; } .converted-usd { + margin-top: 0.3rem; color: var(--color-secondary); } } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 704ad886..d5402852 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -11,41 +11,36 @@ type PriceData = { currencyUnit: string } -type InitialState = { +type GwwState = { path: RoutePath + key: number authToken?: string defaultLanguage?: string } +type GwwAction = { + type: "navigate" | "reset-current-screen" + [payloadKey: string]: string | undefined +} + +type GwwContextType = { + state: GwwState + dispatch: React.Dispatch +} + declare interface Window { __G_DATA: { - initialState: InitialState + GwwState: GwwState ssrData: NormalizedCacheObject } } type ServerRendererFunction = (path: RoutePath) => Promise<{ - initialState: InitialState + GwwState: GwwState initialMarkup: string pageData: RouteInfo }> -type GwwState = { - path: RoutePath - authToken?: string - defaultLanguage?: string -} - -type GwwAction = { - type: "navigate" - [payloadKey: string]: string | undefined -} - -type GwwContextType = { - state: GwwState - dispatch: React.Dispatch -} - type GeetestValidationData = { geetestChallenge: string geetestSecCode: string @@ -104,3 +99,21 @@ type UseMyUpdates = { } type SpinnerSize = "small" | "big" + +type InvoiceInput = { + currency: "USD" | "SATS" + + // undefined in input is used to indicate their changing state + amount?: number | "" + destination?: string + memo?: string + + satAmount?: number // from price conversion + + valid?: boolean // from parsing + paymentType?: "lightning" | "onchain" | "intraledger" | "lnurl" + + fixedAmount?: boolean // if the invoice has amount + paymentRequset?: string // if payment is lightning + reciepientWalletId?: string // if payment is intraledger +} diff --git a/views/index.ejs b/views/index.ejs index 88e04030..c01b2ad1 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -20,7 +20,7 @@ crossorigin="anonymous" > diff --git a/webpack.config.js b/webpack.config.js index 761dc82b..f28c90e5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,8 +9,16 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin") const config = { devtool: isDev ? "inline-source-map" : false, resolve: { - modules: [path.resolve("./src"), path.resolve("./node_modules")], + modules: [ + path.resolve("./src"), + path.resolve("./node_modules"), + path.resolve("./src/galoy-client/node_modules"), + ], extensions: [".ts", ".tsx", ".js", ".json"], + fallback: { + stream: require.resolve("stream-browserify"), + url: require.resolve("url"), + }, }, entry: { main: ["./src/renderers/dom.tsx"], @@ -59,6 +67,9 @@ const config = { }, }, plugins: [ + new webpack.ProvidePlugin({ + Buffer: ["buffer", "Buffer"], + }), new webpack.DefinePlugin({ "process.env": JSON.stringify(process.env), }), diff --git a/yarn.lock b/yarn.lock index 775946cd..382bb59d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4513,7 +4513,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6758,6 +6758,11 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" @@ -6773,6 +6778,11 @@ qs@6.9.6: resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee" integrity sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ== +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -6876,7 +6886,7 @@ readable-stream@1.1.x: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^3.4.0: +readable-stream@^3.4.0, readable-stream@^3.5.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -7470,6 +7480,14 @@ stack-utils@^2.0.3: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +stream-browserify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + string-env-interpolation@1.0.1, string-env-interpolation@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz#ad4397ae4ac53fe6c91d1402ad6f6a52862c7152" @@ -8094,6 +8112,14 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + use-debounce@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-7.0.1.tgz#380e6191cc13ad29f8e2149a12b5c37cc2891190"