Merge branch 'htmx-templ-rewrite' into develop
|
@ -7,3 +7,9 @@ delphi.market
|
||||||
# required for hot reload
|
# required for hot reload
|
||||||
public/hotreload
|
public/hotreload
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# templ
|
||||||
|
*_templ.go
|
||||||
|
|
||||||
|
# tailwindcss
|
||||||
|
public/css/tailwind.css
|
||||||
|
|
9
Makefile
|
@ -1,14 +1,21 @@
|
||||||
.PHONY: build run test
|
.PHONY: build run test
|
||||||
|
|
||||||
SOURCE := $(shell find db env lib lnd pages public server -type f) main.go
|
SOURCE := $(shell find db env lib lnd public server -type f) main.go
|
||||||
|
|
||||||
build: delphi.market
|
build: delphi.market
|
||||||
|
|
||||||
delphi.market: $(SOURCE)
|
delphi.market: $(SOURCE)
|
||||||
|
tailwindcss -i public/css/_tw-input.css -o public/css/tailwind.css
|
||||||
|
templ generate -path server/router/pages
|
||||||
go build -o delphi.market .
|
go build -o delphi.market .
|
||||||
|
|
||||||
run:
|
run:
|
||||||
|
tailwindcss -i public/css/_tw-input.css -o public/css/tailwind.css
|
||||||
|
templ generate -path server/router/pages
|
||||||
go run .
|
go run .
|
||||||
|
|
||||||
|
dev:
|
||||||
|
bash hotreload.sh
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -count=1 ./server/router/handler/...
|
go test -v -count=1 ./server/router/handler/...
|
||||||
|
|
6
db/db.go
|
@ -13,7 +13,7 @@ type DB struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
initSqlPath = "./db/init.sql"
|
schemaPath = "./db/schema.sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(dbUrl string) (*DB, error) {
|
func New(dbUrl string) (*DB, error) {
|
||||||
|
@ -42,7 +42,7 @@ func (db *DB) Reset(dbName string) error {
|
||||||
if err = db.Clear(dbName); err != nil {
|
if err = db.Clear(dbName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if f, err = ioutil.ReadFile(initSqlPath); err != nil {
|
if f, err = ioutil.ReadFile(schemaPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = db.Exec(string(f)); err != nil {
|
if _, err = db.Exec(string(f)); err != nil {
|
||||||
|
@ -53,7 +53,7 @@ func (db *DB) Reset(dbName string) error {
|
||||||
|
|
||||||
func (db *DB) Clear(dbName string) error {
|
func (db *DB) Clear(dbName string) error {
|
||||||
var (
|
var (
|
||||||
tables = []string{"lnauth", "users", "sessions", "markets", "shares", "invoices", "order_side", "orders", "matches"}
|
tables = []string{"lnauth", "users", "sessions", "invoices", "markets", "shares", "invoices", "order_side", "orders", "withdrawals"}
|
||||||
sql []string
|
sql []string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
68
db/init.sql
|
@ -1,67 +1 @@
|
||||||
CREATE TABLE lnauth(
|
CREATE DATABASE "delphi_test";
|
||||||
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,
|
|
||||||
msats BIGINT NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE sessions(
|
|
||||||
pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
|
||||||
session_id VARCHAR(48)
|
|
||||||
);
|
|
||||||
CREATE TABLE invoices(
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
|
||||||
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,
|
|
||||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
confirmed_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
held_since TIMESTAMP WITH TIME ZONE,
|
|
||||||
description TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE markets(
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
CREATE EXTENSION "uuid-ossp";
|
|
||||||
CREATE TABLE shares(
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
market_id INTEGER NOT NULL REFERENCES markets(id),
|
|
||||||
win BOOLEAN,
|
|
||||||
description TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TYPE order_side AS ENUM ('BUY', 'SELL');
|
|
||||||
CREATE TABLE orders(
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
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),
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
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(),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
|
||||||
bolt11 TEXT NOT NULL UNIQUE,
|
|
||||||
paid_at TIMESTAMP WITH TIME ZONE
|
|
||||||
);
|
|
||||||
|
|
106
db/invoice.go
|
@ -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
|
|
||||||
}
|
|
18
db/lnauth.go
|
@ -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
|
|
||||||
}
|
|
353
db/market.go
|
@ -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, ×tamp, &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
|
|
||||||
}
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
CREATE TABLE users(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL DEFAULT LEFT(md5(random()::text), 8),
|
||||||
|
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(
|
||||||
|
id VARCHAR(48) PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
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 SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
msats BIGINT NOT NULL,
|
||||||
|
msats_received BIGINT,
|
||||||
|
hash TEXT NOT NULL UNIQUE,
|
||||||
|
bolt11 TEXT 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,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE markets(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
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),
|
||||||
|
invoice_id INTEGER NOT NULL UNIQUE REFERENCES invoices(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shares(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
market_id INTEGER NOT NULL REFERENCES markets(id),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
win BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE order_side AS ENUM ('BUY', 'SELL');
|
||||||
|
|
||||||
|
CREATE TABLE orders(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
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 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 SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
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
|
||||||
|
);
|
|
@ -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
|
|
||||||
}
|
|
80
db/types.go
|
@ -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
|
|
||||||
}
|
|
||||||
)
|
|
17
db/user.go
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -12,7 +12,8 @@ services:
|
||||||
- 127.0.0.1:5432:5432
|
- 127.0.0.1:5432:5432
|
||||||
volumes:
|
volumes:
|
||||||
- delphi:/var/lib/postgresql/data
|
- delphi:/var/lib/postgresql/data
|
||||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
- ./db/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||||
- ./postgresql.conf:/var/lib/postgresql/data/postgresql.conf # for some reason this can't be mounted on first run
|
- ./postgresql.conf:/var/lib/postgresql/data/postgresql.conf # for some reason this can't be mounted on first run
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
@ -0,0 +1,512 @@
|
||||||
|
flf2a$ 5 4 20 15 1
|
||||||
|
Square by Chris Gill, 30-JUN-94 -- based on .sig of Jeb Hagan.
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
|__|@
|
||||||
|
|__|@
|
||||||
|
@@
|
||||||
|
____ @
|
||||||
|
| | |@
|
||||||
|
|_|_|@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
_____ @
|
||||||
|
_| | |_ @
|
||||||
|
|_ _|@
|
||||||
|
|_ _|@
|
||||||
|
|__|__| @@
|
||||||
|
__,-,__ @
|
||||||
|
| ' '__|@
|
||||||
|
|__ |@
|
||||||
|
|_______|@
|
||||||
|
|_| @@
|
||||||
|
__ ___ @
|
||||||
|
|__| |@
|
||||||
|
| __|@
|
||||||
|
|___|__|@
|
||||||
|
@@
|
||||||
|
__,-,__ @
|
||||||
|
| ' '__|@
|
||||||
|
| __|@
|
||||||
|
|_______|@
|
||||||
|
|_| @@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
,' _|@
|
||||||
|
| | @
|
||||||
|
| |_ @
|
||||||
|
`.___|@@
|
||||||
|
___ @
|
||||||
|
|_ `.@
|
||||||
|
| |@
|
||||||
|
_| |@
|
||||||
|
|___,'@@
|
||||||
|
__ _ __ @
|
||||||
|
| | | |@
|
||||||
|
> < @
|
||||||
|
|__|_|__|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
_| |_ @
|
||||||
|
|_ _|@
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
|_|@@
|
||||||
|
@
|
||||||
|
______ @
|
||||||
|
|______|@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
__ @
|
||||||
|
|__|@
|
||||||
|
@@
|
||||||
|
___@
|
||||||
|
/ /@
|
||||||
|
,' ,' @
|
||||||
|
/__/ @
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| |@
|
||||||
|
| -- |@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
____ @
|
||||||
|
|_ | @
|
||||||
|
_| |_ @
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
|__ |@
|
||||||
|
| __|@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
|__ |@
|
||||||
|
|__ |@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
_____ @
|
||||||
|
| | | @
|
||||||
|
|__ |@
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| __|@
|
||||||
|
|__ |@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| __|@
|
||||||
|
| __ |@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| |@
|
||||||
|
|_ |@
|
||||||
|
|____|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| __ |@
|
||||||
|
| __ |@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| __ |@
|
||||||
|
|__ |@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
|__|@
|
||||||
|
__ @
|
||||||
|
|__|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
|__|@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
|_|@@
|
||||||
|
__ @
|
||||||
|
,' _|@
|
||||||
|
/ / @
|
||||||
|
\ \_ @
|
||||||
|
`.__|@@
|
||||||
|
@
|
||||||
|
______ @
|
||||||
|
|______|@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
|_ `. @
|
||||||
|
\ \@
|
||||||
|
_/ /@
|
||||||
|
|__,' @@
|
||||||
|
_____ @
|
||||||
|
|__ |@
|
||||||
|
', ,-'@
|
||||||
|
|--| @
|
||||||
|
'--' @@
|
||||||
|
_________ @
|
||||||
|
| ___ |@
|
||||||
|
| | _ |@
|
||||||
|
| |______|@
|
||||||
|
|_________|@@
|
||||||
|
_______ @
|
||||||
|
| _ |@
|
||||||
|
| |@
|
||||||
|
|___|___|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| __ \@
|
||||||
|
| __ <@
|
||||||
|
|______/@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| |@
|
||||||
|
| ---|@
|
||||||
|
|______|@
|
||||||
|
@@
|
||||||
|
_____ @
|
||||||
|
| \ @
|
||||||
|
| -- |@
|
||||||
|
|_____/ @
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| ___|@
|
||||||
|
| ___|@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| ___|@
|
||||||
|
| ___|@
|
||||||
|
|___| @
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| __|@
|
||||||
|
| | |@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| | |@
|
||||||
|
| |@
|
||||||
|
|___|___|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
|_ _|@
|
||||||
|
_| |_ @
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
_____ @
|
||||||
|
_| |@
|
||||||
|
| |@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
__ __ @
|
||||||
|
| |/ |@
|
||||||
|
| < @
|
||||||
|
|__|\__|@
|
||||||
|
@@
|
||||||
|
_____ @
|
||||||
|
| |_ @
|
||||||
|
| |@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| | |@
|
||||||
|
| |@
|
||||||
|
|__|_|__|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| | |@
|
||||||
|
| |@
|
||||||
|
|__|____|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| |@
|
||||||
|
| - |@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| __ \@
|
||||||
|
| __/@
|
||||||
|
|___| @
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| |@
|
||||||
|
| - _|@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
| __ \@
|
||||||
|
| <@
|
||||||
|
|___|__|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| __|@
|
||||||
|
|__ |@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
|_ _|@
|
||||||
|
| | @
|
||||||
|
|___| @
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| | |@
|
||||||
|
| | |@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
___ ___ @
|
||||||
|
| | |@
|
||||||
|
| | |@
|
||||||
|
\_____/ @
|
||||||
|
@@
|
||||||
|
________ @
|
||||||
|
| | | |@
|
||||||
|
| | | |@
|
||||||
|
|________|@
|
||||||
|
@@
|
||||||
|
___ ___ @
|
||||||
|
| | |@
|
||||||
|
|- -|@
|
||||||
|
|___|___|@
|
||||||
|
@@
|
||||||
|
___ ___ @
|
||||||
|
| | |@
|
||||||
|
\ / @
|
||||||
|
|___| @
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
|__ |@
|
||||||
|
| __|@
|
||||||
|
|_______|@
|
||||||
|
@@
|
||||||
|
____ @
|
||||||
|
| _|@
|
||||||
|
| | @
|
||||||
|
| |_ @
|
||||||
|
|____|@@
|
||||||
|
___ @
|
||||||
|
\ \ @
|
||||||
|
`. `. @
|
||||||
|
\__\@
|
||||||
|
@@
|
||||||
|
____ @
|
||||||
|
|_ |@
|
||||||
|
| |@
|
||||||
|
_| |@
|
||||||
|
|____|@@
|
||||||
|
____ @
|
||||||
|
| |@
|
||||||
|
|_/\_|@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
______ @
|
||||||
|
|______|@@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
|_| @
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.---.-.@
|
||||||
|
| _ |@
|
||||||
|
|___._|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
| |--.@
|
||||||
|
| _ |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.----.@
|
||||||
|
| __|@
|
||||||
|
|____|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
.--| |@
|
||||||
|
| _ |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
| -__|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
.' _|@
|
||||||
|
| _|@
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
| _ |@
|
||||||
|
|___ |@
|
||||||
|
|_____|@@
|
||||||
|
__ @
|
||||||
|
| |--.@
|
||||||
|
| |@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
|__|@
|
||||||
|
| |@
|
||||||
|
|__|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
|__|@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|___|@@
|
||||||
|
__ @
|
||||||
|
| |--.@
|
||||||
|
| < @
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.--------.@
|
||||||
|
| |@
|
||||||
|
|__|__|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
| |@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
| _ |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
| _ |@
|
||||||
|
| __|@
|
||||||
|
|__| @@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
| _ |@
|
||||||
|
|__ |@
|
||||||
|
|__|@@
|
||||||
|
@
|
||||||
|
.----.@
|
||||||
|
| _|@
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
|__ --|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
| |_ @
|
||||||
|
| _|@
|
||||||
|
|____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.--.--.@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.--.--.@
|
||||||
|
| | |@
|
||||||
|
\___/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.--.--.--.@
|
||||||
|
| | | |@
|
||||||
|
|________|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.--.--.@
|
||||||
|
|_ _|@
|
||||||
|
|__.__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
.--.--.@
|
||||||
|
| | |@
|
||||||
|
|___ |@
|
||||||
|
|_____|@@
|
||||||
|
@
|
||||||
|
.-----.@
|
||||||
|
|-- __|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
/ / @
|
||||||
|
\ \_ @
|
||||||
|
|___|@@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|__|@@
|
||||||
|
___ @
|
||||||
|
|_ | @
|
||||||
|
\ \@
|
||||||
|
_/ /@
|
||||||
|
|___| @@
|
||||||
|
___ @
|
||||||
|
| ' |@
|
||||||
|
|_,_| @
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
.--.--.@
|
||||||
|
|-----|@
|
||||||
|
| - |@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
.--.--.@
|
||||||
|
|-----|@
|
||||||
|
| _ |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
.--.--.@
|
||||||
|
|--|--|@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
.--.--.@
|
||||||
|
|---.-|@
|
||||||
|
| _ |@
|
||||||
|
|___._|@
|
||||||
|
@@
|
||||||
|
.--.--.@
|
||||||
|
|-----|@
|
||||||
|
| _ |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
.--.--.@
|
||||||
|
|--|--|@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
_______ @
|
||||||
|
| __ \@
|
||||||
|
| __ <@
|
||||||
|
| |____/@
|
||||||
|
|__| @@
|
|
@ -0,0 +1,569 @@
|
||||||
|
flf2a$ 5 4 20 0 16
|
||||||
|
Font : Dr. Pepper (after a name in one sig done in this style).
|
||||||
|
Author: Eero Tamminen, t150315@cc.tut.fi.
|
||||||
|
|
||||||
|
Characters '#' and '&' are lousy and I'm not very satisfied
|
||||||
|
with the '$' or 't'... Suggestions?
|
||||||
|
|
||||||
|
Explanation of first line:
|
||||||
|
flf2 - "magic number" for file identifiction
|
||||||
|
a - should always be `a', for now
|
||||||
|
$ - the "hardblank" -- prints s a blank, but can't be smushed
|
||||||
|
5 - height of a character
|
||||||
|
4 - height of a character, not including descenders
|
||||||
|
20 - max line length (excluding comment lines) + fudge factor
|
||||||
|
0 - default smushmode for this font
|
||||||
|
16 - number of comment lines
|
||||||
|
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
|_/@
|
||||||
|
<_>@
|
||||||
|
@@
|
||||||
|
_ _@
|
||||||
|
|/|/@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
$_|_|_$@
|
||||||
|
$_|_|_$@
|
||||||
|
| | @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
||_@
|
||||||
|
<_-<@
|
||||||
|
/__/@
|
||||||
|
|| @@
|
||||||
|
__@
|
||||||
|
<>/ /@
|
||||||
|
/ / @
|
||||||
|
/_/<>@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
< > @
|
||||||
|
/.\/$@
|
||||||
|
\_/\$@
|
||||||
|
@@
|
||||||
|
_@
|
||||||
|
|/@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
__@
|
||||||
|
/ /@
|
||||||
|
| | @
|
||||||
|
| | @
|
||||||
|
\_\@@
|
||||||
|
__ @
|
||||||
|
\ \ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
/_/ @@
|
||||||
|
@
|
||||||
|
_/\_@
|
||||||
|
> <@
|
||||||
|
\/ @
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
_| |_ @
|
||||||
|
|_ _|@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_@
|
||||||
|
|/@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|___|@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
<_>@
|
||||||
|
@@
|
||||||
|
__@
|
||||||
|
/ /@
|
||||||
|
/ / @
|
||||||
|
/_/ @
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| |@
|
||||||
|
| / |@
|
||||||
|
`___'@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
/ |@
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
<_ >@
|
||||||
|
/ / @
|
||||||
|
<___>@
|
||||||
|
@@
|
||||||
|
____@
|
||||||
|
<__ /@
|
||||||
|
<_ \@
|
||||||
|
<___/@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
/. | @
|
||||||
|
/_ .|@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| __|@
|
||||||
|
`__ \@
|
||||||
|
|___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| __>@
|
||||||
|
| . \@
|
||||||
|
`___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
|_ |@
|
||||||
|
/ / @
|
||||||
|
/_/ @
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
< . >@
|
||||||
|
/ . \@
|
||||||
|
\___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
`_ /@
|
||||||
|
/_/ @
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
<_>@
|
||||||
|
_ @
|
||||||
|
<_>@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
<_>@
|
||||||
|
_ @
|
||||||
|
|/ @
|
||||||
|
@@
|
||||||
|
__@
|
||||||
|
/ /@
|
||||||
|
< < @
|
||||||
|
\_\@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
|___|@
|
||||||
|
___ @
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
\ \ @
|
||||||
|
> >@
|
||||||
|
/_/ @
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
<_. >@
|
||||||
|
/_/ @
|
||||||
|
<_> @
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| "|@
|
||||||
|
| \_|@
|
||||||
|
`___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
| |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . >@
|
||||||
|
| . \@
|
||||||
|
|___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| _>@
|
||||||
|
| <__@
|
||||||
|
`___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . \@
|
||||||
|
| | |@
|
||||||
|
|___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| __>@
|
||||||
|
| _> @
|
||||||
|
|___>@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| __>@
|
||||||
|
| _> @
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
/ _> @
|
||||||
|
| <_/\@
|
||||||
|
`____/@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
| |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
_| |@
|
||||||
|
\__/@
|
||||||
|
@@
|
||||||
|
_ __@
|
||||||
|
| / /@
|
||||||
|
| \ @
|
||||||
|
|_\_\@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
| | @
|
||||||
|
| |_ @
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
__ __ @
|
||||||
|
| \ \@
|
||||||
|
| |@
|
||||||
|
|_|_|_|@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
| \ |@
|
||||||
|
| |@
|
||||||
|
|_\_|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
| | |@
|
||||||
|
`___'@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . \@
|
||||||
|
| _/@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
| | |@
|
||||||
|
`___\@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . \@
|
||||||
|
| /@
|
||||||
|
|_\_\@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
/ __>@
|
||||||
|
\__ \@
|
||||||
|
<___/@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
|_ _|@
|
||||||
|
| | @
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
| ' |@
|
||||||
|
`___'@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
| ' |@
|
||||||
|
|__/ @
|
||||||
|
@@
|
||||||
|
_ _ _ @
|
||||||
|
| | | |@
|
||||||
|
| | | |@
|
||||||
|
|__/_/ @
|
||||||
|
@@
|
||||||
|
__ _$@
|
||||||
|
\ \/ @
|
||||||
|
\ \ @
|
||||||
|
_/\_\ @
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
\ /@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
____@
|
||||||
|
|_ /@
|
||||||
|
/ / @
|
||||||
|
/___|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
| | @
|
||||||
|
| |_ @
|
||||||
|
|___|@@
|
||||||
|
__ @
|
||||||
|
\ \ @
|
||||||
|
\ \ @
|
||||||
|
\_\@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
|_ |@
|
||||||
|
| |@
|
||||||
|
_| |@
|
||||||
|
|___|@@
|
||||||
|
/\ @
|
||||||
|
</\>@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
\|@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
<_> |@
|
||||||
|
<___|@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
| |_ @
|
||||||
|
| . \@
|
||||||
|
|___/@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
/ | '@
|
||||||
|
\_|_.@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
_| |@
|
||||||
|
/ . |@
|
||||||
|
\___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
/ ._>@
|
||||||
|
\___.@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| | '@
|
||||||
|
| |- @
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
/ . |@
|
||||||
|
\_. |@
|
||||||
|
<___'@@
|
||||||
|
_ @
|
||||||
|
| |_ @
|
||||||
|
| . |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
<_>@
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
<_>@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
<__'@@
|
||||||
|
_ @
|
||||||
|
| |__@
|
||||||
|
| / /@
|
||||||
|
|_\_\@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
._ _ _ @
|
||||||
|
| ' ' |@
|
||||||
|
|_|_|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
._ _ @
|
||||||
|
| ' |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
/ . \@
|
||||||
|
\___/@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| . \@
|
||||||
|
| _/@
|
||||||
|
|_| @@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
/ . |@
|
||||||
|
\_ |@
|
||||||
|
|_|@@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
| '_>@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___@
|
||||||
|
<_-<@
|
||||||
|
/__/@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
$_| |_$@
|
||||||
|
| | @
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
`___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
|__/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ _ _ @
|
||||||
|
| | | |@
|
||||||
|
|__/_/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
__ @
|
||||||
|
\ \/@
|
||||||
|
/\_\@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
`_. |@
|
||||||
|
<___'@@
|
||||||
|
@
|
||||||
|
.___@
|
||||||
|
/ /@
|
||||||
|
/___@
|
||||||
|
@@
|
||||||
|
__@
|
||||||
|
/ /@
|
||||||
|
/ | @
|
||||||
|
\ | @
|
||||||
|
\_\@@
|
||||||
|
||@
|
||||||
|
||@
|
||||||
|
||@
|
||||||
|
||@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
\ \ @
|
||||||
|
| \@
|
||||||
|
| /@
|
||||||
|
/_/ @@
|
||||||
|
@
|
||||||
|
/\/|@
|
||||||
|
|/\/ @
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
<>_<>@
|
||||||
|
| . |@
|
||||||
|
| |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
<>_<>@
|
||||||
|
| . |@
|
||||||
|
| | |@
|
||||||
|
`___'@
|
||||||
|
@@
|
||||||
|
<>_<>@
|
||||||
|
| | |@
|
||||||
|
| ' |@
|
||||||
|
`___'@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
<>_<>@
|
||||||
|
`_> |@
|
||||||
|
<___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
<>_<>@
|
||||||
|
/ . \@
|
||||||
|
\___/@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
<>_<>@
|
||||||
|
| | |@
|
||||||
|
`___|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| . >@
|
||||||
|
| . \@
|
||||||
|
| ._/@
|
||||||
|
|_| @@
|
||||||
|
196
|
||||||
|
<>_<>@
|
||||||
|
| . |@
|
||||||
|
| |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
214
|
||||||
|
<>_<>@
|
||||||
|
| . |@
|
||||||
|
| | |@
|
||||||
|
`___'@
|
||||||
|
@@
|
||||||
|
220
|
||||||
|
<>_<>@
|
||||||
|
| | |@
|
||||||
|
| ' |@
|
||||||
|
`___'@
|
||||||
|
@@
|
||||||
|
223
|
||||||
|
___ @
|
||||||
|
| . >@
|
||||||
|
| . \@
|
||||||
|
| ._/@
|
||||||
|
|_| @@
|
||||||
|
228
|
||||||
|
@
|
||||||
|
<>_<>@
|
||||||
|
`_> |@
|
||||||
|
<___|@
|
||||||
|
@@
|
||||||
|
246
|
||||||
|
@
|
||||||
|
<>_<>@
|
||||||
|
/ . \@
|
||||||
|
\___/@
|
||||||
|
@@
|
||||||
|
252
|
||||||
|
@
|
||||||
|
<>_<>@
|
||||||
|
| | |@
|
||||||
|
`___|@
|
||||||
|
@@
|
|
@ -0,0 +1,617 @@
|
||||||
|
flf2a$ 6 5 32 15 4
|
||||||
|
Font name is graffiti.flf
|
||||||
|
This figlet font designed by Leigh Purdie (purdie@zeus.usq.edu.au)
|
||||||
|
'fig-fonted' by Leigh Purdie and Tim Maggio (tim@claremont.com)
|
||||||
|
Date: 5 Mar 1994
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@
|
||||||
|
$@@
|
||||||
|
._.@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
\|@
|
||||||
|
__@
|
||||||
|
\/@@
|
||||||
|
/\/\@
|
||||||
|
)/)/@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
__| || |__@
|
||||||
|
\ __ /@
|
||||||
|
| || | @
|
||||||
|
/_ ~~ _\@
|
||||||
|
|_||_| @@
|
||||||
|
____/\__@
|
||||||
|
/ / /_/@
|
||||||
|
\__/ / \ @
|
||||||
|
/ / / \@
|
||||||
|
/_/ /__ /@
|
||||||
|
\/ \/ @@
|
||||||
|
_ /\ @
|
||||||
|
/ \ / / @
|
||||||
|
\_// /_ @
|
||||||
|
/ // \@
|
||||||
|
/ / \_/@
|
||||||
|
\/ @@
|
||||||
|
____ @
|
||||||
|
/ _ \ @
|
||||||
|
> _ </\@
|
||||||
|
/ <_\ \/@
|
||||||
|
\_____\ \@
|
||||||
|
\/@@
|
||||||
|
/\@
|
||||||
|
)/@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
$ ___$@
|
||||||
|
$ / /$@
|
||||||
|
$ / / $@
|
||||||
|
$( ( $@
|
||||||
|
$ \ \ $@
|
||||||
|
$ \__\$@@
|
||||||
|
$___ $@
|
||||||
|
$\ \ $@
|
||||||
|
$ \ \ $@
|
||||||
|
$ ) )$@
|
||||||
|
$ / / $@
|
||||||
|
$/__/ $@@
|
||||||
|
$ $@
|
||||||
|
$ /\|\/\ $@
|
||||||
|
$_) (__$@
|
||||||
|
$\_ _/$@
|
||||||
|
$ ) \ $@
|
||||||
|
$ \/\|\/ $@@
|
||||||
|
$ $@
|
||||||
|
$ .__ $@
|
||||||
|
$ __| |___$@
|
||||||
|
$/__ __/$@
|
||||||
|
$ |__| $@
|
||||||
|
$ $@@
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$/\@
|
||||||
|
$)/@@
|
||||||
|
$ $@
|
||||||
|
$ $@
|
||||||
|
$ ______$@
|
||||||
|
$/_____/$@
|
||||||
|
$ $@
|
||||||
|
$ $@@
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$/\@
|
||||||
|
$\/@@
|
||||||
|
$ /\$@
|
||||||
|
$ / /$@
|
||||||
|
$ / / $@
|
||||||
|
$ / / $@
|
||||||
|
$/ / $@
|
||||||
|
$\/ $@@
|
||||||
|
_______ @
|
||||||
|
\ _ \ @
|
||||||
|
/ /_\ \ @
|
||||||
|
\ \_/ \@
|
||||||
|
\_____ /@
|
||||||
|
\/ @@
|
||||||
|
____ @
|
||||||
|
/_ |@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
________ @
|
||||||
|
\_____ \ @
|
||||||
|
/ ____/ @
|
||||||
|
/ \ @
|
||||||
|
\_______ \@
|
||||||
|
\/@@
|
||||||
|
________ @
|
||||||
|
\_____ \ @
|
||||||
|
_(__ < @
|
||||||
|
/ \@
|
||||||
|
/______ /@
|
||||||
|
\/ @@
|
||||||
|
_____ @
|
||||||
|
/ | | @
|
||||||
|
/ | |_@
|
||||||
|
/ ^ /@
|
||||||
|
\____ | @
|
||||||
|
|__| @@
|
||||||
|
.________@
|
||||||
|
| ____/@
|
||||||
|
|____ \ @
|
||||||
|
/ \@
|
||||||
|
/______ /@
|
||||||
|
\/ @@
|
||||||
|
________@
|
||||||
|
/ _____/@
|
||||||
|
/ __ \ @
|
||||||
|
\ |__\ \@
|
||||||
|
\_____ /@
|
||||||
|
\/ @@
|
||||||
|
_________ @
|
||||||
|
\______ \@
|
||||||
|
/ /@
|
||||||
|
/ / @
|
||||||
|
/____/ @
|
||||||
|
@@
|
||||||
|
______ @
|
||||||
|
/ __ \ @
|
||||||
|
> < @
|
||||||
|
/ -- \@
|
||||||
|
\______ /@
|
||||||
|
\/ @@
|
||||||
|
________ @
|
||||||
|
/ __ \@
|
||||||
|
\____ /@
|
||||||
|
/ / @
|
||||||
|
/____/ @
|
||||||
|
@@
|
||||||
|
$ $@
|
||||||
|
$/\$@
|
||||||
|
$\/$@
|
||||||
|
$/\$@
|
||||||
|
$\/$@
|
||||||
|
$ $@@
|
||||||
|
$ $@
|
||||||
|
$/\$@
|
||||||
|
$\/$@
|
||||||
|
$/\$@
|
||||||
|
$)/$@
|
||||||
|
$ $@@
|
||||||
|
$ __$@
|
||||||
|
$ / /$@
|
||||||
|
$/ / $@
|
||||||
|
$\ \ $@
|
||||||
|
$ \_\$@
|
||||||
|
$ $@@
|
||||||
|
$ $@
|
||||||
|
$ ______$@
|
||||||
|
$/_____/$@
|
||||||
|
$/_____/$@
|
||||||
|
$ $@
|
||||||
|
$ $@@
|
||||||
|
$__ $@
|
||||||
|
$\ \ $@
|
||||||
|
$ \ \$@
|
||||||
|
$ / /$@
|
||||||
|
$/_/ $@
|
||||||
|
$ $@@
|
||||||
|
_________ @
|
||||||
|
\_____ \@
|
||||||
|
/ __/@
|
||||||
|
| | @
|
||||||
|
|___| @
|
||||||
|
<___> @@
|
||||||
|
_____ @
|
||||||
|
/ ___ \ @
|
||||||
|
/ / ._\ \@
|
||||||
|
< \_____/@
|
||||||
|
\_____\ @
|
||||||
|
@@
|
||||||
|
_____ @
|
||||||
|
/ _ \ @
|
||||||
|
/ /_\ \ @
|
||||||
|
/ | \@
|
||||||
|
\____|__ /@
|
||||||
|
\/ @@
|
||||||
|
__________ @
|
||||||
|
\______ \@
|
||||||
|
| | _/@
|
||||||
|
| | \@
|
||||||
|
|______ /@
|
||||||
|
\/ @@
|
||||||
|
_________ @
|
||||||
|
\_ ___ \ @
|
||||||
|
/ \ \/ @
|
||||||
|
\ \____@
|
||||||
|
\______ /@
|
||||||
|
\/ @@
|
||||||
|
________ @
|
||||||
|
\______ \ @
|
||||||
|
| | \ @
|
||||||
|
| ` \@
|
||||||
|
/_______ /@
|
||||||
|
\/ @@
|
||||||
|
___________@
|
||||||
|
\_ _____/@
|
||||||
|
| __)_ @
|
||||||
|
| \@
|
||||||
|
/_______ /@
|
||||||
|
\/ @@
|
||||||
|
___________@
|
||||||
|
\_ _____/@
|
||||||
|
| __) @
|
||||||
|
| \ @
|
||||||
|
\___ / @
|
||||||
|
\/ @@
|
||||||
|
________ @
|
||||||
|
/ _____/ @
|
||||||
|
/ \ ___ @
|
||||||
|
\ \_\ \@
|
||||||
|
\______ /@
|
||||||
|
\/ @@
|
||||||
|
___ ___ @
|
||||||
|
/ | \ @
|
||||||
|
/ ~ \@
|
||||||
|
\ Y /@
|
||||||
|
\___|_ / @
|
||||||
|
\/ @@
|
||||||
|
.___ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
____.@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
/\__| |@
|
||||||
|
\________|@
|
||||||
|
@@
|
||||||
|
____ __.@
|
||||||
|
| |/ _|@
|
||||||
|
| < @
|
||||||
|
| | \ @
|
||||||
|
|____|__ \@
|
||||||
|
\/@@
|
||||||
|
.____ @
|
||||||
|
| | @
|
||||||
|
| | @
|
||||||
|
| |___ @
|
||||||
|
|_______ \@
|
||||||
|
\/@@
|
||||||
|
_____ @
|
||||||
|
/ \ @
|
||||||
|
/ \ / \ @
|
||||||
|
/ Y \@
|
||||||
|
\____|__ /@
|
||||||
|
\/ @@
|
||||||
|
_______ @
|
||||||
|
\ \ @
|
||||||
|
/ | \ @
|
||||||
|
/ | \@
|
||||||
|
\____|__ /@
|
||||||
|
\/ @@
|
||||||
|
________ @
|
||||||
|
\_____ \ @
|
||||||
|
/ | \ @
|
||||||
|
/ | \@
|
||||||
|
\_______ /@
|
||||||
|
\/ @@
|
||||||
|
__________ @
|
||||||
|
\______ \@
|
||||||
|
| ___/@
|
||||||
|
| | @
|
||||||
|
|____| @
|
||||||
|
@@
|
||||||
|
________ @
|
||||||
|
\_____ \ @
|
||||||
|
/ / \ \ @
|
||||||
|
/ \_/. \@
|
||||||
|
\_____\ \_/@
|
||||||
|
\__>@@
|
||||||
|
__________ @
|
||||||
|
\______ \@
|
||||||
|
| _/@
|
||||||
|
| | \@
|
||||||
|
|____|_ /@
|
||||||
|
\/ @@
|
||||||
|
_________@
|
||||||
|
/ _____/@
|
||||||
|
\_____ \ @
|
||||||
|
/ \@
|
||||||
|
/_______ /@
|
||||||
|
\/ @@
|
||||||
|
___________@
|
||||||
|
\__ ___/@
|
||||||
|
| | @
|
||||||
|
| | @
|
||||||
|
|____| @
|
||||||
|
@@
|
||||||
|
____ ___ @
|
||||||
|
| | \@
|
||||||
|
| | /@
|
||||||
|
| | / @
|
||||||
|
|______/ @
|
||||||
|
@@
|
||||||
|
____ ____@
|
||||||
|
\ \ / /@
|
||||||
|
\ Y / @
|
||||||
|
\ / @
|
||||||
|
\___/ @
|
||||||
|
@@
|
||||||
|
__ __ @
|
||||||
|
/ \ / \@
|
||||||
|
\ \/\/ /@
|
||||||
|
\ / @
|
||||||
|
\__/\ / @
|
||||||
|
\/ @@
|
||||||
|
____ ___@
|
||||||
|
\ \/ /@
|
||||||
|
\ / @
|
||||||
|
/ \ @
|
||||||
|
/___/\ \@
|
||||||
|
\_/@@
|
||||||
|
_____.___.@
|
||||||
|
\__ | |@
|
||||||
|
/ | |@
|
||||||
|
\____ |@
|
||||||
|
/ ______|@
|
||||||
|
\/ @@
|
||||||
|
__________@
|
||||||
|
\____ /@
|
||||||
|
/ / @
|
||||||
|
/ /_ @
|
||||||
|
/_______ \@
|
||||||
|
\/@@
|
||||||
|
$.____ $@
|
||||||
|
$| _|$@
|
||||||
|
$| | $@
|
||||||
|
$| | $@
|
||||||
|
$| |_ $@
|
||||||
|
$|____|$@@
|
||||||
|
/\ @
|
||||||
|
\ \ @
|
||||||
|
\ \ @
|
||||||
|
\ \ @
|
||||||
|
\ \@
|
||||||
|
\/@@
|
||||||
|
$ ____.$@
|
||||||
|
$|_ |$@
|
||||||
|
$ | |$@
|
||||||
|
$ | |$@
|
||||||
|
$ _| |$@
|
||||||
|
$|____|$@@
|
||||||
|
$ /\ $@
|
||||||
|
$/ \$@
|
||||||
|
$\/\/$@
|
||||||
|
$ $@
|
||||||
|
$ $@
|
||||||
|
$ $@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
______@
|
||||||
|
/_____/@@
|
||||||
|
/\@
|
||||||
|
\(@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
\__ \ @
|
||||||
|
/ __ \_@
|
||||||
|
(____ /@
|
||||||
|
\/ @@
|
||||||
|
___. @
|
||||||
|
\_ |__ @
|
||||||
|
| __ \ @
|
||||||
|
| \_\ \@
|
||||||
|
|___ /@
|
||||||
|
\/ @@
|
||||||
|
@
|
||||||
|
____ @
|
||||||
|
_/ ___\ @
|
||||||
|
\ \___ @
|
||||||
|
\___ >@
|
||||||
|
\/ @@
|
||||||
|
.___@
|
||||||
|
__| _/@
|
||||||
|
/ __ | @
|
||||||
|
/ /_/ | @
|
||||||
|
\____ | @
|
||||||
|
\/ @@
|
||||||
|
@
|
||||||
|
____ @
|
||||||
|
_/ __ \ @
|
||||||
|
\ ___/ @
|
||||||
|
\___ >@
|
||||||
|
\/ @@
|
||||||
|
_____ @
|
||||||
|
_/ ____\@
|
||||||
|
\ __\ @
|
||||||
|
| | @
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
____ @
|
||||||
|
/ ___\ @
|
||||||
|
/ /_/ >@
|
||||||
|
\___ / @
|
||||||
|
/_____/ @@
|
||||||
|
.__ @
|
||||||
|
| |__ @
|
||||||
|
| | \ @
|
||||||
|
| Y \@
|
||||||
|
|___| /@
|
||||||
|
\/ @@
|
||||||
|
.__ @
|
||||||
|
|__|@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|__|@
|
||||||
|
@@
|
||||||
|
__ @
|
||||||
|
|__|@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
/\__| |@
|
||||||
|
\______|@@
|
||||||
|
__ @
|
||||||
|
| | __@
|
||||||
|
| |/ /@
|
||||||
|
| < @
|
||||||
|
|__|_ \@
|
||||||
|
\/@@
|
||||||
|
.__ @
|
||||||
|
| | @
|
||||||
|
| | @
|
||||||
|
| |__@
|
||||||
|
|____/@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
/ \ @
|
||||||
|
| Y Y \@
|
||||||
|
|__|_| /@
|
||||||
|
\/ @@
|
||||||
|
@
|
||||||
|
____ @
|
||||||
|
/ \ @
|
||||||
|
| | \@
|
||||||
|
|___| /@
|
||||||
|
\/ @@
|
||||||
|
@
|
||||||
|
____ @
|
||||||
|
/ _ \ @
|
||||||
|
( <_> )@
|
||||||
|
\____/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
______ @
|
||||||
|
\____ \ @
|
||||||
|
| |_> >@
|
||||||
|
| __/ @
|
||||||
|
|__| @@
|
||||||
|
@
|
||||||
|
______@
|
||||||
|
/ ____/@
|
||||||
|
< <_| |@
|
||||||
|
\__ |@
|
||||||
|
|__|@@
|
||||||
|
@
|
||||||
|
_______ @
|
||||||
|
\_ __ \@
|
||||||
|
| | \/@
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
______@
|
||||||
|
/ ___/@
|
||||||
|
\___ \ @
|
||||||
|
/____ >@
|
||||||
|
\/ @@
|
||||||
|
__ @
|
||||||
|
_/ |_ @
|
||||||
|
\ __\@
|
||||||
|
| | @
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
__ __ @
|
||||||
|
| | \@
|
||||||
|
| | /@
|
||||||
|
|____/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ __@
|
||||||
|
\ \/ /@
|
||||||
|
\ / @
|
||||||
|
\_/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
__ _ __@
|
||||||
|
\ \/ \/ /@
|
||||||
|
\ / @
|
||||||
|
\/\_/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ ___@
|
||||||
|
\ \/ /@
|
||||||
|
> < @
|
||||||
|
/__/\_ \@
|
||||||
|
\/@@
|
||||||
|
@
|
||||||
|
___.__.@
|
||||||
|
< | |@
|
||||||
|
\___ |@
|
||||||
|
/ ____|@
|
||||||
|
\/ @@
|
||||||
|
@
|
||||||
|
________@
|
||||||
|
\___ /@
|
||||||
|
/ / @
|
||||||
|
/_____ \@
|
||||||
|
\/@@
|
||||||
|
$ ___$@
|
||||||
|
$/ / $@
|
||||||
|
$\ \ $@
|
||||||
|
$< < $@
|
||||||
|
$/ / $@
|
||||||
|
$\_\_$@@
|
||||||
|
$._.$@
|
||||||
|
$| |$@
|
||||||
|
$|_|$@
|
||||||
|
$|-|$@
|
||||||
|
$| |$@
|
||||||
|
$|_|$@@
|
||||||
|
$___ $@
|
||||||
|
$ \ \$@
|
||||||
|
$ / /$@
|
||||||
|
$ > >$@
|
||||||
|
$ \ \$@
|
||||||
|
$_/_/$@@
|
||||||
|
$ ___ $@
|
||||||
|
$/ _ \_/\$@
|
||||||
|
$\/ \___/$@
|
||||||
|
$ $@
|
||||||
|
$ $@
|
||||||
|
$ $@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
|
@ -0,0 +1,614 @@
|
||||||
|
flf2a$ 6 5 15 1 1
|
||||||
|
rectangles.flf by David Villegas <mnementh@netcom.com> 12/94
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@@
|
||||||
|
__ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|__|@
|
||||||
|
|__|@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
|_|_|@
|
||||||
|
$$$ @
|
||||||
|
$$$ @
|
||||||
|
$$$ @@
|
||||||
|
_ _ @
|
||||||
|
_| | |_ @
|
||||||
|
|_ _|@
|
||||||
|
|_ _|@
|
||||||
|
|_|_| @
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
_| |_ @
|
||||||
|
| __|@
|
||||||
|
|__ |@
|
||||||
|
|_ _|@
|
||||||
|
|_| @@
|
||||||
|
@
|
||||||
|
__ __ @
|
||||||
|
|__| |@
|
||||||
|
| __|@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
_ @
|
||||||
|
_| |_ @
|
||||||
|
| __|@
|
||||||
|
| __|@
|
||||||
|
|_ _|@
|
||||||
|
|_| @@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$ @@
|
||||||
|
_ @
|
||||||
|
_|_|@
|
||||||
|
| | @
|
||||||
|
| | @
|
||||||
|
|_|_ @
|
||||||
|
|_|@@
|
||||||
|
_ @
|
||||||
|
|_|_ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
_|_|@
|
||||||
|
|_| @@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| | | |@
|
||||||
|
|- -|@
|
||||||
|
|_|_|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
_| |_ @
|
||||||
|
|_ _|@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
|_|@@
|
||||||
|
$$$ @
|
||||||
|
$$$ @
|
||||||
|
___ @
|
||||||
|
|___|@
|
||||||
|
$$$ @
|
||||||
|
$$$ @@
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
$ @
|
||||||
|
_ @
|
||||||
|
|_|@
|
||||||
|
$ @@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
/ |@
|
||||||
|
/ / @
|
||||||
|
|_/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| |@
|
||||||
|
| | |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|_ | @
|
||||||
|
_| |_ @
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|_ |@
|
||||||
|
| _|@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|_ |@
|
||||||
|
|_ |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| | |@
|
||||||
|
|_ |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
|_ |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
| . |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|_ |@
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
| . |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
|_ |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
|_|@
|
||||||
|
_ @
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
|_|@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
|_|@@
|
||||||
|
__@
|
||||||
|
/ /@
|
||||||
|
/ / @
|
||||||
|
< < @
|
||||||
|
\ \ @
|
||||||
|
\_\@@
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @
|
||||||
|
_____ @
|
||||||
|
|_____|@
|
||||||
|
|_____|@
|
||||||
|
$$$$$ @@
|
||||||
|
__ @
|
||||||
|
\ \ @
|
||||||
|
\ \ @
|
||||||
|
> >@
|
||||||
|
/ / @
|
||||||
|
/_/ @@
|
||||||
|
_____ @
|
||||||
|
|___ |@
|
||||||
|
| _|@
|
||||||
|
|_| @
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __ |@
|
||||||
|
| |___|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| _ |@
|
||||||
|
| |@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __ |@
|
||||||
|
| __ -|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| |@
|
||||||
|
| --|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
____ @
|
||||||
|
| \ @
|
||||||
|
| | |@
|
||||||
|
|____/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __|@
|
||||||
|
| __|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __|@
|
||||||
|
| __|@
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __|@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| | |@
|
||||||
|
| |@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| |@
|
||||||
|
|- -|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
__ @
|
||||||
|
__| |@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| | |@
|
||||||
|
| -|@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
__ @
|
||||||
|
| | @
|
||||||
|
| |__ @
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| |@
|
||||||
|
| | | |@
|
||||||
|
|_|_|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| | |@
|
||||||
|
| | | |@
|
||||||
|
|_|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| |@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| _ |@
|
||||||
|
| __|@
|
||||||
|
|__| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| |@
|
||||||
|
| | |@
|
||||||
|
|__ _|@
|
||||||
|
|__|@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __ |@
|
||||||
|
| -|@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __|@
|
||||||
|
|__ |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
|_ _|@
|
||||||
|
| | @
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| | |@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| | |@
|
||||||
|
| | |@
|
||||||
|
\___/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ _ _ @
|
||||||
|
| | | |@
|
||||||
|
| | | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
__ __ @
|
||||||
|
| | |@
|
||||||
|
|- -|@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
__ __ @
|
||||||
|
| | |@
|
||||||
|
|_ _|@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
|__ |@
|
||||||
|
| __|@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
| | @
|
||||||
|
| | @
|
||||||
|
| |_ @
|
||||||
|
|___|@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
| \ @
|
||||||
|
\ \ @
|
||||||
|
\_|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
|_ |@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
_| |@
|
||||||
|
|___|@@
|
||||||
|
_____ @
|
||||||
|
| _ |@
|
||||||
|
|_| |_|@
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @@
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @
|
||||||
|
_____ @
|
||||||
|
|_____|@@
|
||||||
|
___ @
|
||||||
|
|_ |@
|
||||||
|
|_|@
|
||||||
|
$$$ @
|
||||||
|
$$$ @
|
||||||
|
$$$ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| .'|@
|
||||||
|
|__,|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
| |_ @
|
||||||
|
| . |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
_| |@
|
||||||
|
| . |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| -_|@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
| _|@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
|_ |@
|
||||||
|
|___|@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
| |_ @
|
||||||
|
| |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
|_|@
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
|_|@
|
||||||
|
| |@
|
||||||
|
_| |@
|
||||||
|
|___|@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
| |_ @
|
||||||
|
| '_|@
|
||||||
|
|_,_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| |@
|
||||||
|
|_|_|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| |@
|
||||||
|
|_|_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
| _|@
|
||||||
|
|_| @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
|_ |@
|
||||||
|
|_|@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|_ -|@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_ @
|
||||||
|
| |_ @
|
||||||
|
| _|@
|
||||||
|
|_| @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
\_/ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_ _ _ @
|
||||||
|
| | | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
|_'_|@
|
||||||
|
|_,_|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
|_ |@
|
||||||
|
|___|@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
___ @
|
||||||
|
|- _|@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
___ @
|
||||||
|
| _|@
|
||||||
|
_| | @
|
||||||
|
|_ | @
|
||||||
|
| |_ @
|
||||||
|
|___|@@
|
||||||
|
_ @
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
| |@
|
||||||
|
|_|@@
|
||||||
|
___ @
|
||||||
|
|_ | @
|
||||||
|
| |_ @
|
||||||
|
| _|@
|
||||||
|
_| | @
|
||||||
|
|___| @@
|
||||||
|
_____ @
|
||||||
|
| | |@
|
||||||
|
|_|___|@
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @
|
||||||
|
$$$$$ @@
|
||||||
|
__ __ @
|
||||||
|
|__|__|@
|
||||||
|
| _ |@
|
||||||
|
| |@
|
||||||
|
|__|__|@
|
||||||
|
@@
|
||||||
|
__ __ @
|
||||||
|
|__|__|@
|
||||||
|
| |@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
__ __ @
|
||||||
|
|__|__|@
|
||||||
|
| | |@
|
||||||
|
| | |@
|
||||||
|
|_____|@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
|_|_|@
|
||||||
|
___ @
|
||||||
|
| .'|@
|
||||||
|
|__,|@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
|_|_|@
|
||||||
|
___ @
|
||||||
|
| . |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
_ _ @
|
||||||
|
|_|_|@
|
||||||
|
_ _ @
|
||||||
|
| | |@
|
||||||
|
|___|@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
_____ @
|
||||||
|
| __ |@
|
||||||
|
| __ -|@
|
||||||
|
| ___|@
|
||||||
|
|_| @@
|
54
go.mod
|
@ -1,35 +1,38 @@
|
||||||
module git.ekzyis.com/ekzyis/delphi.market
|
module git.ekzyis.com/ekzyis/delphi.market
|
||||||
|
|
||||||
go 1.20
|
go 1.21
|
||||||
|
|
||||||
|
toolchain go1.22.4
|
||||||
|
|
||||||
replace git.schwanenlied.me/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e => gitlab.com/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e
|
replace git.schwanenlied.me/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e => gitlab.com/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/a-h/templ v0.2.747
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2
|
github.com/btcsuite/btcd/btcec/v2 v2.3.2
|
||||||
|
github.com/btcsuite/btcd/btcutil v1.1.3
|
||||||
github.com/btcsuite/btcutil v1.0.2
|
github.com/btcsuite/btcutil v1.0.2
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/labstack/echo/v4 v4.11.1
|
github.com/labstack/echo/v4 v4.11.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/lightninglabs/lndclient v0.16.0-11
|
github.com/lightninglabs/lndclient v0.16.0-11
|
||||||
github.com/lightningnetwork/lnd v0.16.0-beta
|
github.com/lightningnetwork/lnd v0.16.0-beta
|
||||||
|
github.com/lukesampson/figlet v0.0.0-20190211215653-8a3ef4a6ac42
|
||||||
github.com/namsral/flag v1.7.4-pre
|
github.com/namsral/flag v1.7.4-pre
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.8.4
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
|
|
||||||
gopkg.in/guregu/null.v4 v4.0.0
|
gopkg.in/guregu/null.v4 v4.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
|
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
|
||||||
github.com/aead/siphash v1.0.1 // indirect
|
github.com/aead/siphash v1.0.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.0.3 // indirect
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/btcsuite/btcd v0.23.5-0.20230125025938-be056b0a0b2f // indirect
|
github.com/btcsuite/btcd v0.23.5-0.20230125025938-be056b0a0b2f // indirect
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.3 // indirect
|
|
||||||
github.com/btcsuite/btcd/btcutil/psbt v1.1.5 // indirect
|
github.com/btcsuite/btcd/btcutil/psbt v1.1.5 // indirect
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect
|
||||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
|
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
|
||||||
github.com/btcsuite/btcutil/psbt v1.0.2 // indirect
|
|
||||||
github.com/btcsuite/btcwallet v0.16.7 // indirect
|
github.com/btcsuite/btcwallet v0.16.7 // indirect
|
||||||
github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2 // indirect
|
github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2 // indirect
|
||||||
github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect
|
github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect
|
||||||
|
@ -39,24 +42,16 @@ require (
|
||||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect
|
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect
|
||||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect
|
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect
|
||||||
github.com/btcsuite/winsvc v1.0.0 // indirect
|
github.com/btcsuite/winsvc v1.0.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.1.1 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
||||||
github.com/coreos/bbolt v1.3.3 // indirect
|
|
||||||
github.com/coreos/etcd v3.3.22+incompatible // indirect
|
|
||||||
github.com/coreos/go-semver v0.3.0 // indirect
|
github.com/coreos/go-semver v0.3.0 // indirect
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
|
|
||||||
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
|
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
|
||||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
|
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
|
||||||
github.com/decred/dcrd/lru v1.0.0 // indirect
|
github.com/decred/dcrd/lru v1.0.0 // indirect
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
|
||||||
github.com/dsnet/compress v0.0.1 // indirect
|
github.com/dsnet/compress v0.0.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/dvyukov/go-fuzz v0.0.0-20210602112143-b1f3d6f4ef4e // indirect
|
|
||||||
github.com/fergusstrange/embedded-postgres v1.10.0 // indirect
|
github.com/fergusstrange/embedded-postgres v1.10.0 // indirect
|
||||||
github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect
|
|
||||||
github.com/go-errors/errors v1.0.1 // indirect
|
github.com/go-errors/errors v1.0.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
|
@ -87,7 +82,6 @@ require (
|
||||||
github.com/kkdai/bstream v1.0.0 // indirect
|
github.com/kkdai/bstream v1.0.0 // indirect
|
||||||
github.com/klauspost/compress v1.13.6 // indirect
|
github.com/klauspost/compress v1.13.6 // indirect
|
||||||
github.com/klauspost/pgzip v1.2.5 // indirect
|
github.com/klauspost/pgzip v1.2.5 // indirect
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
|
|
||||||
github.com/labstack/gommon v0.4.0 // indirect
|
github.com/labstack/gommon v0.4.0 // indirect
|
||||||
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect
|
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect
|
||||||
github.com/lightninglabs/neutrino v0.15.0 // indirect
|
github.com/lightninglabs/neutrino v0.15.0 // indirect
|
||||||
|
@ -102,7 +96,7 @@ require (
|
||||||
github.com/lightningnetwork/lnd/tor v1.1.0 // indirect
|
github.com/lightningnetwork/lnd/tor v1.1.0 // indirect
|
||||||
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect
|
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||||
github.com/mholt/archiver/v3 v3.5.0 // indirect
|
github.com/mholt/archiver/v3 v3.5.0 // indirect
|
||||||
github.com/miekg/dns v1.1.43 // indirect
|
github.com/miekg/dns v1.1.43 // indirect
|
||||||
|
@ -126,6 +120,7 @@ require (
|
||||||
github.com/ulikunitz/xz v0.5.10 // indirect
|
github.com/ulikunitz/xz v0.5.10 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
|
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
|
||||||
go.etcd.io/bbolt v1.3.6 // indirect
|
go.etcd.io/bbolt v1.3.6 // indirect
|
||||||
|
@ -136,27 +131,22 @@ require (
|
||||||
go.etcd.io/etcd/pkg/v3 v3.5.7 // indirect
|
go.etcd.io/etcd/pkg/v3 v3.5.7 // indirect
|
||||||
go.etcd.io/etcd/raft/v3 v3.5.7 // indirect
|
go.etcd.io/etcd/raft/v3 v3.5.7 // indirect
|
||||||
go.etcd.io/etcd/server/v3 v3.5.7 // indirect
|
go.etcd.io/etcd/server/v3 v3.5.7 // indirect
|
||||||
go.opentelemetry.io/contrib v0.20.0 // indirect
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.0.1 // indirect
|
go.opentelemetry.io/otel v1.0.1 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp v0.20.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1 // indirect
|
||||||
go.opentelemetry.io/otel/metric v0.20.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk v1.0.1 // indirect
|
go.opentelemetry.io/otel/sdk v1.0.1 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/export/metric v0.20.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v0.20.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/trace v1.0.1 // indirect
|
go.opentelemetry.io/otel/trace v1.0.1 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v0.9.0 // indirect
|
go.opentelemetry.io/proto/otlp v0.9.0 // indirect
|
||||||
go.uber.org/atomic v1.7.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.6.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
go.uber.org/zap v1.17.0 // indirect
|
golang.org/x/crypto v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.13.0 // indirect
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
golang.org/x/mod v0.12.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/net v0.15.0 // indirect
|
golang.org/x/net v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.12.0 // indirect
|
golang.org/x/sys v0.21.0 // indirect
|
||||||
golang.org/x/term v0.12.0 // indirect
|
golang.org/x/term v0.19.0 // indirect
|
||||||
golang.org/x/text v0.13.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
golang.org/x/time v0.3.0 // indirect
|
golang.org/x/time v0.3.0 // indirect
|
||||||
golang.org/x/tools v0.13.0 // indirect
|
golang.org/x/tools v0.13.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced // indirect
|
google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced // indirect
|
||||||
|
|
|
@ -4,12 +4,16 @@ PID=$(pidof delphi.market)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
echo ":: remote port forwarding for dev1.delphi.market ::"
|
||||||
|
ssh -fnNR 4322:localhost:4321 dev1.delphi.market
|
||||||
|
echo
|
||||||
|
|
||||||
function restart_server() {
|
function restart_server() {
|
||||||
set +e
|
set +e
|
||||||
[[ -z "$PID" ]] || kill -15 $PID
|
[[ -z "$PID" ]] || kill -15 $PID
|
||||||
ENV=development make build -B
|
ENV=development make build -B
|
||||||
set -e
|
set -e
|
||||||
./delphi.market >> server.log 2>&1 &
|
./delphi.market 2>&1 &
|
||||||
PID=$(pidof delphi.market)
|
PID=$(pidof delphi.market)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,8 +29,7 @@ function cleanup() {
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
restart
|
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
|
restart
|
||||||
done
|
done
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lukesampson/figlet/figletlib"
|
||||||
|
"golang.org/x/exp/rand"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Figlet(fontName string, text string) string {
|
||||||
|
var (
|
||||||
|
fontsDir = "fonts"
|
||||||
|
// download figlet fonts from http://www.figlet.org/
|
||||||
|
font *figletlib.Font
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if fontName == "random" {
|
||||||
|
if files, err := os.ReadDir(fontsDir); err != nil {
|
||||||
|
log.Printf("error reading directory %s: %v\n", fontsDir, err)
|
||||||
|
return ""
|
||||||
|
} else {
|
||||||
|
fontName = files[rand.Intn(len(files))].Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if font, err = figletlib.GetFontByName(fontsDir, fontName); err != nil {
|
||||||
|
log.Printf("could not find font %s: %v\n", fontName, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
b := new(strings.Builder)
|
||||||
|
figletlib.FPrintMsg(b, text, font, 80, font.Settings(), "left")
|
||||||
|
return b.String()
|
||||||
|
}
|
16
lib/qr.go
|
@ -1,16 +0,0 @@
|
||||||
package lib
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
|
|
||||||
"github.com/skip2/go-qrcode"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ToQR(s string) (string, error) {
|
|
||||||
png, err := qrcode.Encode(s, qrcode.Medium, 256)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
qr := base64.StdEncoding.EncodeToString([]byte(png))
|
|
||||||
return qr, nil
|
|
||||||
}
|
|
163
lnd/invoice.go
|
@ -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
|
|
||||||
}
|
|
70
lnd/lnd.go
|
@ -1,9 +1,15 @@
|
||||||
package lnd
|
package lnd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/db"
|
||||||
"github.com/lightninglabs/lndclient"
|
"github.com/lightninglabs/lndclient"
|
||||||
|
"github.com/lightningnetwork/lnd/invoices"
|
||||||
|
"github.com/lightningnetwork/lnd/lntypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LNDClient struct {
|
type LNDClient struct {
|
||||||
|
@ -25,3 +31,67 @@ func New(config *LNDConfig) (*LNDClient, error) {
|
||||||
log.Printf("Connected to %s running LND v%s", config.LndAddress, lnd.Version.Version)
|
log.Printf("Connected to %s running LND v%s", config.LndAddress, lnd.Version.Version)
|
||||||
return lnd, nil
|
return lnd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (lnd *LNDClient) CheckInvoices(db *db.DB) {
|
||||||
|
var (
|
||||||
|
pending bool
|
||||||
|
rows *sql.Rows
|
||||||
|
hash lntypes.Hash
|
||||||
|
inv *lndclient.Invoice
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
for {
|
||||||
|
// fetch all pending invoices
|
||||||
|
if rows, err = db.Query("" +
|
||||||
|
"SELECT hash, expires_at " +
|
||||||
|
"FROM invoices " +
|
||||||
|
"WHERE confirmed_at IS NULL AND expires_at > CURRENT_TIMESTAMP"); err != nil {
|
||||||
|
log.Printf("error checking invoices: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = false
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
pending = true
|
||||||
|
|
||||||
|
var (
|
||||||
|
h string
|
||||||
|
expiresAt time.Time
|
||||||
|
)
|
||||||
|
rows.Scan(&h, &expiresAt)
|
||||||
|
|
||||||
|
if hash, err = lntypes.MakeHashFromStr(h); err != nil {
|
||||||
|
log.Printf("error parsing hash: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inv, err = lnd.Client.LookupInvoice(context.TODO(), hash); err != nil {
|
||||||
|
log.Printf("error looking up invoice: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inv.State.IsFinal() {
|
||||||
|
log.Printf("invoice pending: %s %s", h, time.Until(expiresAt))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inv.State == invoices.ContractSettled {
|
||||||
|
if _, err = db.Exec(
|
||||||
|
"UPDATE invoices SET msats_received = $1, confirmed_at = $2 WHERE hash = $3",
|
||||||
|
inv.AmountPaid, inv.SettleDate, h); err != nil {
|
||||||
|
log.Printf("error updating invoice %s: %v", h, err)
|
||||||
|
}
|
||||||
|
log.Printf("invoice confirmed: %s", h)
|
||||||
|
} else if inv.State == invoices.ContractCanceled {
|
||||||
|
log.Printf("invoice expired: %s", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll faster if there are pending invoices
|
||||||
|
if pending {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
} else {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
package lnd
|
|
|
@ -10,21 +10,31 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (lnd *LNDClient) PayInvoice(tx *sql.Tx, bolt11 string) error {
|
func (lnd *LNDClient) PayInvoice(tx *sql.Tx, bolt11 string) error {
|
||||||
maxFeeSats := btcutil.Amount(10)
|
|
||||||
ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
log.Printf("attempting to pay bolt11 %s ...\n", bolt11)
|
log.Printf("attempting to pay bolt11 %s ...\n", bolt11)
|
||||||
|
|
||||||
|
maxFeeSats := btcutil.Amount(10)
|
||||||
payChan := lnd.Client.PayInvoice(ctx, bolt11, maxFeeSats, nil)
|
payChan := lnd.Client.PayInvoice(ctx, bolt11, maxFeeSats, nil)
|
||||||
|
|
||||||
res := <-payChan
|
res := <-payChan
|
||||||
|
|
||||||
if res.Err != nil {
|
if res.Err != nil {
|
||||||
log.Printf("error paying bolt11: %s -- %s\n", bolt11, res.Err)
|
log.Printf("error paying bolt11: %s -- %s\n", bolt11, res.Err)
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return res.Err
|
return res.Err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("successfully paid bolt11: %s\n", bolt11)
|
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()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.Err
|
return res.Err
|
||||||
}
|
}
|
||||||
|
|
46
main.go
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -20,26 +21,31 @@ var (
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
var (
|
var (
|
||||||
dbUrl string
|
dbUrl string
|
||||||
lndAddress string
|
lndAddress string
|
||||||
lndCert string
|
lndCert string
|
||||||
lndMacaroonDir string
|
tlsData []byte
|
||||||
lndNetwork string
|
lndMacaroon string
|
||||||
db_ *db.DB
|
lndNetwork string
|
||||||
lnd_ *lnd.LNDClient
|
db_ *db.DB
|
||||||
ctx router.ServerContext
|
lnd_ *lnd.LNDClient
|
||||||
err error
|
ctx router.Context
|
||||||
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
if err = env.Load(); err != nil {
|
if err = env.Load(); err != nil {
|
||||||
log.Fatalf("error loading env vars: %v", err)
|
log.Fatalf("error loading env vars: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.StringVar(&dbUrl, "DATABASE_URL", "delphi.market", "Public URL of website")
|
flag.StringVar(&dbUrl, "DATABASE_URL", "delphi.market", "Public URL of website")
|
||||||
flag.StringVar(&lndAddress, "LND_ADDRESS", "localhost:10001", "LND gRPC server address")
|
flag.StringVar(&lndAddress, "LND_ADDRESS", "localhost:10001", "LND gRPC server address")
|
||||||
flag.StringVar(&lndCert, "LND_CERT", "", "Path to LND TLS certificate")
|
flag.StringVar(&lndCert, "LND_CERT", "", "LND TLS certificate in hex")
|
||||||
flag.StringVar(&lndMacaroonDir, "LND_MACAROON_DIR", "", "LND macaroon directory")
|
flag.StringVar(&lndMacaroon, "LND_MACAROON", "", "LND macaroon in hex")
|
||||||
flag.StringVar(&lndNetwork, "LND_NETWORK", "regtest", "LND network")
|
flag.StringVar(&lndNetwork, "LND_NETWORK", "regtest", "LND network")
|
||||||
env.Parse()
|
env.Parse()
|
||||||
|
|
||||||
figlet()
|
figlet()
|
||||||
|
|
||||||
log.Printf("Commit: %s", env.CommitShortSha)
|
log.Printf("Commit: %s", env.CommitShortSha)
|
||||||
log.Printf("Public URL: %s", env.PublicURL)
|
log.Printf("Public URL: %s", env.PublicURL)
|
||||||
log.Printf("Environment: %s", env.Env)
|
log.Printf("Environment: %s", env.Env)
|
||||||
|
@ -48,19 +54,25 @@ func init() {
|
||||||
log.Fatalf("error connecting to database: %v", err)
|
log.Fatalf("error connecting to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tlsData, err = hex.DecodeString(lndCert); err != nil {
|
||||||
|
log.Printf("[warn] error decoding LND TLS certificate: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
if lnd_, err = lnd.New(&lnd.LNDConfig{
|
if lnd_, err = lnd.New(&lnd.LNDConfig{
|
||||||
LndAddress: lndAddress,
|
LndAddress: lndAddress,
|
||||||
TLSPath: lndCert,
|
CustomMacaroonHex: lndMacaroon,
|
||||||
MacaroonDir: lndMacaroonDir,
|
TLSData: string(tlsData),
|
||||||
Network: lndclient.Network(lndNetwork),
|
Network: lndclient.Network(lndNetwork),
|
||||||
|
Insecure: false,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("[warn] error connecting to LND: %v\n", err)
|
log.Printf("[warn] error connecting to LND: %v\n", err)
|
||||||
lnd_ = nil
|
lnd_ = nil
|
||||||
} else {
|
} else {
|
||||||
lnd_.CheckInvoices(db_)
|
go lnd_.CheckInvoices(db_)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = server.ServerContext{
|
ctx = server.Context{
|
||||||
|
Environment: env.Env,
|
||||||
PublicURL: env.PublicURL,
|
PublicURL: env.PublicURL,
|
||||||
CommitShortSha: env.CommitShortSha,
|
CommitShortSha: env.CommitShortSha,
|
||||||
CommitLongSha: env.CommitLongSha,
|
CommitLongSha: env.CommitLongSha,
|
||||||
|
|
|
@ -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="/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>
|
|
|
@ -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="/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>
|
|
|
@ -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="/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>
|
|
|
@ -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="/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>
|
|
106
pages/user.html
|
@ -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="/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>
|
|
|
@ -1,37 +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="/index.css" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#091833" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header class="flex flex-row text-center justify-center pt-1">
|
|
||||||
<nav>
|
|
||||||
<a href="/">home</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div class="container flex flex-column text-center">
|
|
||||||
<code>
|
|
||||||
<strong>
|
|
||||||
<pre>
|
|
||||||
_ _ ___ ___
|
|
||||||
| || | / _ \ / _ \
|
|
||||||
| || |_| | | | | | |
|
|
||||||
|__ _| |_| | |_| |
|
|
||||||
|_| \___/ \___/ </pre>
|
|
||||||
</strong>
|
|
||||||
</code>
|
|
||||||
<div class="font-mono mb-1">Bad Request</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,37 +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="/index.css" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#091833" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header class="flex flex-row text-center justify-center pt-1">
|
|
||||||
<nav>
|
|
||||||
<a href="/">home</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div class="container flex flex-column text-center">
|
|
||||||
<code>
|
|
||||||
<strong>
|
|
||||||
<pre>
|
|
||||||
_ _ ___ _ _
|
|
||||||
| || | / _ \| || |
|
|
||||||
| || |_| | | | || |_
|
|
||||||
|__ _| |_| |__ _|
|
|
||||||
|_| \___/ |_| </pre>
|
|
||||||
</strong>
|
|
||||||
</code>
|
|
||||||
<div class="font-mono mb-1">Not Found</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,37 +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="/index.css" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#091833" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header class="flex flex-row text-center justify-center pt-1">
|
|
||||||
<nav>
|
|
||||||
<a href="/">home</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div class="container flex flex-column text-center">
|
|
||||||
<code>
|
|
||||||
<strong>
|
|
||||||
<pre>
|
|
||||||
_ _ ___ ____
|
|
||||||
| || | / _ \| ___|
|
|
||||||
| || |_| | | |___ \
|
|
||||||
|__ _| |_| |___) |
|
|
||||||
|_| \___/|____/ </pre>
|
|
||||||
</strong>
|
|
||||||
</code>
|
|
||||||
<div class="font-mono mb-1">Method Not Allowed</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,37 +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="/index.css" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#091833" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header class="flex flex-row text-center justify-center pt-1">
|
|
||||||
<nav>
|
|
||||||
<a href="/">home</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div class="container flex flex-column text-center">
|
|
||||||
<code>
|
|
||||||
<strong>
|
|
||||||
<pre>
|
|
||||||
____ ___ ___
|
|
||||||
| ___| / _ \ / _ \
|
|
||||||
|___ \| | | | | | |
|
|
||||||
___) | |_| | |_| |
|
|
||||||
|____/ \___/ \___/ </pre>
|
|
||||||
</strong>
|
|
||||||
</code>
|
|
||||||
<div class="font-mono mb-1">Internal Server Error</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 449 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 8.8 KiB |
|
@ -0,0 +1,168 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #191d21;
|
||||||
|
--color: #d3d3d3;
|
||||||
|
--muted: #6c757d;
|
||||||
|
--lightning: #fada5e;
|
||||||
|
--nostr: #8d45dd;
|
||||||
|
--black: #000;
|
||||||
|
--white: #fff;
|
||||||
|
--bg-success: #149e613d;
|
||||||
|
--fg-success: #35df8d;
|
||||||
|
--bg-error: #f5395e3d;
|
||||||
|
--fg-error: #ff7386;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--color);
|
||||||
|
transition: background-color 150ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
min-height: 85svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
#content {
|
||||||
|
min-height: 90svh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-color: var(--muted);
|
||||||
|
border-style: dashed;
|
||||||
|
@apply pb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not(.no-link),
|
||||||
|
button[hx-get],
|
||||||
|
button[hx-post],
|
||||||
|
button[type="submit"],
|
||||||
|
.button {
|
||||||
|
transition: background-color 150ms ease-in, color 150ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not(.no-link):hover,
|
||||||
|
button[hx-get]:hover,
|
||||||
|
button[hx-post]:hover,
|
||||||
|
button[type="submit"]:hover,
|
||||||
|
.button:hover {
|
||||||
|
background-color: var(--color);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not(.no-link),
|
||||||
|
button[hx-get] {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[hx-post],
|
||||||
|
button[type="submit"],
|
||||||
|
.button {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a,
|
||||||
|
button[hx-get],
|
||||||
|
button[hx-post],
|
||||||
|
button[type="submit"] {
|
||||||
|
padding: 0 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
width: 300px;
|
||||||
|
aspect-ratio: 560/315;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-reset {
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitbox {
|
||||||
|
padding: 15px;
|
||||||
|
margin: -15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon {
|
||||||
|
border: none;
|
||||||
|
padding: 0.5em 3em;
|
||||||
|
transition: background-color 150ms ease-in, color 150ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon.success {
|
||||||
|
background-color: var(--bg-success);
|
||||||
|
color: var(--fg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon.success:hover {
|
||||||
|
background-color: var(--fg-success);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon.error {
|
||||||
|
background-color: var(--bg-error);
|
||||||
|
color: var(--fg-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon.error:hover {
|
||||||
|
background-color: var(--fg-error);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figlet {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 144px;
|
||||||
|
@apply my-3
|
||||||
|
}
|
||||||
|
|
||||||
|
.login, .signup {
|
||||||
|
text-decoration: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
padding: 0.25em 1em !important;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightning {
|
||||||
|
background-color: var(--lightning) !important;
|
||||||
|
color: var(--black) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightning:hover {
|
||||||
|
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--lightning));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr {
|
||||||
|
background-color: var(--nostr) !important;
|
||||||
|
color: var(--white) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr:hover {
|
||||||
|
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--nostr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 799 B After Width: | Height: | Size: 620 B |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
173
public/index.css
|
@ -1,173 +0,0 @@
|
||||||
body {
|
|
||||||
background-color: #091833;
|
|
||||||
color: #ffffff;
|
|
||||||
caret-color: transparent;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #8787a4;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
background: #8787A4;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
a.selected {
|
|
||||||
background: #8787A4;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
nav > a {
|
|
||||||
margin: 0 3px;
|
|
||||||
}
|
|
||||||
nav > form > button {
|
|
||||||
color: #8787a4;
|
|
||||||
text-decoration: underline;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
margin: 0 3px;
|
|
||||||
}
|
|
||||||
nav > form > button:hover {
|
|
||||||
background: #8787A4;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
#qr > a > img {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
#qr > a > img {
|
|
||||||
width: 30%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#qr > a:hover {
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
height: 1px;
|
|
||||||
background: repeating-linear-gradient(90deg,#fff,#fff 6px,transparent 1px,transparent 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
-webkit-box-sizing: border-box;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
-ms-box-sizing: border-box;
|
|
||||||
-o-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 1em auto;
|
|
||||||
padding: 0 1em;
|
|
||||||
min-width: 40vw;
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.container {
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.container {
|
|
||||||
max-width: 80vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.container {
|
|
||||||
max-width: 60vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.flex-column {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.text-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-bold {
|
|
||||||
font: bold;
|
|
||||||
}
|
|
||||||
.font-mono {
|
|
||||||
font-family: "Lucida Console", "Courier New", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-center {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-100vh {
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-100p {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-auto {
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
.mb-1 {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
.mb-s {
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
}
|
|
||||||
.mr-s {
|
|
||||||
margin-right: 0.2em;
|
|
||||||
}
|
|
||||||
.pt-1 {
|
|
||||||
padding-top: 1em;
|
|
||||||
}
|
|
||||||
.mx-1 {
|
|
||||||
margin: 0 .2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-wrap {
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.align-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.align-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.scroll-x {
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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')
|
|
||||||
}
|
|
|
@ -8,58 +8,74 @@ import (
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2"
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
"github.com/btcsuite/btcutil/bech32"
|
"github.com/btcsuite/btcutil/bech32"
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/env"
|
"git.ekzyis.com/ekzyis/delphi.market/env"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LNAuth struct {
|
type LnAuth struct {
|
||||||
K1 string
|
K1 string
|
||||||
LNURL string
|
LNURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LNAuthResponse struct {
|
type LnAuthCallback struct {
|
||||||
K1 string `query:"k1"`
|
K1 string `query:"k1"`
|
||||||
Sig string `query:"sig"`
|
Sig string `query:"sig"`
|
||||||
Key string `query:"key"`
|
Key string `query:"key"`
|
||||||
|
Action string `query:"action"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLNAuth() (*LNAuth, error) {
|
func NewLnAuth(action string) (*LnAuth, error) {
|
||||||
k1 := make([]byte, 32)
|
var (
|
||||||
_, err := rand.Read(k1)
|
k1 = make([]byte, 32)
|
||||||
if err != nil {
|
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)
|
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))
|
k1hex = hex.EncodeToString(k1)
|
||||||
conv, err := bech32.ConvertBits(url, 8, 5, true)
|
url = []byte(fmt.Sprintf("https://%s/api/lnauth/callback?tag=login&k1=%s&action=%s", env.PublicURL, k1hex, action))
|
||||||
if err != nil {
|
|
||||||
|
if bech32Url, err = bech32.ConvertBits(url, 8, 5, true); err != nil {
|
||||||
return nil, fmt.Errorf("bech32.ConvertBits error: %w", err)
|
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 nil, fmt.Errorf("bech32.Encode error: %w", err)
|
||||||
}
|
}
|
||||||
return &LNAuth{k1hex, lnurl}, nil
|
|
||||||
|
return &LnAuth{k1hex, lnurl}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func VerifyLNAuth(r *LNAuthResponse) (bool, error) {
|
func VerifyLNAuth(r *LnAuthCallback) (bool, error) {
|
||||||
var k1Bytes, sigBytes, keyBytes []byte
|
var (
|
||||||
k1Bytes, err := hex.DecodeString(r.K1)
|
k1Bytes, sigBytes, keyBytes []byte
|
||||||
if err != nil {
|
key *secp256k1.PublicKey
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if k1Bytes, err = hex.DecodeString(r.K1); err != nil {
|
||||||
return false, fmt.Errorf("k1 decode error: %w", err)
|
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)
|
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)
|
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)
|
return false, fmt.Errorf("key parse error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ecdsaKey := ecdsa.PublicKey{Curve: btcec.S256(), X: key.X(), Y: key.Y()}
|
ecdsaKey := ecdsa.PublicKey{Curve: btcec.S256(), X: key.X(), Y: key.Y()}
|
||||||
return ecdsa.VerifyASN1(&ecdsaKey, k1Bytes, sigBytes), nil
|
return ecdsa.VerifyASN1(&ecdsaKey, k1Bytes, sigBytes), nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,49 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
|
"github.com/a-h/templ"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func httpErrorHandler(err error, c echo.Context) {
|
func httpErrorHandler(sc context.Context) echo.HTTPErrorHandler {
|
||||||
c.Logger().Error(err)
|
return func(err error, c echo.Context) {
|
||||||
code := http.StatusInternalServerError
|
var (
|
||||||
if httpError, ok := err.(*echo.HTTPError); ok {
|
code = http.StatusInternalServerError
|
||||||
code = httpError.Code
|
page = pages.Error
|
||||||
|
buf *bytes.Buffer
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Logger().Error(err)
|
||||||
|
|
||||||
|
if httpError, ok := err.(*echo.HTTPError); ok {
|
||||||
|
code = httpError.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), "violates check constraint") ||
|
||||||
|
strings.Contains(err.Error(), "violates unique constraint") {
|
||||||
|
code = 400
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = templ.GetBuffer()
|
||||||
|
defer templ.ReleaseBuffer(buf)
|
||||||
|
|
||||||
|
if err = page(code).Render(context.RenderContext(sc, c), buf); err != nil {
|
||||||
|
c.Logger().Error(err)
|
||||||
|
code = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure that HTMX selects and targets correct element
|
||||||
|
c.Response().Header().Add("HX-Retarget", "#content")
|
||||||
|
c.Response().Header().Add("HX-Reselect", "#content")
|
||||||
|
|
||||||
|
if err = c.HTML(code, buf.String()); err != nil {
|
||||||
|
c.Logger().Error(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "violates check constraint") || strings.Contains(err.Error(), "violates unique constraint") {
|
|
||||||
code = 400
|
|
||||||
}
|
|
||||||
c.JSON(code, map[string]any{"status": code})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
package context
|
package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/db"
|
"git.ekzyis.com/ekzyis/delphi.market/db"
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/lnd"
|
"git.ekzyis.com/ekzyis/delphi.market/lnd"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerContext struct {
|
type Context struct {
|
||||||
|
context.Context
|
||||||
Environment string
|
Environment string
|
||||||
PublicURL string
|
PublicURL string
|
||||||
CommitShortSha string
|
CommitShortSha string
|
||||||
|
@ -16,19 +19,20 @@ type ServerContext struct {
|
||||||
Lnd *lnd.LNDClient
|
Lnd *lnd.LNDClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sc *ServerContext) Render(c echo.Context, code int, name string, data map[string]any) error {
|
type RenderContextKey string
|
||||||
envVars := map[string]any{
|
|
||||||
"PUBLIC_URL": sc.PublicURL,
|
|
||||||
"COMMIT_SHORT_SHA": sc.CommitShortSha,
|
|
||||||
"COMMIT_LONG_SHA": sc.CommitLongSha,
|
|
||||||
"VERSION": sc.Version,
|
|
||||||
}
|
|
||||||
merge(&data, &envVars)
|
|
||||||
return c.Render(code, name, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func merge[T comparable](target *map[T]any, src *map[T]any) {
|
var (
|
||||||
for k, v := range *src {
|
EnvContextKey RenderContextKey = "env"
|
||||||
(*target)[k] = v
|
SessionContextKey RenderContextKey = "session"
|
||||||
}
|
CommitContextKey RenderContextKey = "commit"
|
||||||
|
ReqPathContextKey RenderContextKey = "reqPath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RenderContext(sc Context, c echo.Context) context.Context {
|
||||||
|
ctx := c.Request().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
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleAbout(sc context.Context) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return pages.About().Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,225 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleAuth(sc context.Context, action string) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
|
||||||
|
if c.Param("method") == "lightning" {
|
||||||
|
return LnAuth(sc, c, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Param("method") == "nostr" {
|
||||||
|
return NostrAuth(sc, c, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// on session guard redirects to /login,
|
||||||
|
// we need to make sure that HTMX selects and targets correct element
|
||||||
|
c.Response().Header().Add("HX-Retarget", "#content")
|
||||||
|
c.Response().Header().Add("HX-Reselect", "#content")
|
||||||
|
|
||||||
|
return pages.Auth(mapAction(action)).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LnAuth(sc context.Context, c echo.Context, action string) 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)
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if lnAuth, err = auth.NewLnAuth(action); 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
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
Value: sessionId,
|
||||||
|
Secure: true,
|
||||||
|
Expires: expires,
|
||||||
|
})
|
||||||
|
|
||||||
|
return pages.LnAuth(lnAuth.LNURL, mapAction(action)).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
|
||||||
|
pqErr *pq.Error
|
||||||
|
)
|
||||||
|
|
||||||
|
bail := func(code int, reason string) error {
|
||||||
|
if tx != nil {
|
||||||
|
// manual rollback is only required for tests afaik
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
return c.JSON(code, map[string]string{"status": "ERROR", "reason": reason})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = c.Bind(&query); err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
} else if query.K1 == "" || query.Sig == "" || query.Key == "" || query.Action == "" {
|
||||||
|
return bail(http.StatusBadRequest, "bad query")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.QueryRow("SELECT session_id FROM lnauth WHERE k1 = $1 LIMIT 1", query.K1).Scan(&sessionId)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return bail(http.StatusNotFound, "session not found")
|
||||||
|
} else if err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err = auth.VerifyLNAuth(&query)
|
||||||
|
if err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
} else if !ok {
|
||||||
|
return bail(http.StatusBadRequest, "bad signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Action == "register" {
|
||||||
|
err = tx.QueryRow(""+
|
||||||
|
"INSERT INTO users(ln_pubkey) VALUES ($1) "+
|
||||||
|
"ON CONFLICT(ln_pubkey) DO UPDATE SET ln_pubkey = $1 "+
|
||||||
|
"RETURNING id", query.Key).Scan(&userId)
|
||||||
|
if err != nil {
|
||||||
|
pqErr, ok = err.(*pq.Error)
|
||||||
|
if ok && pqErr.Code == "23505" {
|
||||||
|
return bail(http.StatusBadRequest, "user already exists")
|
||||||
|
}
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
} else if query.Action == "login" {
|
||||||
|
err = tx.QueryRow("SELECT id FROM users WHERE ln_pubkey = $1", query.Key).Scan(&userId)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return bail(http.StatusNotFound, "user not found")
|
||||||
|
} else if err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return bail(http.StatusBadRequest, "bad action")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec("INSERT INTO sessions(id, user_id) VALUES($1, $2)", sessionId, userId); err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec("DELETE FROM lnauth WHERE k1 = $1", query.K1); err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "OK"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NostrAuth(sc context.Context, c echo.Context, action string) error {
|
||||||
|
return echo.NewHTTPError(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSessionCheck(sc context.Context) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
db = sc.Db
|
||||||
|
ctx = c.Request().Context()
|
||||||
|
cookie *http.Cookie
|
||||||
|
userId int
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if cookie, err = c.Cookie("session"); err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, "no session cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = db.QueryRowContext(ctx,
|
||||||
|
"SELECT user_id FROM sessions WHERE id = $1", cookie.Value).
|
||||||
|
Scan(&userId); err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, "session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX can't follow 302 redirects with a Location header
|
||||||
|
// it requires a HX-Location header and 200 response instead
|
||||||
|
// see https://github.com/bigskysoftware/htmx/issues/2052
|
||||||
|
c.Response().Header().Set("HX-Location", "/")
|
||||||
|
return c.JSON(http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapAction(action string) string {
|
||||||
|
// LNURL spec uses "register" but we want to show "signup" to the user
|
||||||
|
// see https://github.com/lnurl/luds/blob/luds/04.md
|
||||||
|
switch action {
|
||||||
|
case "register":
|
||||||
|
return "signup"
|
||||||
|
default:
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleLogout(sc context.Context) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
db = sc.Db
|
||||||
|
ctx = c.Request().Context()
|
||||||
|
cookie *http.Cookie
|
||||||
|
sessionId string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if cookie, err = c.Cookie("session"); err != nil {
|
||||||
|
// cookie not found
|
||||||
|
return c.JSON(http.StatusNotFound, "session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionId = cookie.Value
|
||||||
|
if _, err = db.ExecContext(ctx,
|
||||||
|
"DELETE FROM sessions WHERE id = $1", 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.Redirect(http.StatusSeeOther, "/")
|
||||||
|
// c.Response().Header().Set("HX-Location", "/")
|
||||||
|
// return c.JSON(http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,41 +1,46 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"database/sql"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/db"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleIndex(sc context.ServerContext) echo.HandlerFunc {
|
func HandleIndex(sc context.Context) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
markets []db.Market
|
db = sc.Db
|
||||||
|
ctx = c.Request().Context()
|
||||||
|
rows *sql.Rows
|
||||||
err error
|
err error
|
||||||
data map[string]any
|
markets []types.Market
|
||||||
)
|
)
|
||||||
if err = sc.Db.FetchActiveMarkets(&markets); err != nil {
|
|
||||||
|
if rows, err = db.QueryContext(ctx, ""+
|
||||||
|
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, "+
|
||||||
|
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, u.msats "+
|
||||||
|
"FROM markets m "+
|
||||||
|
"JOIN users u ON m.user_id = u.id "+
|
||||||
|
"JOIN invoices i ON m.invoice_id = i.id "+
|
||||||
|
"WHERE i.confirmed_at IS NOT NULL"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data = map[string]any{
|
|
||||||
"session": c.Get("session"),
|
for rows.Next() {
|
||||||
"markets": markets,
|
var m types.Market
|
||||||
|
var u types.User
|
||||||
|
if err = rows.Scan(
|
||||||
|
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate,
|
||||||
|
&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.User = u
|
||||||
|
markets = append(markets, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sc.Render(c, http.StatusOK, "index.html", data)
|
return pages.Index(markets).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleMarkets(sc context.ServerContext) 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,109 +2,68 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/db"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/lib"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
"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/labstack/echo/v4"
|
||||||
"github.com/lightningnetwork/lnd/lntypes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleInvoiceStatus(sc context.ServerContext) echo.HandlerFunc {
|
func HandleInvoice(sc context.Context) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
invoiceId string
|
db = sc.Db
|
||||||
invoice db.Invoice
|
ctx = c.Request().Context()
|
||||||
u db.User
|
hash = c.Param("hash")
|
||||||
qr string
|
u = c.Get("session").(types.User)
|
||||||
err error
|
inv = types.Invoice{}
|
||||||
|
expiresIn int
|
||||||
|
paid bool
|
||||||
|
redirectUrl templ.SafeURL
|
||||||
|
qr templ.Component
|
||||||
|
err error
|
||||||
)
|
)
|
||||||
invoiceId = c.Param("id")
|
|
||||||
if err = sc.Db.FetchInvoice(&db.FetchInvoiceWhere{Id: invoiceId}, &invoice); err == sql.ErrNoRows {
|
if err = db.QueryRowContext(ctx, ""+
|
||||||
|
"SELECT user_id, msats, COALESCE(msats_received, 0), expires_at, confirmed_at, bolt11, COALESCE(description, '') "+
|
||||||
|
"FROM invoices "+
|
||||||
|
"WHERE hash = $1", hash).
|
||||||
|
Scan(&inv.UserId, &inv.Msats, &inv.MsatsReceived, &inv.ExpiresAt, &inv.ConfirmedAt, &inv.Bolt11, &inv.Description); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
c.Logger().Error(err)
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Id != inv.UserId {
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
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)
|
expiresIn = int(time.Until(inv.ExpiresAt).Seconds())
|
||||||
}
|
paid = inv.MsatsReceived >= inv.Msats
|
||||||
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
|
redirectUrl = toRedirectUrl(inv.Description)
|
||||||
return err
|
|
||||||
}
|
qr = components.Invoice(hash, inv.Bolt11, int(inv.Msats), expiresIn, paid, redirectUrl)
|
||||||
invoice.Preimage = ""
|
|
||||||
data := map[string]any{
|
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
"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.ServerContext) echo.HandlerFunc {
|
var (
|
||||||
return func(c echo.Context) error {
|
marketRegexp = regexp.MustCompile("^create market (?P<id>[0-9]+)$")
|
||||||
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 sc.Render(c, http.StatusOK, "invoice.html", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleInvoices(sc context.ServerContext) echo.HandlerFunc {
|
func toRedirectUrl(description string) templ.SafeURL {
|
||||||
return func(c echo.Context) error {
|
var m []string
|
||||||
var (
|
if m = marketRegexp.FindStringSubmatch(description); m != nil {
|
||||||
u db.User
|
marketId := m[marketRegexp.SubexpIndex("id")]
|
||||||
invoices []db.Invoice
|
return templ.SafeURL(fmt.Sprintf("/market/%s", marketId))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return "/"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,304 @@
|
||||||
|
package handler_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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 TestLnAuthSignup(t *testing.T) {
|
||||||
|
var (
|
||||||
|
assert = assert.New(t)
|
||||||
|
sc = context.Context{Db: db}
|
||||||
|
e *echo.Echo
|
||||||
|
c echo.Context
|
||||||
|
req *http.Request
|
||||||
|
rec *httptest.ResponseRecorder
|
||||||
|
cookies []*http.Cookie
|
||||||
|
sessionId string
|
||||||
|
dbSessionId string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
e, req, rec = test.HTTPMocks("GET", "/signup/lightning", nil)
|
||||||
|
c = e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("method")
|
||||||
|
c.SetParamValues("lightning")
|
||||||
|
|
||||||
|
handler.HandleAuth(sc, "register")(c)
|
||||||
|
assert.Equal(http.StatusOK, rec.Code, "wrong status code")
|
||||||
|
|
||||||
|
// Set-Cookie header present
|
||||||
|
cookies = rec.Result().Cookies()
|
||||||
|
assert.Equal(1, len(cookies), "wrong number of Set-Cookie headers")
|
||||||
|
assert.Equal("session", cookies[0].Name, "wrong cookie name")
|
||||||
|
|
||||||
|
// new challenge inserted which matches cookie value
|
||||||
|
sessionId = cookies[0].Value
|
||||||
|
err = db.QueryRow("SELECT session_id FROM lnauth WHERE session_id = $1", sessionId).Scan(&dbSessionId)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal(sessionId, dbSessionId, "wrong session id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLnAuthSignupCallbackUserNotExists(t *testing.T) {
|
||||||
|
var (
|
||||||
|
assert = assert.New(t)
|
||||||
|
e *echo.Echo
|
||||||
|
c echo.Context
|
||||||
|
sc context.Context
|
||||||
|
req *http.Request
|
||||||
|
rec *httptest.ResponseRecorder
|
||||||
|
lnAuth *auth.LnAuth
|
||||||
|
sk *secp256k1.PrivateKey
|
||||||
|
pk *secp256k1.PublicKey
|
||||||
|
sig string
|
||||||
|
key string
|
||||||
|
sessionId string
|
||||||
|
userId int
|
||||||
|
count int
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
lnAuth, err = auth.NewLnAuth("register")
|
||||||
|
assert.NoErrorf(err, "error creating challenge")
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
"INSERT INTO lnauth(k1, lnurl) VALUES($1, $2) RETURNING session_id",
|
||||||
|
lnAuth.K1, lnAuth.LNURL).Scan(&sessionId)
|
||||||
|
assert.NoError(err, "error creating challenge")
|
||||||
|
|
||||||
|
sk, pk, err = test.GenerateKeyPair()
|
||||||
|
assert.NoError(err, "error generating keypair")
|
||||||
|
|
||||||
|
sig, err = test.Sign(sk, lnAuth.K1)
|
||||||
|
assert.NoError(err, "error signing k1")
|
||||||
|
|
||||||
|
key = hex.EncodeToString(pk.SerializeCompressed())
|
||||||
|
|
||||||
|
sc = context.Context{Db: db}
|
||||||
|
e, req, rec = test.HTTPMocks(
|
||||||
|
"GET",
|
||||||
|
fmt.Sprintf("/api/lnauth?tag=login&k1=%s&key=%s&sig=%s&action=%s", lnAuth.K1, key, sig, "register"),
|
||||||
|
nil)
|
||||||
|
c = e.NewContext(req, rec)
|
||||||
|
|
||||||
|
handler.HandleLnAuthCallback(sc)(c)
|
||||||
|
assert.Equal(http.StatusOK, rec.Code, "wrong status code")
|
||||||
|
|
||||||
|
// user created
|
||||||
|
err = db.QueryRow("SELECT id FROM users WHERE ln_pubkey = $1", key).Scan(&userId)
|
||||||
|
assert.NoError(err, "error fetching user")
|
||||||
|
|
||||||
|
// session created
|
||||||
|
err = db.QueryRow("SELECT COUNT(1) FROM sessions WHERE id = $1 AND user_id = $2", sessionId, userId).Scan(&count)
|
||||||
|
assert.NoError(err, "error fetching session")
|
||||||
|
assert.Equal(1, count, "invalid session count")
|
||||||
|
|
||||||
|
// challenge deleted
|
||||||
|
err = db.QueryRow("SELECT COUNT(1) FROM lnauth WHERE k1 = $1", lnAuth.K1).Scan(&count)
|
||||||
|
assert.NoError(err, "error fetching challenge")
|
||||||
|
assert.Equal(count, 0, "challenge not deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLnAuthSignupCallbackUserExists(t *testing.T) {
|
||||||
|
var (
|
||||||
|
assert = assert.New(t)
|
||||||
|
e *echo.Echo
|
||||||
|
c echo.Context
|
||||||
|
sc context.Context
|
||||||
|
req *http.Request
|
||||||
|
rec *httptest.ResponseRecorder
|
||||||
|
lnAuth *auth.LnAuth
|
||||||
|
sk *secp256k1.PrivateKey
|
||||||
|
pk *secp256k1.PublicKey
|
||||||
|
sig string
|
||||||
|
key string
|
||||||
|
sessionId string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
lnAuth, err = auth.NewLnAuth("register")
|
||||||
|
assert.NoErrorf(err, "error creating challenge")
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
"INSERT INTO lnauth(k1, lnurl) VALUES($1, $2) RETURNING session_id",
|
||||||
|
lnAuth.K1, lnAuth.LNURL).Scan(&sessionId)
|
||||||
|
assert.NoError(err, "error creating challenge")
|
||||||
|
|
||||||
|
sk, pk, err = test.GenerateKeyPair()
|
||||||
|
assert.NoError(err, "error generating keypair")
|
||||||
|
|
||||||
|
sig, err = test.Sign(sk, lnAuth.K1)
|
||||||
|
assert.NoError(err, "error signing k1")
|
||||||
|
|
||||||
|
key = hex.EncodeToString(pk.SerializeCompressed())
|
||||||
|
|
||||||
|
// create user before signup
|
||||||
|
_, err = db.Exec("INSERT INTO users(ln_pubkey) VALUES($1) RETURNING id", key)
|
||||||
|
assert.NoError(err, "error creating user")
|
||||||
|
|
||||||
|
sc = context.Context{Db: db}
|
||||||
|
e, req, rec = test.HTTPMocks(
|
||||||
|
"GET",
|
||||||
|
fmt.Sprintf("/api/lnauth?tag=login&k1=%s&key=%s&sig=%s&action=%s", lnAuth.K1, key, sig, "register"),
|
||||||
|
nil)
|
||||||
|
c = e.NewContext(req, rec)
|
||||||
|
|
||||||
|
// does not throw an error for UX reasons
|
||||||
|
handler.HandleLnAuthCallback(sc)(c)
|
||||||
|
assert.Equal(http.StatusOK, rec.Code, "wrong status code")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLnAuthLogin(t *testing.T) {
|
||||||
|
var (
|
||||||
|
assert = assert.New(t)
|
||||||
|
sc = context.Context{Db: db}
|
||||||
|
e *echo.Echo
|
||||||
|
c echo.Context
|
||||||
|
req *http.Request
|
||||||
|
rec *httptest.ResponseRecorder
|
||||||
|
cookies []*http.Cookie
|
||||||
|
sessionId string
|
||||||
|
dbSessionId string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
e, req, rec = test.HTTPMocks("GET", "/login/lightning", nil)
|
||||||
|
c = e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("method")
|
||||||
|
c.SetParamValues("lightning")
|
||||||
|
|
||||||
|
err = handler.HandleAuth(sc, "login")(c)
|
||||||
|
assert.NoError(err, "handler returned error")
|
||||||
|
|
||||||
|
// Set-Cookie header present
|
||||||
|
cookies = rec.Result().Cookies()
|
||||||
|
assert.Equal(len(cookies), 1, "wrong number of Set-Cookie headers")
|
||||||
|
assert.Equal("session", cookies[0].Name, "wrong cookie name")
|
||||||
|
|
||||||
|
// new challenge inserted which matches cookie value
|
||||||
|
sessionId = cookies[0].Value
|
||||||
|
err = db.QueryRow("SELECT session_id FROM lnauth WHERE session_id = $1", sessionId).Scan(&dbSessionId)
|
||||||
|
assert.NoError(err)
|
||||||
|
assert.Equal(sessionId, dbSessionId, "wrong session id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLnAuthLoginCallbackUserNotExists(t *testing.T) {
|
||||||
|
var (
|
||||||
|
assert = assert.New(t)
|
||||||
|
e *echo.Echo
|
||||||
|
c echo.Context
|
||||||
|
sc context.Context
|
||||||
|
req *http.Request
|
||||||
|
rec *httptest.ResponseRecorder
|
||||||
|
lnAuth *auth.LnAuth
|
||||||
|
sk *secp256k1.PrivateKey
|
||||||
|
pk *secp256k1.PublicKey
|
||||||
|
sig string
|
||||||
|
key string
|
||||||
|
sessionId string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
lnAuth, err = auth.NewLnAuth("login")
|
||||||
|
assert.NoErrorf(err, "error creating challenge")
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
"INSERT INTO lnauth(k1, lnurl) VALUES($1, $2) RETURNING session_id",
|
||||||
|
lnAuth.K1, lnAuth.LNURL).Scan(&sessionId)
|
||||||
|
assert.NoError(err, "error creating challenge")
|
||||||
|
|
||||||
|
sk, pk, err = test.GenerateKeyPair()
|
||||||
|
assert.NoError(err, "error generating keypair")
|
||||||
|
|
||||||
|
sig, err = test.Sign(sk, lnAuth.K1)
|
||||||
|
assert.NoError(err, "error signing k1")
|
||||||
|
|
||||||
|
key = hex.EncodeToString(pk.SerializeCompressed())
|
||||||
|
|
||||||
|
sc = context.Context{Db: db}
|
||||||
|
e, req, rec = test.HTTPMocks(
|
||||||
|
"GET",
|
||||||
|
fmt.Sprintf("/api/lnauth?tag=login&k1=%s&key=%s&sig=%s&action=%s", lnAuth.K1, key, sig, "login"),
|
||||||
|
nil)
|
||||||
|
c = e.NewContext(req, rec)
|
||||||
|
|
||||||
|
// must throw error because user does not exist
|
||||||
|
handler.HandleLnAuthCallback(sc)(c)
|
||||||
|
assert.Equal(http.StatusNotFound, rec.Code, "wrong status code")
|
||||||
|
assert.Contains(rec.Body.String(), "\"reason\":\"user not found\"", "user check failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLnAuthLoginCallbackUserExists(t *testing.T) {
|
||||||
|
var (
|
||||||
|
assert = assert.New(t)
|
||||||
|
e *echo.Echo
|
||||||
|
c echo.Context
|
||||||
|
sc context.Context
|
||||||
|
req *http.Request
|
||||||
|
rec *httptest.ResponseRecorder
|
||||||
|
lnAuth *auth.LnAuth
|
||||||
|
sk *secp256k1.PrivateKey
|
||||||
|
pk *secp256k1.PublicKey
|
||||||
|
sig string
|
||||||
|
key string
|
||||||
|
sessionId string
|
||||||
|
userId int
|
||||||
|
count int
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
lnAuth, err = auth.NewLnAuth("login")
|
||||||
|
assert.NoErrorf(err, "error creating challenge")
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
"INSERT INTO lnauth(k1, lnurl) VALUES($1, $2) RETURNING session_id",
|
||||||
|
lnAuth.K1, lnAuth.LNURL).Scan(&sessionId)
|
||||||
|
assert.NoError(err, "error creating challenge")
|
||||||
|
|
||||||
|
sk, pk, err = test.GenerateKeyPair()
|
||||||
|
assert.NoError(err, "error generating keypair")
|
||||||
|
|
||||||
|
sig, err = test.Sign(sk, lnAuth.K1)
|
||||||
|
assert.NoError(err, "error signing k1")
|
||||||
|
|
||||||
|
key = hex.EncodeToString(pk.SerializeCompressed())
|
||||||
|
|
||||||
|
// create user such that login does not fail
|
||||||
|
err = db.QueryRow("INSERT INTO users(ln_pubkey) VALUES($1) RETURNING id", key).Scan(&userId)
|
||||||
|
assert.NoError(err, "error creating user")
|
||||||
|
|
||||||
|
sc = context.Context{Db: db}
|
||||||
|
e, req, rec = test.HTTPMocks(
|
||||||
|
"GET",
|
||||||
|
fmt.Sprintf("/api/lnauth?tag=login&k1=%s&key=%s&sig=%s&action=%s", lnAuth.K1, key, sig, "login"),
|
||||||
|
nil)
|
||||||
|
c = e.NewContext(req, rec)
|
||||||
|
|
||||||
|
handler.HandleLnAuthCallback(sc)(c)
|
||||||
|
assert.Equal(http.StatusOK, rec.Code, "wrong status code")
|
||||||
|
|
||||||
|
// session created
|
||||||
|
err = db.QueryRow("SELECT COUNT(1) FROM sessions WHERE id = $1 AND user_id = $2", sessionId, userId).Scan(&count)
|
||||||
|
assert.NoError(err, "error fetching session")
|
||||||
|
assert.Equal(1, count, "invalid session count")
|
||||||
|
|
||||||
|
// challenge deleted
|
||||||
|
err = db.QueryRow("SELECT COUNT(1) FROM lnauth WHERE k1 = $1", lnAuth.K1).Scan(&count)
|
||||||
|
assert.NoError(err, "error fetching challenge")
|
||||||
|
assert.Equal(count, 0, "challenge not deleted")
|
||||||
|
}
|
|
@ -1,75 +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/auth"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
func HandleLogin(sc context.ServerContext) 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.ServerContext) 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"})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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.ServerContext
|
|
||||||
req *http.Request
|
|
||||||
rec *httptest.ResponseRecorder
|
|
||||||
cookies []*http.Cookie
|
|
||||||
sessionId string
|
|
||||||
dbSessionId string
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
sc = context.ServerContext{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.ServerContext
|
|
||||||
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.ServerContext{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")
|
|
||||||
}
|
|
|
@ -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.ServerContext) 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"})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +1,9 @@
|
||||||
package handler_test
|
package handler_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
"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"
|
"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() {
|
func init() {
|
||||||
|
@ -22,51 +11,5 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogout(t *testing.T) {
|
func TestLogout(t *testing.T) {
|
||||||
var (
|
t.Skip()
|
||||||
assert = assert.New(t)
|
|
||||||
e *echo.Echo
|
|
||||||
c echo.Context
|
|
||||||
sc context.ServerContext
|
|
||||||
req *http.Request
|
|
||||||
rec *httptest.ResponseRecorder
|
|
||||||
pk *secp256k1.PublicKey
|
|
||||||
s *db_.Session
|
|
||||||
key string
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
sc = context.ServerContext{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")
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,475 +1,117 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
context_ "context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/db"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/lib"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
|
"github.com/a-h/templ"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
|
||||||
"github.com/lightningnetwork/lnd/lntypes"
|
"github.com/lightningnetwork/lnd/lntypes"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleMarket(sc context.ServerContext) echo.HandlerFunc {
|
func HandleCreate(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.ServerContext) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
|
db = sc.Db
|
||||||
|
lnd = sc.Lnd
|
||||||
tx *sql.Tx
|
tx *sql.Tx
|
||||||
u db.User
|
ctx = c.Request().Context()
|
||||||
m db.Market
|
u = c.Get("session").(types.User)
|
||||||
invoice *db.Invoice
|
question = c.FormValue("question")
|
||||||
msats int64
|
description = c.FormValue("description")
|
||||||
invDescription string
|
endDate = c.FormValue("end_date")
|
||||||
data map[string]any
|
|
||||||
qr string
|
|
||||||
hash lntypes.Hash
|
hash lntypes.Hash
|
||||||
|
paymentRequest string
|
||||||
|
cost = lnwire.MilliSatoshi(1000e3)
|
||||||
|
expiry = int64(600)
|
||||||
|
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
||||||
|
invoiceId int
|
||||||
|
marketId int
|
||||||
|
invDescription string
|
||||||
|
qr templ.Component
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if err := c.Bind(&m); err != nil {
|
// TODO: validation
|
||||||
return echo.NewHTTPError(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// transaction start
|
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// need to commit before starting to poll invoice status
|
if hash, paymentRequest, err = lnd.Client.AddInvoice(ctx,
|
||||||
tx.Commit()
|
&invoicesrpc.AddInvoiceData{
|
||||||
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
Value: cost,
|
||||||
|
Expiry: expiry,
|
||||||
data = map[string]any{
|
}); err != nil {
|
||||||
"id": invoice.Id,
|
return err
|
||||||
"bolt11": invoice.PaymentRequest,
|
|
||||||
"amount": msats,
|
|
||||||
"qr": qr,
|
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusPaymentRequired, data)
|
|
||||||
|
if err = tx.QueryRowContext(ctx, ""+
|
||||||
|
"INSERT INTO invoices (user_id, msats, hash, bolt11, expires_at) "+
|
||||||
|
"VALUES ($1, $2, $3, $4, $5) "+
|
||||||
|
"RETURNING id",
|
||||||
|
u.Id, cost, hash.String(), paymentRequest, expiresAt).Scan(&invoiceId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.QueryRowContext(ctx, ""+
|
||||||
|
"INSERT INTO markets (question, description, end_date, user_id, invoice_id) "+
|
||||||
|
"VALUES ($1, $2, $3, $4, $5) "+
|
||||||
|
"RETURNING id",
|
||||||
|
question, description, endDate, u.Id, invoiceId).Scan(&marketId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
invDescription = fmt.Sprintf("create market %d", marketId)
|
||||||
|
if _, err = tx.ExecContext(ctx, ""+
|
||||||
|
"UPDATE invoices SET description = $1 WHERE id = $2",
|
||||||
|
invDescription, invoiceId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
qr = components.Invoice(hash.String(), paymentRequest, int(cost), int(expiry), false, toRedirectUrl(invDescription))
|
||||||
|
|
||||||
|
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleMarketOrders(sc context.ServerContext) echo.HandlerFunc {
|
func HandleMarket(sc context.Context) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
marketId int64
|
db = sc.Db
|
||||||
orders []db.Order
|
ctx = c.Request().Context()
|
||||||
err error
|
id = c.Param("id")
|
||||||
)
|
m = types.Market{}
|
||||||
if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil {
|
u = types.User{}
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Bad Request")
|
err error
|
||||||
}
|
|
||||||
if err = sc.Db.FetchMarketOrders(marketId, &orders); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, orders)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleOrder(sc context.ServerContext) 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.ServerContext) 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 == "" {
|
if err = db.QueryRowContext(ctx, ""+
|
||||||
return echo.NewHTTPError(http.StatusBadRequest)
|
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, "+
|
||||||
}
|
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, msats "+
|
||||||
u = c.Get("session").(db.User)
|
"FROM markets m JOIN users u ON m.user_id = u.id "+
|
||||||
|
"WHERE m.id = $1", id).Scan(
|
||||||
// transaction start
|
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate,
|
||||||
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
|
&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err != nil {
|
||||||
defer cancel()
|
if err == sql.ErrNoRows {
|
||||||
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
|
return echo.NewHTTPError(http.StatusNotFound)
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit()
|
m.User = u
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, nil)
|
return pages.Market(m).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleOrders(sc context.ServerContext) 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.ServerContext) 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.ServerContext) 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.ServerContext) 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})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,30 +1,15 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
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/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleUser(sc context.ServerContext) echo.HandlerFunc {
|
func HandleUser(sc context.Context) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
u := c.Get("session").(types.User)
|
||||||
u db.User
|
return pages.User(&u).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
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 sc.Render(c, http.StatusOK, "user.html", data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.ServerContext) 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LNDGuard(sc context.ServerContext) echo.MiddlewareFunc {
|
func LNDGuard(sc context.Context) echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
if sc.Lnd != nil {
|
if sc.Lnd != nil {
|
||||||
|
|
|
@ -3,34 +3,36 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/db"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Session(sc context.ServerContext) echo.MiddlewareFunc {
|
func Session(sc context.Context) echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
|
db = sc.Db
|
||||||
|
ctx = c.Request().Context()
|
||||||
cookie *http.Cookie
|
cookie *http.Cookie
|
||||||
err error
|
err error
|
||||||
s *db.Session
|
u = types.User{}
|
||||||
u *db.User
|
|
||||||
)
|
)
|
||||||
if cookie, err = c.Cookie("session"); err != nil {
|
if cookie, err = c.Cookie("session"); err != nil {
|
||||||
// cookie not found
|
// cookie not found
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
s = &db.Session{SessionId: cookie.Value}
|
if err = db.QueryRowContext(
|
||||||
if err = sc.Db.FetchSession(s); err == nil {
|
ctx,
|
||||||
|
""+
|
||||||
|
"SELECT u.id, u.name, u.created_at, COALESCE(u.ln_pubkey, ''), COALESCE(u.nostr_pubkey, ''), u.msats "+
|
||||||
|
"FROM sessions s LEFT JOIN users u ON u.id = s.user_id "+
|
||||||
|
"WHERE s.id = $1",
|
||||||
|
cookie.Value).
|
||||||
|
Scan(&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err == nil {
|
||||||
// session found
|
// session found
|
||||||
u = &db.User{Pubkey: s.Pubkey, Msats: s.Msats, LastSeen: time.Now()}
|
c.Set("session", u)
|
||||||
if err = sc.Db.UpdateUser(u); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.Set("session", *u)
|
|
||||||
} else if err != sql.ErrNoRows {
|
} else if err != sql.ErrNoRows {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -39,12 +41,13 @@ func Session(sc context.ServerContext) echo.MiddlewareFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SessionGuard(sc context.ServerContext) echo.MiddlewareFunc {
|
func SessionGuard(sc context.Context) echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
session := c.Get("session")
|
session := c.Get("session")
|
||||||
if session == nil {
|
if session == nil {
|
||||||
return c.Redirect(http.StatusTemporaryRedirect, "/login")
|
// this seems to work for non-interactive and htmx requests
|
||||||
|
return c.Redirect(http.StatusSeeOther, "/login")
|
||||||
}
|
}
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
|
||||||
|
templ About() {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col">
|
||||||
|
@components.Figlet("random", "about")
|
||||||
|
<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>
|
||||||
|
<iframe
|
||||||
|
class="my-3 mx-auto"
|
||||||
|
src="https://www.youtube.com/embed/DB5TfX7eaVY?si=FFG9wEun1VRl5p6w"
|
||||||
|
title="YouTube video player"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
<h1>👨💻 FOSS?</h1>
|
||||||
|
<p class="mb-3">
|
||||||
|
Yes! The code is available on
|
||||||
|
<a href="https://github.com/ekzyis/delphi.market/" target="_blank" rel="noopener noreferrer">Github</a>
|
||||||
|
or
|
||||||
|
<a href="https://git.ekzy.is/ekzyis/delphi.market/" target="_blank" rel="noopener noreferrer">Gitea</a>
|
||||||
|
under the MIT License.
|
||||||
|
</p>
|
||||||
|
<h1>💬 Contact?</h1>
|
||||||
|
<p class="mb-3">
|
||||||
|
If you have feedback, questions, bugs, ideas or anything else, feel free to reach out to me:
|
||||||
|
<br/>
|
||||||
|
</p>
|
||||||
|
<div class="text-center">
|
||||||
|
<a
|
||||||
|
href="https://stacker.news/ek"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
stacker news
|
||||||
|
</a> |
|
||||||
|
<a
|
||||||
|
href="https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FxNnPk9DkTbQJ6NckWom9mi5vheo_VPLm%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAnFUiU0M8jS1JY34LxUoPr7mdJlFZwf3pFkjRrhprdQs%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
simplex
|
||||||
|
</a> |
|
||||||
|
<a
|
||||||
|
href="https://t.me/ekzyis"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>telegram</a> |
|
||||||
|
<a
|
||||||
|
href="https://signal.me/#eu/Qa/0P3M1keYAUju1LMktobIwHvEKTEMaQfGjjkL5U5ajYpsa6uFKvp165NDCTOXK"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
signal
|
||||||
|
</a> |
|
||||||
|
<a
|
||||||
|
href="https://njump.me/npub16x07c4qz05yhqe2gy2q2u9ax359d2lc0tsh6wn3y70dmk8nv2j2s96s89d"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>nostr</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Auth(action string) {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col text-center">
|
||||||
|
@components.Figlet("random", action)
|
||||||
|
<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={ string(templ.SafeURL(fmt.Sprintf("/%s/lightning", action))) }
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{ action } with lightning
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex signup nostr my-3 items-center"
|
||||||
|
hx-get={ string(templ.SafeURL(fmt.Sprintf("/%s/nostr", action))) }
|
||||||
|
>
|
||||||
|
<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>{ action } with nostr
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col mb-3 text-center">
|
||||||
|
if action == "signup" {
|
||||||
|
<small>
|
||||||
|
<a class="text-muted" href="/login">not your first time?</a>
|
||||||
|
</small>
|
||||||
|
} else {
|
||||||
|
<small><a class="text-muted" href="/signup">first time?</a></small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import "git.ekzyis.com/ekzyis/delphi.market/lib"
|
||||||
|
|
||||||
|
templ Figlet(font string, text string) {
|
||||||
|
<code class="figlet">
|
||||||
|
<strong>
|
||||||
|
<pre>
|
||||||
|
{ lib.Figlet(font, text) }
|
||||||
|
</pre>
|
||||||
|
</strong>
|
||||||
|
</code>
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
|
||||||
|
templ Footer() {
|
||||||
|
<footer class="flex justify-center my-3">
|
||||||
|
<div>
|
||||||
|
<hr/>
|
||||||
|
<small>
|
||||||
|
<code>
|
||||||
|
running
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("https://github.com/ekzyis/delphi.market/commit/" + ctx.Value(c.CommitContextKey).(string)) }
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>{ ctx.Value(c.CommitContextKey).(string) }</a>
|
||||||
|
</code>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
|
||||||
|
templ Head() {
|
||||||
|
<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/tailwind.css"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<meta name="theme-color" content="#191d21"/>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</head>
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Invoice(hash string, bolt11 string, msats int, expiresIn int, paid bool, redirectUrl templ.SafeURL) {
|
||||||
|
<div class="p-5 border border-muted bg-background text-center font-mono">
|
||||||
|
<div id="close" class="flex justify-end"><button class="w-fit text-muted hitbox hover:text-reset">X</button></div>
|
||||||
|
<div>Payment Required</div>
|
||||||
|
<div class="my-1">@Qr(bolt11, "lightning:"+bolt11)</div>
|
||||||
|
<div class="my-1">{ format(msats) }</div>
|
||||||
|
@InvoiceStatus(hash, expiresIn, paid, redirectUrl)
|
||||||
|
<div class="none" id="bolt11-data" bolt11-data={ templ.JSONString(bolt11) } hx-preserve></div>
|
||||||
|
<script type="text/javascript" id="bolt11-js" hx-preserve>
|
||||||
|
var $ = selector => document.querySelector(selector)
|
||||||
|
$("#close").addEventListener("click", function () {
|
||||||
|
// abort in-flight polls and prevent new polls
|
||||||
|
htmx.trigger("#poll", "htmx:abort")
|
||||||
|
$("#poll").addEventListener("htmx:beforeRequest", e => e.preventDefault())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ InvoiceStatus(hash string, expiresIn int, paid bool, redirectUrl templ.SafeURL) {
|
||||||
|
if paid {
|
||||||
|
<div class="font-mono neon success my-1">PAID</div>
|
||||||
|
<div
|
||||||
|
id="poll"
|
||||||
|
hx-get={ string(redirectUrl) }
|
||||||
|
hx-trigger="load delay:3s"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#content"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-select-oob="#modal" />
|
||||||
|
}
|
||||||
|
else if expiresIn <= 0 {
|
||||||
|
<div class="font-mono neon error my-1">EXPIRED</div>
|
||||||
|
} else {
|
||||||
|
<!-- invoice is pending -->
|
||||||
|
<div class="font-mono my-1" id="countdown" countdown-data={ templ.JSONString(expiresIn) } hx-preserve></div>
|
||||||
|
<script type="text/javascript" id="countdown-js" hx-preserve>
|
||||||
|
var $ = selector => document.querySelector(selector)
|
||||||
|
var expiresIn = JSON.parse($("#countdown").getAttribute("countdown-data"))
|
||||||
|
|
||||||
|
function pad(num, places) {
|
||||||
|
return String(num).padStart(places, "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
function _countdown() {
|
||||||
|
var minutes = Math.floor(expiresIn / 60)
|
||||||
|
var seconds = expiresIn % 60
|
||||||
|
var text = `${pad(minutes, 2)}:${pad(seconds, 2)}`
|
||||||
|
try {
|
||||||
|
$("#countdown").innerText = text
|
||||||
|
expiresIn--
|
||||||
|
} catch {
|
||||||
|
// countdown element disappeared
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_countdown()
|
||||||
|
var interval = setInterval(_countdown, 1000)
|
||||||
|
</script>
|
||||||
|
<div
|
||||||
|
id="poll"
|
||||||
|
hx-get={ string(templ.SafeURL("/invoice/" + hash)) }
|
||||||
|
hx-trigger="load delay:1s"
|
||||||
|
hx-target="#modal"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#modal" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func format(msats int) string {
|
||||||
|
sats := msats / 1000
|
||||||
|
if sats == 1 {
|
||||||
|
return fmt.Sprintf("%d sat", sats)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d sats", sats)
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
templ Modal(component templ.Component) {
|
||||||
|
if component != nil {
|
||||||
|
<div
|
||||||
|
id="modal"
|
||||||
|
class="fixed left-0 top-0 w-screen h-screen"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<div class="flex justify-center p-3 w-screen">
|
||||||
|
@component
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var $ = selector => document.querySelector(selector)
|
||||||
|
$("#close").addEventListener("click", function () {
|
||||||
|
$("#modal").removeAttribute("class")
|
||||||
|
$("#modal").setAttribute("class", "hidden")
|
||||||
|
$("#modal").innerHTML = ""
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div id="modal" class="hidden"></div>
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
|
||||||
|
templ Nav() {
|
||||||
|
<header class="mt-3">
|
||||||
|
<nav
|
||||||
|
id="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>
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Qr(value string, href string) {
|
||||||
|
if href != "" {
|
||||||
|
<a
|
||||||
|
class="mx-auto no-link"
|
||||||
|
href={ templ.SafeURL(href) }
|
||||||
|
>
|
||||||
|
<img src={ "data:image/jpeg;base64," + qrEncode(value) }/>
|
||||||
|
</a>
|
||||||
|
<small class="flex my-1 mx-auto">
|
||||||
|
<span class="block w-[188px] overflow-hidden">{ value }</span>
|
||||||
|
<button id="copy" class="ms-1 button w-[64px]" hx-preserve>copy</button>
|
||||||
|
</small>
|
||||||
|
@CopyButton(value)
|
||||||
|
} else {
|
||||||
|
<img src={ "data:image/jpeg;base64," + qrEncode(value) }/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ CopyButton(value string) {
|
||||||
|
<div class="none" id="copy-data" copy-data={ templ.JSONString(value) } hx-preserve></div>
|
||||||
|
<script type="text/javascript" id="copy-js" hx-preserve>
|
||||||
|
var $ = selector => document.querySelector(selector)
|
||||||
|
var value = JSON.parse($("#copy-data").getAttribute("copy-data"))
|
||||||
|
$("#copy").onclick = function () {
|
||||||
|
window.navigator.clipboard.writeText(value)
|
||||||
|
$("#copy").textContent = "copied"
|
||||||
|
setTimeout(() => $("#copy").textContent = "copy", 1000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrEncode(value string) string {
|
||||||
|
png, err := qrcode.Encode(value, qrcode.Medium, 256)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(png))
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Error(code int) {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@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>
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
c "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"
|
||||||
|
"fmt"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Index(markets []types.Market) {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col text-center">
|
||||||
|
@components.Figlet("random", "delphi")
|
||||||
|
<div class="font-mono my-3"><small>A prediction market using the lightning network</small></div>
|
||||||
|
<div
|
||||||
|
id="grid-container"
|
||||||
|
class="border border-muted text-start"
|
||||||
|
hx-target="#grid-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#grid-container"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
<div class="border border-muted">
|
||||||
|
<button hx-get="/" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/") }>markets</button>
|
||||||
|
<button hx-get="/create" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/create") }>create</button>
|
||||||
|
</div>
|
||||||
|
if ctx.Value(c.ReqPathContextKey).(string) == "/" {
|
||||||
|
<div class="grid grid-cols-[auto_fit-content(10%)_fit-content(10%)]">
|
||||||
|
for _, m := range markets {
|
||||||
|
<span class="ps-3 border-b border-muted pb-3 mt-3">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/market/%d", m.Id)) }>{ m.Question }</a>
|
||||||
|
<div class="text-small text-muted">{m.User.Name} / {humanize.Time(m.CreatedAt)} / {humanize.Time(m.EndDate)}</div>
|
||||||
|
</span>
|
||||||
|
<span class="px-3 border-b border-muted pb-3 mt-3 flex"><div class="self-center">51%</div></span>
|
||||||
|
<span class="pe-3 border-b border-muted pb-3 mt-3 flex"><div class="self-center">0</div></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<form
|
||||||
|
hx-post="/create"
|
||||||
|
hx-target="#modal"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#modal"
|
||||||
|
class="flex flex-col mx-3"
|
||||||
|
>
|
||||||
|
<label class="my-1" for="question">question</label>
|
||||||
|
<input
|
||||||
|
id="question"
|
||||||
|
name="question"
|
||||||
|
type="text"
|
||||||
|
class="my-1 p-1 text-black"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="mt-3 mb-1">
|
||||||
|
<label for="description">description</label>
|
||||||
|
<span class="px-1 text-small text-muted">optional</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
class="my-1 p-1 text-black"
|
||||||
|
></textarea>
|
||||||
|
<label class="mt-3" for="end_date">end date</label>
|
||||||
|
<input
|
||||||
|
id="end_date"
|
||||||
|
name="end_date"
|
||||||
|
type="date"
|
||||||
|
class="my-1 p-1 text-black"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" class="mt-3">submit</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Modal(nil)
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
func tabStyle(path string, tab string) string {
|
||||||
|
class := "!no-underline"
|
||||||
|
if path == tab {
|
||||||
|
class += " font-bold border-b-none"
|
||||||
|
}
|
||||||
|
return class
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
|
||||||
|
templ LnAuth(lnurl string, action string) {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col text-center">
|
||||||
|
@components.Figlet("random", action)
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
@components.Qr(lnurl, "lightning:"+lnurl)
|
||||||
|
</div>
|
||||||
|
<div hx-get="/session" hx-trigger="every 1s" hx-swap="none"></div>
|
||||||
|
</div>
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
|
||||||
|
templ Login() {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col text-center">
|
||||||
|
@components.Figlet("random", "login")
|
||||||
|
<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 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">
|
||||||
|
<small><a class="text-muted" href="/signup">first time?</a></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Add countdown? Use or at least show somewhere precise timestamps?
|
||||||
|
|
||||||
|
templ Market(m types.Market) {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col">
|
||||||
|
<small>
|
||||||
|
@components.Figlet("random", "market")
|
||||||
|
</small>
|
||||||
|
<div class="text-center font-bold my-1">{ m.Question }</div>
|
||||||
|
<div class="text-center text-muted my-1">{humanize.Time(m.EndDate)}</div>
|
||||||
|
<div class="text-center text-muted my-1"></div>
|
||||||
|
<blockquote cite="ekzyis" class="p-4 mb-4 border-s-4 border-muted">
|
||||||
|
{ m.Description }
|
||||||
|
<div class="text-muted text-right pt-4">― {m.User.Name}, { humanize.Time(m.CreatedAt) }</div>
|
||||||
|
</blockquote>
|
||||||
|
<div class="flex justify-center my-1">
|
||||||
|
<button class="neon success mx-1">BET YES</button>
|
||||||
|
<button class="neon error mx-1">BET NO</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Modal(nil)
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ User(user *types.User) {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col">
|
||||||
|
@components.Figlet("random", "user")
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 gap-4 my-3 mx-auto"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#content"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-select-oob="#nav"
|
||||||
|
>
|
||||||
|
<div class="font-bold">id</div>
|
||||||
|
<div>{ strconv.Itoa(user.Id) }</div>
|
||||||
|
<div class="font-bold">name</div>
|
||||||
|
<div>{ user.Name }</div>
|
||||||
|
<div class="font-bold">joined</div>
|
||||||
|
<div>{ user.CreatedAt.Format(time.DateOnly) }</div>
|
||||||
|
<div class="font-bold">sats</div>
|
||||||
|
<div>{ strconv.Itoa(int(user.Msats) / 1000) }</div>
|
||||||
|
<button hx-post="/logout" class="col-span-2">logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
|
@ -8,96 +8,26 @@ import (
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/middleware"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerContext = context.ServerContext
|
type Context = context.Context
|
||||||
|
|
||||||
type MiddlewareFunc func(sc ServerContext) echo.MiddlewareFunc
|
func Init(e *echo.Echo, sc Context) {
|
||||||
type HandlerFunc = func(sc ServerContext) echo.HandlerFunc
|
e.Use(middleware.Session(sc))
|
||||||
|
|
||||||
func AddRoutes(e *echo.Echo, sc ServerContext) {
|
e.GET("/", handler.HandleIndex(sc))
|
||||||
mountMiddleware(e, sc)
|
e.GET("/create", handler.HandleIndex(sc))
|
||||||
addFrontendRoutes(e, sc)
|
e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(sc))
|
||||||
addBackendRoutes(e, sc)
|
e.GET("/market/:id", handler.HandleMarket(sc))
|
||||||
}
|
e.GET("/about", handler.HandleAbout(sc))
|
||||||
|
|
||||||
func mountMiddleware(e *echo.Echo, sc ServerContext) {
|
e.GET("/login", handler.HandleAuth(sc, "login"))
|
||||||
Use(e, sc, middleware.Session)
|
e.GET("/login/:method", handler.HandleAuth(sc, "login"))
|
||||||
}
|
e.GET("/signup", handler.HandleAuth(sc, "register"))
|
||||||
|
e.GET("/signup/:method", handler.HandleAuth(sc, "register"))
|
||||||
func addFrontendRoutes(e *echo.Echo, sc ServerContext) {
|
e.GET("/api/lnauth/callback", handler.HandleLnAuthCallback(sc))
|
||||||
GET(e, sc, "/user",
|
e.GET("/session", handler.HandleSessionCheck(sc))
|
||||||
handler.HandleUser,
|
|
||||||
middleware.SessionGuard)
|
e.GET("/user", handler.HandleUser(sc), middleware.SessionGuard(sc))
|
||||||
GET(e, sc, "/market/:id",
|
e.POST("/logout", handler.HandleLogout(sc), middleware.SessionGuard(sc))
|
||||||
handler.HandleMarket,
|
|
||||||
middleware.SessionGuard)
|
e.GET("/invoice/:hash", handler.HandleInvoice(sc), middleware.SessionGuard(sc))
|
||||||
POST(e, sc, "/market/:id/order",
|
|
||||||
handler.HandleOrder,
|
|
||||||
middleware.SessionGuard,
|
|
||||||
middleware.LNDGuard)
|
|
||||||
GET(e, sc, "/invoice/:id",
|
|
||||||
handler.HandleInvoice,
|
|
||||||
middleware.SessionGuard)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addBackendRoutes(e *echo.Echo, sc ServerContext) {
|
|
||||||
GET(e, sc, "/api/markets", handler.HandleMarkets)
|
|
||||||
POST(e, sc, "/api/market",
|
|
||||||
handler.HandleCreateMarket,
|
|
||||||
middleware.SessionGuard,
|
|
||||||
middleware.LNDGuard)
|
|
||||||
GET(e, sc, "/api/market/:id", handler.HandleMarket)
|
|
||||||
GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders)
|
|
||||||
GET(e, sc, "/api/market/:id/stats", handler.HandleMarketStats)
|
|
||||||
POST(e, sc, "/api/market/:id/settle",
|
|
||||||
handler.HandleMarketSettlement,
|
|
||||||
middleware.SessionGuard,
|
|
||||||
middleware.LNDGuard)
|
|
||||||
POST(e, sc, "/api/order",
|
|
||||||
handler.HandleOrder,
|
|
||||||
middleware.SessionGuard,
|
|
||||||
middleware.LNDGuard)
|
|
||||||
DELETE(e, sc, "/api/order/:id",
|
|
||||||
handler.HandleDeleteOrder,
|
|
||||||
middleware.SessionGuard)
|
|
||||||
GET(e, sc, "/api/orders",
|
|
||||||
handler.HandleOrders,
|
|
||||||
middleware.SessionGuard)
|
|
||||||
GET(e, sc, "/api/login", handler.HandleLogin)
|
|
||||||
GET(e, sc, "/api/login/callback", handler.HandleLoginCallback)
|
|
||||||
POST(e, sc, "/api/logout", handler.HandleLogout)
|
|
||||||
GET(e, sc, "/api/session", handler.HandleCheckSession)
|
|
||||||
GET(e, sc, "/api/invoice/:id",
|
|
||||||
handler.HandleInvoiceStatus,
|
|
||||||
middleware.SessionGuard)
|
|
||||||
GET(e, sc, "/api/invoices",
|
|
||||||
handler.HandleInvoices,
|
|
||||||
middleware.SessionGuard)
|
|
||||||
POST(e, sc, "/api/withdrawal",
|
|
||||||
handler.HandleWithdrawal,
|
|
||||||
middleware.SessionGuard,
|
|
||||||
middleware.LNDGuard)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
|
||||||
return e.GET(path, scF(sc), toMiddlewareFunc(sc, scM...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func POST(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
|
||||||
return e.POST(path, scF(sc), toMiddlewareFunc(sc, scM...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DELETE(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
|
||||||
return e.DELETE(path, scF(sc), toMiddlewareFunc(sc, scM...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Use(e *echo.Echo, sc ServerContext, scM ...MiddlewareFunc) {
|
|
||||||
e.Use(toMiddlewareFunc(sc, scM...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMiddlewareFunc(sc ServerContext, scM ...MiddlewareFunc) []echo.MiddlewareFunc {
|
|
||||||
var m []echo.MiddlewareFunc
|
|
||||||
for _, m_ := range scM {
|
|
||||||
m = append(m, m_(sc))
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,55 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"golang.org/x/exp/constraints"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Template struct {
|
|
||||||
templates *template.Template
|
|
||||||
}
|
|
||||||
|
|
||||||
type Number interface {
|
|
||||||
constraints.Integer | constraints.Float
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
|
||||||
return t.templates.ExecuteTemplate(w, name, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseTemplates(pattern string) *Template {
|
|
||||||
return &Template{
|
|
||||||
templates: template.Must(template.New("").Funcs(template.FuncMap{
|
|
||||||
"add": add[int64],
|
|
||||||
"sub": sub[int64],
|
|
||||||
"div": div[int64],
|
|
||||||
"substr": substr,
|
|
||||||
}).ParseGlob("pages/**.html")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add[T Number](arg1 T, arg2 T) T {
|
|
||||||
return arg1 + arg2
|
|
||||||
}
|
|
||||||
|
|
||||||
func sub[T Number](arg1 T, arg2 T) T {
|
|
||||||
return arg1 - arg2
|
|
||||||
}
|
|
||||||
|
|
||||||
func div[T Number](arg1 T, arg2 T) T {
|
|
||||||
return arg1 / arg2
|
|
||||||
}
|
|
||||||
|
|
||||||
func substr(s string, start, length int) string {
|
|
||||||
if start < 0 || start >= len(s) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
end := start + length
|
|
||||||
if end > len(s) {
|
|
||||||
end = len(s)
|
|
||||||
}
|
|
||||||
return s[start:end]
|
|
||||||
}
|
|
|
@ -11,16 +11,17 @@ type Server struct {
|
||||||
*echo.Echo
|
*echo.Echo
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerContext = router.ServerContext
|
type Context = router.Context
|
||||||
|
|
||||||
func New(ctx ServerContext) *Server {
|
func New(ctx Context) *Server {
|
||||||
var (
|
var (
|
||||||
e *echo.Echo
|
e *echo.Echo
|
||||||
s *Server
|
s *Server
|
||||||
)
|
)
|
||||||
e = echo.New()
|
e = echo.New()
|
||||||
|
|
||||||
e.Static("/", "public")
|
e.Static("/", "public")
|
||||||
e.Renderer = router.ParseTemplates("pages/**.html")
|
|
||||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||||
Format: "${time_custom} ${method} ${uri} ${status}\n",
|
Format: "${time_custom} ${method} ${uri} ${status}\n",
|
||||||
CustomTimeFormat: "2006-01-02 15:04:05.00000-0700",
|
CustomTimeFormat: "2006-01-02 15:04:05.00000-0700",
|
||||||
|
@ -30,11 +31,12 @@ func New(ctx ServerContext) *Server {
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
|
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
|
||||||
}))
|
}))
|
||||||
e.HTTPErrorHandler = httpErrorHandler
|
|
||||||
|
e.HTTPErrorHandler = httpErrorHandler(ctx)
|
||||||
|
|
||||||
s = &Server{e}
|
s = &Server{e}
|
||||||
|
|
||||||
router.AddRoutes(e, ctx)
|
router.Init(e, ctx)
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
let
|
||||||
|
nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-23.11";
|
||||||
|
pkgs = import nixpkgs { config = {}; overlays = []; };
|
||||||
|
in
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
go
|
||||||
|
tailwindcss
|
||||||
|
gnumake
|
||||||
|
inotify-tools
|
||||||
|
figlet
|
||||||
|
postgresql
|
||||||
|
];
|
||||||
|
shellHook = ''
|
||||||
|
# install templ if not already installed
|
||||||
|
command -v templ > /dev/null || go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
'';
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./server/router/pages/**/*.templ"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '1rem'
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'background': '191d21',
|
||||||
|
'muted': '#6c757d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
function ({ addComponents }) {
|
||||||
|
addComponents({
|
||||||
|
'.container': {
|
||||||
|
'@screen lg': {
|
||||||
|
maxWidth: '768px',
|
||||||
|
},
|
||||||
|
'@screen xl': {
|
||||||
|
maxWidth: '768px',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -16,8 +15,9 @@ func HTTPMocks(method string, target string, body io.Reader) (*echo.Echo, *http.
|
||||||
rec *httptest.ResponseRecorder
|
rec *httptest.ResponseRecorder
|
||||||
)
|
)
|
||||||
e = echo.New()
|
e = echo.New()
|
||||||
e.Renderer = router.ParseTemplates("pages/**.html")
|
|
||||||
req = httptest.NewRequest(method, target, body)
|
req = httptest.NewRequest(method, target, body)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
|
|
||||||
return e, req, rec
|
return e, req, rec
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/guregu/null.v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Id int
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LnPubkey null.String
|
||||||
|
NostrPubkey null.String
|
||||||
|
Msats int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invoice struct {
|
||||||
|
Id int
|
||||||
|
UserId int
|
||||||
|
Msats int64
|
||||||
|
MsatsReceived int64
|
||||||
|
Hash string
|
||||||
|
Bolt11 string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ConfirmedAt null.Time
|
||||||
|
HeldSince bool
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Market struct {
|
||||||
|
Id int
|
||||||
|
User User
|
||||||
|
Question string
|
||||||
|
Description string
|
||||||
|
CreatedAt time.Time
|
||||||
|
EndDate time.Time
|
||||||
|
}
|
|
@ -1,4 +0,0 @@
|
||||||
> 1%
|
|
||||||
last 2 versions
|
|
||||||
not dead
|
|
||||||
not ie 11
|
|
|
@ -1,5 +0,0 @@
|
||||||
[*.{js,jsx,ts,tsx,vue}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
|
@ -1,18 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
'plugin:vue/vue3-essential',
|
|
||||||
'@vue/standard'
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
parser: '@babel/eslint-parser'
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
|
||||||
'vue/multi-word-component-names': 'off'
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
/dist
|
|
||||||
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
|
@ -1,24 +0,0 @@
|
||||||
# delphi.market
|
|
||||||
|
|
||||||
## Project setup
|
|
||||||
```
|
|
||||||
yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compiles and hot-reloads for development
|
|
||||||
```
|
|
||||||
yarn serve
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compiles and minifies for production
|
|
||||||
```
|
|
||||||
yarn build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lints and fixes files
|
|
||||||
```
|
|
||||||
yarn lint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Customize configuration
|
|
||||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
|
|
@ -1,5 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
'@vue/cli-plugin-babel/preset'
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
||||||
<link rel="icon" href="/favicon.ico">
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
|
||||||
<title>delphi.market</title>
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
|
||||||
<meta name="theme-color" content="#8787a4">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<strong>We're sorry but this site doesn't work properly without JavaScript enabled. Please enable it to
|
|
||||||
continue.</strong>
|
|
||||||
</noscript>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- built files will be auto injected -->
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"module": "esnext",
|
|
||||||
"baseUrl": "./",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lib": [
|
|
||||||
"esnext",
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"scripthost"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"name": "delphi.market",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite --port 4224",
|
|
||||||
"build": "vite build",
|
|
||||||
"serve": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^4.4.0",
|
|
||||||
"chart.js": "^4.4.0",
|
|
||||||
"core-js": "^3.8.3",
|
|
||||||
"pinia": "^2.1.7",
|
|
||||||
"register-service-worker": "^1.7.2",
|
|
||||||
"s-ago": "^2.2.0",
|
|
||||||
"vite": "^4.5.0",
|
|
||||||
"vue": "^3.2.13",
|
|
||||||
"vue-chartjs": "^5.2.0",
|
|
||||||
"vue-router": "4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.12.16",
|
|
||||||
"@babel/eslint-parser": "^7.12.16",
|
|
||||||
"@vue/cli-plugin-babel": "^5.0.8",
|
|
||||||
"@vue/eslint-config-standard": "^6.1.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"eslint": "^7.32.0",
|
|
||||||
"eslint-plugin-import": "^2.25.3",
|
|
||||||
"eslint-plugin-node": "^11.1.0",
|
|
||||||
"eslint-plugin-promise": "^5.1.0",
|
|
||||||
"eslint-plugin-vue": "^8.0.3",
|
|
||||||
"postcss": "^8.4.31",
|
|
||||||
"tailwindcss": "^3.3.5"
|
|
||||||
}
|
|
||||||
}
|
|