Implement market settlement

This commit is contained in:
ekzyis 2023-12-03 23:52:24 +01:00
parent 6088d8aa10
commit fe5feccfee
9 changed files with 165 additions and 27 deletions

View File

@ -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)
);

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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,

View File

@ -9,6 +9,9 @@
|_| |_| |_|\__,_|_| |_|\_\___|\__|</pre>
</div>
<div class="font-mono">{{ market.Description }}</div>
<div v-if="!!market.SettledAt" class="label error font-mono m-auto my-3">
<div>Settled</div>
</div>
<!-- eslint-enable -->
<header class="flex flex-row text-center justify-center pt-1">
<nav>

View File

@ -14,7 +14,7 @@
</p>
<p class="red"><b>You cannot undo this action.</b></p>
</div>
<button class="col-span-2" v-if="selected" @click.prevent="confirm">confirm</button>
<button class="col-span-2" v-if="selected" @click.prevent="confirm" :disabled="!!market.SettledAt">confirm</button>
</div>
<div v-if="err" class="red text-center">{{ err }}</div>
<div v-if="success" class="green text-center">{{ success }}</div>
@ -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)
}

View File

@ -21,7 +21,7 @@
<label>{{ format(buyCost) }} sats</label>
<label>if you win:</label>
<label>+{{ format(buyProfit) }} sats</label>
<button class="col-span-2" type="submit">submit buy order</button>
<button class="col-span-2" type="submit" :disabled="!!market.SettledAt">submit buy order</button>
</form>
<form v-else v-show="showForm" @submit.prevent="submitSellForm">
<label v-if="session.isAuthenticated">you have:</label>
@ -37,7 +37,9 @@
<label>{{ sellShares }} {{ selected }} shares @ {{ format(sellPrice) }} sats</label>
<label>you make:</label>
<label>+{{ format(sellProfit) }} sats</label>
<button class="col-span-2" type="submit" :disabled="userShares === 0">submit sell order</button>
<button class="col-span-2" type="submit" :disabled="userShares === 0 || !!market.SettledAt">
submit sell order
</button>
</form>
<div v-if="err" class="red text-center">{{ err }}</div>
<div v-if="success" class="green text-center">{{ success }}</div>
@ -46,13 +48,14 @@
<script setup>
import { useSession } from '@/stores/session'
import { ref, computed } from 'vue'
import { ref, computed, defineProps } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps(['market'])
const market = ref(props.market)
const session = useSession()
const router = useRouter()
const route = useRoute()
const marketId = route.params.id
// YES NO button logic
// -- which button was pressed?
@ -108,23 +111,13 @@ const sold = ref(0)
const format = (x, i = 3) => x === null ? null : x >= 1 ? Math.round(x) : x === 0 ? x : x.toFixed(i)
// Fetch market data
const market = ref(null)
const url = '/api/market/' + marketId
await fetch(url)
.then(r => r.json())
.then(body => {
market.value = body
})
.catch(console.error)
// Currently, we only support binary markets.
// (only events which can be answered with YES and NO)
const yesShareId = computed(() => {
return market?.value.Shares.find(s => s.Description === 'YES').Id
return market?.value.Shares.find(s => s.Description === 'YES').sid
})
const noShareId = computed(() => {
return market?.value.Shares.find(s => s.Description === 'NO').Id
return market?.value.Shares.find(s => s.Description === 'NO').sid
})
const shareId = computed(() => {
return selected.value === 'YES' ? yesShareId.value : noShareId.value
@ -143,7 +136,7 @@ const submitBuyForm = async () => {
const res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
const resBody = await res.json()
if (res.status !== 402) {
err.value = `error: server responded with HTTP ${resBody.status}`
err.value = `error: server responded with HTTP ${res.status}`
return
}
const invoiceId = resBody.id