From fe5feccfee8faf64a740e6ab389dd0a83aa6f062 Mon Sep 17 00:00:00 2001
From: ekzyis
Date: Sun, 3 Dec 2023 23:52:24 +0100
Subject: [PATCH] Implement market settlement
---
db/init.sql | 1 +
db/market.go | 4 +-
db/types.go | 3 +-
lnd/invoice.go | 15 +++-
server/router/handler/market.go | 121 +++++++++++++++++++++++++-
server/router/router.go | 4 +
vue/src/components/Market.vue | 3 +
vue/src/components/MarketSettings.vue | 14 ++-
vue/src/components/OrderForm.vue | 27 +++---
9 files changed, 165 insertions(+), 27 deletions(-)
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 }}
+
You cannot undo this action.
-
+
{{ err }}
{{ success }}
@@ -39,11 +39,19 @@ const click = (sel) => {
}
const confirm = async () => {
- const sid = market.value.Shares.find(s => s.Description === selected.value).Id
+ success.value = null
+ err.value = null
+ const sid = market.value.Shares.find(s => s.Description === selected.value).sid
const url = '/api/market/' + market.value.Id + '/settle'
const body = JSON.stringify({ sid })
try {
- await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
+ const res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
+ if (res.status === 200) {
+ success.value = 'Market settled'
+ return
+ }
+ const resBody = await res.json()
+ err.value = resBody.reason || `error: server responded with HTTP ${res.status}`
} catch (err) {
console.error(err)
}
diff --git a/vue/src/components/OrderForm.vue b/vue/src/components/OrderForm.vue
index 2858218..83a0c93 100644
--- a/vue/src/components/OrderForm.vue
+++ b/vue/src/components/OrderForm.vue
@@ -21,7 +21,7 @@
-
+
{{ err }}
{{ success }}
@@ -46,13 +48,14 @@