Compare commits

...

9 Commits

Author SHA1 Message Date
ekzyis e655cddca3 Implement signup with lightning 2024-07-12 10:59:57 +02:00
ekzyis c3bd4ae44f Remove text-center in /about 2024-07-12 10:59:57 +02:00
ekzyis a5340bc414 Rename header to nav 2024-07-12 10:59:57 +02:00
ekzyis 18ecd41272 Add lightning, nostr svg 2024-07-12 10:59:57 +02:00
ekzyis 2b4c0e9417 Add signup page 2024-07-12 10:59:57 +02:00
ekzyis d7016e693b Fix error pages 2024-07-12 10:59:57 +02:00
ekzyis bd8ba07174 Use hx-* attributes 2024-07-12 10:59:57 +02:00
ekzyis 06fbd28230 Remove old code
* removed code that will not be used
* removed code that will most likely be rewritten
2024-07-12 10:59:57 +02:00
ekzyis 4ba568951d Update db schema 2024-07-12 10:59:57 +02:00
46 changed files with 526 additions and 2460 deletions

View File

@ -1,23 +1,27 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE lnauth(
k1 VARCHAR(64) NOT NULL PRIMARY KEY,
lnurl TEXT NOT NULL,
created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
session_id VARCHAR(48) NOT NULL DEFAULT encode(gen_random_uuid()::text::bytea, 'base64')
);
CREATE TABLE users(
pubkey TEXT PRIMARY KEY,
last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
ln_pubkey TEXT UNIQUE,
nostr_pubkey TEXT UNIQUE,
msats BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE sessions(
pubkey TEXT NOT NULL REFERENCES users(pubkey),
user_id INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
session_id VARCHAR(48)
);
CREATE TABLE lnauth(
k1 VARCHAR(64) PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
lnurl TEXT NOT NULL,
session_id VARCHAR(48) NOT NULL DEFAULT encode(gen_random_uuid()::text::bytea, 'base64')
);
CREATE TABLE invoices(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
pubkey TEXT NOT NULL REFERENCES users(pubkey),
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
msats BIGINT NOT NULL,
msats_received BIGINT,
preimage TEXT NOT NULL UNIQUE,
@ -29,40 +33,47 @@ CREATE TABLE invoices(
held_since TIMESTAMP WITH TIME ZONE,
description TEXT
);
CREATE TABLE markets(
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
description TEXT NOT NULL,
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
settled_at TIMESTAMP WITH TIME ZONE,
pubkey TEXT NOT NULL REFERENCES users(pubkey),
invoice_id UUID NOT NULL UNIQUE REFERENCES invoices(id)
user_id INTEGER NOT NULL REFERENCES users(id),
invoice_id INTEGER NOT NULL UNIQUE REFERENCES invoices(id)
);
CREATE TABLE shares(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
id SERIAL PRIMARY KEY,
market_id INTEGER NOT NULL REFERENCES markets(id),
win BOOLEAN,
description TEXT NOT NULL
description TEXT NOT NULL,
win BOOLEAN
);
CREATE TYPE order_side AS ENUM ('BUY', 'SELL');
CREATE TABLE orders(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
share_id UUID NOT NULL REFERENCES shares(id),
pubkey TEXT NOT NULL REFERENCES users(pubkey),
share_id INTEGER NOT NULL REFERENCES shares(id),
user_id INTEGER NOT NULL REFERENCES users(id),
side ORDER_SIDE NOT NULL,
quantity BIGINT NOT NULL,
price BIGINT NOT NULL,
invoice_id UUID REFERENCES invoices(id),
order_id UUID REFERENCES orders(id)
invoice_id INTEGER REFERENCES invoices(id),
order_id INTEGER REFERENCES orders(id)
);
ALTER TABLE orders ADD CONSTRAINT order_price CHECK(price > 0 AND price < 100);
ALTER TABLE orders ADD CONSTRAINT order_quantity CHECK(quantity > 0);
CREATE TABLE withdrawals(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
pubkey TEXT NOT NULL REFERENCES users(pubkey),
canceled_at TIMESTAMP WITH TIME ZONE,
user_id INTEGER NOT NULL REFERENCES users(id),
bolt11 TEXT NOT NULL UNIQUE,
paid_at TIMESTAMP WITH TIME ZONE
);

View File

@ -1,106 +0,0 @@
package db
import (
"context"
"database/sql"
"time"
)
func (db *DB) CreateInvoice(tx *sql.Tx, ctx context.Context, invoice *Invoice) error {
if err := tx.QueryRowContext(ctx, ""+
"INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at, description) "+
"VALUES($1, $2, $3, $4, $5, $6, $7, $8) "+
"RETURNING id",
invoice.Pubkey, invoice.Msats, invoice.Preimage, invoice.Hash, invoice.PaymentRequest, invoice.CreatedAt, invoice.ExpiresAt, invoice.Description).Scan(&invoice.Id); err != nil {
return err
}
return nil
}
type FetchInvoiceWhere struct {
Id string
Hash string
}
func (db *DB) FetchInvoice(where *FetchInvoiceWhere, invoice *Invoice) error {
var (
query = "SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since, COALESCE(description, '') FROM invoices "
args []any
)
if where.Id != "" {
query += "WHERE id = $1"
args = append(args, where.Id)
} else if where.Hash != "" {
query += "WHERE hash = $1"
args = append(args, where.Hash)
}
if err := db.QueryRow(query, args...).Scan(
&invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash,
&invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince, &invoice.Description); err != nil {
return err
}
return nil
}
type FetchInvoicesWhere struct {
Unconfirmed bool
}
func (db *DB) FetchInvoices(where *FetchInvoicesWhere, invoices *[]Invoice) error {
var (
rows *sql.Rows
invoice Invoice
err error
)
var (
query = "SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since, COALESCE(description, '') FROM invoices "
)
if where.Unconfirmed {
query += "WHERE confirmed_at IS NULL"
}
if rows, err = db.Query(query); err != nil {
return err
}
defer rows.Close()
for rows.Next() {
rows.Scan(
&invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash,
&invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince, &invoice.Description)
*invoices = append(*invoices, invoice)
}
return nil
}
func (db *DB) FetchUserInvoices(pubkey string, invoices *[]Invoice) error {
var (
rows *sql.Rows
invoice Invoice
err error
)
var (
query = "" +
"SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since, COALESCE(description, ''), " +
"CASE WHEN confirmed_at IS NOT NULL THEN 'PAID' WHEN expires_at < CURRENT_TIMESTAMP THEN 'EXPIRED' ELSE 'PENDING' END AS status " +
"FROM invoices " +
"WHERE pubkey = $1 " +
"ORDER BY created_at DESC"
)
if rows, err = db.Query(query, pubkey); err != nil {
return err
}
defer rows.Close()
for rows.Next() {
rows.Scan(
&invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash,
&invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince, &invoice.Description, &invoice.Status)
*invoices = append(*invoices, invoice)
}
return nil
}
func (db *DB) ConfirmInvoice(tx *sql.Tx, c context.Context, hash string, confirmedAt time.Time, msatsReceived int) error {
if _, err := tx.ExecContext(c, "UPDATE invoices SET confirmed_at = $2, msats_received = $3 WHERE hash = $1", hash, confirmedAt, msatsReceived); err != nil {
return err
}
return nil
}

View File

@ -1,18 +0,0 @@
package db
func (db *DB) CreateLNAuth(lnAuth *LNAuth) error {
err := db.QueryRow(
"INSERT INTO lnauth(k1, lnurl) VALUES($1, $2) RETURNING session_id",
lnAuth.K1, lnAuth.LNURL).Scan(&lnAuth.SessionId)
return err
}
func (db *DB) FetchSessionId(k1 string, sessionId *string) error {
err := db.QueryRow("SELECT session_id FROM lnauth WHERE k1 = $1", k1).Scan(sessionId)
return err
}
func (db *DB) DeleteLNAuth(lnAuth *LNAuth) error {
_, err := db.Exec("DELETE FROM lnauth WHERE k1 = $1", lnAuth.K1)
return err
}

View File

@ -1,353 +0,0 @@
package db
import (
"context"
"database/sql"
"log"
"time"
)
type FetchOrdersWhere struct {
MarketId int
Pubkey string
Confirmed bool
}
func (db *DB) CreateMarket(tx *sql.Tx, ctx context.Context, market *Market) error {
if err := tx.QueryRowContext(ctx, ""+
"INSERT INTO markets(description, end_date, pubkey, invoice_id) "+
"VALUES($1, $2, $3, $4) "+
"RETURNING id", market.Description, market.EndDate, market.Pubkey, market.InvoiceId).Scan(&market.Id); err != nil {
return err
}
// For now, we only support binary markets.
if _, err := tx.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil {
return err
}
return nil
}
func (db *DB) FetchMarket(marketId int, market *Market) error {
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 nil
}
func (db *DB) FetchActiveMarkets(markets *[]Market) error {
var (
rows *sql.Rows
market Market
err error
)
if rows, err = db.Query("" +
"SELECT m.id, m.description, m.end_date FROM markets m " +
"JOIN invoices i ON i.id = m.invoice_id WHERE i.confirmed_at IS NOT NULL"); err != nil {
return err
}
defer rows.Close()
for rows.Next() {
rows.Scan(&market.Id, &market.Description, &market.EndDate)
*markets = append(*markets, market)
}
return nil
}
func (db *DB) FetchShares(marketId int, shares *[]Share) error {
rows, err := db.Query("SELECT id, market_id, description, win FROM shares WHERE market_id = $1 ORDER BY description DESC", marketId)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var share Share
rows.Scan(&share.Id, &share.MarketId, &share.Description, &share.Win)
*shares = append(*shares, share)
}
return nil
}
func (db *DB) FetchShare(tx *sql.Tx, ctx context.Context, shareId string, share *Share) error {
return tx.QueryRowContext(ctx, "SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description)
}
func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
query := "" +
"SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, o.deleted_at, s.description, s.market_id, i.confirmed_at " +
"FROM orders o " +
"JOIN invoices i ON o.invoice_id = i.id " +
"JOIN shares s ON o.share_id = s.id " +
"WHERE o.deleted_at IS NULL "
var args []any
if where.MarketId > 0 {
query += "AND share_id = ANY(SELECT id FROM shares WHERE market_id = $1) "
args = append(args, where.MarketId)
} else if where.Pubkey != "" {
query += "AND o.pubkey = $1 "
args = append(args, where.Pubkey)
}
if where.Confirmed {
query += "AND o.order_id IS NOT NULL "
}
query += "AND (i.confirmed_at IS NOT NULL OR i.expires_at > CURRENT_TIMESTAMP) "
query += "ORDER BY price DESC"
rows, err := db.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var order Order
rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.DeletedAt, &order.Share.Description, &order.Share.MarketId, &order.Invoice.ConfirmedAt)
*orders = append(*orders, order)
}
return nil
}
func (db *DB) CreateOrder(tx *sql.Tx, ctx context.Context, order *Order) error {
if _, err := tx.ExecContext(ctx, ""+
"INSERT INTO orders(share_id, pubkey, side, quantity, price, invoice_id) "+
"VALUES ($1, $2, $3, $4, $5, CASE WHEN $6 = '' THEN NULL ELSE $6::UUID END)",
order.ShareId, order.Pubkey, order.Side, order.Quantity, order.Price, order.InvoiceId.String); err != nil {
return err
}
return nil
}
func (db *DB) FetchOrder(tx *sql.Tx, ctx context.Context, orderId string, order *Order) error {
query := "" +
"SELECT o.id, o.share_id, o.pubkey, o.side, o.quantity, o.price, o.created_at, o.deleted_at, o.order_id, s.description, s.market_id, i.confirmed_at, o.invoice_id, COALESCE(i.msats_received, 0) " +
"FROM orders o " +
"LEFT JOIN invoices i ON o.invoice_id = i.id " +
"JOIN shares s ON o.share_id = s.id " +
"WHERE o.id = $1"
return tx.QueryRowContext(ctx, query, orderId).Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.CreatedAt, &order.DeletedAt, &order.OrderId, &order.Share.Description, &order.MarketId, &order.Invoice.ConfirmedAt, &order.InvoiceId, &order.Invoice.MsatsReceived)
}
func (db *DB) FetchUserOrders(pubkey string, orders *[]Order) error {
query := "" +
"SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.created_at, o.deleted_at, s.description, s.market_id, i.confirmed_at, " +
"CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' WHEN o.deleted_at IS NOT NULL THEN 'CANCELED' ELSE 'PENDING' END AS status, o.order_id, o.invoice_id " +
"FROM orders o " +
"LEFT JOIN invoices i ON o.invoice_id = i.id " +
"JOIN shares s ON o.share_id = s.id " +
"WHERE o.pubkey = $1 AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL) OR o.side = 'SELL' ) " +
"ORDER BY o.created_at DESC"
rows, err := db.Query(query, pubkey)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var order Order
rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.CreatedAt, &order.DeletedAt, &order.ShareDescription, &order.Share.MarketId, &order.Invoice.ConfirmedAt, &order.Status, &order.OrderId, &order.InvoiceId)
*orders = append(*orders, order)
}
return nil
}
func (db *DB) FetchMarketOrders(marketId int64, orders *[]Order) error {
query := "" +
"SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.created_at, s.description, s.market_id, " +
"CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' ELSE 'PENDING' END AS status, o.order_id, o.invoice_id " +
"FROM orders o " +
"JOIN shares s ON o.share_id = s.id " +
"LEFT JOIN invoices i ON o.invoice_id = i.id " +
"WHERE s.market_id = $1 AND o.deleted_at IS NULL AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL) OR o.side = 'SELL' ) " +
"ORDER BY o.created_at DESC"
rows, err := db.Query(query, marketId)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var order Order
rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.CreatedAt, &order.ShareDescription, &order.Share.MarketId, &order.Status, &order.OrderId, &order.InvoiceId)
*orders = append(*orders, order)
}
return nil
}
func (db *DB) RunMatchmaking(orderId string) {
var (
ctx context.Context
cancel context.CancelFunc
tx *sql.Tx
o1 Order
o2 Order
err error
)
ctx, cancel = context.WithTimeout(context.TODO(), 10*time.Second)
defer cancel()
if tx, err = db.BeginTx(ctx, nil); err != nil {
log.Println(err)
return
}
// TODO: assert that order was confirmed
if err = db.FetchOrder(tx, ctx, orderId, &o1); err != nil {
log.Println(err)
tx.Rollback()
return
}
if err = db.FindOrderMatches(tx, ctx, &o1, &o2); err != nil {
log.Println(err)
tx.Rollback()
return
}
if o2.OrderId.Valid {
log.Printf("assertion failed: order %s matched order %s but order_id already set to %s\n", o1.Id, o2.Id, o2.OrderId.String)
tx.Rollback()
return
}
if o1.Id == o2.Id {
log.Printf("assertion failed: order %s matched itself", o1.Id)
tx.Rollback()
return
}
if err = db.MatchOrders(tx, ctx, &o1, &o2); err != nil {
log.Println(err)
tx.Rollback()
return
}
tx.Commit()
}
func (db *DB) FindOrderMatches(tx *sql.Tx, ctx context.Context, o1 *Order, o2 *Order) error {
query := "" +
"SELECT o.id, o.order_id, o.side, o.quantity, o.price, o.pubkey FROM orders o " +
"JOIN shares s ON s.id = o.share_id " +
"LEFT JOIN invoices i ON i.id = o.invoice_id " +
// only match orders which are not soft deleted
"WHERE o.deleted_at IS NULL " +
// only match orders which are not already settled
"AND o.order_id IS NULL " +
// only match orders from other users
"AND o.pubkey <> $1 " +
// orders must always be for same market and have same quantity
"AND o.quantity = $2 AND s.market_id = $3 " +
// BUY orders must have been confirmed by paying the invoice
"AND CASE WHEN o.side = 'BUY' THEN i.confirmed_at IS NOT NULL ELSE 1=1 END " +
"AND (" +
// -- BUY orders match if they are for different shares and the sum of their prices equal 100
// -- example: BUY 5 YES @ 60 <> BUY 5 NO @ 40
" ( $5 = 'BUY' AND o.side = 'BUY' AND o.price = (100-$6) AND o.share_id <> $4 ) " +
// -- BUY orders match SELL orders if they are for the same share and have same price
// -- example: BUY 5 YES @ 60 <> SELL 5 YES @ 60
" OR ( $5 = 'BUY' AND o.side = 'SELL' AND o.price = $6 AND o.share_id = $4 ) " +
// -- SELL orders match BUY orders if they are for the same share and have same price
// -- example: SELL 5 YES @ 60 <> BUY 5 YES @ 60
" OR ( $5 = 'SELL' AND o.side = 'BUY' AND o.price = $6 AND o.share_id = $4 ) " +
") " +
// match oldest order first
"ORDER BY o.created_at ASC LIMIT 1"
return tx.QueryRowContext(ctx, query, o1.Pubkey, o1.Quantity, o1.Share.MarketId, o1.ShareId, o1.Side, o1.Price).Scan(&o2.Id, &o2.OrderId, &o2.Side, &o2.Quantity, &o2.Price, &o2.Pubkey)
}
func (db *DB) MatchOrders(tx *sql.Tx, ctx context.Context, o1 *Order, o2 *Order) error {
var err error
if _, err = tx.ExecContext(ctx, "UPDATE orders SET order_id = $1 WHERE id = $2", o1.Id, o2.Id); err != nil {
tx.Rollback()
return err
}
if _, err = tx.ExecContext(ctx, "UPDATE orders SET order_id = $1 WHERE id = $2", o2.Id, o1.Id); err != nil {
tx.Rollback()
return err
}
if o1.Side == "SELL" {
if _, err = tx.ExecContext(ctx, "UPDATE users SET msats = msats + $1 WHERE pubkey = $2", (o1.Price*o1.Quantity)*1000, o1.Pubkey); err != nil {
tx.Rollback()
return err
}
}
if o2.Side == "SELL" {
if _, err = tx.ExecContext(ctx, "UPDATE users SET msats = msats + $1 WHERE pubkey = $2", (o2.Price*o2.Quantity)*1000, o2.Pubkey); err != nil {
tx.Rollback()
return err
}
}
log.Printf("Matched orders: %s <> %s\n", o1.Id, o2.Id)
return nil
}
// [
//
// { "x": <timestamp>, "y": { <share_description>: <score>, ... } },
//
// ]
type MarketStat struct {
X time.Time `json:"x"`
Y map[string]int `json:"y"`
}
type MarketStats = []MarketStat
func (db *DB) FetchMarketStats(marketId int64, stats *MarketStats) error {
query := "" +
"SELECT " +
"s.description, " +
"GREATEST(i.confirmed_at, i2.confirmed_at) AS confirmed_at, " +
"SUM(o.price * o.quantity) OVER (PARTITION BY o.share_id ORDER BY o.created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS score " +
"FROM orders o " +
"JOIN orders o2 ON o2.id = o.order_id " +
"JOIN shares s ON s.id = o.share_id " +
"JOIN invoices i ON i.id = o.invoice_id " +
"JOIN invoices i2 ON i2.id = o2.invoice_id " +
"WHERE s.market_id = $1 AND i.confirmed_at IS NOT NULL AND o.order_id IS NOT NULL ORDER BY i.confirmed_at ASC"
rows, err := db.Query(query, marketId)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var stat MarketStat
var (
timestamp time.Time
description string
score int
)
rows.Scan(&description, &timestamp, &score)
stat.X = timestamp
stat.Y = map[string]int{
description: score,
}
*stats = append(*stats, stat)
}
return nil
}
func (db *DB) FetchUserBalance(tx *sql.Tx, ctx context.Context, marketId int, pubkey string, balance *map[string]any) error {
query := "" +
"SELECT s.description, " +
"SUM(CASE WHEN o.side = 'BUY' THEN o.quantity ELSE -o.quantity END) " +
"FROM orders o " +
"LEFT JOIN invoices i ON i.id = o.invoice_id " +
"JOIN shares s ON s.id = o.share_id " +
"WHERE o.pubkey = $1 AND s.market_id = $2 " +
// ignore canceled orders
"AND o.deleted_at IS NULL " +
"AND ( " +
// shares from BUY orders are received if they were paid (necessary precondition for matchmaking) and found a matching order
" (o.side = 'BUY' AND i.confirmed_at IS NOT NULL AND o.order_id IS NOT NULL) " +
// shares from SELL orders are deducted immediately to prevent double-spends
" OR o.side = 'SELL' " +
") " +
"GROUP BY o.pubkey, s.description"
rows, err := tx.QueryContext(ctx, query, pubkey, marketId)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var (
sdesc string
val int
)
if err = rows.Scan(&sdesc, &val); err != nil {
return err
}
(*balance)[sdesc] = val
}
return nil
}

