diff --git a/db/init.sql b/db/init.sql index 5f57f35..e13c5c2 100644 --- a/db/init.sql +++ b/db/init.sql @@ -31,6 +31,7 @@ CREATE TABLE markets( id SERIAL PRIMARY KEY, description TEXT NOT NULL, end_date TIMESTAMP WITH TIME ZONE NOT NULL, + settled_at TIMESTAMP WITH TIME ZONE, pubkey TEXT NOT NULL REFERENCES users(pubkey), invoice_id UUID NOT NULL UNIQUE REFERENCES invoices(id) ); diff --git a/db/market.go b/db/market.go index 1c7f144..803fc21 100644 --- a/db/market.go +++ b/db/market.go @@ -28,7 +28,7 @@ func (db *DB) CreateMarket(tx *sql.Tx, ctx context.Context, market *Market) erro } func (db *DB) FetchMarket(marketId int, market *Market) error { - if err := db.QueryRow("SELECT id, description, end_date, pubkey FROM markets WHERE id = $1", marketId).Scan(&market.Id, &market.Description, &market.EndDate, &market.Pubkey); err != nil { + if err := db.QueryRow("SELECT id, description, end_date, pubkey, settled_at FROM markets WHERE id = $1", marketId).Scan(&market.Id, &market.Description, &market.EndDate, &market.Pubkey, &market.SettledAt); err != nil { return err } return nil @@ -324,6 +324,8 @@ func (db *DB) FetchUserBalance(tx *sql.Tx, ctx context.Context, marketId int, pu "LEFT JOIN invoices i ON i.id = o.invoice_id " + "JOIN shares s ON s.id = o.share_id " + "WHERE o.pubkey = $1 AND s.market_id = $2 AND o.deleted_at IS NULL " + + // TODO: is there a bug here? shouldn't i also check that SELL orders have no order_id set? + // (also see user payout query during market settlement) "AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL AND o.order_id IS NOT NULL) OR o.side = 'SELL' ) " + "GROUP BY o.pubkey, s.description" rows, err := tx.QueryContext(ctx, query, pubkey, marketId) diff --git a/db/types.go b/db/types.go index 3b26fd0..0bfd449 100644 --- a/db/types.go +++ b/db/types.go @@ -29,11 +29,12 @@ type ( Id Serial `json:"id"` Description string `json:"description"` EndDate time.Time `json:"endDate"` + SettledAt null.Time `json:"settledAt"` Pubkey string `json:"pubkey"` InvoiceId UUID } Share struct { - Id UUID + Id UUID `json:"sid"` MarketId int Description string } diff --git a/lnd/invoice.go b/lnd/invoice.go index 59df1ad..4a1c4ae 100644 --- a/lnd/invoice.go +++ b/lnd/invoice.go @@ -115,13 +115,24 @@ func (lnd *LNDClient) CheckInvoice(d *db.DB, hash lntypes.Hash) { // Run matchmaking if an order was paid var orderId string + var deleted bool if err = d.QueryRowContext(ctx, - "SELECT o.id FROM orders o WHERE invoice_id = (SELECT i.id FROM invoices i WHERE hash = $1)", + "SELECT o.id, o.deleted_at IS NOT NULL FROM orders o WHERE invoice_id = (SELECT i.id FROM invoices i WHERE hash = $1)", hash.String(), - ).Scan(&orderId); err != nil && err != sql.ErrNoRows { + ).Scan(&orderId, &deleted); err != nil && err != sql.ErrNoRows { handleLoopError(err) continue } + if deleted { + // order was canceled before it was paid. refund sats immediately. + // this can happen if the market was settled between creating the order and paying the corresponding invoice. + if _, err := tx.ExecContext(ctx, "UPDATE users SET msats = msats + $1", int64(lnInvoice.AmountPaid)); err != nil { + tx.Rollback() + break + } + log.Printf("order %s canceled. refunded sats to user.", orderId) + break + } if orderId != "" { go d.RunMatchmaking(orderId) } diff --git a/server/router/handler/market.go b/server/router/handler/market.go index d787e18..87ecff8 100644 --- a/server/router/handler/market.go +++ b/server/router/handler/market.go @@ -46,6 +46,7 @@ func HandleMarket(sc context.ServerContext) echo.HandlerFunc { "Id": market.Id, "Pubkey": market.Pubkey, "Description": market.Description, + "SettledAt": market.SettledAt, "Shares": shares, } if session := c.Get("session"); session != nil { @@ -154,6 +155,7 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { u db.User o db.Order s db.Share + m db.Market invoice *db.Invoice msats int64 description string @@ -181,12 +183,21 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { tx.Rollback() return err } + + if err = sc.Db.FetchMarket(s.MarketId, &m); err == sql.ErrNoRows { + return c.JSON(http.StatusNotFound, nil) + } else if err != nil { + return err + } + + if m.SettledAt.Valid { + return c.JSON(http.StatusBadRequest, map[string]string{"reason": "market already settled"}) + } + description = fmt.Sprintf("%s %d %s shares @ %d sats [market:%d]", strings.ToUpper(o.Side), o.Quantity, s.Description, o.Price, s.MarketId) if o.Side == "BUY" { - // === Create invoice === - // We do this for BUY and SELL orders such that we can continue to use `invoice.confirmed_at IS NOT NULL` - // to check for confirmed orders + // BUY orders require payment if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, o.Pubkey, msats, description); err != nil { tx.Rollback() return err @@ -347,3 +358,107 @@ func HandleMarketStats(sc context.ServerContext) echo.HandlerFunc { return c.JSON(http.StatusOK, stats) } } + +func HandleMarketSettlement(sc context.ServerContext) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + marketId int64 + market db.Market + s db.Share + tx *sql.Tx + u db.User + err error + ) + if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil { + return c.JSON(http.StatusBadRequest, nil) + } + + if err = c.Bind(&s); err != nil || s.Id == "" { + return c.JSON(http.StatusBadRequest, nil) + } + + if err = sc.Db.FetchMarket(int(marketId), &market); err == sql.ErrNoRows { + return c.JSON(http.StatusNotFound, map[string]string{"reason": "market not found"}) + } else if err != nil { + return err + } + + u = c.Get("session").(db.User) + + // only market owner can settle market + if market.Pubkey != u.Pubkey { + return c.JSON(http.StatusForbidden, map[string]string{"reason": "not your market"}) + } + + // market already settled? + if market.SettledAt.Valid { + return c.JSON(http.StatusBadRequest, map[string]string{"reason": "market already settled"}) + } + + // transaction start + ctx, cancel := context_.WithTimeout(c.Request().Context(), 10*time.Second) + defer cancel() + if tx, err = sc.Db.BeginTx(ctx, nil); err != nil { + return err + } + defer tx.Commit() + + query := "" + + "WITH " + + " pending_orders AS ( " + + " SELECT o.id, o.side, o.pubkey, i.msats_received FROM orders o " + + " LEFT JOIN invoices i ON i.id = o.invoice_id" + + " JOIN shares s ON s.id = o.share_id " + + " WHERE s.market_id = $1 " + + " AND o.deleted_at IS NULL AND o.order_id IS NULL " + + " ), " + + " update_users_refund AS ( " + + " UPDATE users u " + + " SET msats = msats + po.msats_received " + + " FROM ( " + + " SELECT pubkey, msats_received " + + " FROM pending_orders " + + " WHERE msats_received IS NOT NULL" + + " ) AS po " + + " WHERE po.pubkey = u.pubkey " + + " RETURNING u.pubkey::TEXT " + + " ), " + + " user_shares AS ( " + + " SELECT o.pubkey, o.share_id, " + + " SUM(CASE WHEN o.side = 'BUY' THEN o.quantity ELSE -o.quantity END) AS sum " + + " FROM orders o " + + " LEFT JOIN invoices i ON i.id = o.invoice_id " + + " JOIN shares s ON s.id = o.share_id " + + " WHERE s.market_id = $1 AND o.deleted_at IS NULL AND s.id = $2" + + " AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL AND o.order_id IS NOT NULL) OR o.side = 'SELL' ) " + + " GROUP BY o.pubkey, o.share_id " + + " ), " + + " update_users_payout AS ( " + + " UPDATE users u " + + " SET msats = msats + (us.sum * 100 * 1000) " + + " FROM (SELECT pubkey, sum FROM user_shares) us " + + " WHERE u.pubkey = us.pubkey " + + " RETURNING u.pubkey::TEXT " + + " ), " + + " update_orders AS ( " + + " UPDATE orders o " + + " SET deleted_at = CURRENT_TIMESTAMP " + + " WHERE id IN (SELECT id FROM pending_orders) " + + " RETURNING o.id::TEXT " + + " ) " + + "SELECT * FROM update_users_refund UNION SELECT * FROM update_users_payout UNION SELECT * FROM update_orders" + if _, err = tx.ExecContext(ctx, query, marketId, s.Id); err != nil { + tx.Rollback() + return err + } + + if _, err = tx.ExecContext(ctx, "UPDATE markets SET settled_at = CURRENT_TIMESTAMP WHERE id = $1", marketId); err != nil { + tx.Rollback() + return err + } + + tx.Commit() + + return c.JSON(http.StatusOK, nil) + } +} diff --git a/server/router/router.go b/server/router/router.go index 6fc09a4..24aa5af 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -48,6 +48,10 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) { GET(e, sc, "/api/market/:id", handler.HandleMarket) GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders) GET(e, sc, "/api/market/:id/stats", handler.HandleMarketStats) + POST(e, sc, "/api/market/:id/settle", + handler.HandleMarketSettlement, + middleware.SessionGuard, + middleware.LNDGuard) POST(e, sc, "/api/order", handler.HandleOrder, middleware.SessionGuard, diff --git a/vue/src/components/Market.vue b/vue/src/components/Market.vue index ca3076a..e16be7f 100644 --- a/vue/src/components/Market.vue +++ b/vue/src/components/Market.vue @@ -9,6 +9,9 @@ |_| |_| |_|\__,_|_| |_|\_\___|\__|
{{ market.Description }}
+
+
Settled
+