323 lines
10 KiB
Go
323 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.ekzyis.com/ekzyis/delphi.market/lib/lmsr"
|
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
|
"github.com/a-h/templ"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
)
|
|
|
|
func HandleCreate(sc context.Context) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var (
|
|
db = sc.Db
|
|
lnd = sc.Lnd
|
|
tx *sql.Tx
|
|
ctx = c.Request().Context()
|
|
u = c.Get("session").(types.User)
|
|
question = c.FormValue("question")
|
|
description = c.FormValue("description")
|
|
endDate = c.FormValue("end_date")
|
|
hash lntypes.Hash
|
|
paymentRequest string
|
|
cost = lnwire.MilliSatoshi(10_000e3) // creating a market costs 10k sats
|
|
|
|
// The cost is used to fund the market.
|
|
// Maximum possible amount of money the market maker can lose is b*ln2.
|
|
// This means if we can only payout as many sats as we paid for the market,
|
|
// we need to solve for b: b = cost / ln2
|
|
b = (float64(cost) / 1000) / math.Log(2)
|
|
|
|
expiry = int64(600)
|
|
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
|
invoiceId int
|
|
marketId int
|
|
invDescription string
|
|
qr templ.Component
|
|
err error
|
|
)
|
|
// TODO: validation
|
|
|
|
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if hash, paymentRequest, err = lnd.Client.AddInvoice(ctx,
|
|
&invoicesrpc.AddInvoiceData{
|
|
Value: cost,
|
|
Expiry: expiry,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tx.QueryRowContext(ctx, ""+
|
|
"INSERT INTO invoices (user_id, msats, hash, bolt11, expires_at) "+
|
|
"VALUES ($1, $2, $3, $4, $5) "+
|
|
"RETURNING id",
|
|
u.Id, cost, hash.String(), paymentRequest, expiresAt).Scan(&invoiceId); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tx.QueryRowContext(ctx, ""+
|
|
"INSERT INTO markets (question, description, end_date, user_id, invoice_id, lmsr_b) "+
|
|
"VALUES ($1, $2, $3, $4, $5, $6) "+
|
|
"RETURNING id",
|
|
question, description, endDate, u.Id, invoiceId, b).Scan(&marketId); err != nil {
|
|
return err
|
|
}
|
|
|
|
invDescription = fmt.Sprintf("create market %d", marketId)
|
|
if _, err = tx.ExecContext(ctx, ""+
|
|
"UPDATE invoices SET description = $1 WHERE id = $2",
|
|
invDescription, invoiceId); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
|
|
qr = components.Invoice(hash.String(), paymentRequest, int(cost), int(expiry), false, toRedirectUrl(invDescription))
|
|
|
|
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
|
}
|
|
}
|
|
|
|
func HandleMarket(sc context.Context) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var (
|
|
db = sc.Db
|
|
ctx = c.Request().Context()
|
|
u = types.User{}
|
|
id = c.Param("id")
|
|
quantity = c.QueryParam("q")
|
|
q int64
|
|
m = types.Market{}
|
|
mU = types.User{}
|
|
l = types.LMSR{}
|
|
total float64
|
|
quote0 = types.MarketQuote{}
|
|
quote1 = types.MarketQuote{}
|
|
uQ0 int
|
|
uQ1 int
|
|
err error
|
|
)
|
|
|
|
if c.Get("session") != nil {
|
|
u = c.Get("session").(types.User)
|
|
} else {
|
|
u.Id = -1
|
|
}
|
|
|
|
if quantity == "" {
|
|
q = 1
|
|
} else if q, err = strconv.ParseInt(quantity, 10, 64); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "q must be integer")
|
|
}
|
|
|
|
if err = db.QueryRowContext(ctx, ""+
|
|
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, m.lmsr_b, "+
|
|
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, u.msats "+
|
|
"FROM markets m "+
|
|
"JOIN users u ON m.user_id = u.id "+
|
|
"JOIN invoices i ON m.invoice_id = i.id "+
|
|
"WHERE m.id = $1 AND i.confirmed_at IS NOT NULL", id).Scan(
|
|
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate, &l.B,
|
|
&mU.Id, &mU.Name, &mU.CreatedAt, &mU.LnPubkey, &mU.NostrPubkey, &mU.Msats); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusNotFound)
|
|
}
|
|
return err
|
|
}
|
|
m.User = mU
|
|
|
|
if err = db.QueryRowContext(ctx, ""+
|
|
"SELECT "+
|
|
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 0), 0) AS q1, "+
|
|
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 1), 0) AS q2, "+
|
|
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 0 AND o.user_id = $2), 0) AS uq1, "+
|
|
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 1 AND o.user_id = $2), 0) AS uq2 "+
|
|
"FROM orders o "+
|
|
"JOIN markets m ON o.market_id = m.id "+
|
|
"JOIN invoices i ON o.invoice_id = i.id "+
|
|
// QUESTION: Should unpaid orders contribute to quantity or not?
|
|
//
|
|
// The answer is relevant for concurrent orders:
|
|
// If they do, one can artificially increase the price for others
|
|
// by creating a lot of pending orders.
|
|
// If they don't, one can buy infinite amount of shares at the same price
|
|
// by creating a lot of small but concurrent orders.
|
|
// I think this means that pending order must be scoped to a user
|
|
// but this isn't sybil resistant.
|
|
//
|
|
// For now, we will ignore pending orders.
|
|
"WHERE o.market_id = $1 AND i.confirmed_at IS NOT NULL", id, u.Id).Scan(
|
|
&l.Q1, &l.Q2, &uQ0, &uQ1); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusNotFound)
|
|
}
|
|
return err
|
|
}
|
|
|
|
total = lmsr.Quote(l.B, l.Q1, l.Q2, int(q))
|
|
quote0 = types.MarketQuote{
|
|
Outcome: 0,
|
|
AvgPrice: total / float64(q),
|
|
TotalPrice: total,
|
|
Reward: float64(q) - total,
|
|
}
|
|
|
|
total = lmsr.Quote(l.B, l.Q2, l.Q1, int(q))
|
|
quote1 = types.MarketQuote{
|
|
Outcome: 1,
|
|
AvgPrice: total / float64(q),
|
|
TotalPrice: total,
|
|
Reward: float64(q) - total,
|
|
}
|
|
|
|
return pages.Market(m, quote0, quote1, uQ0, uQ1).Render(context.RenderContext(sc, c), c.Response().Writer)
|
|
}
|
|
}
|
|
|
|
func HandleOrder(sc context.Context) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
var (
|
|
db = sc.Db
|
|
lnd = sc.Lnd
|
|
tx *sql.Tx
|
|
ctx = c.Request().Context()
|
|
u = c.Get("session").(types.User)
|
|
id = c.Param("id")
|
|
quantity = c.FormValue("q")
|
|
outcome = c.FormValue("o")
|
|
q int64
|
|
o int64
|
|
m = types.Market{}
|
|
mU = types.User{}
|
|
l = types.LMSR{}
|
|
totalF float64
|
|
total int
|
|
hash lntypes.Hash
|
|
paymentRequest string
|
|
expiry = int64(60)
|
|
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
|
invoiceId int
|
|
invDescription string
|
|
orderId int
|
|
qr templ.Component
|
|
err error
|
|
)
|
|
|
|
if quantity == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "q must be given")
|
|
} else if q, err = strconv.ParseInt(quantity, 10, 64); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "q must be integer")
|
|
}
|
|
|
|
if outcome == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "o must be given")
|
|
} else if o, err = strconv.ParseInt(outcome, 10, 64); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "o must be integer")
|
|
}
|
|
if o < 0 && o > 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "o must be 0 or 1")
|
|
}
|
|
|
|
// TODO: refactor since this uses same queries as function above
|
|
if err = db.QueryRowContext(ctx, ""+
|
|
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, m.lmsr_b, "+
|
|
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, u.msats "+
|
|
"FROM markets m "+
|
|
"JOIN users u ON m.user_id = u.id "+
|
|
"JOIN invoices i ON m.invoice_id = i.id "+
|
|
"WHERE m.id = $1 AND i.confirmed_at IS NOT NULL", id).Scan(
|
|
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate, &l.B,
|
|
&mU.Id, &mU.Name, &mU.CreatedAt, &mU.LnPubkey, &mU.NostrPubkey, &mU.Msats); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusNotFound)
|
|
}
|
|
return err
|
|
}
|
|
m.User = mU
|
|
|
|
if err = db.QueryRowContext(ctx, ""+
|
|
"SELECT "+
|
|
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 0), 0) AS q1, "+
|
|
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 1), 0) AS q2 "+
|
|
"FROM orders o "+
|
|
"JOIN markets m ON o.market_id = m.id "+
|
|
"JOIN invoices i ON o.invoice_id = i.id "+
|
|
"WHERE o.market_id = $1 AND i.confirmed_at IS NOT NULL", id).Scan(
|
|
&l.Q1, &l.Q2); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusNotFound)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if o == 0 {
|
|
totalF = lmsr.Quote(l.B, l.Q1, l.Q2, int(q))
|
|
} else if o == 1 {
|
|
totalF = lmsr.Quote(l.B, l.Q2, l.Q1, int(q))
|
|
}
|
|
|
|
total = int(math.Round(totalF * 1000))
|
|
|
|
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if hash, paymentRequest, err = lnd.Client.AddInvoice(ctx,
|
|
&invoicesrpc.AddInvoiceData{
|
|
Value: lnwire.MilliSatoshi(total),
|
|
Expiry: expiry,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tx.QueryRowContext(ctx, ""+
|
|
"INSERT INTO invoices (user_id, msats, hash, bolt11, expires_at) "+
|
|
"VALUES ($1, $2, $3, $4, $5) "+
|
|
"RETURNING id",
|
|
u.Id, total, hash.String(), paymentRequest, expiresAt).Scan(&invoiceId); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tx.QueryRowContext(ctx, ""+
|
|
"INSERT INTO orders (market_id, user_id, quantity, outcome, invoice_id) "+
|
|
"VALUES ($1, $2, $3, $4, $5) "+
|
|
"RETURNING id",
|
|
id, u.Id, q, o, invoiceId).Scan(&orderId); err != nil {
|
|
return err
|
|
}
|
|
|
|
invDescription = fmt.Sprintf("create order %d for market %s", orderId, id)
|
|
if _, err = tx.ExecContext(ctx, ""+
|
|
"UPDATE invoices SET description = $1 WHERE id = $2",
|
|
invDescription, invoiceId); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
|
|
qr = components.Invoice(hash.String(), paymentRequest, total, int(expiry), false, toRedirectUrl(invDescription))
|
|
|
|
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
|
}
|
|
}
|