View File

@ -1,17 +0,0 @@
package db
func (db *DB) CreateSession(s *Session) error {
_, err := db.Exec("INSERT INTO sessions(pubkey, session_id) VALUES($1, $2)", s.Pubkey, s.SessionId)
return err
}
func (db *DB) FetchSession(s *Session) error {
query := "SELECT u.pubkey, u.msats FROM sessions s LEFT JOIN users u ON u.pubkey = s.pubkey WHERE session_id = $1"
err := db.QueryRow(query, s.SessionId).Scan(&s.Pubkey, &s.Msats)
return err
}
func (db *DB) DeleteSession(s *Session) error {
_, err := db.Exec("DELETE FROM sessions where session_id = $1", s.SessionId)
return err
}

View File

@ -1,80 +0,0 @@
package db
import (
"time"
"gopkg.in/guregu/null.v4"
)
type (
Serial = int
UUID = string
LNAuth struct {
K1 string
LNURL string
CreatedAt time.Time
SessionId string
}
User struct {
Pubkey string
Msats int64
LastSeen time.Time
}
Session struct {
Pubkey string
Msats int64
SessionId string
}
Market struct {
Id Serial `json:"id"`
Description string `json:"description"`
EndDate time.Time `json:"endDate"`
SettledAt null.Time `json:"settledAt"`
Pubkey string `json:"pubkey"`
InvoiceId UUID
}
Share struct {
Id UUID `json:"sid"`
MarketId int
Description string
Win bool
}
Invoice struct {
Id UUID
Pubkey string
Msats int64
MsatsReceived int64
Preimage string
Hash string
PaymentRequest string
CreatedAt time.Time
ExpiresAt time.Time
ConfirmedAt null.Time
HeldSince null.Time
Description string
Status string
}
Order struct {
Id UUID
CreatedAt time.Time
DeletedAt null.Time
ShareId string `json:"sid"`
ShareDescription string
Share
Pubkey string
Side string `json:"side"`
Quantity int64 `json:"quantity"`
Price int64 `json:"price"`
InvoiceId null.String
Invoice
OrderId null.String
}
Withdrawal struct {
Id UUID
CreatedAt time.Time
DeletedAt null.Time
Pubkey string
Bolt11 string `json:"bolt11"`
PaidAt null.Time
}
)

