wip: create market

TODO:
* redirect to /markets/:id
  * redirect already works but page does not exist
  * make sure invoice modal is closed on redirect
* add button to close invoice
This commit is contained in:
ekzyis 2024-07-15 12:57:51 +02:00
parent 07648c30f6
commit 9f6a2f2151
12 changed files with 446 additions and 12 deletions

View File

@ -24,10 +24,9 @@ CREATE TABLE invoices(
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id),
msats BIGINT NOT NULL, msats BIGINT NOT NULL,
msats_received BIGINT, msats_received BIGINT,
preimage TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL UNIQUE, hash TEXT NOT NULL UNIQUE,
bolt11 TEXT NOT NULL, bolt11 TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
confirmed_at TIMESTAMP WITH TIME ZONE, confirmed_at TIMESTAMP WITH TIME ZONE,
held_since TIMESTAMP WITH TIME ZONE, held_since TIMESTAMP WITH TIME ZONE,
@ -37,7 +36,8 @@ CREATE TABLE invoices(
CREATE TABLE markets( CREATE TABLE markets(
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
description TEXT NOT NULL, question TEXT NOT NULL,
description TEXT,
end_date TIMESTAMP WITH TIME ZONE NOT NULL, end_date TIMESTAMP WITH TIME ZONE NOT NULL,
settled_at TIMESTAMP WITH TIME ZONE, settled_at TIMESTAMP WITH TIME ZONE,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id),

View File

@ -1,9 +1,15 @@
package lnd package lnd
import ( import (
"context"
"database/sql"
"log" "log"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"github.com/lightninglabs/lndclient" "github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lntypes"
) )
type LNDClient struct { type LNDClient struct {
@ -25,3 +31,49 @@ func New(config *LNDConfig) (*LNDClient, error) {
log.Printf("Connected to %s running LND v%s", config.LndAddress, lnd.Version.Version) log.Printf("Connected to %s running LND v%s", config.LndAddress, lnd.Version.Version)
return lnd, nil return lnd, nil
} }
func (lnd *LNDClient) PollInvoices(db *db.DB) {
var (
rows *sql.Rows
hash lntypes.Hash
inv *lndclient.Invoice
err error
)
for {
// fetch all pending invoices
if rows, err = db.Query("SELECT hash FROM invoices WHERE confirmed_at IS NULL AND expires_at > CURRENT_TIMESTAMP"); err != nil {
log.Printf("error checking invoices: %v", err)
}
for rows.Next() {
var h string
rows.Scan(&h)
if hash, err = lntypes.MakeHashFromStr(h); err != nil {
log.Printf("error parsing hash: %v", err)
continue
}
if inv, err = lnd.Client.LookupInvoice(context.TODO(), hash); err != nil {
log.Printf("error looking up invoice: %v", err)
continue
}
if !inv.State.IsFinal() {
log.Printf("invoice pending: %s", h)
continue
}
if inv.State == invoices.ContractSettled {
if _, err = db.Exec(
"UPDATE invoices SET msats_received = $1, confirmed_at = $2 WHERE hash = $3",
inv.AmountPaid, inv.SettleDate, h); err != nil {
log.Printf("error updating invoice %s: %v", h, err)
}
log.Printf("invoice confirmed: %s", h)
} else if inv.State == invoices.ContractCanceled {
log.Printf("invoice expired: %s", h)
}
}
time.Sleep(5 * time.Second)
}
}

View File

@ -68,7 +68,7 @@ func init() {
log.Printf("[warn] error connecting to LND: %v\n", err) log.Printf("[warn] error connecting to LND: %v\n", err)
lnd_ = nil lnd_ = nil
} else { } else {
// lnd_.CheckInvoices(db_) go lnd_.PollInvoices(db_)
} }
ctx = server.Context{ ctx = server.Context{

View File

@ -10,6 +10,10 @@
--nostr: #8d45dd; --nostr: #8d45dd;
--black: #000; --black: #000;
--white: #fff; --white: #fff;
--bg-success: #149e613d;
--fg-success: #35df8d;
--bg-error: #f5395e3d;
--fg-error: #ff7386;
} }
@layer base { @layer base {
@ -31,13 +35,15 @@
a:not(.no-link), a:not(.no-link),
button[hx-get], button[hx-get],
button[hx-post] { button[hx-post],
button[type="submit"] {
transition: background-color 150ms ease-in, color 150ms ease-in; transition: background-color 150ms ease-in, color 150ms ease-in;
} }
a:not(.no-link):hover, a:not(.no-link):hover,
button[hx-get]:hover, button[hx-get]:hover,
button[hx-post]:hover { button[hx-post]:hover,
button[type="submit"]:hover {
background-color: var(--color); background-color: var(--color);
color: var(--background); color: var(--background);
} }
@ -47,13 +53,15 @@
text-decoration: underline; text-decoration: underline;
} }
button[hx-post] { button[hx-post],
button[type="submit"] {
border-width: 1px; border-width: 1px;
} }
nav a, nav a,
button[hx-get], button[hx-get],
button[hx-post] { button[hx-post],
button[type="submit"] {
padding: 0 0.25em; padding: 0 0.25em;
} }
@ -70,6 +78,31 @@
color: var(--muted); color: var(--muted);
} }
.label {
border: none;
padding: 0.5em 3em;
}
.label.success {
background-color: var(--bg-success);
color: var(--fg-success);
}
.label.success:hover {
background-color: var(--fg-success);
color: var(--white);
}
.label.error {
background-color: var(--bg-error);
color: var(--fg-error);
}
label.error:hover {
background-color: var(--fg-error);
color: var(--white);
}
.figlet { .figlet {
display: flex; display: flex;
align-items: center; align-items: center;
@ -105,4 +138,8 @@
.nostr:hover { .nostr:hover {
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--nostr)); filter: brightness(125%) drop-shadow(0 0 0.33rem var(--nostr));
} }
#modal {
backdrop-filter: blur(10px);
}
} }

