Implement market settlement
This commit is contained in:
parent
6088d8aa10
commit
fe5feccfee
@ -31,6 +31,7 @@ CREATE TABLE markets(
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
settled_at TIMESTAMP WITH TIME ZONE,
|
||||||
pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
||||||
invoice_id UUID NOT NULL UNIQUE REFERENCES invoices(id)
|
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 {
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
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 " +
|
"LEFT JOIN invoices i ON i.id = o.invoice_id " +
|
||||||
"JOIN shares s ON s.id = o.share_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 " +
|
"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' ) " +
|
"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"
|
"GROUP BY o.pubkey, s.description"
|
||||||
rows, err := tx.QueryContext(ctx, query, pubkey, marketId)
|
rows, err := tx.QueryContext(ctx, query, pubkey, marketId)
|
||||||
|
@ -29,11 +29,12 @@ type (
|
|||||||
Id Serial `json:"id"`
|
Id Serial `json:"id"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
EndDate time.Time `json:"endDate"`
|
EndDate time.Time `json:"endDate"`
|
||||||
|
SettledAt null.Time `json:"settledAt"`
|
||||||
Pubkey string `json:"pubkey"`
|
Pubkey string `json:"pubkey"`
|
||||||
InvoiceId UUID
|
InvoiceId UUID
|
||||||
}
|
}
|
||||||
Share struct {
|
Share struct {
|
||||||
Id UUID
|
Id UUID `json:"sid"`
|
||||||
MarketId int
|
MarketId int
|
||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
@ -115,13 +115,24 @@ func (lnd *LNDClient) CheckInvoice(d *db.DB, hash lntypes.Hash) {
|
|||||||
|
|
||||||
// Run matchmaking if an order was paid
|
// Run matchmaking if an order was paid
|
||||||
var orderId string
|
var orderId string
|
||||||
|
var deleted bool
|
||||||
if err = d.QueryRowContext(ctx,
|
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(),
|
hash.String(),
|
||||||
).Scan(&orderId); err != nil && err != sql.ErrNoRows {
|
).Scan(&orderId, &deleted); err != nil && err != sql.ErrNoRows {
|
||||||
handleLoopError(err)
|
handleLoopError(err)
|
||||||
continue
|
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 != "" {
|
if orderId != "" {
|
||||||
go d.RunMatchmaking(orderId)
|
go d.RunMatchmaking(orderId)
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ func HandleMarket(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
"Id": market.Id,
|
"Id": market.Id,
|
||||||
"Pubkey": market.Pubkey,
|
"Pubkey": market.Pubkey,
|
||||||
"Description": market.Description,
|
"Description": market.Description,
|
||||||
|
"SettledAt": market.SettledAt,
|
||||||
"Shares": shares,
|
"Shares": shares,
|
||||||
}
|
}
|
||||||
if session := c.Get("session"); session != nil {
|
if session := c.Get("session"); session != nil {
|
||||||
@ -154,6 +155,7 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
u db.User
|
u db.User
|
||||||
o db.Order
|
o db.Order
|
||||||
s db.Share
|
s db.Share
|
||||||
|
m db.Market
|
||||||
invoice *db.Invoice
|
invoice *db.Invoice
|
||||||
msats int64
|
msats int64
|
||||||
description string
|
description string
|
||||||
@ -181,12 +183,21 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
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)
|
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" {
|
if o.Side == "BUY" {
|
||||||
// === Create invoice ===
|
// BUY orders require payment
|
||||||
// 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
|
|
||||||
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, 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()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
@ -347,3 +358,107 @@ func HandleMarketStats(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
return c.JSON(http.StatusOK, stats)
|
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", handler.HandleMarket)
|
||||||
GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders)
|
GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders)
|
||||||
GET(e, sc, "/api/market/:id/stats", handler.HandleMarketStats)
|
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",
|
POST(e, sc, "/api/order",
|
||||||
handler.HandleOrder,
|
handler.HandleOrder,
|
||||||
middleware.SessionGuard,
|
middleware.SessionGuard,
|
||||||
|
@ -9,6 +9,9 @@
|
|||||||
|_| |_| |_|\__,_|_| |_|\_\___|\__|</pre>
|
|_| |_| |_|\__,_|_| |_|\_\___|\__|</pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-mono">{{ market.Description }}</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 -->
|
<!-- eslint-enable -->
|
||||||
<header class="flex flex-row text-center justify-center pt-1">
|
<header class="flex flex-row text-center justify-center pt-1">
|
||||||
<nav>
|
<nav>
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="red"><b>You cannot undo this action.</b></p>
|
<p class="red"><b>You cannot undo this action.</b></p>
|
||||||
</div>
|
</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>
|
||||||
<div v-if="err" class="red text-center">{{ err }}</div>
|
<div v-if="err" class="red text-center">{{ err }}</div>
|
||||||
<div v-if="success" class="green text-center">{{ success }}</div>
|
<div v-if="success" class="green text-center">{{ success }}</div>
|
||||||
@ -39,11 +39,19 @@ const click = (sel) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const confirm = async () => {
|
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 url = '/api/market/' + market.value.Id + '/settle'
|
||||||
const body = JSON.stringify({ sid })
|
const body = JSON.stringify({ sid })
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
<label>{{ format(buyCost) }} sats</label>
|
<label>{{ format(buyCost) }} sats</label>
|
||||||
<label>if you win:</label>
|
<label>if you win:</label>
|
||||||
<label>+{{ format(buyProfit) }} sats</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>
|
||||||
<form v-else v-show="showForm" @submit.prevent="submitSellForm">
|
<form v-else v-show="showForm" @submit.prevent="submitSellForm">
|
||||||
<label v-if="session.isAuthenticated">you have:</label>
|
<label v-if="session.isAuthenticated">you have:</label>
|
||||||
@ -37,7 +37,9 @@
|
|||||||
<label>{{ sellShares }} {{ selected }} shares @ {{ format(sellPrice) }} sats</label>
|
<label>{{ sellShares }} {{ selected }} shares @ {{ format(sellPrice) }} sats</label>
|
||||||
<label>you make:</label>
|
<label>you make:</label>
|
||||||
<label>+{{ format(sellProfit) }} sats</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>
|
</form>
|
||||||
<div v-if="err" class="red text-center">{{ err }}</div>
|
<div v-if="err" class="red text-center">{{ err }}</div>
|
||||||
<div v-if="success" class="green text-center">{{ success }}</div>
|
<div v-if="success" class="green text-center">{{ success }}</div>
|
||||||
@ -46,13 +48,14 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useSession } from '@/stores/session'
|
import { useSession } from '@/stores/session'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, defineProps } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps(['market'])
|
||||||
|
const market = ref(props.market)
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const marketId = route.params.id
|
|
||||||
|
|
||||||
// YES NO button logic
|
// YES NO button logic
|
||||||
// -- which button was pressed?
|
// -- 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)
|
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.
|
// Currently, we only support binary markets.
|
||||||
// (only events which can be answered with YES and NO)
|
// (only events which can be answered with YES and NO)
|
||||||
const yesShareId = computed(() => {
|
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(() => {
|
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(() => {
|
const shareId = computed(() => {
|
||||||
return selected.value === 'YES' ? yesShareId.value : noShareId.value
|
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 res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
|
||||||
const resBody = await res.json()
|
const resBody = await res.json()
|
||||||
if (res.status !== 402) {
|
if (res.status !== 402) {
|
||||||
err.value = `error: server responded with HTTP ${resBody.status}`
|
err.value = `error: server responded with HTTP ${res.status}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const invoiceId = resBody.id
|
const invoiceId = resBody.id
|
||||||
|
Loading…
x
Reference in New Issue
Block a user