View File

@ -1,17 +0,0 @@
package db
func (db *DB) CreateUser(u *User) error {
_, err := db.Exec(
"INSERT INTO users(pubkey) VALUES ($1) ON CONFLICT(pubkey) DO UPDATE SET last_seen = CURRENT_TIMESTAMP",
u.Pubkey)
return err
}
func (db *DB) FetchUser(pubkey string, u *User) error {
return db.QueryRow("SELECT pubkey, last_seen FROM users WHERE pubkey = $1", pubkey).Scan(&u.Pubkey, &u.LastSeen)
}
func (db *DB) UpdateUser(u *User) error {
_, err := db.Exec("UPDATE users SET last_seen = $1 WHERE pubkey = $2", u.LastSeen, u.Pubkey)
return err
}

View File

@ -1,13 +0,0 @@
package db
import (
"context"
"database/sql"
)
func (db *DB) CreateWithdrawal(tx *sql.Tx, c context.Context, w *Withdrawal) error {
if _, err := tx.ExecContext(c, "INSERT INTO withdrawals(pubkey, bolt11) VALUES ($1, $2)", w.Pubkey, w.Bolt11); err != nil {
return err
}
return nil
}

View File

@ -31,6 +31,6 @@ trap cleanup EXIT
restart
tail -f server.log &
while inotifywait -r -e modify db/ env/ lib/ lnd/ pages/ public/ server/; do
while inotifywait -r -e modify db/ env/ lib/ lnd/ public/ server/; do
restart
done

View File

@ -1,163 +0,0 @@
package lnd
import (
"context"
"database/sql"
"log"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
)
func (lnd *LNDClient) CreateInvoice(tx *sql.Tx, ctx context.Context, d *db.DB, pubkey string, msats int64, description string) (*db.Invoice, error) {
var (
expiry time.Duration = time.Hour
preimage lntypes.Preimage
hash lntypes.Hash
paymentRequest string
lnInvoice *lndclient.Invoice
dbInvoice *db.Invoice
err error
)
if preimage, err = generateNewPreimage(); err != nil {
return nil, err
}
hash = preimage.Hash()
if paymentRequest, err = lnd.Invoices.AddHoldInvoice(ctx, &invoicesrpc.AddInvoiceData{
Hash: &hash,
Value: lnwire.MilliSatoshi(msats),
Expiry: int64(expiry / time.Millisecond),
}); err != nil {
return nil, err
}
if lnInvoice, err = lnd.Client.LookupInvoice(ctx, hash); err != nil {
return nil, err
}
dbInvoice = &db.Invoice{
Pubkey: pubkey,
Msats: msats,
Preimage: preimage.String(),
PaymentRequest: paymentRequest,
Hash: hash.String(),
CreatedAt: lnInvoice.CreationDate,
ExpiresAt: lnInvoice.CreationDate.Add(expiry),
Description: description,
}
if err := d.CreateInvoice(tx, ctx, dbInvoice); err != nil {
return nil, err
}
return dbInvoice, nil
}
func (lnd *LNDClient) CheckInvoice(d *db.DB, hash lntypes.Hash) {
var (
pollInterval = 5 * time.Second
invoice db.Invoice
lnInvoice *lndclient.Invoice
preimage lntypes.Preimage
err error
)
if err = d.FetchInvoice(&db.FetchInvoiceWhere{Hash: hash.String()}, &invoice); err != nil {
log.Println(err)
return
}
for {
ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
var tx *sql.Tx
if tx, err = d.BeginTx(ctx, nil); err != nil {
cancel()
continue
}
handleLoopError := func(err error) {
log.Println(err)
tx.Rollback()
cancel()
time.Sleep(pollInterval)
}
log.Printf("lookup invoice: hash=%s", hash)
if lnInvoice, err = lnd.Client.LookupInvoice(ctx, hash); err != nil {
handleLoopError(err)
continue
}
if time.Now().After(invoice.ExpiresAt) {
// cancel invoices after expiration if no matching order found yet
if err = lnd.Invoices.CancelInvoice(ctx, hash); err != nil {
handleLoopError(err)
continue
}
log.Printf("invoice expired: hash=%s", hash)
tx.Commit()
break
}
if lnInvoice.AmountPaid == lnInvoice.Amount {
if preimage, err = lntypes.MakePreimageFromStr(invoice.Preimage); err != nil {
handleLoopError(err)
continue
}
// TODO settle invoice after matching order was found
if err = lnd.Invoices.SettleInvoice(ctx, preimage); err != nil {
handleLoopError(err)
continue
}
if err = d.ConfirmInvoice(tx, ctx, hash.String(), time.Now(), int(lnInvoice.AmountPaid)); err != nil {
handleLoopError(err)
continue
}
log.Printf("invoice confirmed: hash=%s", hash)
// Run matchmaking if an order was paid
var orderId string
var deleted bool
if err = d.QueryRowContext(ctx,
"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(),
).Scan(&orderId, &deleted); err != nil && err != sql.ErrNoRows {
handleLoopError(err)
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 != "" {
go d.RunMatchmaking(orderId)
}
tx.Commit()
break
}
time.Sleep(pollInterval)
}
}
func (lnd *LNDClient) CheckInvoices(d *db.DB) error {
var (
invoices []db.Invoice
err error
hash lntypes.Hash
)
if err = d.FetchInvoices(&db.FetchInvoicesWhere{Unconfirmed: true}, &invoices); err != nil {
return err
}
for _, invoice := range invoices {
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
return err
}
go lnd.CheckInvoice(d, hash)
}
return nil
}

View File

@ -1 +0,0 @@
package lnd

View File

@ -10,21 +10,31 @@ import (
)
func (lnd *LNDClient) PayInvoice(tx *sql.Tx, bolt11 string) error {
maxFeeSats := btcutil.Amount(10)
ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)
defer cancel()
log.Printf("attempting to pay bolt11 %s ...\n", bolt11)
maxFeeSats := btcutil.Amount(10)
payChan := lnd.Client.PayInvoice(ctx, bolt11, maxFeeSats, nil)
res := <-payChan
if res.Err != nil {
log.Printf("error paying bolt11: %s -- %s\n", bolt11, res.Err)
tx.Rollback()
return res.Err
}
log.Printf("successfully paid bolt11: %s\n", bolt11)
if _, err := tx.ExecContext(ctx, "UPDATE withdrawals SET paid_at = CURRENT_TIMESTAMP WHERE bolt11 = $1", bolt11); err != nil {
if _, err := tx.ExecContext(ctx,
"UPDATE withdrawals SET paid_at = CURRENT_TIMESTAMP WHERE bolt11 = $1",
bolt11,
); err != nil {
tx.Rollback()
return err
}
return res.Err
}

View File

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

View File