View File

@ -0,0 +1,69 @@
package handler
import (
"database/sql"
"fmt"
"net/http"
"regexp"
"time"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"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"
)
func HandleInvoice(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
db = sc.Db
ctx = c.Request().Context()
hash = c.Param("hash")
u = c.Get("session").(types.User)
inv = types.Invoice{}
expiresIn int
paid bool
redirectUrl templ.SafeURL
qr templ.Component
err error
)
if err = db.QueryRowContext(ctx, ""+
"SELECT user_id, msats, COALESCE(msats_received, 0), expires_at, confirmed_at, bolt11, description "+
"FROM invoices "+
"WHERE hash = $1", hash).
Scan(&inv.UserId, &inv.Msats, &inv.MsatsReceived, &inv.ExpiresAt, &inv.ConfirmedAt, &inv.Bolt11, &inv.Description); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound)
}
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
if u.Id != inv.UserId {
return echo.NewHTTPError(http.StatusNotFound)
}
expiresIn = int(time.Until(inv.ExpiresAt).Seconds())
paid = inv.MsatsReceived >= inv.Msats
redirectUrl = toRedirectUrl(inv.Description)
qr = components.Invoice(hash, inv.Bolt11, int(inv.Msats), expiresIn, paid, redirectUrl)
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
}
}
var (
marketRegexp = regexp.MustCompile("^create market (?P<id>[0-9]+)$")
)
func toRedirectUrl(description string) templ.SafeURL {
var m []string
if m = marketRegexp.FindStringSubmatch(description); m != nil {
marketId := m[marketRegexp.SubexpIndex("id")]
return templ.SafeURL(fmt.Sprintf("/market/%s", marketId))
}
return "/"
}

View File

