Implement market settlement
This commit is contained in:
parent
6088d8aa10
commit
fe5feccfee
@ -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)
|
||||
);
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user