@ -1,54 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>delphi.market</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="/css/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#091833" />
{{ if eq .ENV "development" }}
<script defer src="/hotreload.js"></script>
{{ end }}
</head>
<body>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<a href="/">home</a>
{{ if .session }}
<a href='/user'>user</a>
{{ else }} <a href="/login">login</a> {{ end }}
</nav>
</header>
<div class="container flex flex-column text-center">
<code>
<strong>
<pre>
_ _ _ _
__| | ___| |_ __ | |__ (_)
/ _` |/ _ \ | '_ \| '_ \| |
| (_| | __/ | |_) | | | | |
\__,_|\___|_| .__/|_| |_|_|
|_| </pre>
</strong>
</code>
<div class="font-mono mb-1">A prediction market using the lightning network [WIP]</div>
<div class="font-mono mb-1">ACTIVE MARKETS</div>
{{ range .markets }}
<a href="/market/{{.Id}}">{{.Description}}</a>
{{ end }}
</div>
<footer class="flex justify-center">
<div>
<hr />
<code><a href="https://github.com/ekzyis/delphi.market/commit/{{.COMMIT_LONG_SHA}}">{{.VERSION}}</a></code>
</div>
</footer>
</body>
</html>

View File

@ -1,113 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>delphi.market</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="/css/index.css" />
<link rel="stylesheet" href="/market.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#091833" />
{{ if eq .ENV "development" }}
<script defer src="/hotreload.js"></script>
{{ end }}
</head>
<body>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<a href="/">home</a>
{{ if .session }}
<a href='/user'>user</a>
{{ else }} <a href="/login">login</a> {{ end }}
</nav>
</header>
<div class="container flex flex-column text-center justify-center">
<code>
<strong>
<pre>
_ _ ___ ____
| || | / _ \___ \
| || |_| | | |__) |
|__ _| |_| / __/
|_| \___/_____|</pre>
</strong>
</code>
<div class="font-mono mb-1">
<div class="mb-1">Payment Required</div>
<div id="status" hidden>
<div id="status-label"></div>
<div id="countdown" hidden>Redirecting in 3 ...</div>
</div>
</div>
<div id="qr">
<a href="lightning:{{.lnurl}}">
<img class="m-auto mb-1" src="data:image/png;base64,{{.qr}}" width="50%" />
</a>
<div class="font-mono word-wrap mb-1">{{.lnurl}}</div>
<details class="font-mono mb-1 align-left">
<summary>details</summary>
<div>id: {{.invoice.Id}}</div>
<div>amount: {{div .invoice.Msats 1000}} sats</div>
<div>created: {{.invoice.CreatedAt}}</div>
<div>expiry : {{.invoice.ExpiresAt}}</div>
<div class="word-wrap">hash: {{.invoice.Hash}}</div>
</details>
</div>
</div>
</body>
<script>
const statusElement = document.querySelector("#status")
const label = document.querySelector("#status-label")
const status = "{{.status}}"
const redirectUrl = "{{.redirectURL}}"
function poll() {
const invoiceId = "{{.invoice.Id}}"
const countdown = document.querySelector("#countdown")
const redirect = () => {
clearInterval(interval)
countdown.removeAttribute("hidden")
let timer = 2
const redirect = setInterval(() => {
countdown.textContent = `Redirecting in ${timer--} ...`
if (timer === -1) {
window.location.href = redirectUrl;
}
}, 1000)
}
const interval = setInterval(async () => {
const body = await fetch(`/api/invoice/${invoiceId}`)
.then((r) => r.json())
.catch(console.error)
if (body.ConfirmedAt) {
statusElement.removeAttribute("hidden")
statusElement.classList.add("yes")
label.textContent = "Paid"
if (redirectUrl) redirect()
} else if (new Date(body.ExpiresAt) <= new Date()) {
statusElement.removeAttribute("hidden")
statusElement.classList.add("no")
label.textContent = "Expired"
if (redirectUrl) redirect()
}
}, 1000)
}
if (status) {
console.log(status)
statusElement.removeAttribute("hidden")
label.textContent = status
if (status === "Paid") {
statusElement.classList.add("yes")
}
else if (status === "Expired") {
statusElement.classList.add("no")
}
}
else poll()
</script>
</html>

View File

@ -1,77 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>delphi.market</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="/css/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#091833" />
{{ if eq .ENV "development" }}
<script defer src="/hotreload.js"></script>
{{ end }}
</head>
<body>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<a href="/">home</a>
{{ if .session }}
<a href='/user'>user</a>
{{ else }} <a href="/login">login</a> {{ end }}
</nav>
</header>
<div class="container flex flex-column text-center justify-center">
<code>
<strong>
<pre>
_ _
| | ___ __ _(_)_ __
| |/ _ \ / _` | | '_ \
| | (_) | (_| | | | | |
|_|\___/ \__, |_|_| |_|
|___/ </pre>
</strong>
</code>
<div id="qr">
<div class="mb-1">Login with Lightning</div>
<a href="lightning:{{.lnurl}}"><img class="m-auto mb-1" src="data:image/png;base64,{{.qr}}" width="100%" /></a>
<div class="font-mono word-wrap">{{.lnurl}}</div>
</div>
<div id="lnauth-success" hidden>
<div>Login successful</div>
<div>You are <span id="lnauth-pubkey" class="font-mono"></span></div>
<div id="lnauth-countdown">Redirecting in 3 ...</div>
</div>
</div>
</body>
<script>
const qr = document.querySelector("#qr")
const success = document.querySelector("#lnauth-success")
const pubkey = document.querySelector("#lnauth-pubkey")
const countdown = document.querySelector("#lnauth-countdown")
const interval = setInterval(async () => {
const body = await fetch(`/api/session`)
.then((r) => r.json())
.catch(console.error)
if (body.pubkey) {
qr.setAttribute("hidden", true)
pubkey.textContent = body.pubkey.slice(0, 10)
success.removeAttribute("hidden")
clearInterval(interval)
let timer = 2
const redirect = setInterval(() => {
countdown.textContent = `Redirecting in ${timer--} ...`
if (timer === -1) {
window.location.href = "https://{{.PUBLIC_URL}}/";
}
}, 1000)
}
}, 1000)
</script>
</html>

View File