@ -0,0 +1,85 @@
package handler
import (
"database/sql"
"fmt"
"time"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"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
amount = lnwire.MilliSatoshi(1000)
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: amount,
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, amount, 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) "+
"VALUES ($1, $2, $3, $4, $5) "+
"RETURNING id",
question, description, endDate, u.Id, invoiceId).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(amount), int(expiry), false, toRedirectUrl(invDescription))
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
}
}

View File

@ -0,0 +1,68 @@
package components
import (
"strconv"
)
templ Invoice(hash string, bolt11 string, msats int, expiresIn int, paid bool, redirectUrl templ.SafeURL) {
<div class="p-5 border border-muted bg-background text-center font-mono">
<div>Payment Required</div>
<div class="my-3">@Qr(bolt11, "lightning:"+bolt11)</div>
<div class="my-1">{ strconv.Itoa(msats/1000) } sats</div>
@InvoiceStatus(hash, expiresIn, paid, redirectUrl)
<div class="none" id="bolt11" bolt11-data={ templ.JSONString(bolt11) } hx-preserve></div>
<script type="text/javascript" id="bolt11-js" hx-preserve>
var $ = selector => document.querySelector(selector)
var bolt11 = JSON.parse($("#bolt11").getAttribute("bolt11-data"))
console.log(bolt11)
</script>
</div>
}
templ InvoiceStatus(hash string, expiresIn int, paid bool, redirectUrl templ.SafeURL) {
if paid {
<div class="font-mono label success my-1">PAID</div>
<div
hx-get={ string(redirectUrl) }
hx-trigger="load delay:3s"
hx-target="#content"
hx-swap="outerHTML"
hx-select="#content" />
}
else if expiresIn <= 0 {
<div class="font-mono label error my-1">EXPIRED</div>
} else {
<!-- invoice is pending -->
<div class="font-mono my-1" id="countdown" countdown-data={ templ.JSONString(expiresIn) } hx-preserve></div>
<script type="text/javascript" id="countdown-js" hx-preserve>
var $ = selector => document.querySelector(selector)
var expiresIn = JSON.parse($("#countdown").getAttribute("countdown-data"))
function pad(num, places) {
return String(num).padStart(places, "0")
}
function _countdown() {
var minutes = Math.floor(expiresIn / 60)
var seconds = expiresIn % 60
var text = `${pad(minutes, 2)}:${pad(seconds, 2)}`
try {
$("#countdown").innerText = text
expiresIn--
} catch {
// countdown element disappeared
clearInterval(interval)
}
}
_countdown()
var interval = setInterval(_countdown, 1000)
</script>
<div
hx-get={ string(templ.SafeURL("/invoice/" + hash)) }
hx-trigger="load delay:1s"
hx-target="#modal"
hx-swap="outerHTML"
hx-select="#modal" />
}
}

View File

@ -0,0 +1,21 @@
package components
templ Modal(component templ.Component) {
if component != nil {
<div
id="modal"
class="fixed left-0 top-0 w-screen h-screen"
>
<div
class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<!-- TODO: add background -->
<div class="flex justify-center p-3 w-screen">
@component
</div>
</div>
</div>
} else {
<div id="modal" class="hidden"></div>
}
}

View File

