From f195bee7e987108e8594259e3a53520d2708b3f4 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 15 Jul 2024 12:57:51 +0200 Subject: [PATCH] wip: create market TODO: * create market page with buttons for betting * validate end date --- db/schema.sql | 6 +- lnd/lnd.go | 67 +++++++++++ main.go | 2 +- public/css/_tw-input.css | 57 +++++++++- server/router/handler/invoice.go | 69 ++++++++++++ server/router/handler/market.go | 111 +++++++++++++++++++ server/router/pages/components/invoice.templ | 84 ++++++++++++++ server/router/pages/components/modal.templ | 29 +++++ server/router/pages/components/qr.templ | 18 +++ server/router/pages/index.templ | 79 ++++++++++++- server/router/pages/lnAuth.templ | 1 - server/router/pages/market.templ | 28 +++++ server/router/router.go | 5 + tailwind.config.js | 7 +- types/types.go | 28 ++++- 15 files changed, 578 insertions(+), 13 deletions(-) create mode 100644 server/router/handler/invoice.go create mode 100644 server/router/handler/market.go create mode 100644 server/router/pages/components/invoice.templ create mode 100644 server/router/pages/components/modal.templ create mode 100644 server/router/pages/market.templ diff --git a/db/schema.sql b/db/schema.sql index e2f2e31..d492d05 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -24,10 +24,9 @@ CREATE TABLE invoices( user_id INTEGER NOT NULL REFERENCES users(id), msats BIGINT NOT NULL, msats_received BIGINT, - preimage TEXT NOT NULL UNIQUE, hash TEXT NOT NULL UNIQUE, 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, confirmed_at TIMESTAMP WITH TIME ZONE, held_since TIMESTAMP WITH TIME ZONE, @@ -37,7 +36,8 @@ CREATE TABLE invoices( CREATE TABLE markets( id SERIAL PRIMARY KEY, 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, settled_at TIMESTAMP WITH TIME ZONE, user_id INTEGER NOT NULL REFERENCES users(id), diff --git a/lnd/lnd.go b/lnd/lnd.go index 3e0efd7..d33f0e5 100644 --- a/lnd/lnd.go +++ b/lnd/lnd.go @@ -1,9 +1,15 @@ package lnd import ( + "context" + "database/sql" "log" + "time" + "git.ekzyis.com/ekzyis/delphi.market/db" "github.com/lightninglabs/lndclient" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/lntypes" ) type LNDClient struct { @@ -25,3 +31,64 @@ func New(config *LNDConfig) (*LNDClient, error) { log.Printf("Connected to %s running LND v%s", config.LndAddress, lnd.Version.Version) return lnd, nil } + +func (lnd *LNDClient) PollInvoices(db *db.DB) { + var ( + pending bool + rows *sql.Rows + hash lntypes.Hash + inv *lndclient.Invoice + err error + ) + for { + // fetch all pending invoices + if rows, err = db.Query("SELECT hash, expires_at FROM invoices WHERE confirmed_at IS NULL AND expires_at > CURRENT_TIMESTAMP"); err != nil { + log.Printf("error checking invoices: %v", err) + } + + pending = false + + for rows.Next() { + pending = true + + var ( + h string + expiresAt time.Time + ) + rows.Scan(&h, &expiresAt) + + 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 %s", h, expiresAt.Sub(time.Now())) + 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) + } + } + + // poll faster if there are pending invoices + if pending { + time.Sleep(1 * time.Second) + } else { + time.Sleep(5 * time.Second) + } + } +} diff --git a/main.go b/main.go index 1ca81f1..35b110e 100644 --- a/main.go +++ b/main.go @@ -68,7 +68,7 @@ func init() { log.Printf("[warn] error connecting to LND: %v\n", err) lnd_ = nil } else { - // lnd_.CheckInvoices(db_) + go lnd_.PollInvoices(db_) } ctx = server.Context{ diff --git a/public/css/_tw-input.css b/public/css/_tw-input.css index f28c2a4..bfd0ecb 100644 --- a/public/css/_tw-input.css +++ b/public/css/_tw-input.css @@ -10,6 +10,10 @@ --nostr: #8d45dd; --black: #000; --white: #fff; + --bg-success: #149e613d; + --fg-success: #35df8d; + --bg-error: #f5395e3d; + --fg-error: #ff7386; } @layer base { @@ -41,13 +45,17 @@ a:not(.no-link), button[hx-get], - button[hx-post] { + button[hx-post], + button[type="submit"], + .button { transition: background-color 150ms ease-in, color 150ms ease-in; } a:not(.no-link):hover, button[hx-get]:hover, - button[hx-post]:hover { + button[hx-post]:hover, + button[type="submit"]:hover, + .button:hover { background-color: var(--color); color: var(--background); } @@ -57,13 +65,16 @@ text-decoration: underline; } - button[hx-post] { + button[hx-post], + button[type="submit"], + .button { border-width: 1px; } nav a, button[hx-get], - button[hx-post] { + button[hx-post], + button[type="submit"] { padding: 0 0.25em; } @@ -80,6 +91,40 @@ color: var(--muted); } + .text-reset { + color: var(--color); + } + + .hitbox { + padding: 15px; + margin: -15px; + } + + .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 { display: flex; align-items: center; @@ -115,4 +160,8 @@ .nostr:hover { filter: brightness(125%) drop-shadow(0 0 0.33rem var(--nostr)); } + + #modal { + backdrop-filter: blur(10px); + } } \ No newline at end of file diff --git a/server/router/handler/invoice.go b/server/router/handler/invoice.go new file mode 100644 index 0000000..450a32d --- /dev/null +++ b/server/router/handler/invoice.go @@ -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, COALESCE(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[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 "/" +} diff --git a/server/router/handler/market.go b/server/router/handler/market.go new file mode 100644 index 0000000..40ac8d1 --- /dev/null +++ b/server/router/handler/market.go @@ -0,0 +1,111 @@ +package handler + +import ( + "database/sql" + "fmt" + "net/http" + "time" + + "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 + 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) + } +} + +func HandleMarket(sc context.Context) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + db = sc.Db + ctx = c.Request().Context() + id = c.Param("id") + market = types.Market{} + err error + ) + + if err = db.QueryRowContext(ctx, ""+ + "SELECT id, question, description, end_date, user_id "+ + "FROM markets "+ + "WHERE id = $1", id).Scan(&market.Id, &market.Question, &market.Description, &market.EndDate, &market.UserId); err != nil { + if err == sql.ErrNoRows { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + return pages.Market(market).Render(context.RenderContext(sc, c), c.Response().Writer) + } +} diff --git a/server/router/pages/components/invoice.templ b/server/router/pages/components/invoice.templ new file mode 100644 index 0000000..0b4d284 --- /dev/null +++ b/server/router/pages/components/invoice.templ @@ -0,0 +1,84 @@ +package components + +import ( + "fmt" +) + +templ Invoice(hash string, bolt11 string, msats int, expiresIn int, paid bool, redirectUrl templ.SafeURL) { +
+
+
Payment Required
+
@Qr(bolt11, "lightning:"+bolt11)
+
{ format(msats) }
+ @InvoiceStatus(hash, expiresIn, paid, redirectUrl) +
+ +
+} + +templ InvoiceStatus(hash string, expiresIn int, paid bool, redirectUrl templ.SafeURL) { + if paid { +
PAID
+
+ } + else if expiresIn <= 0 { +
EXPIRED
+ } else { + +
+ +
+ } +} + +func format(msats int) string { + sats := msats / 1000 + if sats == 1 { + return fmt.Sprintf("%d sat", sats) + } + return fmt.Sprintf("%d sats", sats) +} \ No newline at end of file diff --git a/server/router/pages/components/modal.templ b/server/router/pages/components/modal.templ new file mode 100644 index 0000000..9575740 --- /dev/null +++ b/server/router/pages/components/modal.templ @@ -0,0 +1,29 @@ +package components + +templ Modal(component templ.Component) { + if component != nil { + + } else { + + } +} diff --git a/server/router/pages/components/qr.templ b/server/router/pages/components/qr.templ index b0e2a1b..739a782 100644 --- a/server/router/pages/components/qr.templ +++ b/server/router/pages/components/qr.templ @@ -13,11 +13,29 @@ templ Qr(value string, href string) { > + + { value } + + + @CopyButton(value) } else { } } +templ CopyButton(value string) { +
+ +} + func qrEncode(value string) string { png, err := qrcode.Encode(value, qrcode.Medium, 256) if err != nil { diff --git a/server/router/pages/index.templ b/server/router/pages/index.templ index 7247772..ba684ec 100644 --- a/server/router/pages/index.templ +++ b/server/router/pages/index.templ @@ -1,6 +1,9 @@ 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() { @@ -9,9 +12,81 @@ templ Index() { @components.Nav()
@components.Figlet("random", "delphi") -
A prediction market using the lightning network
+
A prediction market using the lightning network
+
+
+ + +
+ if ctx.Value(c.ReqPathContextKey).(string) == "/" { +
+ +
+ } else { +
+ + +
+ + optional +
+ + + + +
+ } +
+ @components.Modal(nil) @components.Footer() } + +func tabStyle(path string, tab string) string { + class := "!no-underline" + if path == tab { + class += " font-bold border-b-none" + } + return class +} diff --git a/server/router/pages/lnAuth.templ b/server/router/pages/lnAuth.templ index 02d15cf..3d3f867 100644 --- a/server/router/pages/lnAuth.templ +++ b/server/router/pages/lnAuth.templ @@ -18,7 +18,6 @@ templ LnAuth(lnurl string, action string) { hx-push-url="true" > @components.Qr(lnurl, "lightning:"+lnurl) - { lnurl }
diff --git a/server/router/pages/market.templ b/server/router/pages/market.templ new file mode 100644 index 0000000..897b9c4 --- /dev/null +++ b/server/router/pages/market.templ @@ -0,0 +1,28 @@ +package pages + +import ( + "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components" + "git.ekzyis.com/ekzyis/delphi.market/types" +) + +templ Market(m types.Market) { + + @components.Head() + + @components.Nav() +
+ + @components.Figlet("random", "market") + +
{ m.Question }
+
{ m.Description }
+
+ + +
+
+ @components.Modal(nil) + @components.Footer() + + +} \ No newline at end of file diff --git a/server/router/router.go b/server/router/router.go index 804f890..bca25ab 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -14,6 +14,9 @@ func Init(e *echo.Echo, sc Context) { e.Use(middleware.Session(sc)) e.GET("/", handler.HandleIndex(sc)) + e.GET("/create", handler.HandleIndex(sc)) + e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(sc)) + e.GET("/market/:id", handler.HandleMarket(sc)) e.GET("/about", handler.HandleAbout(sc)) e.GET("/login", handler.HandleAuth(sc, "login")) @@ -25,4 +28,6 @@ func Init(e *echo.Echo, sc Context) { e.GET("/user", handler.HandleUser(sc), middleware.SessionGuard(sc)) e.POST("/logout", handler.HandleLogout(sc), middleware.SessionGuard(sc)) + + e.GET("/invoice/:hash", handler.HandleInvoice(sc), middleware.SessionGuard(sc)) } diff --git a/tailwind.config.js b/tailwind.config.js index 90ba410..e7f3003 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -6,7 +6,12 @@ module.exports = { center: true, padding: '1rem' }, - extend: {}, + extend: { + colors: { + 'background': '191d21', + 'muted': '#6c757d', + }, + }, }, plugins: [ function ({ addComponents }) { diff --git a/types/types.go b/types/types.go index 92b62af..1a82626 100644 --- a/types/types.go +++ b/types/types.go @@ -1,6 +1,10 @@ package types -import "time" +import ( + "time" + + "gopkg.in/guregu/null.v4" +) type User struct { Id int @@ -9,3 +13,25 @@ type User struct { NostrPubkey string 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 +} + +type Market struct { + Id int + UserId int + Question string + Description string + EndDate time.Time +}