@ -1,82 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>delphi.market</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="/css/index.css" />
<link rel="stylesheet" href="/market.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#091833" />
{{ if eq .ENV "development" }}
<script defer src="/hotreload.js"></script>
{{ end }}
</head>
<body>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<a href="/">home</a>
{{ if .session }}
<a href='/user'>user</a>
{{ else }} <a href="/login">login</a> {{ end }}
</nav>
</header>
<div class="container flex flex-column text-center">
<code>
<strong>
<pre>
_ _
_ __ ___ __ _ _ __| | _____| |_
| '_ ` _ \ / _` | '__| |/ / _ \ __|
| | | | | | (_| | | | < __/ |_
|_| |_| |_|\__,_|_| |_|\_\___|\__|</pre>
</strong>
</code>
<div class="font-mono mb-1">{{.Description}}</div>
<div class="align-left">
<span class="font-mono mb-1"><strong>Order Book</strong></span>
<table class="w-100p mb-1">
<tr>
<th class="align-left"></th>
<th class="align-right"></th>
</tr>
{{ range .Orders }}
<tr class='{{ if eq .Side "BUY" }}yes{{ else }}no{{ end }}'>
<td class="align-center">{{ .Side }}</td>
<td class="align-center">{{ .Share.Description }}</td>
<td class="align-right">{{.Quantity}} @ {{.Price}} ⚡</td>
</tr>
{{ end }}
</table>
</div>
<hr />
<div class="align-left">
<span class="font-mono mb-1"><strong>Order Form</strong></span>
<form id="form" class="order-form" action="/market/{{$.Id}}/order" method="post">
<button id="buy" type="button" class="order-button yes w-100p selected">BUY</button>
<button id="sell" type="button" class="order-button no w-100p">SELL</button>
<input id="market-id" hidden name="market_id" value="{{$.Id}}" />
<input id="side" hidden name="side" value="BUY" />
<label>share</label>
<select name="share_id">
<option value="{{.YesShare.Id}}">YES</option>
<option value="{{.NoShare.Id}}">NO</option>
</select>
<label>quantity</label>
<input id="quantity" type="number" name="quantity" placeholder="quantity" />
<label>price [sats ⚡]</label>
<input id="price" type="number" name="price" placeholder="price" />
<label id="submit-label"></label>
<button type="submit">SUBMIT</button>
</form>
</div>
</div>
</body>
<script src="/order.js"></script>
</html>

View File

@ -1,106 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>delphi.market</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="/css/index.css" />
<link rel="stylesheet" href="/market.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#091833" />
{{ if eq .ENV "development" }}
<script defer src="/hotreload.js"></script>
{{ end }}
</head>
<body>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<a href="/">home</a>
{{ if .session }}
<a href='/user'>user</a>
{{ else }} <a href="/login">login</a> {{ end }}
</nav>
</header>
<div class="container flex flex-column text-center">
<code>
<strong>
<pre>
_ _ ___ ___ _ __
| | | / __|/ _ \ '__|
| |_| \__ \ __/ |
\__,_|___/\___|_| </pre>
</strong>
</code>
<div class="font-mono mb-1 align-left word-wrap">
<div class="mb-1">
You are: {{substr .session.Pubkey 0 8}}
</div>
<div>
<form class="align-left" action="/logout" method="post">
<button type="submit">logout</button>
</form>
</div>
</div>
<div class="align-left mb-1">
<span class="font-mono mb-1"><strong>Open Orders</strong></span>
<table class="w-100p mb-1">
<tr>
<th class="align-center">Market</th>
<th class="align-center"></th>
<th class="align-center"></th>
<th class="align-right"></th>
</tr>
{{ range .Orders }}
{{ if .Invoice.ConfirmedAt.Valid }}
<tr class='{{ if eq .Side "BUY" }}yes{{ else }}no{{ end }}'>
<td class="align-center">
<a href="/market/{{.Share.MarketId}}">{{.Share.MarketId}}</a>
</td>
<td class="align-center">{{.Side}}</td>
<td class="align-center">{{.Share.Description}}</td>
<td class="align-right">{{.Quantity}} @ {{.Price}} ⚡</td>
</tr>
{{ end }}
{{ end }}
</table>
</div>
<div class="align-left mb-1">
<span class="font-mono mb-1"><strong>Unpaid Orders</strong></span>
<table class="w-100p mb-1">
<tr>
<th class="align-center">Market</th>
<th class="align-center"></th>
<th class="align-center"></th>
<th class="align-right"></th>
<th class="align-center">Invoice</th>
</tr>
{{ range .Orders }}
{{ if not .Invoice.ConfirmedAt.Valid }}
<tr class='{{ if eq .Side "BUY" }}yes{{ else }}no{{ end }}'>
<td class="align-center">
<a href="/market/{{.Share.MarketId}}">{{.Share.MarketId}}</a>
</td>
<td class="align-center">{{.Side}}</td>
<td class="align-center">{{.Share.Description}}</td>
<td class="align-right">{{.Quantity}} @ {{.Price}} ⚡</td>
<td class="align-center"><a href="/invoice/{{.InvoiceId}}">invoice</a></td>
</tr>
{{ end }}
{{ end }}
</table>
</div>
</div>
<footer class="flex justify-center">
<div>
<hr />
<code><a href="https://github.com/ekzyis/delphi.market/commit/{{.COMMIT_LONG_SHA}}">{{.VERSION}}</a></code>
</div>
</footer>
</body>
</html>

View File

@ -29,17 +29,23 @@
@apply pb-1;
}
a {
a:not(.no-link),
button[hx-get],
button[hx-post] {
text-decoration: underline;
transition: background-color 150ms ease-in, color 150ms ease-in;
}
a:hover {
a:not(.no-link):hover,
button[hx-get]:hover,
button[hx-post]:hover {
background-color: var(--color);
color: var(--background-color);
}
nav a {
nav a,
button[hx-get],
button[hx-post] {
padding: 0 0.25em;
}
@ -64,10 +70,12 @@
@apply my-3
}
.login {
.login, .signup {
text-decoration: none !important;
transition: none !important;
padding: 0.25em 1em !important;
width: fit-content;
margin: 0 auto;
padding: 0.25em 1em;
border-radius: 5px;
font-weight: bold;
}

View File

@ -1,16 +0,0 @@
const marketId = document.querySelector("#market-id").value
const buyBtn = document.querySelector("#buy")
const sellBtn = document.querySelector("#sell")
const sideInput = document.querySelector("#side")
buyBtn.onclick = function (e) {
buyBtn.classList.add("selected")
sellBtn.classList.remove("selected")
sideInput.setAttribute("value", "BUY")
}
sellBtn.onclick = function(e) {
buyBtn.classList.remove("selected")
sellBtn.classList.add("selected")
sideInput.setAttribute("value", 'SELL')
}

View File

@ -1,47 +0,0 @@
.order-button {
border: none;
margin: auto;
border-radius: 4px;
font-size: 20px;
}
.order-form {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.sx-1 {
margin: 0 1rem;
padding: 0 1rem;
}
.yes {
background-color: rgba(20,158,97,.24);
color: #35df8d;
}
button.yes:hover {
background-color: #35df8d;
color: white;
}
button.yes.selected {
background-color: #35df8d;
color: white;
}
.no {
background-color: rgba(245,57,94,.24);
color: #ff7386;
}
button.no:hover {
background-color: #ff7386;
color: white;
}
button.no.selected {
background-color: #ff7386;
color: white;
}
th {
font-size: small;
}

View File

@ -8,58 +8,74 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcutil/bech32"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"git.ekzyis.com/ekzyis/delphi.market/env"
)
type LNAuth struct {
type LnAuth struct {
K1 string
LNURL string
}
type LNAuthResponse struct {
type LnAuthCallback struct {
K1 string `query:"k1"`
Sig string `query:"sig"`
Key string `query:"key"`
Tag string `query:"tag"`
}
func NewLNAuth() (*LNAuth, error) {
k1 := make([]byte, 32)
_, err := rand.Read(k1)
if err != nil {
func NewLnAuth(tag string) (*LnAuth, error) {
var (
k1 = make([]byte, 32)
k1hex string
url []byte
bech32Url []byte
lnurl string
err error
)
if _, err := rand.Read(k1); err != nil {
return nil, fmt.Errorf("rand.Read error: %w", err)
}
k1hex := hex.EncodeToString(k1)
url := []byte(fmt.Sprintf("https://%s/api/login/callback?tag=login&k1=%s&action=login", env.PublicURL, k1hex))
conv, err := bech32.ConvertBits(url, 8, 5, true)
if err != nil {
k1hex = hex.EncodeToString(k1)
url = []byte(fmt.Sprintf("https://%s/api/lnauth/callback?tag=%s&k1=%s&action=login", env.PublicURL, tag, k1hex))
if bech32Url, err = bech32.ConvertBits(url, 8, 5, true); err != nil {
return nil, fmt.Errorf("bech32.ConvertBits error: %w", err)
}
lnurl, err := bech32.Encode("lnurl", conv)
if err != nil {
if lnurl, err = bech32.Encode("lnurl", bech32Url); err != nil {
return nil, fmt.Errorf("bech32.Encode error: %w", err)
}
return &LNAuth{k1hex, lnurl}, nil
return &LnAuth{k1hex, lnurl}, nil
}
func VerifyLNAuth(r *LNAuthResponse) (bool, error) {
var k1Bytes, sigBytes, keyBytes []byte
k1Bytes, err := hex.DecodeString(r.K1)
if err != nil {
func VerifyLNAuth(r *LnAuthCallback) (bool, error) {
var (
k1Bytes, sigBytes, keyBytes []byte
key *secp256k1.PublicKey
err error
)
if k1Bytes, err = hex.DecodeString(r.K1); err != nil {
return false, fmt.Errorf("k1 decode error: %w", err)
}
sigBytes, err = hex.DecodeString(r.Sig)
if err != nil {
if sigBytes, err = hex.DecodeString(r.Sig); err != nil {
return false, fmt.Errorf("sig decode error: %w", err)
}
keyBytes, err = hex.DecodeString(r.Key)
if err != nil {
if keyBytes, err = hex.DecodeString(r.Key); err != nil {
return false, fmt.Errorf("key decode error: %w", err)
}
key, err := btcec.ParsePubKey(keyBytes)
if err != nil {
if key, err = btcec.ParsePubKey(keyBytes); err != nil {
return false, fmt.Errorf("key parse error: %w", err)
}
ecdsaKey := ecdsa.PublicKey{Curve: btcec.S256(), X: key.X(), Y: key.Y()}
return ecdsa.VerifyASN1(&ecdsaKey, k1Bytes, sigBytes), nil
}

View File

@ -25,6 +25,7 @@ var (
EnvContextKey RenderContextKey = "env"
SessionContextKey RenderContextKey = "session"
CommitContextKey RenderContextKey = "commit"
ReqPathContextKey RenderContextKey = "reqPath"
)
func RenderContext(sc Context, c echo.Context) context.Context {
@ -32,5 +33,6 @@ func RenderContext(sc Context, c echo.Context) context.Context {
ctx = context.WithValue(ctx, EnvContextKey, sc.Environment)
ctx = context.WithValue(ctx, SessionContextKey, c.Get("session"))
ctx = context.WithValue(ctx, CommitContextKey, sc.CommitShortSha)
ctx = context.WithValue(ctx, ReqPathContextKey, c.Request().URL.Path)
return ctx
}

View File

@ -1,9 +1,6 @@
package handler
import (
"net/http"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
"github.com/labstack/echo/v4"
@ -14,16 +11,3 @@ func HandleIndex(sc context.Context) echo.HandlerFunc {
return pages.Index().Render(context.RenderContext(sc, c), c.Response().Writer)
}
}
func HandleMarkets(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
markets []db.Market
err error
)
if err = sc.Db.FetchActiveMarkets(&markets); err != nil {
return err
}
return c.JSON(http.StatusOK, markets)
}
}

View File

@ -1,110 +0,0 @@
package handler
import (
"database/sql"
"net/http"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/lib"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4"
"github.com/lightningnetwork/lnd/lntypes"
)
func HandleInvoiceStatus(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
invoiceId string
invoice db.Invoice
u db.User
qr string
err error
)
invoiceId = c.Param("id")
if err = sc.Db.FetchInvoice(&db.FetchInvoiceWhere{Id: invoiceId}, &invoice); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound)
} else if err != nil {
return err
}
if u = c.Get("session").(db.User); invoice.Pubkey != u.Pubkey {
return echo.NewHTTPError(http.StatusUnauthorized)
}
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
return err
}
invoice.Preimage = ""
data := map[string]any{
"Id": invoice.Id,
"Msats": invoice.Msats,
"MsatsReceived": invoice.MsatsReceived,
"Hash": invoice.Hash,
"PaymentRequest": invoice.PaymentRequest,
"CreatedAt": invoice.CreatedAt,
"ExpiresAt": invoice.ExpiresAt,
"ConfirmedAt": invoice.ConfirmedAt,
"HeldSince": invoice.HeldSince,
"Description": invoice.Description,
"Qr": qr,
}
return c.JSON(http.StatusOK, data)
}
}
func HandleInvoice(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
invoiceId string
invoice db.Invoice
u db.User
hash lntypes.Hash
qr string
status string
err error
)
invoiceId = c.Param("id")
if err = sc.Db.FetchInvoice(&db.FetchInvoiceWhere{Id: invoiceId}, &invoice); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound)
} else if err != nil {
return err
}
if u = c.Get("session").(db.User); invoice.Pubkey != u.Pubkey {
return echo.NewHTTPError(http.StatusUnauthorized)
}
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
return err
}
go sc.Lnd.CheckInvoice(sc.Db, hash)
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
return err
}
if invoice.ConfirmedAt.Valid {
status = "Paid"
} else if time.Now().After(invoice.ExpiresAt) {
status = "Expired"
}
data := map[string]any{
"session": c.Get("session"),
"invoice": invoice,
"status": status,
"lnurl": invoice.PaymentRequest,
"qr": qr,
}
return c.Render(http.StatusOK, "invoice.html", data)
}
}
func HandleInvoices(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
u db.User
invoices []db.Invoice
err error
)
u = c.Get("session").(db.User)
if err = sc.Db.FetchUserInvoices(u.Pubkey, &invoices); err != nil {
return err
}
return c.JSON(http.StatusOK, invoices)
}
}

View File

