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..a29ca45 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,49 @@ 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 ( + 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) + } +} 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 8a84091..3d62f84 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 { @@ -31,13 +35,15 @@ a:not(.no-link), button[hx-get], - button[hx-post] { + button[hx-post], + button[type="submit"] { 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 { background-color: var(--color); color: var(--background); } @@ -47,13 +53,15 @@ text-decoration: underline; } - button[hx-post] { + button[hx-post], + button[type="submit"] { border-width: 1px; } nav a, button[hx-get], - button[hx-post] { + button[hx-post], + button[type="submit"] { padding: 0 0.25em; } @@ -70,6 +78,31 @@ 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 { display: flex; align-items: center; @@ -105,4 +138,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..8b31daf --- /dev/null +++ b/server/router/handler/invoice.go @@ -0,0 +1,52 @@ +package handler + +import ( + "database/sql" + "net/http" + "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 + qr templ.Component + err error + ) + + if err = db.QueryRowContext(ctx, ""+ + "SELECT user_id, msats, COALESCE(msats_received, 0), expires_at, confirmed_at, bolt11 "+ + "FROM invoices "+ + "WHERE hash = $1", hash). + Scan(&inv.UserId, &inv.Msats, &inv.MsatsReceived, &inv.ExpiresAt, &inv.ConfirmedAt, &inv.Bolt11); 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 + + qr = components.Invoice(hash, inv.Bolt11, int(inv.Msats), expiresIn, paid) + + return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer) + } +} diff --git a/server/router/handler/market.go b/server/router/handler/market.go new file mode 100644 index 0000000..7dfc336 --- /dev/null +++ b/server/router/handler/market.go @@ -0,0 +1,74 @@ +package handler + +import ( + "database/sql" + "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 + 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 = db.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.ExecContext(ctx, ""+ + "INSERT INTO markets (question, description, end_date, user_id, invoice_id) "+ + "VALUES ($1, $2, $3, $4, $5)", + question, description, endDate, u.Id, invoiceId); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + qr = components.Invoice(hash.String(), paymentRequest, int(amount), int(expiry), false) + + return components.Modal(qr).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..c7e4134 --- /dev/null +++ b/server/router/pages/components/invoice.templ @@ -0,0 +1,62 @@ +package components + +import ( + "strconv" +) + +templ Invoice(hash string, bolt11 string, msats int, expiresIn int, paid bool) { +
+
Payment Required
+
@Qr(bolt11, "lightning:"+bolt11)
+
{ strconv.Itoa(msats/1000) } sats
+ @InvoiceStatus(hash, expiresIn, paid) +
+ +
+} + +templ InvoiceStatus(hash string, expiresIn int, paid bool) { + if paid { +
PAID
+ } + else if expiresIn <= 0 { +
EXPIRED
+ } else { + +
+ +
+ } +} \ 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..2f1f73a --- /dev/null +++ b/server/router/pages/components/modal.templ @@ -0,0 +1,21 @@ +package components + +templ Modal(component templ.Component) { + if component != nil { + + } else { + + } +} 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/router.go b/server/router/router.go index 804f890..ff55887 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -14,6 +14,8 @@ 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("/about", handler.HandleAbout(sc)) 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.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..7b10d5e 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,17 @@ 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 +}