diff --git a/db/init.sql b/db/init.sql index 157036b..58800ee 100644 --- a/db/init.sql +++ b/db/init.sql @@ -47,7 +47,8 @@ CREATE TABLE orders( side ORDER_SIDE NOT NULL, quantity BIGINT NOT NULL, price BIGINT NOT NULL, - invoice_id UUID NOT NULL REFERENCES invoices(id) + invoice_id UUID NOT NULL REFERENCES invoices(id), + order_id UUID REFERENCES orders(id) ); ALTER TABLE orders ADD CONSTRAINT order_price CHECK(price > 0 AND price < 100); ALTER TABLE orders ADD CONSTRAINT order_quantity CHECK(quantity > 0); diff --git a/db/invoice.go b/db/invoice.go index 82d60db..161acc9 100644 --- a/db/invoice.go +++ b/db/invoice.go @@ -1,12 +1,13 @@ package db import ( + "context" "database/sql" "time" ) -func (db *DB) CreateInvoice(invoice *Invoice) error { - if err := db.QueryRow(""+ +func (db *DB) CreateInvoice(tx *sql.Tx, ctx context.Context, invoice *Invoice) error { + if err := tx.QueryRowContext(ctx, ""+ "INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at, description) "+ "VALUES($1, $2, $3, $4, $5, $6, $7, $8) "+ "RETURNING id", @@ -70,6 +71,33 @@ func (db *DB) FetchInvoices(where *FetchInvoicesWhere, invoices *[]Invoice) erro return nil } +func (db *DB) FetchUserInvoices(pubkey string, invoices *[]Invoice) error { + var ( + rows *sql.Rows + invoice Invoice + err error + ) + var ( + query = "" + + "SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since, COALESCE(description, ''), " + + "CASE WHEN confirmed_at IS NOT NULL THEN 'PAID' WHEN expires_at < CURRENT_TIMESTAMP THEN 'EXPIRED' ELSE 'WAITING' END AS status " + + "FROM invoices " + + "WHERE pubkey = $1 " + + "ORDER BY created_at DESC" + ) + if rows, err = db.Query(query, pubkey); err != nil { + return err + } + defer rows.Close() + for rows.Next() { + rows.Scan( + &invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash, + &invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince, &invoice.Description, &invoice.Status) + *invoices = append(*invoices, invoice) + } + return nil +} + func (db *DB) ConfirmInvoice(hash string, confirmedAt time.Time, msatsReceived int) error { if _, err := db.Exec("UPDATE invoices SET confirmed_at = $2, msats_received = $3 WHERE hash = $1", hash, confirmedAt, msatsReceived); err != nil { return err diff --git a/db/market.go b/db/market.go index 0ca6ad3..98fd678 100644 --- a/db/market.go +++ b/db/market.go @@ -1,6 +1,9 @@ package db -import "database/sql" +import ( + "context" + "database/sql" +) type FetchOrdersWhere struct { MarketId int @@ -8,15 +11,15 @@ type FetchOrdersWhere struct { Confirmed bool } -func (db *DB) CreateMarket(market *Market) error { - if err := db.QueryRow(""+ +func (db *DB) CreateMarket(tx *sql.Tx, ctx context.Context, market *Market) error { + if err := tx.QueryRowContext(ctx, ""+ "INSERT INTO markets(description, end_date, invoice_id) "+ "VALUES($1, $2, $3) "+ "RETURNING id", market.Description, market.EndDate, market.InvoiceId).Scan(&market.Id); err != nil { return err } // For now, we only support binary markets. - if _, err := db.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil { + if _, err := tx.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil { return err } return nil @@ -62,8 +65,8 @@ func (db *DB) FetchShares(marketId int, shares *[]Share) error { return nil } -func (db *DB) FetchShare(shareId string, share *Share) error { - return db.QueryRow("SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description) +func (db *DB) FetchShare(tx *sql.Tx, ctx context.Context, shareId string, share *Share) error { + return tx.QueryRowContext(ctx, "SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description) } func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error { @@ -99,8 +102,8 @@ func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error { return nil } -func (db *DB) CreateOrder(order *Order) error { - if _, err := db.Exec(""+ +func (db *DB) CreateOrder(tx *sql.Tx, ctx context.Context, order *Order) error { + if _, err := tx.ExecContext(ctx, ""+ "INSERT INTO orders(share_id, pubkey, side, quantity, price, invoice_id) "+ "VALUES ($1, $2, $3, $4, $5, $6)", order.ShareId, order.Pubkey, order.Side, order.Quantity, order.Price, order.InvoiceId); err != nil { @@ -108,3 +111,47 @@ func (db *DB) CreateOrder(order *Order) error { } return nil } + +func (db *DB) FetchUserOrders(pubkey string, orders *[]Order) error { + query := "" + + "SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, i.confirmed_at, " + + "CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' ELSE 'WAITING' END AS status " + + "FROM orders o " + + "JOIN invoices i ON o.invoice_id = i.id " + + "JOIN shares s ON o.share_id = s.id " + + "WHERE o.pubkey = $1 AND i.confirmed_at IS NOT NULL " + + "ORDER BY o.created_at DESC" + rows, err := db.Query(query, pubkey) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var order Order + rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.ShareDescription, &order.Share.MarketId, &order.Invoice.ConfirmedAt, &order.Status) + *orders = append(*orders, order) + } + return nil +} + +func (db *DB) FetchMarketOrders(marketId int64, orders *[]Order) error { + query := "" + + "SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, " + + "CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' ELSE 'WAITING' END AS status " + + "FROM orders o " + + "JOIN shares s ON o.share_id = s.id " + + "JOIN invoices i ON i.id = o.invoice_id " + + "WHERE s.market_id = $1 AND i.confirmed_at IS NOT NULL " + + "ORDER BY o.created_at DESC" + rows, err := db.Query(query, marketId) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var order Order + rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.ShareDescription, &order.Share.MarketId, &order.Status) + *orders = append(*orders, order) + } + return nil +} diff --git a/db/types.go b/db/types.go index e0d817e..9e64896 100644 --- a/db/types.go +++ b/db/types.go @@ -47,11 +47,13 @@ type ( ConfirmedAt null.Time HeldSince null.Time Description string + Status string } Order struct { - Id UUID - CreatedAt time.Time - ShareId string `json:"sid"` + Id UUID + CreatedAt time.Time + ShareId string `json:"sid"` + ShareDescription string Share Pubkey string Side string `json:"side"` diff --git a/lnd/invoice.go b/lnd/invoice.go index 337fa8d..84d9163 100644 --- a/lnd/invoice.go +++ b/lnd/invoice.go @@ -2,6 +2,7 @@ package lnd import ( "context" + "database/sql" "log" "time" @@ -12,7 +13,7 @@ import ( "github.com/lightningnetwork/lnd/lnwire" ) -func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, description string) (*db.Invoice, error) { +func (lnd *LNDClient) CreateInvoice(tx *sql.Tx, ctx context.Context, d *db.DB, pubkey string, msats int64, description string) (*db.Invoice, error) { var ( expiry time.Duration = time.Hour preimage lntypes.Preimage @@ -26,14 +27,14 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri return nil, err } hash = preimage.Hash() - if paymentRequest, err = lnd.Invoices.AddHoldInvoice(context.TODO(), &invoicesrpc.AddInvoiceData{ + if paymentRequest, err = lnd.Invoices.AddHoldInvoice(ctx, &invoicesrpc.AddInvoiceData{ Hash: &hash, Value: lnwire.MilliSatoshi(msats), Expiry: int64(expiry / time.Millisecond), }); err != nil { return nil, err } - if lnInvoice, err = lnd.Client.LookupInvoice(context.TODO(), hash); err != nil { + if lnInvoice, err = lnd.Client.LookupInvoice(ctx, hash); err != nil { return nil, err } dbInvoice = &db.Invoice{ @@ -46,7 +47,7 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri ExpiresAt: lnInvoice.CreationDate.Add(expiry), Description: description, } - if err := d.CreateInvoice(dbInvoice); err != nil { + if err := d.CreateInvoice(tx, ctx, dbInvoice); err != nil { return nil, err } return dbInvoice, nil diff --git a/server/router/handler/invoice.go b/server/router/handler/invoice.go index 45e888d..f9d1073 100644 --- a/server/router/handler/invoice.go +++ b/server/router/handler/invoice.go @@ -93,3 +93,18 @@ func HandleInvoice(sc context.ServerContext) echo.HandlerFunc { return sc.Render(c, http.StatusOK, "invoice.html", data) } } + +func HandleInvoices(sc context.ServerContext) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + u db.User + invoices []db.Invoice + err error + ) + u = c.Get("session").(db.User) + if err = sc.Db.FetchUserInvoices(u.Pubkey, &invoices); err != nil { + return err + } + return c.JSON(http.StatusOK, invoices) + } +} diff --git a/server/router/handler/market.go b/server/router/handler/market.go index 3c0b917..8b2babc 100644 --- a/server/router/handler/market.go +++ b/server/router/handler/market.go @@ -1,11 +1,13 @@ package handler import ( + context_ "context" "database/sql" "fmt" "net/http" "strconv" "strings" + "time" "git.ekzyis.com/ekzyis/delphi.market/db" "git.ekzyis.com/ekzyis/delphi.market/lib" @@ -50,6 +52,7 @@ func HandleMarket(sc context.ServerContext) echo.HandlerFunc { func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc { return func(c echo.Context) error { var ( + tx *sql.Tx u db.User m db.Market invoice *db.Invoice @@ -64,26 +67,41 @@ func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc { return echo.NewHTTPError(http.StatusBadRequest) } + // transaction start + ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + if tx, err = sc.Db.BeginTx(ctx, nil); err != nil { + tx.Rollback() + return err + } + defer tx.Commit() + u = c.Get("session").(db.User) msats = 1000 // TODO: add [market:] for redirect after payment - invDescription = fmt.Sprintf("create market \"%s\" (%s)", m.Description, m.EndDate) - if invoice, err = sc.Lnd.CreateInvoice(sc.Db, u.Pubkey, msats, invDescription); err != nil { + invDescription = fmt.Sprintf("create market \"%s\"", m.Description) + if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, u.Pubkey, msats, invDescription); err != nil { + tx.Rollback() return err } if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil { + tx.Rollback() return err } if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil { + tx.Rollback() return err } - go sc.Lnd.CheckInvoice(sc.Db, hash) - m.InvoiceId = invoice.Id - if err := sc.Db.CreateMarket(&m); err != nil { + if err := sc.Db.CreateMarket(tx, ctx, &m); err != nil { + tx.Rollback() return err } + // need to commit before starting to poll invoice status + tx.Commit() + go sc.Lnd.CheckInvoice(sc.Db, hash) + data = map[string]any{ "id": invoice.Id, "bolt11": invoice.PaymentRequest, @@ -94,9 +112,27 @@ func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc { } } +func HandleMarketOrders(sc context.ServerContext) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + marketId int64 + orders []db.Order + err error + ) + if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Bad Request") + } + if err = sc.Db.FetchMarketOrders(marketId, &orders); err != nil { + return err + } + return c.JSON(http.StatusOK, orders) + } +} + func HandleOrder(sc context.ServerContext) echo.HandlerFunc { return func(c echo.Context) error { var ( + tx *sql.Tx u db.User o db.Order s db.Share @@ -122,7 +158,18 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { u = c.Get("session").(db.User) o.Pubkey = u.Pubkey msats = o.Quantity * o.Price * 1000 - if err = sc.Db.FetchShare(o.ShareId, &s); err != nil { + + // transaction start + ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + if tx, err = sc.Db.BeginTx(ctx, nil); err != nil { + tx.Rollback() + return err + } + defer tx.Commit() + + if err = sc.Db.FetchShare(tx, ctx, o.ShareId, &s); err != nil { + tx.Rollback() return err } description = fmt.Sprintf("%s %d %s shares @ %d sats [market:%d]", strings.ToUpper(o.Side), o.Quantity, s.Description, o.Price, s.MarketId) @@ -130,24 +177,29 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { // TODO: if SELL order, check share balance of user // Create HODL invoice - if invoice, err = sc.Lnd.CreateInvoice(sc.Db, o.Pubkey, msats, description); err != nil { + if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, o.Pubkey, msats, description); err != nil { + tx.Rollback() return err } // Create QR code to pay HODL invoice if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil { + tx.Rollback() return err } if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil { + tx.Rollback() return err } // Create (unconfirmed) order o.InvoiceId = invoice.Id - if err := sc.Db.CreateOrder(&o); err != nil { + if err := sc.Db.CreateOrder(tx, ctx, &o); err != nil { + tx.Rollback() return err } - // Start goroutine to poll status and update invoice in background + // need to commit before startign to poll invoice status + tx.Commit() go sc.Lnd.CheckInvoice(sc.Db, hash) // TODO: find matching orders @@ -161,3 +213,18 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { return c.JSON(http.StatusPaymentRequired, data) } } + +func HandleOrders(sc context.ServerContext) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + u db.User + orders []db.Order + err error + ) + u = c.Get("session").(db.User) + if err = sc.Db.FetchUserOrders(u.Pubkey, &orders); err != nil { + return err + } + return c.JSON(http.StatusOK, orders) + } +} diff --git a/server/router/router.go b/server/router/router.go index 25a3b8b..c1fb901 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -46,10 +46,14 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) { middleware.SessionGuard, middleware.LNDGuard) GET(e, sc, "/api/market/:id", handler.HandleMarket) + GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders) POST(e, sc, "/api/order", handler.HandleOrder, middleware.SessionGuard, middleware.LNDGuard) + GET(e, sc, "/api/orders", + handler.HandleOrders, + middleware.SessionGuard) GET(e, sc, "/api/login", handler.HandleLogin) GET(e, sc, "/api/login/callback", handler.HandleLoginCallback) POST(e, sc, "/api/logout", handler.HandleLogout) @@ -57,6 +61,9 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) { GET(e, sc, "/api/invoice/:id", handler.HandleInvoiceStatus, middleware.SessionGuard) + GET(e, sc, "/api/invoices", + handler.HandleInvoices, + middleware.SessionGuard) } func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route { diff --git a/vue/package.json b/vue/package.json index 43f3368..260bc1c 100644 --- a/vue/package.json +++ b/vue/package.json @@ -12,6 +12,7 @@ "core-js": "^3.8.3", "pinia": "^2.1.7", "register-service-worker": "^1.7.2", + "s-ago": "^2.2.0", "vite": "^4.5.0", "vue": "^3.2.13", "vue-router": "4" diff --git a/vue/src/components/Invoice.vue b/vue/src/components/Invoice.vue index 367a4bf..4974215 100644 --- a/vue/src/components/Invoice.vue +++ b/vue/src/components/Invoice.vue @@ -22,14 +22,18 @@
+ faucet + + faucet.mutinynet.com + payment hash {{ invoice.Hash }} created at - {{ invoice.CreatedAt }} + {{ invoice.CreatedAt }} ({{ ago(new Date(invoice.CreatedAt)) }}) expires at - {{ invoice.ExpiresAt }} + {{ invoice.ExpiresAt }} ({{ ago(new Date(invoice.ExpiresAt)) }}) sats {{ invoice.Msats / 1000 }} @@ -54,8 +58,9 @@ diff --git a/vue/src/components/MarketForm.vue b/vue/src/components/MarketForm.vue index ee960e7..7551925 100644 --- a/vue/src/components/MarketForm.vue +++ b/vue/src/components/MarketForm.vue @@ -10,6 +10,7 @@
+
{{ err }}
+ + diff --git a/vue/src/components/OrderForm.vue b/vue/src/components/OrderForm.vue new file mode 100644 index 0000000..fb1bf80 --- /dev/null +++ b/vue/src/components/OrderForm.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/vue/src/components/OrderRow.vue b/vue/src/components/OrderRow.vue new file mode 100644 index 0000000..410b06b --- /dev/null +++ b/vue/src/components/OrderRow.vue @@ -0,0 +1,22 @@ + + + diff --git a/vue/src/components/StyledLink.vue b/vue/src/components/StyledLink.vue new file mode 100644 index 0000000..11831cd --- /dev/null +++ b/vue/src/components/StyledLink.vue @@ -0,0 +1,16 @@ + + + diff --git a/vue/src/components/UserInvoices.vue b/vue/src/components/UserInvoices.vue new file mode 100644 index 0000000..7554146 --- /dev/null +++ b/vue/src/components/UserInvoices.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/vue/src/components/UserOrders.vue b/vue/src/components/UserOrders.vue new file mode 100644 index 0000000..80ee0ec --- /dev/null +++ b/vue/src/components/UserOrders.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/vue/src/components/UserSettings.vue b/vue/src/components/UserSettings.vue new file mode 100644 index 0000000..2fe2f69 --- /dev/null +++ b/vue/src/components/UserSettings.vue @@ -0,0 +1,18 @@ + + + diff --git a/vue/src/index.css b/vue/src/index.css index e6d2af9..d3d3e38 100644 --- a/vue/src/index.css +++ b/vue/src/index.css @@ -50,6 +50,7 @@ a.selected { .success:hover { background-color: #35df8d; + color: white; } .red { @@ -63,6 +64,7 @@ a.selected { .error:hover { background-color: #ff7386; + color: white; } .text-muted { diff --git a/vue/src/main.js b/vue/src/main.js index 3fa9819..299b429 100644 --- a/vue/src/main.js +++ b/vue/src/main.js @@ -10,6 +10,11 @@ import LoginView from '@/views/LoginView' import UserView from '@/views/UserView' import MarketView from '@/views/MarketView' import InvoiceView from '@/views/InvoiceView' +import UserSettings from '@/components/UserSettings' +import UserInvoices from '@/components/UserInvoices' +import UserOrders from '@/components/UserOrders' +import OrderForm from '@/components/OrderForm' +import MarketOrders from '@/components/MarketOrders' const routes = [ { @@ -19,10 +24,21 @@ const routes = [ path: '/login', component: LoginView }, { - path: '/user', component: UserView + path: '/user', + component: UserView, + children: [ + { path: 'settings', name: 'user', component: UserSettings }, + { path: 'invoices', name: 'invoices', component: UserInvoices }, + { path: 'orders', name: 'orders', component: UserOrders } + ] }, { - path: '/market/:id', component: MarketView + path: '/market/:id', + component: MarketView, + children: [ + { path: 'form', name: 'form', component: OrderForm }, + { path: 'orders', name: 'market-orders', component: MarketOrders } + ] }, { path: '/invoice/:id', component: InvoiceView diff --git a/vue/src/views/UserView.vue b/vue/src/views/UserView.vue index 73201f6..7a04c25 100644 --- a/vue/src/views/UserView.vue +++ b/vue/src/views/UserView.vue @@ -9,21 +9,29 @@ \__,_|___/\___|_| -
-
authenticated as {{ session.pubkey.slice(0, 8) }}
- -
+
+ +
+ + + -const logout = async () => { - await session.logout() - router.push('/') + diff --git a/vue/yarn.lock b/vue/yarn.lock index f20a5cf..bbd5f2a 100644 --- a/vue/yarn.lock +++ b/vue/yarn.lock @@ -4012,6 +4012,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +s-ago@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/s-ago/-/s-ago-2.2.0.tgz#4143a9d0176b3100dcf649c78e8a1ec8a59b1312" + integrity sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA== + safe-array-concat@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c"