@ -0,0 +1,57 @@
package handler_test
import (
"net/http"
"net/http/httptest"
"testing"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/handler"
"git.ekzyis.com/ekzyis/delphi.market/test"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func init() {
test.Init(&db)
}
func TestLnAuth(t *testing.T) {
var (
assert = assert.New(t)
e *echo.Echo
c echo.Context
sc context.Context
req *http.Request
rec *httptest.ResponseRecorder
cookies []*http.Cookie
sessionId string
dbSessionId string
err error
)
sc = context.Context{Db: db}
e, req, rec = test.HTTPMocks("GET", "/login", nil)
c = e.NewContext(req, rec)
err = handler.HandleLogin(sc)(c)
assert.NoErrorf(err, "handler returned error")
// Set-Cookie header present
cookies = rec.Result().Cookies()
assert.Equalf(len(cookies), 1, "wrong number of Set-Cookie headers")
assert.Equalf(cookies[0].Name, "session", "wrong cookie name")
// new challenge inserted
sessionId = cookies[0].Value
err = db.QueryRow("SELECT session_id FROM lnauth WHERE session_id = $1", sessionId).Scan(&dbSessionId)
if !assert.NoError(err) {
return
}
// inserted challenge matches cookie value
assert.Equalf(sessionId, dbSessionId, "wrong session id")
}
func TestLnAuthCallback(t *testing.T) {
t.Skip()
}

View File

@ -1,11 +1,6 @@
package handler
import (
"database/sql"
"net/http"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/auth"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
"github.com/labstack/echo/v4"
@ -16,65 +11,3 @@ func HandleLogin(sc context.Context) echo.HandlerFunc {
return pages.Login().Render(context.RenderContext(sc, c), c.Response().Writer)
}
}
// func HandleLogin(sc context.Context) echo.HandlerFunc {
// return func(c echo.Context) error {
// var (
// lnAuth *auth.LNAuth
// dbLnAuth db.LNAuth
// err error
// expires time.Time = time.Now().Add(60 * 60 * 24 * 365 * time.Second)
// qr string
// data map[string]any
// )
// if lnAuth, err = auth.NewLNAuth(); err != nil {
// return err
// }
// dbLnAuth = db.LNAuth{K1: lnAuth.K1, LNURL: lnAuth.LNURL}
// if err = sc.Db.CreateLNAuth(&dbLnAuth); err != nil {
// return err
// }
// c.SetCookie(&http.Cookie{Name: "session", HttpOnly: true, Path: "/", Value: dbLnAuth.SessionId, Secure: true, Expires: expires})
// if qr, err = lib.ToQR(lnAuth.LNURL); err != nil {
// return err
// }
// data = map[string]any{
// "lnurl": lnAuth.LNURL,
// "qr": qr,
// }
// return c.JSON(http.StatusOK, data)
// }
// }
func HandleLoginCallback(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
query auth.LNAuthResponse
sessionId string
err error
)
if err := c.Bind(&query); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
if err = sc.Db.FetchSessionId(query.K1, &sessionId); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "session not found"})
} else if err != nil {
return err
}
if ok, err := auth.VerifyLNAuth(&query); err != nil {
return err
} else if !ok {
return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "bad signature"})
}
if err = sc.Db.CreateUser(&db.User{Pubkey: query.Key}); err != nil {
return err
}
if err = sc.Db.CreateSession(&db.Session{Pubkey: query.Key, SessionId: sessionId}); err != nil {
return err
}
if err = sc.Db.DeleteLNAuth(&db.LNAuth{K1: query.K1}); err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"status": "OK"})
}
}

View File

@ -1,123 +0,0 @@
package handler_test
import (
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"testing"
db_ "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/auth"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/handler"
"git.ekzyis.com/ekzyis/delphi.market/test"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func init() {
test.Init(&db)
}
func TestLogin(t *testing.T) {
var (
assert = assert.New(t)
e *echo.Echo
c echo.Context
sc context.Context
req *http.Request
rec *httptest.ResponseRecorder
cookies []*http.Cookie
sessionId string
dbSessionId string
err error
)
sc = context.Context{Db: db}
e, req, rec = test.HTTPMocks("GET", "/login", nil)
c = e.NewContext(req, rec)
err = handler.HandleLogin(sc)(c)
assert.NoErrorf(err, "handler returned error")
// Set-Cookie header present
cookies = rec.Result().Cookies()
assert.Equalf(len(cookies), 1, "wrong number of Set-Cookie headers")
assert.Equalf(cookies[0].Name, "session", "wrong cookie name")
// new challenge inserted
sessionId = cookies[0].Value
err = db.QueryRow("SELECT session_id FROM lnauth WHERE session_id = $1", sessionId).Scan(&dbSessionId)
if !assert.NoError(err) {
return
}
// inserted challenge matches cookie value
assert.Equalf(sessionId, dbSessionId, "wrong session id")
}
func TestLoginCallback(t *testing.T) {
var (
assert = assert.New(t)
e *echo.Echo
c echo.Context
sc context.Context
req *http.Request
rec *httptest.ResponseRecorder
sk *secp256k1.PrivateKey
pk *secp256k1.PublicKey
lnAuth *auth.LNAuth
dbLnAuth *db_.LNAuth
u *db_.User
s *db_.Session
key string
sig string
err error
)
lnAuth, err = auth.NewLNAuth()
if !assert.NoErrorf(err, "error creating challenge") {
return
}
dbLnAuth = &db_.LNAuth{K1: lnAuth.K1, LNURL: lnAuth.LNURL}
err = db.CreateLNAuth(dbLnAuth)
if !assert.NoErrorf(err, "error inserting challenge") {
return
}
sk, pk, err = test.GenerateKeyPair()
if !assert.NoErrorf(err, "error generating keypair") {
return
}
sig, err = test.Sign(sk, lnAuth.K1)
if !assert.NoErrorf(err, "error signing k1") {
return
}
key = hex.EncodeToString(pk.SerializeCompressed())
sc = context.Context{Db: db}
e, req, rec = test.HTTPMocks("GET", fmt.Sprintf("/api/login?k1=%s&key=%s&sig=%s", lnAuth.K1, key, sig), nil)
c = e.NewContext(req, rec)
err = handler.HandleLoginCallback(sc)(c)
assert.NoErrorf(err, "handler returned error")
// user created
u = new(db_.User)
err = db.FetchUser(key, u)
if assert.NoErrorf(err, "error fetching user") {
assert.Equalf(u.Pubkey, key, "pubkeys do not match")
}
// session created
s = &db_.Session{SessionId: dbLnAuth.SessionId}
err = db.FetchSession(s)
if assert.NoErrorf(err, "error fetching session") {
assert.Equalf(s.Pubkey, u.Pubkey, "session pubkey does not match user pubkey")
}
// challenge deleted
err = db.FetchSessionId(u.Pubkey, &dbLnAuth.SessionId)
assert.ErrorIs(err, sql.ErrNoRows, "challenge not deleted")
}

View File

@ -1,31 +0,0 @@
package handler
import (
"net/http"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4"
)
func HandleLogout(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
cookie *http.Cookie
sessionId string
err error
)
if cookie, err = c.Cookie("session"); err != nil {
// cookie not found
return c.JSON(http.StatusNotFound, map[string]string{"reason": "session not found"})
}
sessionId = cookie.Value
if err = sc.Db.DeleteSession(&db.Session{SessionId: sessionId}); err != nil {
return err
}
// tell browser that cookie is expired and thus can be deleted
c.SetCookie(&http.Cookie{Name: "session", HttpOnly: true, Path: "/", Value: sessionId, Secure: true, Expires: time.Now()})
return c.JSON(http.StatusSeeOther, map[string]string{"status": "OK"})
}
}

View File