@ -1,6 +1,9 @@
package pages package pages
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components" import (
c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
)
templ Index() { templ Index() {
<html> <html>
@ -9,9 +12,81 @@ templ Index() {
@components.Nav() @components.Nav()
<div id="content" class="flex flex-col text-center"> <div id="content" class="flex flex-col text-center">
@components.Figlet("random", "delphi") @components.Figlet("random", "delphi")
<div class="font-mono my-3">A prediction market using the lightning network</div> <div class="font-mono my-3"><small>A prediction market using the lightning network</small></div>
<div
id="grid-container"
class="border border-[var(--muted)] text-start"
hx-target="#grid-container"
hx-swap="outerHTML"
hx-select="#grid-container"
hx-push-url="true"
>
<div class="border-b border-[var(--muted)]">
<button hx-get="/" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/") }>markets</button>
<button hx-get="/create" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/create") }>create</button>
</div> </div>
if ctx.Value(c.ReqPathContextKey).(string) == "/" {
<div class="grid grid-cols-[auto_fit-content(10%)_fit-content(10%)] gap-1">
<!--
<span class="mt-1 ms-3">Will X happen?</span>
<span class="mt-1">51%</span>
<span class="mt-1 me-3 text-nowrap">1504 sats</span>
<span class="mb-1 ms-3">Will X happen?</span>
<span class="mb-1">51%</span>
<span class="mb-1 me-3 text-nowrap">1504 sats</span>
-->
</div>
} else {
<form
hx-post="/create"
hx-target="#modal"
hx-swap="outerHTML"
hx-select="#modal"
class="flex flex-col mx-3"
>
<label class="my-1" for="question">question</label>
<input
id="question"
name="question"
type="text"
class="my-1 p-1 text-black"
autocomplete="off"
required
/>
<div class="mt-3 mb-1">
<label for="description">description</label>
<span class="px-1 text-small text-muted">optional</span>
</div>
<textarea
id="description"
name="description"
type="text"
class="my-1 p-1 text-black"
></textarea>
<label class="mt-3" for="end_date">end date</label>
<input
id="end_date"
name="end_date"
type="date"
class="my-1 p-1 text-black"
autocomplete="off"
required
/>
<button type="submit" class="mt-3">submit</button>
</form>
}
</div>
</div>
@components.Modal(nil)
@components.Footer() @components.Footer()
</body> </body>
</html> </html>
} }
func tabStyle(path string, tab string) string {
class := "!no-underline"
if path == tab {
class += " font-bold border-b-none"
}
return class
}

View File

@ -14,6 +14,8 @@ func Init(e *echo.Echo, sc Context) {
e.Use(middleware.Session(sc)) e.Use(middleware.Session(sc))
e.GET("/", handler.HandleIndex(sc)) e.GET("/", handler.HandleIndex(sc))
e.GET("/create", handler.HandleIndex(sc))
e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(sc))
e.GET("/about", handler.HandleAbout(sc)) e.GET("/about", handler.HandleAbout(sc))
e.GET("/login", handler.HandleAuth(sc, "login")) e.GET("/login", handler.HandleAuth(sc, "login"))
@ -25,4 +27,6 @@ func Init(e *echo.Echo, sc Context) {
e.GET("/user", handler.HandleUser(sc), middleware.SessionGuard(sc)) e.GET("/user", handler.HandleUser(sc), middleware.SessionGuard(sc))
e.POST("/logout", handler.HandleLogout(sc), middleware.SessionGuard(sc)) e.POST("/logout", handler.HandleLogout(sc), middleware.SessionGuard(sc))
e.GET("/invoice/:hash", handler.HandleInvoice(sc), middleware.SessionGuard(sc))
} }

View File

@ -6,7 +6,12 @@ module.exports = {
center: true, center: true,
padding: '1rem' padding: '1rem'
}, },
extend: {}, extend: {
colors: {
'background': '191d21',
'muted': '#6c757d',
},
},
}, },
plugins: [ plugins: [
function ({ addComponents }) { function ({ addComponents }) {

View File

@ -1,6 +1,10 @@
package types package types
import "time" import (
"time"
"gopkg.in/guregu/null.v4"
)
type User struct { type User struct {
Id int Id int
@ -9,3 +13,17 @@ type User struct {
NostrPubkey string NostrPubkey string
Msats int64 Msats int64
} }
type Invoice struct {
Id int
UserId int
Msats int64
MsatsReceived int64
Hash string
Bolt11 string
CreatedAt time.Time
ExpiresAt time.Time
ConfirmedAt null.Time
HeldSince bool
Description string
}