diff --git a/api/api.go b/api/api.go index 7f3fb944..8d44cecd 100644 --- a/api/api.go +++ b/api/api.go @@ -491,6 +491,39 @@ func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) { return balances, nil } +func (api *api) GetTransactions(ctx context.Context) (*TransactionsResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } + transactions, err := api.svc.GetLNClient().ListTransactions(ctx, 0, 100000000000, 1000, 0, false, "") + if err != nil { + return nil, err + } + return &transactions, nil +} + +func (api *api) SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } + resp, err := api.svc.GetLNClient().SendPaymentSync(ctx, invoice) + if err != nil { + return nil, err + } + return resp, nil +} + +func (api *api) CreateInvoice(ctx context.Context, amount int64, description string) (*CreateInvoiceResponse, error) { + if api.svc.GetLNClient() == nil { + return nil, errors.New("LNClient not started") + } + invoice, err := api.svc.GetLNClient().MakeInvoice(ctx, amount, description, "", 0) + if err != nil { + return nil, err + } + return invoice, nil +} + // TODO: remove dependency on this endpoint func (api *api) RequestMempoolApi(endpoint string) (interface{}, error) { url := api.cfg.GetEnv().MempoolApi + endpoint diff --git a/api/models.go b/api/models.go index 7d72d83b..c1d201f1 100644 --- a/api/models.go +++ b/api/models.go @@ -34,6 +34,9 @@ type API interface { SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) RedeemOnchainFunds(ctx context.Context, toAddress string) (*RedeemOnchainFundsResponse, error) GetBalances(ctx context.Context) (*BalancesResponse, error) + GetTransactions(ctx context.Context) (*TransactionsResponse, error) + SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) + CreateInvoice(ctx context.Context, amount int64, description string) (*CreateInvoiceResponse, error) RequestMempoolApi(endpoint string) (interface{}, error) GetInfo(ctx context.Context) (*InfoResponse, error) GetEncryptedMnemonic() *EncryptedMnemonicResponse @@ -177,6 +180,10 @@ type RedeemOnchainFundsResponse struct { type OnchainBalanceResponse = lnclient.OnchainBalanceResponse type BalancesResponse = lnclient.BalancesResponse +type SendPaymentResponse = lnclient.PayInvoiceResponse +type CreateInvoiceResponse = lnclient.Transaction +type TransactionsResponse = []lnclient.Transaction + // debug api type SendPaymentProbesRequest struct { Invoice string `json:"invoice"` @@ -217,6 +224,15 @@ type SignMessageResponse struct { Signature string `json:"signature"` } +type WalletSendRequest struct { + Invoice string `json:"invoice"` +} + +type WalletReceiveRequest struct { + Amount int64 `json:"amount"` + Description string `json:"description"` +} + type ResetRouterRequest struct { Key string `json:"key"` } diff --git a/frontend/src/components/TransactionsList.tsx b/frontend/src/components/TransactionsList.tsx new file mode 100644 index 00000000..4104460d --- /dev/null +++ b/frontend/src/components/TransactionsList.tsx @@ -0,0 +1,102 @@ +import dayjs from "dayjs"; +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; + +import Loading from "src/components/Loading"; +import { useTransactions } from "src/hooks/useTransactions"; + +function TransactionsList() { + const { data: transactions } = useTransactions(); + + if (!transactions) { + return ; + } + + console.info(transactions); + + return ( +
+ {!transactions?.length ? ( +

+ No transactions, yet. + {/* Deposit Bitcoin Button */} +

+ ) : ( + <> + {transactions?.map((tx, i) => { + const type = tx.type; + + return ( +
openDetails(tx)} + > +
+
+ {type == "outgoing" ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+

+ {type == "incoming" ? "Received" : "Sent"} +

+
+

+ {dayjs(tx.settled_at * 1000).fromNow()} + {tx.description} +

+
+
+
+

+ {type == "outgoing" ? "-" : "+"}{" "} + {Math.floor(tx.amount / 1000)} +

+

sats

+ + {/* {!!tx.totalAmountFiat && ( +

+ ~{tx.totalAmountFiat} +

+ )} */} +
+
+
+
+ ); + })} + {/* {transaction && ( + { + setModalOpen(false); + }} + /> + )} */} + + )} +
+ ); +} + +export default TransactionsList; diff --git a/frontend/src/hooks/useTransactions.ts b/frontend/src/hooks/useTransactions.ts new file mode 100644 index 00000000..394768fd --- /dev/null +++ b/frontend/src/hooks/useTransactions.ts @@ -0,0 +1,16 @@ +import useSWR, { SWRConfiguration } from "swr"; + +import { Transaction } from "src/types"; +import { swrFetcher } from "src/utils/swr"; + +const pollConfiguration: SWRConfiguration = { + refreshInterval: 3000, +}; + +export function useTransactions(poll = false) { + return useSWR( + "/api/transactions", + swrFetcher, + poll ? pollConfiguration : undefined + ); +} diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index ab719334..50ef15b8 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -47,6 +47,8 @@ import { LNDForm } from "src/screens/setup/node/LNDForm"; import { PhoenixdForm } from "src/screens/setup/node/PhoenixdForm"; import { PresetNodeForm } from "src/screens/setup/node/PresetNodeForm"; import Wallet from "src/screens/wallet"; +import Receive from "src/screens/wallet/Receive"; +import Send from "src/screens/wallet/Send"; import SignMessage from "src/screens/wallet/SignMessage"; const routes = [ @@ -68,6 +70,16 @@ const routes = [ index: true, element: , }, + { + path: "receive", + element: , + handle: { crumb: () => "Receive" }, + }, + { + path: "send", + element: , + handle: { crumb: () => "Send" }, + }, { path: "sign-message", element: , diff --git a/frontend/src/screens/wallet/Receive.tsx b/frontend/src/screens/wallet/Receive.tsx new file mode 100644 index 00000000..9d5e3629 --- /dev/null +++ b/frontend/src/screens/wallet/Receive.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import AppHeader from "src/components/AppHeader"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { useToast } from "src/components/ui/use-toast"; +import { useCSRF } from "src/hooks/useCSRF"; +import { CreateInvoiceRequest, Transaction } from "src/types"; +import { request } from "src/utils/request"; + +export default function Receive() { + const { data: csrf } = useCSRF(); + const { toast } = useToast(); + const [isLoading, setLoading] = React.useState(false); + const [amount, setAmount] = React.useState(); + const [description, setDescription] = React.useState(""); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!csrf) { + throw new Error("csrf not loaded"); + } + try { + setLoading(true); + const invoice = await request("/api/wallet/receive", { + method: "POST", + headers: { + "X-CSRF-Token": csrf, + "Content-Type": "application/json", + }, + body: JSON.stringify({ amount, description } as CreateInvoiceRequest), + }); + setAmount(null); + setDescription(""); + if (invoice) { + // setSignature(signMessageResponse.signature); + console.info(invoice); + toast({ + title: "Successfully created invoice", + }); + } + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to create invoice: " + e, + }); + console.error(e); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+
+ + { + setAmount(parseInt(e.target.value)); + }} + min={1} + autoFocus + /> +
+
+ + { + setDescription(e.target.value); + }} + /> +
+
+ + Create Invoice + +
+
+
+
+ ); +} diff --git a/frontend/src/screens/wallet/Send.tsx b/frontend/src/screens/wallet/Send.tsx new file mode 100644 index 00000000..4356c333 --- /dev/null +++ b/frontend/src/screens/wallet/Send.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import AppHeader from "src/components/AppHeader"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { useToast } from "src/components/ui/use-toast"; +import { useCSRF } from "src/hooks/useCSRF"; +import { PayInvoiceResponse } from "src/types"; +import { request } from "src/utils/request"; + +export default function Send() { + const { data: csrf } = useCSRF(); + const { toast } = useToast(); + const [isLoading, setLoading] = React.useState(false); + const [invoice, setInvoice] = React.useState(""); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!csrf) { + throw new Error("csrf not loaded"); + } + try { + setLoading(true); + const payInvoiceResponse = await request( + "/api/wallet/send", + { + method: "POST", + headers: { + "X-CSRF-Token": csrf, + "Content-Type": "application/json", + }, + body: JSON.stringify({ invoice: invoice.trim() }), + } + ); + // setSignatureMessage(message); + // setMessage(""); + if (payInvoiceResponse) { + // setSignature(signMessageResponse.signature); + toast({ + title: "Successfully paid invoice", + }); + } + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to send: " + e, + }); + console.error(e); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+
+ + { + setInvoice(e.target.value); + }} + /> +
+
+ + Continue + +
+
+
+
+ ); +} diff --git a/frontend/src/screens/wallet/index.tsx b/frontend/src/screens/wallet/index.tsx index 565b22cf..fe9077c9 100644 --- a/frontend/src/screens/wallet/index.tsx +++ b/frontend/src/screens/wallet/index.tsx @@ -1,9 +1,16 @@ -import { ExternalLinkIcon } from "lucide-react"; +import { + ArrowDownIcon, + ArrowUpIcon, + ExternalLinkIcon, + ScanIcon, +} from "lucide-react"; +import { Link } from "react-router-dom"; import AlbyHead from "src/assets/images/alby-head.svg"; import AppHeader from "src/components/AppHeader"; import BreezRedeem from "src/components/BreezRedeem"; import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; +import TransactionsList from "src/components/TransactionsList"; import { Button } from "src/components/ui/button"; import { Card, @@ -30,13 +37,33 @@ function Wallet() { return ( <> -
+
{new Intl.NumberFormat().format( Math.floor(balances.lightning.totalSpendable / 1000) )}{" "} sats
+
+ + + + + + + + + +
@@ -104,6 +131,8 @@ function Wallet() { + + ); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a2ca746a..c5978f95 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -207,6 +207,16 @@ export type SignMessageResponse = { signature: string; }; +export type PayInvoiceResponse = { + preimage: string; + fee: number; +}; + +export type CreateInvoiceRequest = { + amount: number; + description: string; +}; + export type OpenChannelRequest = { pubkey: string; amount: number; @@ -315,6 +325,21 @@ export type BalancesResponse = { lightning: LightningBalanceResponse; }; +export type Transaction = { + type: string; + invoice: string; + description: string; + description_hash: string; + preimage: string; + payment_hash: string; + amount: number; + fees_paid: number; + created_at: number; + expires_at: number; + settled_at: number; + metadata: string[]; +}; + export type NewChannelOrderStatus = "pay" | "success" | "opening"; export type NewChannelOrder = { diff --git a/http/http_service.go b/http/http_service.go index d9c26c53..0e2fb620 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -106,6 +106,9 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { e.POST("/api/wallet/redeem-onchain-funds", httpSvc.redeemOnchainFundsHandler, authMiddleware) e.POST("/api/wallet/sign-message", httpSvc.signMessageHandler, authMiddleware) e.POST("/api/wallet/sync", httpSvc.walletSyncHandler, authMiddleware) + e.POST("/api/wallet/send", httpSvc.sendHandler, authMiddleware) + e.POST("/api/wallet/receive", httpSvc.receiveHandler, authMiddleware) + e.GET("/api/transactions", httpSvc.transactionsHandler, authMiddleware) e.GET("/api/balances", httpSvc.balancesHandler, authMiddleware) e.POST("/api/reset-router", httpSvc.resetRouterHandler, authMiddleware) e.POST("/api/stop", httpSvc.stopHandler, authMiddleware) @@ -393,6 +396,58 @@ func (httpSvc *HttpService) balancesHandler(c echo.Context) error { return c.JSON(http.StatusOK, balances) } +func (httpSvc *HttpService) sendHandler(c echo.Context) error { + var walletSendRequest api.WalletSendRequest + if err := c.Bind(&walletSendRequest); err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: fmt.Sprintf("Bad request: %s", err.Error()), + }) + } + + resp, err := httpSvc.api.SendPayment(c.Request().Context(), walletSendRequest.Invoice) + + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: err.Error(), + }) + } + + return c.JSON(http.StatusOK, resp) +} + +func (httpSvc *HttpService) receiveHandler(c echo.Context) error { + var walletReceiveRequest api.WalletReceiveRequest + if err := c.Bind(&walletReceiveRequest); err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: fmt.Sprintf("Bad request: %s", err.Error()), + }) + } + + invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), walletReceiveRequest.Amount, walletReceiveRequest.Description) + + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: err.Error(), + }) + } + + return c.JSON(http.StatusOK, invoice) +} + +func (httpSvc *HttpService) transactionsHandler(c echo.Context) error { + ctx := c.Request().Context() + + transactions, err := httpSvc.api.GetTransactions(ctx) + + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: err.Error(), + }) + } + + return c.JSON(http.StatusOK, transactions) +} + func (httpSvc *HttpService) walletSyncHandler(c echo.Context) error { httpSvc.api.SyncWallet()