@ -1,20 +1,9 @@
package handler_test
import (
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"testing"
db_ "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/handler"
"git.ekzyis.com/ekzyis/delphi.market/test"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func init() {
@ -22,51 +11,5 @@ func init() {
}
func TestLogout(t *testing.T) {
var (
assert = assert.New(t)
e *echo.Echo
c echo.Context
sc context.Context
req *http.Request
rec *httptest.ResponseRecorder
pk *secp256k1.PublicKey
s *db_.Session
key string
err error
)
sc = context.Context{Db: db}
e, req, rec = test.HTTPMocks("POST", "/logout", nil)
_, pk, err = test.GenerateKeyPair()
if !assert.NoErrorf(err, "error generating keypair") {
return
}
key = hex.EncodeToString(pk.SerializeCompressed())
err = sc.Db.CreateUser(&db_.User{Pubkey: key})
if !assert.NoErrorf(err, "error creating user") {
return
}
s = &db_.Session{Pubkey: key}
err = sc.Db.QueryRow("SELECT encode(gen_random_uuid()::text::bytea, 'base64')").Scan(&s.SessionId)
if !assert.NoErrorf(err, "error creating session id") {
return
}
// create session
err = sc.Db.CreateSession(s)
if !assert.NoErrorf(err, "error creating session") {
return
}
// session authentication via cookie
req.Header.Add("cookie", fmt.Sprintf("session=%s", s.SessionId))
c = e.NewContext(req, rec)
err = handler.HandleLogout(sc)(c)
assert.NoErrorf(err, "handler returned error")
// session must have been deleted
err = sc.Db.FetchSession(s)
assert.ErrorIsf(err, sql.ErrNoRows, "session not deleted")
t.Skip()
}

View File

@ -1,475 +0,0 @@
package handler
import (
context_ "context"
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/lib"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4"
"github.com/lightningnetwork/lnd/lntypes"
)
func HandleMarket(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
marketId int64
market db.Market
shares []db.Share
orders []db.Order
err error
data map[string]any
u db.User
tx *sql.Tx
)
if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Bad Request")
}
if err = sc.Db.FetchMarket(int(marketId), &market); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
} else if err != nil {
return err
}
if err = sc.Db.FetchShares(market.Id, &shares); err != nil {
return err
}
if err = sc.Db.FetchOrders(&db.FetchOrdersWhere{MarketId: market.Id, Confirmed: true}, &orders); err != nil {
return err
}
data = map[string]any{
"Id": market.Id,
"Pubkey": market.Pubkey,
"Description": market.Description,
"SettledAt": market.SettledAt,
"Shares": shares,
}
if session := c.Get("session"); session != nil {
u = session.(db.User)
ctx, cancel := context_.WithTimeout(context_.TODO(), 10*time.Second)
defer cancel()
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
return err
}
defer tx.Commit()
uBalance := make(map[string]any)
if err = sc.Db.FetchUserBalance(tx, ctx, int(marketId), u.Pubkey, &uBalance); err != nil {
tx.Rollback()
return err
}
lib.Merge(&data, &map[string]any{"user": uBalance})
}
return c.JSON(http.StatusOK, data)
}
}
func HandleCreateMarket(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
tx *sql.Tx
u db.User
m db.Market
invoice *db.Invoice
msats int64
invDescription string
data map[string]any
qr string
hash lntypes.Hash
err error
)
if err := c.Bind(&m); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
// transaction start
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
return err
}
defer tx.Commit()
u = c.Get("session").(db.User)
m.Pubkey = u.Pubkey
msats = 1000
// TODO: add [market:<id>] for redirect after payment
invDescription = fmt.Sprintf("create market \"%s\"", m.Description)
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, u.Pubkey, msats, invDescription); err != nil {
tx.Rollback()
return err
}
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
tx.Rollback()
return err
}
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
tx.Rollback()
return err
}
m.InvoiceId = invoice.Id
if err := sc.Db.CreateMarket(tx, ctx, &m); err != nil {
tx.Rollback()
return err
}
// need to commit before starting to poll invoice status
tx.Commit()
go sc.Lnd.CheckInvoice(sc.Db, hash)
data = map[string]any{
"id": invoice.Id,
"bolt11": invoice.PaymentRequest,
"amount": msats,
"qr": qr,
}
return c.JSON(http.StatusPaymentRequired, data)
}
}
func HandleMarketOrders(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
marketId int64
orders []db.Order
err error
)
if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Bad Request")
}
if err = sc.Db.FetchMarketOrders(marketId, &orders); err != nil {
return err
}
return c.JSON(http.StatusOK, orders)
}
}
func HandleOrder(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
tx *sql.Tx
u db.User
o db.Order
s db.Share
m db.Market
invoice *db.Invoice
msats int64
description string
data map[string]any
qr string
hash lntypes.Hash
err error
)
if err := c.Bind(&o); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
u = c.Get("session").(db.User)
o.Pubkey = u.Pubkey
msats = o.Quantity * o.Price * 1000
// transaction start
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
return err
}
defer tx.Commit()
if err = sc.Db.FetchShare(tx, ctx, o.ShareId, &s); err != nil {
tx.Rollback()
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)
if o.Side == "BUY" {
// BUY orders require payment
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, o.Pubkey, msats, description); err != nil {
tx.Rollback()
return err
}
// Create QR code to pay HODL invoice
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
tx.Rollback()
return err
}
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
tx.Rollback()
return err
}
// Create (unconfirmed) order
o.InvoiceId.String = invoice.Id
if err := sc.Db.CreateOrder(tx, ctx, &o); err != nil {
tx.Rollback()
return err
}
// need to commit before starting to poll invoice status
tx.Commit()
go sc.Lnd.CheckInvoice(sc.Db, hash)
data = map[string]any{
"id": invoice.Id,
"bolt11": invoice.PaymentRequest,
"amount": msats,
"qr": qr,
}
return c.JSON(http.StatusPaymentRequired, data)
}
// sell order: check user balance
balance := make(map[string]any)
if err = sc.Db.FetchUserBalance(tx, ctx, s.MarketId, o.Pubkey, &balance); err != nil {
return err
}
if balance[s.Description].(int) < int(o.Quantity) {
tx.Rollback()
return c.JSON(http.StatusBadRequest, nil)
}
// SELL orders don't require payment by user
if err := sc.Db.CreateOrder(tx, ctx, &o); err != nil {
tx.Rollback()
return err
}
tx.Commit()
return c.JSON(http.StatusCreated, nil)
}
}
func HandleDeleteOrder(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
orderId string
tx *sql.Tx
u db.User
o db.Order
msats int64
err error
)
if orderId = c.Param("id"); orderId == "" {
return echo.NewHTTPError(http.StatusBadRequest)
}
u = c.Get("session").(db.User)
// transaction start
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
return err
}
defer tx.Commit()
if err = sc.Db.FetchOrder(tx, ctx, orderId, &o); err != nil {
tx.Rollback()
return err
}
if u.Pubkey != o.Pubkey {
// order does not belong to user
tx.Rollback()
return echo.NewHTTPError(http.StatusForbidden)
}
if o.OrderId.Valid {
// order already settled
tx.Rollback()
return echo.NewHTTPError(http.StatusBadRequest)
}
if o.DeletedAt.Valid {
// order already deleted
tx.Rollback()
return echo.NewHTTPError(http.StatusBadRequest)
}
if o.Invoice.ConfirmedAt.Valid {
// order already paid: we need to move paid sats to user balance before deleting the order
// TODO update order and session on client
msats = o.Invoice.MsatsReceived
if res, err := tx.ExecContext(ctx, "UPDATE users SET msats = msats + $1 WHERE pubkey = $2", msats, u.Pubkey); err != nil {
tx.Rollback()
return err
} else {
// make sure exactly one row was affected
if rowsAffected, err := res.RowsAffected(); err != nil {
tx.Rollback()
return err
} else if rowsAffected != 1 {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError)
}
}
}
if _, err = tx.ExecContext(ctx, "UPDATE orders SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1", o.Id); err != nil {
tx.Rollback()
return err
}
tx.Commit()
return c.JSON(http.StatusOK, nil)
}
}
func HandleOrders(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
u db.User
orders []db.Order
err error
)
u = c.Get("session").(db.User)
if err = sc.Db.FetchUserOrders(u.Pubkey, &orders); err != nil {
return err
}
return c.JSON(http.StatusOK, orders)
}
}
func HandleMarketStats(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
marketId int64
stats db.MarketStats
err error
)
if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Bad Request")
}
if err = sc.Db.FetchMarketStats(marketId, &stats); err != nil {
return err
}
return c.JSON(http.StatusOK, stats)
}
}
func HandleMarketSettlement(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
marketId int64
market db.Market
s db.Share
tx *sql.Tx
u db.User
query string
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()
// refund users for pending BUY orders
query = "" +
"UPDATE users u SET msats = msats + pending_orders.msats_received FROM ( " +
" SELECT 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 " +
// an order is pending if it wasn't canceled and wasn't matched yet
// (the o.side = 'BUY' shouldn't be necessary since i.msats_received will be NULL for SELL orders anyway
// but added here for clarification anyway)
" AND o.side = 'BUY' AND o.deleted_at IS NULL AND o.order_id IS NULL " +
") AS pending_orders WHERE pending_orders.pubkey = u.pubkey"
if _, err = tx.ExecContext(ctx, query, marketId); err != nil {
tx.Rollback()
return err
}
// now cancel pending orders
query = "" +
"UPDATE orders o SET deleted_at = CURRENT_TIMESTAMP WHERE id IN ( " +
// basically same subquery as above
" SELECT o.id FROM orders o " +
" JOIN shares s ON s.id = o.share_id " +
// again, orders are pending if they weren't canceled and weren't matched yet
" WHERE s.market_id = $1 AND o.deleted_at IS NULL and o.order_id IS NULL " +
")"
if _, err = tx.ExecContext(ctx, query, marketId); err != nil {
tx.Rollback()
return err
}
// payout
query = "" +
// * 100 since winning shares expire at 100 sats per share
// * 1000 to convert sats to msats
"UPDATE users u SET msats = msats + (user_shares.quantity * 100 * 1000) " +
"FROM ( " +
" SELECT o.pubkey, o.share_id, " +
" SUM(CASE WHEN o.side = 'BUY' THEN o.quantity ELSE -o.quantity END) AS quantity " +
" FROM orders o " +
" LEFT JOIN invoices i ON i.id = o.invoice_id " +
" JOIN shares s ON s.id = o.share_id " +
// only consider uncanceled orders for winning shares
" WHERE s.market_id = $1 AND o.deleted_at IS NULL AND s.id = $2 " +
// BUY orders must be paid and be matched. SELL orders must simply not be canceled to be considered.
" 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 " +
") AS user_shares WHERE user_shares.pubkey = u.pubkey"
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
}
if _, err = tx.ExecContext(ctx, "UPDATE shares SET win = (id = $1) WHERE market_id = $2", s.Id, marketId); err != nil {
tx.Rollback()
return err
}
tx.Commit()
return c.JSON(http.StatusOK, nil)
}
}

View File

@ -1,30 +0,0 @@
package handler
import (
"database/sql"
"net/http"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4"
)
func HandleCheckSession(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
cookie *http.Cookie
s db.Session
err error
)
if cookie, err = c.Cookie("session"); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"reason": "cookie required"})
}
s = db.Session{SessionId: cookie.Value}
if err = sc.Db.FetchSession(&s); err == sql.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{"reason": "session not found"})
} else if err != nil {
return c.JSON(http.StatusInternalServerError, nil)
}
return c.JSON(http.StatusOK, map[string]any{"pubkey": s.Pubkey, "msats": s.Msats})
}
}

View File

@ -0,0 +1,125 @@
package handler
import (
"database/sql"
"net/http"
"time"
"git.ekzyis.com/ekzyis/delphi.market/lib"
"git.ekzyis.com/ekzyis/delphi.market/server/auth"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
"github.com/labstack/echo/v4"
)
func HandleSignup(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Param("method") == "lightning" {
return LnAuthSignup(sc, c)
}
return pages.Signup().Render(context.RenderContext(sc, c), c.Response().Writer)
}
}
func LnAuthSignup(sc context.Context, c echo.Context) error {
var (
db = sc.Db
ctx = c.Request().Context()
lnAuth *auth.LnAuth
sessionId string
// sessions expire in 30 days. TODO: refresh sessions
expires = time.Now().Add(60 * 60 * 24 * 30 * time.Second)
qr string
err error
)
if lnAuth, err = auth.NewLnAuth("signup"); err != nil {
return err
}
if err = db.QueryRowContext(
ctx,
"INSERT INTO lnauth(k1, lnurl) VALUES($1, $2) RETURNING session_id",
lnAuth.K1, lnAuth.LNURL).Scan(&sessionId); err != nil {
return err
}
if qr, err = lib.ToQR(lnAuth.LNURL); err != nil {
return err
}
c.SetCookie(&http.Cookie{
Name: "session",
HttpOnly: true,
Path: "/",
Value: sessionId,
Secure: true,
Expires: expires,
})
return pages.LnAuthSignup(qr, lnAuth.LNURL).Render(context.RenderContext(sc, c), c.Response().Writer)
}
func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
db = sc.Db
tx *sql.Tx
ctx = c.Request().Context()
query auth.LnAuthCallback
sessionId string
userId int
ok bool
err error
)
if err = c.Bind(&query); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
return err
}
err = tx.QueryRow("SELECT session_id FROM lnauth WHERE k1 = $1 LIMIT 1", query.K1).Scan(&sessionId)
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "session not found"})
} else if err != nil {
return err
}
ok, err = auth.VerifyLNAuth(&query)
if err != nil {
return err
} else if !ok {
return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "bad signature"})
}
if query.Tag == "signup" {
if err = tx.QueryRow("INSERT INTO users(ln_pubkey) VALUES ($1) RETURNING id").Scan(&userId); err != nil {
return err
}
} else if query.Tag == "login" {
err = tx.QueryRow("SELECT id FROM users WHERE ln_pubkey = $1", query.Key).Scan(&userId)
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "user not found"})
} else if err != nil {
return err
}
} else {
return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "tag must be signup or login"})
}
if _, err = tx.Exec("INSERT INTO sessions(user_id, session_id) VALUES($1, $2)", userId, sessionId); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM lnauth WHERE k1 = $1", query.K1); err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"status": "OK"})
}
}

View File

@ -1,30 +0,0 @@
package handler
import (
"net/http"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4"
)
func HandleUser(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
u db.User
orders []db.Order
err error
data map[string]any
)
u = c.Get("session").(db.User)
if err = sc.Db.FetchOrders(&db.FetchOrdersWhere{Pubkey: u.Pubkey}, &orders); err != nil {
return err
}
data = map[string]any{
"session": c.Get("session"),
"user": u,
"Orders": orders,
}
return c.Render(http.StatusOK, "user.html", data)
}
}

View File

@ -1,79 +0,0 @@
package handler
import (
context_ "context"
"database/sql"
"net/http"
"strings"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4"
"github.com/lightningnetwork/lnd/zpay32"
)
func HandleWithdrawal(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
w db.Withdrawal
u db.User
inv *zpay32.Invoice
tx *sql.Tx
err error
)
if err := c.Bind(&w); err != nil {
code := http.StatusBadRequest
return c.JSON(code, map[string]any{"status": code, "reason": "bolt11 required"})
}
if inv, err = zpay32.Decode(w.Bolt11, sc.Lnd.ChainParams); err != nil {
code := http.StatusBadRequest
return c.JSON(code, map[string]any{"status": code, "reason": "zpay32 decode error"})
}
// transaction start
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
return err
}
defer tx.Commit()
u = c.Get("session").(db.User)
w.Pubkey = u.Pubkey
// TODO deduct network fee from user balance
if u.Msats < int64(*inv.MilliSat) {
tx.Rollback()
code := http.StatusBadRequest
return c.JSON(code, map[string]any{"status": code, "reason": "insufficient balance"})
}
// create withdrawal
if err = sc.Db.CreateWithdrawal(tx, ctx, &w); err != nil {
tx.Rollback()
if strings.Contains(err.Error(), "violates unique constraint") {
code := http.StatusBadRequest
return c.JSON(code, map[string]any{"status": code, "reason": "bolt11 already submitted"})
}
return err
}
// pay invoice via LND
if err = sc.Lnd.PayInvoice(tx, w.Bolt11); err != nil {
tx.Rollback()
return err
}
// deduct balance from user
if _, err = tx.ExecContext(ctx, "UPDATE users SET msats = msats - $1 WHERE pubkey = $2", int64(*inv.MilliSat), u.Pubkey); err != nil {
tx.Rollback()
return err
}
tx.Commit()
return c.JSON(http.StatusOK, nil)
}
}

View File

@ -1,11 +1,8 @@
package middleware
import (
"database/sql"
"net/http"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4"
)
@ -13,27 +10,24 @@ import (
func Session(sc context.Context) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
var (
cookie *http.Cookie
err error
s *db.Session
u *db.User
)
if cookie, err = c.Cookie("session"); err != nil {
// cookie not found
return next(c)
}
s = &db.Session{SessionId: cookie.Value}
if err = sc.Db.FetchSession(s); err == nil {
// session found
u = &db.User{Pubkey: s.Pubkey, Msats: s.Msats, LastSeen: time.Now()}
if err = sc.Db.UpdateUser(u); err != nil {
return err
}
c.Set("session", *u)
} else if err != sql.ErrNoRows {
return err
}
// TODO: implement session middleware
// var (
// cookie *http.Cookie
// err error
// s *db.Session
// u *db.User
// )
// if cookie, err = c.Cookie("session"); err != nil {
// // cookie not found
// return next(c)
// }
// s = &db.Session{SessionId: cookie.Value}
// if err = sc.Db.FetchSession(s); err == nil {
// // session found
// c.Set("session", *u)
// } else if err != sql.ErrNoRows {
// return err
// }
return next(c)
}
}

View File

@ -6,10 +6,9 @@ templ About() {
<html>
@components.Head()
<body class="container">
@components.Header()
<div class="flex flex-col text-center">
@components.Nav()
<div id="content" class="flex flex-col">
@components.Figlet("random", "about")
</div>
<div class="flex flex-col mb-3">
<h1>📈 Prediction market?</h1>
<p>Here is an animated corgi that explains everything you need to know about prediction markets in 7 minutes:</p>
@ -69,6 +68,7 @@ templ About() {
>nostr</a>
</div>
</div>
</div>
@components.Footer()
</body>
</html>

View File

@ -13,6 +13,16 @@ templ Head() {
<link rel="stylesheet" href="/css/tailwind.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#091833"/>
<meta
name="htmx-config"
content='{
"responseHandling": [
{ "code": "204", "swap": false },
{ "code": "[23]..", "swap": true },
{ "code": "[45]..", "swap": true, "error": true }
]
}'
/>
<script src="/js/htmx.js" integrity="sha384-Xh+GLLi0SMFPwtHQjT72aPG19QvKB8grnyRbYBNIdHWc2NkCrz65jlU7YrzO6qRp" crossorigin="anonymous"></script>
if ctx.Value(c.EnvContextKey) == "development" {
<script defer src="/js/hotreload.js"></script>

View File

@ -1,21 +0,0 @@
package components
import c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
templ Header() {
<header class="mt-3">
<nav class="flex flex-row">
<div>
<a href="/">home</a>
</div>
<div class="ms-auto">
<a href="/about">about</a>
if ctx.Value(c.SessionContextKey) != nil {
<a href="/user">user</a>
} else {
<a href="/login">login</a>
}
</div>
</nav>
</header>
}

View File

@ -0,0 +1,21 @@
package components
import c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
templ Nav() {
<header class="mt-3">
<nav class="flex flex-row" hx-target="#content" hx-swap="outerHTML" hx-select="#content" hx-push-url="true">
<div>
<button hx-get="/">home</button>
</div>
<div class="ms-auto">
<button hx-get="/about">about</button>
if ctx.Value(c.SessionContextKey) != nil {
<button hx-get="/user">user</button>
} else {
<button hx-get="/signup">signup</button>
}
</div>
</nav>
</header>
}

View File

@ -10,8 +10,8 @@ templ Error(code int) {
<html>
@components.Head()
<body class="container">
@components.Header()
<div class="flex flex-col text-center">
@components.Nav()
<div id="content" class="flex flex-col text-center">
@components.Figlet("random", strconv.Itoa(code))
<div class="font-mono mb-3">{ http.StatusText(code) }</div>
</div>

View File

@ -6,8 +6,8 @@ templ Index() {
<html>
@components.Head()
<body class="container">
@components.Header()
<div class="flex flex-col text-center">
@components.Nav()
<div id="content" class="flex flex-col text-center">
@components.Figlet("random", "delphi")
<div class="font-mono my-3">A prediction market using the lightning network</div>
</div>

View File

@ -0,0 +1,32 @@
package pages
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
templ LnAuthSignup(qr string, lnurl string) {
<html>
@components.Head()
<body class="container">
@components.Nav()
<div id="content" class="flex flex-col text-center">
@components.Figlet("random", "signup")
<small><code>with lightning</code></small>
<div
class="flex flex-col my-3 text-center"
hx-target="#content"
hx-swap="outerHTML"
hx-select="#content"
hx-push-url="true"
>
<a
class="mx-auto no-link"
href={ templ.SafeURL("lightning:" + lnurl) }
>
<img src={ "data:image/jpeg;base64," + qr }/>
</a>
<small class="mx-auto w-[256px] my-1 break-words">{ lnurl }</small>
</div>
</div>
@components.Footer()
</body>
</html>
}

View File

@ -6,16 +6,42 @@ templ Login() {
<html>
@components.Head()
<body class="container">
@components.Header()
<div class="flex flex-col text-center">
@components.Nav()
<div id="content" class="flex flex-col text-center">
@components.Figlet("random", "login")
<div class="flex flex-col mb-3 text-center">
<button class="flex login lightning my-3 items-center">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="var(--black)"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19 10.1907L8.48754 21L12.6726 12.7423H5L14.6157 3L11.5267 10.2835L19 10.1907Z"></path>
</svg>
login with lightning
</button>
<button class="flex login nostr my-3 items-center">
<svg
class="me-1"
width="16"
height="16"
viewBox="0 0 875 875"
fill="var(--white)"
xmlns="http://www.w3.org/2000/svg"
>
<path
class="cls-1"
d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"
></path>
</svg>
login with nostr
</button>
</div>
<div class="flex flex-col mb-3 text-center">
<button class="login lightning my-3">login with lightning</button>
<button class="login nostr my-3">login with nostr</button>
<small><a class="text-muted" href="/signup">first time?</a></small>
</div>
<div class="flex flex-col mb-3 text-center">
<small><a class="text-muted" href="/signup">new here?</a></small>
</div>
@components.Footer()
</body>

View File

@ -0,0 +1,54 @@
package pages
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
templ Signup() {
<html>
@components.Head()
<body class="container">
@components.Nav()
<div id="content" class="flex flex-col text-center">
@components.Figlet("random", "signup")
<div
class="flex flex-col mb-3 text-center"
hx-target="#content"
hx-swap="outerHTML"
hx-select="#content"
hx-push-url="true"
>
<button class="flex signup lightning my-3 items-center" hx-get="/signup/lightning">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="var(--black)"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19 10.1907L8.48754 21L12.6726 12.7423H5L14.6157 3L11.5267 10.2835L19 10.1907Z"></path>
</svg>
signup with lightning
</button>
<button class="flex signup nostr my-3 items-center">
<svg
class="me-1"
width="16"
height="16"
viewBox="0 0 875 875"
fill="var(--white)"
xmlns="http://www.w3.org/2000/svg"
>
<path
class="cls-1"
d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"
></path>
</svg>signup with nostr
</button>
</div>
<div class="flex flex-col mb-3 text-center">
<small><a class="text-muted" href="/login">not your first time?</a></small>
</div>
</div>
@components.Footer()
</body>
</html>
}

View File

@ -5,15 +5,17 @@ import (
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/handler"
"git.ekzyis.com/ekzyis/delphi.market/server/router/middleware"
)
type Context = context.Context
func Init(e *echo.Echo, sc Context) {
e.Use(middleware.Session(sc))
// e.Use(middleware.Session(sc))
e.GET("/", handler.HandleIndex(sc))
e.GET("/about", handler.HandleAbout(sc))
e.GET("/login", handler.HandleLogin(sc))
e.GET("/signup", handler.HandleSignup(sc))
e.GET("/signup/:method", handler.HandleSignup(sc))
e.GET("/api/lnauth/callback", handler.HandleLnAuthCallback(sc))
}