Merge branch 'vue-rewrite' into develop

This commit is contained in:
ekzyis 2023-11-26 18:56:09 +01:00
commit a2a2d4b0a6
91 changed files with 7838 additions and 392 deletions

View File

@ -1,6 +1,6 @@
.PHONY: build run .PHONY: build run test
SOURCE := $(shell find db env lib lnd pages public server -type f) SOURCE := $(shell find db env lib lnd pages public server -type f) main.go
build: delphi.market build: delphi.market
@ -10,3 +10,5 @@ delphi.market: $(SOURCE)
run: run:
go run . go run .
test:
go test -v -count=1 ./server/router/handler/...

View File

@ -2,44 +2,70 @@ package db
import ( import (
"database/sql" "database/sql"
"log" "fmt"
"io/ioutil"
"github.com/joho/godotenv"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/namsral/flag"
)
var (
db *DB
) )
type DB struct { type DB struct {
*sql.DB *sql.DB
} }
func init() { var (
if err := godotenv.Load(); err != nil { initSqlPath = "./db/init.sql"
log.Fatalf("error loading env vars: %s", err) )
}
var dbUrl string
flag.StringVar(&dbUrl, "DATABASE_URL", "", "Database URL")
flag.Parse()
if dbUrl == "" {
log.Fatal("DATABASE_URL not set")
}
db = initDB(dbUrl)
}
func initDB(url string) *DB { func New(dbUrl string) (*DB, error) {
db, err := sql.Open("postgres", url) var (
if err != nil { db_ *sql.DB
log.Fatal(err) db *DB
err error
)
if db_, err = sql.Open("postgres", dbUrl); err != nil {
return nil, err
} }
// test connection // test connection
_, err = db.Exec("SELECT 1") if _, err = db_.Exec("SELECT 1"); err != nil {
if err != nil { return nil, err
log.Fatal(err)
} }
// TODO: run migrations // TODO: run migrations
return &DB{DB: db} db = &DB{DB: db_}
return db, nil
}
func (db *DB) Reset(dbName string) error {
var (
f []byte
err error
)
if err = db.Clear(dbName); err != nil {
return err
}
if f, err = ioutil.ReadFile(initSqlPath); err != nil {
return err
}
if _, err = db.Exec(string(f)); err != nil {
return err
}
return nil
}
func (db *DB) Clear(dbName string) error {
var (
tables = []string{"lnauth", "users", "sessions", "markets", "shares", "invoices", "order_side", "orders", "matches"}
sql []string
err error
)
for _, t := range tables {
sql = append(sql, fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", t))
}
sql = append(sql, "DROP EXTENSION IF EXISTS \"uuid-ossp\"")
sql = append(sql, "DROP TYPE IF EXISTS order_side")
for _, s := range sql {
if _, err = db.Exec(s); err != nil {
return err
}
}
return nil
} }

View File

@ -12,18 +12,6 @@ CREATE TABLE sessions(
pubkey TEXT NOT NULL REFERENCES users(pubkey), pubkey TEXT NOT NULL REFERENCES users(pubkey),
session_id VARCHAR(48) session_id VARCHAR(48)
); );
CREATE TABLE markets(
id SERIAL PRIMARY KEY,
description TEXT NOT NULL,
active BOOLEAN DEFAULT true
);
CREATE EXTENSION "uuid-ossp";
CREATE TABLE shares(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
market_id INTEGER REFERENCES markets(id),
description TEXT NOT NULL
);
CREATE TABLE invoices( CREATE TABLE invoices(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
pubkey TEXT NOT NULL REFERENCES users(pubkey), pubkey TEXT NOT NULL REFERENCES users(pubkey),
@ -35,7 +23,20 @@ CREATE TABLE invoices(
created_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
confirmed_at TIMESTAMP WITH TIME ZONE, confirmed_at TIMESTAMP WITH TIME ZONE,
held_since TIMESTAMP WITH TIME ZONE held_since TIMESTAMP WITH TIME ZONE,
description TEXT
);
CREATE TABLE markets(
id SERIAL PRIMARY KEY,
description TEXT NOT NULL,
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
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 REFERENCES markets(id),
description TEXT NOT NULL
); );
CREATE TYPE order_side AS ENUM ('BUY', 'SELL'); CREATE TYPE order_side AS ENUM ('BUY', 'SELL');
CREATE TABLE orders( CREATE TABLE orders(
@ -49,6 +50,7 @@ CREATE TABLE orders(
invoice_id UUID NOT NULL REFERENCES invoices(id) invoice_id UUID NOT NULL REFERENCES invoices(id)
); );
ALTER TABLE orders ADD CONSTRAINT order_price CHECK(price > 0 AND price < 100); 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 matches( CREATE TABLE matches(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
oid1 UUID NOT NULL REFERENCES orders(id), oid1 UUID NOT NULL REFERENCES orders(id),

View File

@ -1,13 +1,16 @@
package db package db
import "time" import (
"database/sql"
"time"
)
func CreateInvoice(invoice *Invoice) error { func (db *DB) CreateInvoice(invoice *Invoice) error {
if err := db.QueryRow(""+ if err := db.QueryRow(""+
"INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at) "+ "INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at, description) "+
"VALUES($1, $2, $3, $4, $5, $6, $7) "+ "VALUES($1, $2, $3, $4, $5, $6, $7, $8) "+
"RETURNING id", "RETURNING id",
invoice.Pubkey, invoice.Msats, invoice.Preimage, invoice.Hash, invoice.PaymentRequest, invoice.CreatedAt, invoice.ExpiresAt).Scan(&invoice.Id); err != nil { invoice.Pubkey, invoice.Msats, invoice.Preimage, invoice.Hash, invoice.PaymentRequest, invoice.CreatedAt, invoice.ExpiresAt, invoice.Description).Scan(&invoice.Id); err != nil {
return err return err
} }
return nil return nil
@ -18,9 +21,9 @@ type FetchInvoiceWhere struct {
Hash string Hash string
} }
func FetchInvoice(where *FetchInvoiceWhere, invoice *Invoice) error { func (db *DB) FetchInvoice(where *FetchInvoiceWhere, invoice *Invoice) error {
var ( var (
query = "SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since FROM invoices " query = "SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since, COALESCE(description, '') FROM invoices "
args []any args []any
) )
if where.Id != "" { if where.Id != "" {
@ -32,13 +35,42 @@ func FetchInvoice(where *FetchInvoiceWhere, invoice *Invoice) error {
} }
if err := db.QueryRow(query, args...).Scan( if err := db.QueryRow(query, args...).Scan(
&invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash, &invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash,
&invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince); err != nil { &invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince, &invoice.Description); err != nil {
return err return err
} }
return nil return nil
} }
func ConfirmInvoice(hash string, confirmedAt time.Time, msatsReceived int) error { 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) ConfirmInvoice(hash string, confirmedAt time.Time, msatsReceived int) error {
if _, err := db.Exec("UPDATE invoices SET confirmed_at = $2, msats_received = $3 WHERE hash = $1", hash, confirmedAt, msatsReceived); err != nil { if _, err := db.Exec("UPDATE invoices SET confirmed_at = $2, msats_received = $3 WHERE hash = $1", hash, confirmedAt, msatsReceived); err != nil {
return err return err
} }

View File

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

View File

@ -2,31 +2,53 @@ package db
import "database/sql" import "database/sql"
func FetchMarket(marketId int, market *Market) error { type FetchOrdersWhere struct {
if err := db.QueryRow("SELECT id, description FROM markets WHERE id = $1", marketId).Scan(&market.Id, &market.Description); err != nil { MarketId int
Pubkey string
Confirmed bool
}
func (db *DB) CreateMarket(market *Market) error {
if err := db.QueryRow(""+
"INSERT INTO markets(description, end_date, invoice_id) "+
"VALUES($1, $2, $3) "+
"RETURNING id", market.Description, market.EndDate, market.InvoiceId).Scan(&market.Id); err != nil {
return err
}
// For now, we only support binary markets.
if _, err := db.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil {
return err return err
} }
return nil return nil
} }
func FetchActiveMarkets(markets *[]Market) error { func (db *DB) FetchMarket(marketId int, market *Market) error {
if err := db.QueryRow("SELECT id, description, end_date FROM markets WHERE id = $1", marketId).Scan(&market.Id, &market.Description, &market.EndDate); err != nil {
return err
}
return nil
}
func (db *DB) FetchActiveMarkets(markets *[]Market) error {
var ( var (
rows *sql.Rows rows *sql.Rows
market Market market Market
err error err error
) )
if rows, err = db.Query("SELECT id, description, active FROM markets WHERE active = true"); err != nil { 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 return err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
rows.Scan(&market.Id, &market.Description, &market.Active) rows.Scan(&market.Id, &market.Description, &market.EndDate)
*markets = append(*markets, market) *markets = append(*markets, market)
} }
return nil return nil
} }
func FetchShares(marketId int, shares *[]Share) error { func (db *DB) FetchShares(marketId int, shares *[]Share) error {
rows, err := db.Query("SELECT id, market_id, description FROM shares WHERE market_id = $1 ORDER BY description DESC", marketId) rows, err := db.Query("SELECT id, market_id, description FROM shares WHERE market_id = $1 ORDER BY description DESC", marketId)
if err != nil { if err != nil {
return err return err
@ -40,13 +62,11 @@ func FetchShares(marketId int, shares *[]Share) error {
return nil return nil
} }
type FetchOrdersWhere struct { func (db *DB) FetchShare(shareId string, share *Share) error {
MarketId int return db.QueryRow("SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description)
Pubkey string
Confirmed bool
} }
func FetchOrders(where *FetchOrdersWhere, orders *[]Order) error { func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
query := "" + query := "" +
"SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, i.confirmed_at " + "SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, i.confirmed_at " +
"FROM orders o " + "FROM orders o " +
@ -79,7 +99,7 @@ func FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
return nil return nil
} }
func CreateOrder(order *Order) error { func (db *DB) CreateOrder(order *Order) error {
if _, err := db.Exec(""+ if _, err := db.Exec(""+
"INSERT INTO orders(share_id, pubkey, side, quantity, price, invoice_id) "+ "INSERT INTO orders(share_id, pubkey, side, quantity, price, invoice_id) "+
"VALUES ($1, $2, $3, $4, $5, $6)", "VALUES ($1, $2, $3, $4, $5, $6)",

View File

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

View File

@ -6,61 +6,58 @@ import (
"gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4"
) )
type Serial = int type (
type UUID = string Serial = int
UUID = string
type LNAuth struct { LNAuth struct {
K1 string K1 string
LNURL string LNURL string
CreatdAt time.Time CreatedAt time.Time
SessionId string SessionId string
} }
User struct {
type User struct { Pubkey string
Pubkey string LastSeen time.Time
LastSeen time.Time }
} Session struct {
Pubkey string
type Session struct { SessionId string
Pubkey string }
SessionId string Market struct {
} Id Serial `json:"id"`
Description string `json:"description"`
type Market struct { EndDate time.Time `json:"endDate"`
Id Serial InvoiceId UUID
Description string }
Active bool Share struct {
} Id UUID
MarketId int
type Share struct { Description string
Id UUID }
MarketId int Invoice struct {
Description string Id UUID
} Pubkey string
Msats int64
type Invoice struct { MsatsReceived int64
Id UUID Preimage string
Pubkey string Hash string
Msats int64 PaymentRequest string
MsatsReceived int64 CreatedAt time.Time
Preimage string ExpiresAt time.Time
Hash string ConfirmedAt null.Time
PaymentRequest string HeldSince null.Time
CreatedAt time.Time Description string
ExpiresAt time.Time }
ConfirmedAt null.Time Order struct {
HeldSince null.Time Id UUID
} CreatedAt time.Time
ShareId string `json:"sid"`
type Order struct { Share
Id UUID Pubkey string
CreatedAt time.Time Side string `json:"side"`
ShareId string `form:"share_id"` Quantity int64 `json:"quantity"`
Share Price int64 `json:"price"`
Pubkey string InvoiceId UUID
Side string `form:"side"` Invoice
Quantity int64 `form:"quantity"` }
Price int64 `form:"price"` )
InvoiceId UUID
Invoice
}

View File

@ -1,13 +1,17 @@
package db package db
func CreateUser(u *User) error { func (db *DB) CreateUser(u *User) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO users(pubkey) VALUES ($1) ON CONFLICT(pubkey) DO UPDATE SET last_seen = CURRENT_TIMESTAMP", "INSERT INTO users(pubkey) VALUES ($1) ON CONFLICT(pubkey) DO UPDATE SET last_seen = CURRENT_TIMESTAMP",
u.Pubkey) u.Pubkey)
return err return err
} }
func UpdateUser(u *User) error { 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) _, err := db.Exec("UPDATE users SET last_seen = $1 WHERE pubkey = $2", u.LastSeen, u.Pubkey)
return err return err
} }

10
env/env.go vendored
View File

@ -19,13 +19,17 @@ var (
Version string Version string
) )
func init() { func Load(filenames ...string) error {
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
log.Fatalf("error loading env vars: %s", err) return err
} }
flag.StringVar(&PublicURL, "PUBLIC_URL", "delphi.market", "Public URL of website") flag.StringVar(&PublicURL, "PUBLIC_URL", "delphi.market", "Public URL of website")
flag.IntVar(&Port, "PORT", 4321, "Server port") flag.IntVar(&Port, "PORT", 4321, "Server port")
flag.StringVar(&Env, "ENV", "development", "Specify for which environment files should be built") flag.StringVar(&Env, "ENV", "development", "Specify environment")
return nil
}
func Parse() {
flag.Parse() flag.Parse()
CommitLongSha = execCmd("git", "rev-parse", "HEAD") CommitLongSha = execCmd("git", "rev-parse", "HEAD")
CommitShortSha = execCmd("git", "rev-parse", "--short", "HEAD") CommitShortSha = execCmd("git", "rev-parse", "--short", "HEAD")

176
go.mod
View File

@ -5,15 +5,16 @@ go 1.20
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/btcsuite/btcd/btcec/v2 v2.2.2 github.com/btcsuite/btcd/btcec/v2 v2.3.2
github.com/btcsuite/btcutil v1.0.2 github.com/btcsuite/btcutil v1.0.2
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 v1.0.0 github.com/lightninglabs/lndclient v0.16.0-11
github.com/lightningnetwork/lnd v0.10.0-beta.rc6.0.20200615174244-103c59a4889f github.com/lightningnetwork/lnd v0.16.0-beta
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
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 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
) )
@ -21,82 +22,161 @@ require (
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/beorn7/perks v1.0.0 // indirect github.com/andybalholm/brotli v1.0.3 // indirect
github.com/btcsuite/btcd v0.20.1-beta.0.20200515232429-9f0179fd2c46 // indirect github.com/beorn7/perks v1.0.1 // 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/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/btcutil/psbt v1.0.2 // indirect
github.com/btcsuite/btcwallet v0.11.1-0.20200604005347-6390f167e5f8 // indirect github.com/btcsuite/btcwallet v0.16.7 // indirect
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0 // indirect github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2 // indirect
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0 // indirect github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect
github.com/btcsuite/btcwallet/wallet/txsizes v1.0.0 // indirect github.com/btcsuite/btcwallet/wallet/txsizes v1.2.3 // indirect
github.com/btcsuite/btcwallet/walletdb v1.3.1 // indirect github.com/btcsuite/btcwallet/walletdb v1.4.0 // indirect
github.com/btcsuite/btcwallet/wtxmgr v1.1.1-0.20200604005347-6390f167e5f8 // indirect github.com/btcsuite/btcwallet/wtxmgr v1.5.0 // indirect
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/cenkalti/backoff/v4 v4.1.1 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/coreos/bbolt v1.3.3 // indirect github.com/coreos/bbolt v1.3.3 // indirect
github.com/coreos/etcd v3.3.22+incompatible // 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 v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // 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/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/decred/dcrd/lru v1.0.0 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // 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.0 // indirect
github.com/dvyukov/go-fuzz v0.0.0-20210602112143-b1f3d6f4ef4e // 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.1.1 // 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
github.com/golang/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
github.com/google/btree v1.0.0 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.1.1 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/websocket v1.4.1 // indirect github.com/google/btree v1.0.1 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.14.3 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/jonboulle/clockwork v0.1.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.5.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.10.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.1.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.8.1 // indirect
github.com/jackc/pgx/v4 v4.13.0 // indirect
github.com/jessevdk/go-flags v1.4.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/jrick/logrotate v1.0.0 // indirect github.com/jrick/logrotate v1.0.0 // indirect
github.com/json-iterator/go v1.1.9 // indirect github.com/json-iterator/go v1.1.11 // indirect
github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8 // indirect github.com/juju/loggo v0.0.0-20210728185423-eebad3a902c4 // indirect
github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect github.com/kkdai/bstream v1.0.0 // indirect
github.com/klauspost/compress v1.13.6 // 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.11.1-0.20200316235139-bffc52e8f200 // indirect github.com/lightninglabs/neutrino v0.15.0 // indirect
github.com/lightningnetwork/lightning-onion v1.0.2-0.20200501022730-3c8c8d0b89ea // indirect github.com/lightninglabs/neutrino/cache v1.1.1 // indirect
github.com/lightningnetwork/lnd/clock v1.0.1 // indirect github.com/lightningnetwork/lightning-onion v1.2.1-0.20221202012345-ca23184850a1 // indirect
github.com/lightningnetwork/lnd/queue v1.0.4 // indirect github.com/lightningnetwork/lnd/clock v1.1.0 // indirect
github.com/lightningnetwork/lnd/ticker v1.0.0 // indirect github.com/lightningnetwork/lnd/healthcheck v1.2.2 // indirect
github.com/lightningnetwork/lnd/kvdb v1.4.1 // indirect
github.com/lightningnetwork/lnd/queue v1.1.0 // indirect
github.com/lightningnetwork/lnd/ticker v1.1.0 // indirect
github.com/lightningnetwork/lnd/tlv 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.19 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/miekg/dns v0.0.0-20171125082028-79bfde677fa8 // indirect github.com/mholt/archiver/v3 v3.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/miekg/dns v1.1.43 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/prometheus/client_golang v0.9.3 // indirect github.com/nwaples/rardecode v1.1.2 // indirect
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 // indirect github.com/pierrec/lz4/v4 v4.1.8 // indirect
github.com/prometheus/common v0.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 // indirect github.com/prometheus/client_golang v1.11.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rogpeppe/fastuuid v1.2.0 // indirect github.com/rogpeppe/fastuuid v1.2.0 // indirect
github.com/sirupsen/logrus v1.2.0 // indirect github.com/sirupsen/logrus v1.7.0 // indirect
github.com/soheilhy/cmux v0.1.4 // indirect github.com/soheilhy/cmux v0.1.5 // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // 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/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.uber.org/atomic v1.6.0 // indirect go.etcd.io/bbolt v1.3.6 // indirect
go.uber.org/multierr v1.5.0 // indirect go.etcd.io/etcd/api/v3 v3.5.7 // indirect
go.uber.org/zap v1.14.1 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.7 // indirect
golang.org/x/crypto v0.11.0 // indirect go.etcd.io/etcd/client/v2 v2.305.7 // indirect
golang.org/x/net v0.12.0 // indirect go.etcd.io/etcd/client/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/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/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/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/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/proto/otlp v0.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.17.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect golang.org/x/sys v0.12.0 // indirect
golang.org/x/term v0.10.0 // indirect golang.org/x/term v0.12.0 // indirect
golang.org/x/text v0.11.0 // indirect golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
google.golang.org/genproto v0.0.0-20190927181202-20e1ac93f88c // indirect golang.org/x/tools v0.13.0 // indirect
google.golang.org/grpc v1.24.0 // indirect google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced // indirect
google.golang.org/grpc v1.41.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/errgo.v1 v1.0.1 // indirect gopkg.in/errgo.v1 v1.0.1 // indirect
gopkg.in/macaroon-bakery.v2 v2.0.1 // indirect gopkg.in/macaroon-bakery.v2 v2.0.1 // indirect
gopkg.in/macaroon.v2 v2.1.0 // indirect gopkg.in/macaroon.v2 v2.1.0 // indirect
gopkg.in/yaml.v2 v2.2.3 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
sigs.k8s.io/yaml v1.1.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.20.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
sigs.k8s.io/yaml v1.2.0 // indirect
) )

915
go.sum

File diff suppressed because it is too large Load Diff

0
hotreload.sh Normal file → Executable file
View File

View File

@ -12,7 +12,7 @@ import (
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
) )
func CreateInvoice(pubkey string, msats int64) (*db.Invoice, error) { func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, description string) (*db.Invoice, error) {
var ( var (
expiry time.Duration = time.Hour expiry time.Duration = time.Hour
preimage lntypes.Preimage preimage lntypes.Preimage
@ -44,14 +44,15 @@ func CreateInvoice(pubkey string, msats int64) (*db.Invoice, error) {
Hash: hash.String(), Hash: hash.String(),
CreatedAt: lnInvoice.CreationDate, CreatedAt: lnInvoice.CreationDate,
ExpiresAt: lnInvoice.CreationDate.Add(expiry), ExpiresAt: lnInvoice.CreationDate.Add(expiry),
Description: description,
} }
if err := db.CreateInvoice(dbInvoice); err != nil { if err := d.CreateInvoice(dbInvoice); err != nil {
return nil, err return nil, err
} }
return dbInvoice, nil return dbInvoice, nil
} }
func CheckInvoice(hash lntypes.Hash) { func (lnd *LNDClient) CheckInvoice(d *db.DB, hash lntypes.Hash) {
var ( var (
pollInterval = 5 * time.Second pollInterval = 5 * time.Second
invoice db.Invoice invoice db.Invoice
@ -60,12 +61,7 @@ func CheckInvoice(hash lntypes.Hash) {
err error err error
) )
if !Enabled { if err = d.FetchInvoice(&db.FetchInvoiceWhere{Hash: hash.String()}, &invoice); err != nil {
log.Printf("LND disabled, skipping checking invoice: hash=%s", hash)
return
}
if err = db.FetchInvoice(&db.FetchInvoiceWhere{Hash: hash.String()}, &invoice); err != nil {
log.Println(err) log.Println(err)
return return
} }
@ -100,7 +96,7 @@ func CheckInvoice(hash lntypes.Hash) {
handleLoopError(err) handleLoopError(err)
continue continue
} }
if err = db.ConfirmInvoice(hash.String(), time.Now(), int(lnInvoice.AmountPaid)); err != nil { if err = d.ConfirmInvoice(hash.String(), time.Now(), int(lnInvoice.AmountPaid)); err != nil {
handleLoopError(err) handleLoopError(err)
continue continue
} }
@ -110,3 +106,21 @@ func CheckInvoice(hash lntypes.Hash) {
time.Sleep(pollInterval) time.Sleep(pollInterval)
} }
} }
func (lnd *LNDClient) CheckInvoices(d *db.DB) error {
var (
invoices []db.Invoice
err error
hash lntypes.Hash
)
if err = d.FetchInvoices(&db.FetchInvoicesWhere{Unconfirmed: true}, &invoices); err != nil {
return err
}
for _, invoice := range invoices {
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
return err
}
go lnd.CheckInvoice(d, hash)
}
return nil
}

View File

@ -3,47 +3,25 @@ package lnd
import ( import (
"log" "log"
"github.com/joho/godotenv"
"github.com/lightninglabs/lndclient" "github.com/lightninglabs/lndclient"
"github.com/namsral/flag"
)
var (
lnd *LNDClient
Enabled bool
) )
type LNDClient struct { type LNDClient struct {
lndclient.GrpcLndServices *lndclient.GrpcLndServices
} }
func init() { type LNDConfig = lndclient.LndServicesConfig
func New(config *LNDConfig) (*LNDClient, error) {
var ( var (
lndCert string rcpLndServices *lndclient.GrpcLndServices
lndMacaroonDir string lnd *LNDClient
lndHost string
rpcLndServices *lndclient.GrpcLndServices
err error err error
) )
if err = godotenv.Load(); err != nil { if rcpLndServices, err = lndclient.NewLndServices(config); err != nil {
log.Fatalf("error loading env vars: %s", err) return nil, err
} }
flag.StringVar(&lndCert, "LND_CERT", "", "Path to LND TLS certificate") lnd = &LNDClient{GrpcLndServices: rcpLndServices}
flag.StringVar(&lndMacaroonDir, "LND_MACAROON_DIR", "", "LND macaroon directory") log.Printf("Connected to %s running LND v%s", config.LndAddress, lnd.Version.Version)
flag.StringVar(&lndHost, "LND_HOST", "localhost:10001", "LND gRPC server address") return lnd, nil
flag.Parse()
if rpcLndServices, err = lndclient.NewLndServices(&lndclient.LndServicesConfig{
LndAddress: lndHost,
MacaroonDir: lndMacaroonDir,
TLSPath: lndCert,
// TODO: make network configurable
Network: lndclient.NetworkRegtest,
}); err != nil {
log.Println(err)
Enabled = false
return
}
lnd = &LNDClient{GrpcLndServices: *rpcLndServices}
log.Printf("Connected to %s running LND v%s", lndHost, lnd.Version.Version)
Enabled = true
} }

65
main.go
View File

@ -5,8 +5,13 @@ import (
"log" "log"
"net/http" "net/http"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/env" "git.ekzyis.com/ekzyis/delphi.market/env"
"git.ekzyis.com/ekzyis/delphi.market/lnd"
"git.ekzyis.com/ekzyis/delphi.market/server" "git.ekzyis.com/ekzyis/delphi.market/server"
"git.ekzyis.com/ekzyis/delphi.market/server/router"
"github.com/lightninglabs/lndclient"
"github.com/namsral/flag"
) )
var ( var (
@ -14,11 +19,69 @@ var (
) )
func init() { func init() {
var (
dbUrl string
lndAddress string
lndCert string
lndMacaroonDir string
lndNetwork string
db_ *db.DB
lnd_ *lnd.LNDClient
ctx router.ServerContext
err error
)
if err = env.Load(); err != nil {
log.Fatalf("error loading env vars: %v", err)
}
flag.StringVar(&dbUrl, "DATABASE_URL", "delphi.market", "Public URL of website")
flag.StringVar(&lndAddress, "LND_ADDRESS", "localhost:10001", "LND gRPC server address")
flag.StringVar(&lndCert, "LND_CERT", "", "Path to LND TLS certificate")
flag.StringVar(&lndMacaroonDir, "LND_MACAROON_DIR", "", "LND macaroon directory")
flag.StringVar(&lndNetwork, "LND_NETWORK", "regtest", "LND network")
env.Parse()
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)
s = server.NewServer() if db_, err = db.New(dbUrl); err != nil {
log.Fatalf("error connecting to database: %v", err)
}
if lnd_, err = lnd.New(&lnd.LNDConfig{
LndAddress: lndAddress,
TLSPath: lndCert,
MacaroonDir: lndMacaroonDir,
Network: lndclient.Network(lndNetwork),
}); err != nil {
log.Printf("[warn] error connecting to LND: %v\n", err)
lnd_ = nil
} else {
lnd_.CheckInvoices(db_)
}
ctx = server.ServerContext{
PublicURL: env.PublicURL,
CommitShortSha: env.CommitShortSha,
CommitLongSha: env.CommitLongSha,
Version: env.Version,
Db: db_,
Lnd: lnd_,
}
s = server.New(ctx)
}
func figlet() {
log.Println(
"\n" +
" _ _ _ _ \n" +
" __| | ___| |_ __ | |__ (_)\n" +
" / _` |/ _ \\ | '_ \\| '_ \\| |\n" +
"| (_| | __/ | |_) | | | | |\n" +
" \\__,_|\\___|_| .__/|_| |_|_|\n" +
" |_| .market \n" +
"----------------------------",
)
} }
func main() { func main() {

View File

@ -1,7 +1,11 @@
upstream delphi { upstream delphi-prod-backend {
server 127.0.0.1:4321; server 127.0.0.1:4321;
} }
upstream delphi-prod-frontend {
server 127.0.0.1:4173;
}
server { server {
server_name delphi.market; server_name delphi.market;
listen 80; listen 80;
@ -25,7 +29,16 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Prefix /; proxy_set_header X-Forwarded-Prefix /;
proxy_pass http://delphi$request_uri; proxy_pass http://delphi-prod-frontend$request_uri;
}
location /api {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Prefix /;
proxy_pass http://delphi-prod-backend$request_uri;
} }
include letsencrypt.conf; include letsencrypt.conf;

56
nginx.dev.conf Normal file
View File

@ -0,0 +1,56 @@
upstream delphi-dev-backend {
server 127.0.0.1:4322;
}
upstream delphi-dev-frontend {
server 127.0.0.1:4323;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
server_name dev1.delphi.market;
listen 80;
listen [::]:80;
return 301 https://dev1.delphi.market$request_uri;
}
server {
server_name dev1.delphi.market;
listen 443;
listen [::]:443;
ssl on;
ssl_certificate /etc/letsencrypt/live/dev1.delphi.market/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev1.delphi.market/privkey.pem;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Prefix /;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://delphi-dev-frontend$request_uri;
}
location /api {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Prefix /;
proxy_pass http://delphi-dev-backend$request_uri;
}
location /hotreload {
root /var/www/dev1.delphi;
}
include letsencrypt.conf;
}

View File

@ -30,7 +30,7 @@ func NewLNAuth() (*LNAuth, error) {
return nil, fmt.Errorf("rand.Read error: %w", err) return nil, fmt.Errorf("rand.Read error: %w", err)
} }
k1hex := hex.EncodeToString(k1) k1hex := hex.EncodeToString(k1)
url := []byte(fmt.Sprintf("https://%s/api/login?tag=login&k1=%s&action=login", env.PublicURL, k1hex)) url := []byte(fmt.Sprintf("https://%s/api/login/callback?tag=login&k1=%s&action=login", env.PublicURL, k1hex))
conv, err := bech32.ConvertBits(url, 8, 5, true) conv, err := bech32.ConvertBits(url, 8, 5, true)
if err != nil { if err != nil {
return nil, fmt.Errorf("bech32.ConvertBits error: %w", err) return nil, fmt.Errorf("bech32.ConvertBits error: %w", err)

View File

@ -1,9 +1,8 @@
package server package server
import ( import (
"fmt"
"net/http" "net/http"
"os" "strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@ -14,32 +13,8 @@ func httpErrorHandler(err error, c echo.Context) {
if httpError, ok := err.(*echo.HTTPError); ok { if httpError, ok := err.(*echo.HTTPError); ok {
code = httpError.Code code = httpError.Code
} }
filePath := fmt.Sprintf("public/%d.html", code) if strings.Contains(err.Error(), "violates check constraint") {
var f *os.File code = 400
if f, err = os.Open(filePath); err != nil {
c.Logger().Error(err)
serveError(c, 500)
return
}
if err = c.Stream(code, "text/html", f); err != nil {
c.Logger().Error(err)
serveError(c, 500)
return
} }
} c.JSON(code, map[string]any{"status": code})
func serveError(c echo.Context, code int) error {
var (
f *os.File
err error
)
if f, err = os.Open(fmt.Sprintf("public/%d.html", code)); err != nil {
c.Logger().Error(err)
return err
}
if err = c.Stream(code, "text/html", f); err != nil {
c.Logger().Error(err)
return err
}
return nil
} }

View File

@ -0,0 +1,34 @@
package context
import (
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/lnd"
"github.com/labstack/echo/v4"
)
type ServerContext struct {
Environment string
PublicURL string
CommitShortSha string
CommitLongSha string
Version string
Db *db.DB
Lnd *lnd.LNDClient
}
func (sc *ServerContext) Render(c echo.Context, code int, name string, data map[string]any) error {
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) {
for k, v := range *src {
(*target)[k] = v
}
}

View File

@ -0,0 +1,16 @@
package handler_test
import (
"testing"
db_ "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/test"
)
var (
db *db_.DB
)
func TestMain(m *testing.M) {
test.Main(m, db)
}

View File

@ -4,25 +4,38 @@ import (
"net/http" "net/http"
"git.ekzyis.com/ekzyis/delphi.market/db" "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/lib" "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
func HandleIndex(envVars map[string]any) echo.HandlerFunc { func HandleIndex(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
markets []db.Market markets []db.Market
err error err error
data map[string]any data map[string]any
) )
if err = db.FetchActiveMarkets(&markets); err != nil { if err = sc.Db.FetchActiveMarkets(&markets); err != nil {
return err return err
} }
data = map[string]any{ data = map[string]any{
"session": c.Get("session"), "session": c.Get("session"),
"markets": markets, "markets": markets,
} }
lib.Merge(&data, &envVars)
return c.Render(http.StatusOK, "index.html", data) return sc.Render(c, http.StatusOK, "index.html", data)
}
}
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)
} }
} }

View File

@ -7,21 +7,22 @@ import (
"git.ekzyis.com/ekzyis/delphi.market/db" "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/lib" "git.ekzyis.com/ekzyis/delphi.market/lib"
"git.ekzyis.com/ekzyis/delphi.market/lnd" "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lntypes"
) )
func HandleInvoiceAPI(envVars map[string]any) echo.HandlerFunc { func HandleInvoiceStatus(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
invoiceId string invoiceId string
invoice db.Invoice invoice db.Invoice
u db.User u db.User
qr string
err error err error
) )
invoiceId = c.Param("id") invoiceId = c.Param("id")
if err = db.FetchInvoice(&db.FetchInvoiceWhere{Id: invoiceId}, &invoice); err == sql.ErrNoRows { if err = sc.Db.FetchInvoice(&db.FetchInvoiceWhere{Id: invoiceId}, &invoice); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound) return echo.NewHTTPError(http.StatusNotFound)
} else if err != nil { } else if err != nil {
return err return err
@ -29,12 +30,28 @@ func HandleInvoiceAPI(envVars map[string]any) echo.HandlerFunc {
if u = c.Get("session").(db.User); invoice.Pubkey != u.Pubkey { if u = c.Get("session").(db.User); invoice.Pubkey != u.Pubkey {
return echo.NewHTTPError(http.StatusUnauthorized) return echo.NewHTTPError(http.StatusUnauthorized)
} }
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
return err
}
invoice.Preimage = "" invoice.Preimage = ""
return c.JSON(http.StatusOK, invoice) data := map[string]any{
"Id": invoice.Id,
"Msats": invoice.Msats,
"MsatsReceived": invoice.MsatsReceived,
"Hash": invoice.Hash,
"PaymentRequest": invoice.PaymentRequest,
"CreatedAt": invoice.CreatedAt,
"ExpiresAt": invoice.ExpiresAt,
"ConfirmedAt": invoice.ConfirmedAt,
"HeldSince": invoice.HeldSince,
"Description": invoice.Description,
"Qr": qr,
}
return c.JSON(http.StatusOK, data)
} }
} }
func HandleInvoice(envVars map[string]any) echo.HandlerFunc { func HandleInvoice(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
invoiceId string invoiceId string
@ -46,7 +63,7 @@ func HandleInvoice(envVars map[string]any) echo.HandlerFunc {
err error err error
) )
invoiceId = c.Param("id") invoiceId = c.Param("id")
if err = db.FetchInvoice(&db.FetchInvoiceWhere{Id: invoiceId}, &invoice); err == sql.ErrNoRows { if err = sc.Db.FetchInvoice(&db.FetchInvoiceWhere{Id: invoiceId}, &invoice); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound) return echo.NewHTTPError(http.StatusNotFound)
} else if err != nil { } else if err != nil {
return err return err
@ -57,7 +74,7 @@ func HandleInvoice(envVars map[string]any) echo.HandlerFunc {
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil { if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
return err return err
} }
go lnd.CheckInvoice(hash) go sc.Lnd.CheckInvoice(sc.Db, hash)
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil { if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
return err return err
} }
@ -73,6 +90,6 @@ func HandleInvoice(envVars map[string]any) echo.HandlerFunc {
"lnurl": invoice.PaymentRequest, "lnurl": invoice.PaymentRequest,
"qr": qr, "qr": qr,
} }
return c.Render(http.StatusOK, "invoice.html", data) return sc.Render(c, http.StatusOK, "invoice.html", data)
} }
} }

View File

@ -8,10 +8,11 @@ import (
"git.ekzyis.com/ekzyis/delphi.market/db" "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/lib" "git.ekzyis.com/ekzyis/delphi.market/lib"
"git.ekzyis.com/ekzyis/delphi.market/server/auth" "git.ekzyis.com/ekzyis/delphi.market/server/auth"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
func HandleLogin(envVars map[string]any) echo.HandlerFunc { func HandleLogin(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
lnAuth *auth.LNAuth lnAuth *auth.LNAuth
@ -25,7 +26,7 @@ func HandleLogin(envVars map[string]any) echo.HandlerFunc {
return err return err
} }
dbLnAuth = db.LNAuth{K1: lnAuth.K1, LNURL: lnAuth.LNURL} dbLnAuth = db.LNAuth{K1: lnAuth.K1, LNURL: lnAuth.LNURL}
if err = db.CreateLNAuth(&dbLnAuth); err != nil { if err = sc.Db.CreateLNAuth(&dbLnAuth); err != nil {
return err return err
} }
c.SetCookie(&http.Cookie{Name: "session", HttpOnly: true, Path: "/", Value: dbLnAuth.SessionId, Secure: true, Expires: expires}) c.SetCookie(&http.Cookie{Name: "session", HttpOnly: true, Path: "/", Value: dbLnAuth.SessionId, Secure: true, Expires: expires})
@ -33,16 +34,14 @@ func HandleLogin(envVars map[string]any) echo.HandlerFunc {
return err return err
} }
data = map[string]any{ data = map[string]any{
"session": c.Get("session"), "lnurl": lnAuth.LNURL,
"lnurl": lnAuth.LNURL, "qr": qr,
"qr": qr,
} }
lib.Merge(&data, &envVars) return c.JSON(http.StatusOK, data)
return c.Render(http.StatusOK, "login.html", data)
} }
} }
func HandleLoginCallback(envVars map[string]any) echo.HandlerFunc { func HandleLoginCallback(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
query auth.LNAuthResponse query auth.LNAuthResponse
@ -52,7 +51,7 @@ func HandleLoginCallback(envVars map[string]any) echo.HandlerFunc {
if err := c.Bind(&query); err != nil { if err := c.Bind(&query); err != nil {
return echo.NewHTTPError(http.StatusBadRequest) return echo.NewHTTPError(http.StatusBadRequest)
} }
if err = db.FetchSessionId(query.K1, &sessionId); err == sql.ErrNoRows { if err = sc.Db.FetchSessionId(query.K1, &sessionId); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "session not found"}) return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "session not found"})
} else if err != nil { } else if err != nil {
return err return err
@ -62,13 +61,13 @@ func HandleLoginCallback(envVars map[string]any) echo.HandlerFunc {
} else if !ok { } else if !ok {
return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "bad signature"}) return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "bad signature"})
} }
if err = db.CreateUser(&db.User{Pubkey: query.Key}); err != nil { if err = sc.Db.CreateUser(&db.User{Pubkey: query.Key}); err != nil {
return err return err
} }
if err = db.CreateSession(&db.Session{Pubkey: query.Key, SessionId: sessionId}); err != nil { if err = sc.Db.CreateSession(&db.Session{Pubkey: query.Key, SessionId: sessionId}); err != nil {
return err return err
} }
if err = db.DeleteLNAuth(&db.LNAuth{K1: query.K1}); err != nil { if err = sc.Db.DeleteLNAuth(&db.LNAuth{K1: query.K1}); err != nil {
return err return err
} }
return c.JSON(http.StatusOK, map[string]string{"status": "OK"}) return c.JSON(http.StatusOK, map[string]string{"status": "OK"})

View File

@ -0,0 +1,123 @@
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")
}

View File

@ -5,10 +5,11 @@ import (
"time" "time"
"git.ekzyis.com/ekzyis/delphi.market/db" "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
func HandleLogout(envVars map[string]any) echo.HandlerFunc { func HandleLogout(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
cookie *http.Cookie cookie *http.Cookie
@ -17,14 +18,14 @@ func HandleLogout(envVars map[string]any) echo.HandlerFunc {
) )
if cookie, err = c.Cookie("session"); err != nil { if cookie, err = c.Cookie("session"); err != nil {
// cookie not found // cookie not found
return c.Redirect(http.StatusSeeOther, "/") return c.JSON(http.StatusNotFound, map[string]string{"reason": "session not found"})
} }
sessionId = cookie.Value sessionId = cookie.Value
if err = db.DeleteSession(&db.Session{SessionId: sessionId}); err != nil { if err = sc.Db.DeleteSession(&db.Session{SessionId: sessionId}); err != nil {
return err return err
} }
// tell browser that cookie is expired and thus can be deleted // 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()}) c.SetCookie(&http.Cookie{Name: "session", HttpOnly: true, Path: "/", Value: sessionId, Secure: true, Expires: time.Now()})
return c.Redirect(http.StatusSeeOther, "/") return c.JSON(http.StatusSeeOther, map[string]string{"status": "OK"})
} }
} }

View File

@ -0,0 +1,72 @@
package handler_test
import (
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"testing"
db_ "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/handler"
"git.ekzyis.com/ekzyis/delphi.market/test"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func init() {
test.Init(&db)
}
func TestLogout(t *testing.T) {
var (
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")
}

View File

@ -5,16 +5,16 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"git.ekzyis.com/ekzyis/delphi.market/db" "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/env"
"git.ekzyis.com/ekzyis/delphi.market/lib" "git.ekzyis.com/ekzyis/delphi.market/lib"
"git.ekzyis.com/ekzyis/delphi.market/lnd" "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lntypes"
) )
func HandleMarket(envVars map[string]any) echo.HandlerFunc { func HandleMarket(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
marketId int64 marketId int64
@ -27,53 +27,93 @@ func HandleMarket(envVars map[string]any) echo.HandlerFunc {
if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil { if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Bad Request") return echo.NewHTTPError(http.StatusBadRequest, "Bad Request")
} }
if err = db.FetchMarket(int(marketId), &market); err == sql.ErrNoRows { if err = sc.Db.FetchMarket(int(marketId), &market); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, "Not Found") return echo.NewHTTPError(http.StatusNotFound, "Not Found")
} else if err != nil { } else if err != nil {
return err return err
} }
if err = db.FetchShares(market.Id, &shares); err != nil { if err = sc.Db.FetchShares(market.Id, &shares); err != nil {
return err return err
} }
if err = db.FetchOrders(&db.FetchOrdersWhere{MarketId: market.Id, Confirmed: true}, &orders); err != nil { if err = sc.Db.FetchOrders(&db.FetchOrdersWhere{MarketId: market.Id, Confirmed: true}, &orders); err != nil {
return err return err
} }
data = map[string]any{ data = map[string]any{
"session": c.Get("session"),
"Id": market.Id, "Id": market.Id,
"Description": market.Description, "Description": market.Description,
// shares are sorted by description in descending order "Shares": shares,
// that's how we know that YES must be the first share
"YesShare": shares[0],
"NoShare": shares[1],
"Orders": orders,
} }
lib.Merge(&data, &envVars) return c.JSON(http.StatusOK, data)
return c.Render(http.StatusOK, "market.html", data)
} }
} }
func HandlePostOrder(envVars map[string]any) echo.HandlerFunc { func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
var ( var (
marketId string u db.User
u db.User m db.Market
o db.Order invoice *db.Invoice
invoice *db.Invoice msats int64
msats int64 invDescription string
data map[string]any data map[string]any
qr string qr string
hash lntypes.Hash hash lntypes.Hash
err error err error
)
if err := c.Bind(&m); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
u = c.Get("session").(db.User)
msats = 1000
// TODO: add [market:<id>] for redirect after payment
invDescription = fmt.Sprintf("create market \"%s\" (%s)", m.Description, m.EndDate)
if invoice, err = sc.Lnd.CreateInvoice(sc.Db, u.Pubkey, msats, invDescription); err != nil {
return err
}
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
return err
}
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
return err
}
go sc.Lnd.CheckInvoice(sc.Db, hash)
m.InvoiceId = invoice.Id
if err := sc.Db.CreateMarket(&m); err != nil {
return err
}
data = map[string]any{
"id": invoice.Id,
"bolt11": invoice.PaymentRequest,
"amount": msats,
"qr": qr,
}
return c.JSON(http.StatusPaymentRequired, data)
}
}
func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error {
var (
u db.User
o db.Order
s db.Share
invoice *db.Invoice
msats int64
description string
data map[string]any
qr string
hash lntypes.Hash
err error
) )
marketId = c.Param("id")
// TODO: // TODO:
// [ ] Step 0: If SELL order, check share balance of user // [ ] If SELL order, check share balance of user
// [x] Create HODL invoice
// [x] Create (unconfirmed) order // [x] Create (unconfirmed) order
// [x] Create invoice
// [ ] Find matching orders // [ ] Find matching orders
// [ ] Settle invoice when matching order was found, // [ ] show invoice to user
// else cancel invoice if expired
// parse body // parse body
if err := c.Bind(&o); err != nil { if err := c.Bind(&o); err != nil {
@ -82,11 +122,15 @@ func HandlePostOrder(envVars map[string]any) echo.HandlerFunc {
u = c.Get("session").(db.User) u = c.Get("session").(db.User)
o.Pubkey = u.Pubkey o.Pubkey = u.Pubkey
msats = o.Quantity * o.Price * 1000 msats = o.Quantity * o.Price * 1000
if err = sc.Db.FetchShare(o.ShareId, &s); err != nil {
return err
}
description = fmt.Sprintf("%s %d %s shares @ %d sats [market:%d]", strings.ToUpper(o.Side), o.Quantity, s.Description, o.Price, s.MarketId)
// TODO: if SELL order, check share balance of user // TODO: if SELL order, check share balance of user
// Create HODL invoice // Create HODL invoice
if invoice, err = lnd.CreateInvoice(o.Pubkey, msats); err != nil { if invoice, err = sc.Lnd.CreateInvoice(sc.Db, o.Pubkey, msats, description); err != nil {
return err return err
} }
// Create QR code to pay HODL invoice // Create QR code to pay HODL invoice
@ -97,25 +141,23 @@ func HandlePostOrder(envVars map[string]any) echo.HandlerFunc {
return err return err
} }
// Start goroutine to poll status and update invoice in background
go lnd.CheckInvoice(hash)
// Create (unconfirmed) order // Create (unconfirmed) order
o.InvoiceId = invoice.Id o.InvoiceId = invoice.Id
if err := db.CreateOrder(&o); err != nil { if err := sc.Db.CreateOrder(&o); err != nil {
return err return err
} }
// Start goroutine to poll status and update invoice in background
go sc.Lnd.CheckInvoice(sc.Db, hash)
// TODO: find matching orders // TODO: find matching orders
data = map[string]any{ data = map[string]any{
"session": c.Get("session"), "id": invoice.Id,
"lnurl": invoice.PaymentRequest, "bolt11": invoice.PaymentRequest,
"qr": qr, "amount": msats,
"invoice": *invoice, "qr": qr,
"redirectURL": fmt.Sprintf("https://%s/market/%s", env.PublicURL, marketId),
} }
lib.Merge(&data, &envVars) return c.JSON(http.StatusPaymentRequired, data)
return c.Render(http.StatusPaymentRequired, "invoice.html", data)
} }
} }

View File

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

View File

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

View File

@ -3,14 +3,14 @@ package middleware
import ( import (
"net/http" "net/http"
"git.ekzyis.com/ekzyis/delphi.market/lnd" "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
func LNDGuard(envVars map[string]any) echo.MiddlewareFunc { func LNDGuard(sc context.ServerContext) 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 lnd.Enabled { if sc.Lnd != nil {
return next(c) return next(c)
} }
return echo.NewHTTPError(http.StatusMethodNotAllowed) return echo.NewHTTPError(http.StatusMethodNotAllowed)

View File

@ -6,10 +6,11 @@ import (
"time" "time"
"git.ekzyis.com/ekzyis/delphi.market/db" "git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
func Session(envVars map[string]any) echo.MiddlewareFunc { func Session(sc context.ServerContext) 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 (
@ -23,10 +24,10 @@ func Session(envVars map[string]any) echo.MiddlewareFunc {
return next(c) return next(c)
} }
s = &db.Session{SessionId: cookie.Value} s = &db.Session{SessionId: cookie.Value}
if err = db.FetchSession(s); err == nil { if err = sc.Db.FetchSession(s); err == nil {
// session found // session found
u = &db.User{Pubkey: s.Pubkey, LastSeen: time.Now()} u = &db.User{Pubkey: s.Pubkey, LastSeen: time.Now()}
if err = db.UpdateUser(u); err != nil { if err = sc.Db.UpdateUser(u); err != nil {
return err return err
} }
c.Set("session", *u) c.Set("session", *u)
@ -38,7 +39,7 @@ func Session(envVars map[string]any) echo.MiddlewareFunc {
} }
} }
func SessionGuard(envVars map[string]any) echo.MiddlewareFunc { func SessionGuard(sc context.ServerContext) 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")

View File

@ -3,42 +3,78 @@ package router
import ( import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"git.ekzyis.com/ekzyis/delphi.market/env" "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
"git.ekzyis.com/ekzyis/delphi.market/server/router/handler" "git.ekzyis.com/ekzyis/delphi.market/server/router/handler"
"git.ekzyis.com/ekzyis/delphi.market/server/router/middleware" "git.ekzyis.com/ekzyis/delphi.market/server/router/middleware"
) )
func AddRoutes(e *echo.Echo) { type ServerContext = context.ServerContext
envVars := map[string]any{
"PUBLIC_URL": env.PublicURL, type MiddlewareFunc func(sc ServerContext) echo.MiddlewareFunc
"COMMIT_SHORT_SHA": env.CommitShortSha, type HandlerFunc = func(sc ServerContext) echo.HandlerFunc
"COMMIT_LONG_SHA": env.CommitLongSha,
"VERSION": env.Version, func AddRoutes(e *echo.Echo, sc ServerContext) {
} mountMiddleware(e, sc)
e.Use(middleware.Session(envVars)) addFrontendRoutes(e, sc)
e.GET("/", handler.HandleIndex(envVars)) addBackendRoutes(e, sc)
e.GET("/login", handler.HandleLogin(envVars)) }
e.GET("/api/login", handler.HandleLoginCallback(envVars))
e.GET("/api/session", handler.HandleCheckSession(envVars)) func mountMiddleware(e *echo.Echo, sc ServerContext) {
e.POST("/logout", handler.HandleLogout(envVars)) Use(e, sc, middleware.Session)
e.GET("/user", }
handler.HandleUser(envVars),
middleware.SessionGuard(envVars)) func addFrontendRoutes(e *echo.Echo, sc ServerContext) {
e.GET("/market/:id", GET(e, sc, "/user",
handler.HandleMarket(envVars), handler.HandleUser,
middleware.SessionGuard(envVars)) middleware.SessionGuard)
e.POST("/market/:id/order", GET(e, sc, "/market/:id",
handler.HandlePostOrder(envVars), handler.HandleMarket,
middleware.SessionGuard(envVars), middleware.SessionGuard)
middleware.LNDGuard(envVars)) POST(e, sc, "/market/:id/order",
e.GET("/invoice/:id", handler.HandleOrder,
handler.HandleInvoice(envVars), middleware.SessionGuard,
middleware.SessionGuard(envVars), middleware.LNDGuard)
middleware.LNDGuard(envVars), GET(e, sc, "/invoice/:id",
) handler.HandleInvoice,
e.GET("/api/invoice/:id", middleware.SessionGuard)
handler.HandleInvoiceAPI(envVars), }
middleware.SessionGuard(envVars),
middleware.LNDGuard(envVars), 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)
POST(e, sc, "/api/order",
handler.HandleOrder,
middleware.SessionGuard,
middleware.LNDGuard)
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)
}
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 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
} }

View File

@ -20,12 +20,8 @@ func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Con
return t.templates.ExecuteTemplate(w, name, data) return t.templates.ExecuteTemplate(w, name, data)
} }
var ( func ParseTemplates(pattern string) *Template {
T *Template return &Template{
)
func init() {
T = &Template{
templates: template.Must(template.New("").Funcs(template.FuncMap{ templates: template.Must(template.New("").Funcs(template.FuncMap{
"add": add[int64], "add": add[int64],
"sub": sub[int64], "sub": sub[int64],

View File

@ -11,21 +11,30 @@ type Server struct {
*echo.Echo *echo.Echo
} }
func NewServer() *Server { type ServerContext = router.ServerContext
e := echo.New()
func New(ctx ServerContext) *Server {
var (
e *echo.Echo
s *Server
)
e = echo.New()
e.Static("/", "public") e.Static("/", "public")
e.Renderer = router.ParseTemplates("pages/**.html")
e.Renderer = router.T
router.AddRoutes(e)
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",
})) }))
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"http://localhost:4224", "https://delphi.market", "https://dev1.delphi.market"},
AllowCredentials: true,
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
}))
e.HTTPErrorHandler = httpErrorHandler e.HTTPErrorHandler = httpErrorHandler
return &Server{e} s = &Server{e}
router.AddRoutes(e, ctx)
return s
} }

41
test/hooks.go Normal file
View File

@ -0,0 +1,41 @@
package test
import (
"fmt"
"os"
"path"
"runtime"
"testing"
db_ "git.ekzyis.com/ekzyis/delphi.market/db"
)
var (
dbName string = "delphi_test"
dbUrl string = fmt.Sprintf("postgres://delphi:delphi@localhost:5432/%s?sslmode=disable", dbName)
)
func Init(db **db_.DB) {
// for ParseTemplates to work, cwd needs to be project root
_, filename, _, _ := runtime.Caller(0)
dir := path.Join(path.Dir(filename), "../")
err := os.Chdir(dir)
if err != nil {
panic(err)
}
*db, err = db_.New(dbUrl)
if err != nil {
panic(err)
}
}
func Main(m *testing.M, db *db_.DB) {
if err := db.Reset(dbName); err != nil {
panic(err)
}
retCode := m.Run()
if err := db.Clear(dbName); err != nil {
panic(err)
}
os.Exit(retCode)
}

35
test/lnauth.go Normal file
View File

@ -0,0 +1,35 @@
package test
import (
"crypto/ecdsa"
"crypto/rand"
"encoding/hex"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
func GenerateKeyPair() (*secp256k1.PrivateKey, *secp256k1.PublicKey, error) {
var (
sk *secp256k1.PrivateKey
err error
)
if sk, err = secp256k1.GeneratePrivateKey(); err != nil {
return nil, nil, err
}
return sk, sk.PubKey(), nil
}
func Sign(sk *secp256k1.PrivateKey, k1_ string) (string, error) {
var (
k1 []byte
sig []byte
err error
)
if k1, err = hex.DecodeString(k1_); err != nil {
return "", err
}
if sig, err = ecdsa.SignASN1(rand.Reader, sk.ToECDSA(), k1); err != nil {
return "", err
}
return hex.EncodeToString(sig), nil
}

23
test/mocks.go Normal file
View File

@ -0,0 +1,23 @@
package test
import (
"io"
"net/http"
"net/http/httptest"
"git.ekzyis.com/ekzyis/delphi.market/server/router"
"github.com/labstack/echo/v4"
)
func HTTPMocks(method string, target string, body io.Reader) (*echo.Echo, *http.Request, *httptest.ResponseRecorder) {
var (
e *echo.Echo
req *http.Request
rec *httptest.ResponseRecorder
)
e = echo.New()
e.Renderer = router.ParseTemplates("pages/**.html")
req = httptest.NewRequest(method, target, body)
rec = httptest.NewRecorder()
return e, req, rec
}

4
vue/.browserslistrc Normal file
View File

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

5
vue/.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

18
vue/.eslintrc.js Normal file
View File

@ -0,0 +1,18 @@
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'
}
}

23
vue/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
.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?

24
vue/README.md Normal file
View File

@ -0,0 +1,24 @@
# 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/).

5
vue/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

27
vue/index.html Normal file
View File

@ -0,0 +1,27 @@
<!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>

19
vue/jsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

33
vue/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"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",
"core-js": "^3.8.3",
"pinia": "^2.1.7",
"register-service-worker": "^1.7.2",
"vite": "^4.5.0",
"vue": "^3.2.13",
"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"
}
}

6
vue/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
vue/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

29
vue/public/manifest.json Normal file
View File

@ -0,0 +1,29 @@
{
"short_name": "dm",
"name": "Delphi Market",
"icons": [
{
"src": "/android-chrome-192x192.png",
"type": "image/jpeg",
"sizes": "192x192"
},
{
"src": "/android-chrome-512x512.png",
"type": "image/jpeg",
"sizes": "512x512"
}
],
"background_color": "#091833",
"display": "standalone",
"scope": "/",
"theme_color": "#8787a4",
"description": "Prediction Market on Lightning",
"screenshots": [
{
"src": "/app_screenshot_001.png",
"type": "image/png",
"sizes": "750x1334",
"form_factor": "narrow"
}
]
}

2
vue/public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

44
vue/src/App.vue Normal file
View File

@ -0,0 +1,44 @@
<template>
<div id="container">
<NavBar />
<router-view />
</div>
</template>
<script setup>
import { useSession } from './stores/session'
const session = useSession()
session.checkSession()
</script>
<!-- eslint-disable -->
<!-- eslint wants to combine this <script> and <script setup> which breaks the code ... -->
<script>
import NavBar from './components/NavBar'
export default {
name: 'App',
components: { NavBar }
}
</script>
<style>
html,
body {
background-color: #091833;
color: #ffffff;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #ffffff;
margin-top: 1em;
}
#container {
margin: 1em auto;
width: fit-content;
}
</style>

BIN
vue/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,147 @@
<template>
<div class="flex flex-col">
<router-link v-if="success" :to="callbackUrl" class="label success font-mono">
<div>Paid</div>
<small v-if="redirectTimeout > 0">Redirecting in {{ redirectTimeout }} ...</small>
</router-link>
<div class="font-mono my-3">
Payment Required
</div>
<div v-if="error" class="label error font-mono">
<div>Error</div>
<small>{{ error }}</small>
</div>
<div v-if="invoice">
<figure class="flex flex-col m-auto">
<a class="m-auto" :href="'lightning:' + invoice.PaymentRequest">
<img :src="'data:image/png;base64,' + invoice.Qr" />
</a>
<figcaption class="flex flex-row my-3 font-mono text-xs">
<span class="w-[80%] text-ellipsis overflow-hidden">{{ invoice.PaymentRequest }}</span>
<button @click.prevent="copy">{{ label }}</button>
</figcaption>
</figure>
<div class="grid text-muted text-xs">
<span class="mx-3 my-1">payment hash</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
{{ invoice.Hash }}
</span>
<span class="mx-3 my-1">created at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
{{ invoice.CreatedAt }}
</span>
<span class="mx-3 my-1">expires at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
{{ invoice.ExpiresAt }}
</span>
<span class="mx-3 my-1">sats</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
{{ invoice.Msats / 1000 }}
</span>
<span class="mx-3 my-1">description</span>
<span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
<span v-if="invoice.DescriptionMarketId">
<span v-if="invoice.Description">
<span>{{ invoice.Description }}</span>
<router-link :to="'/market/' + invoice.DescriptionMarketId">[market]</router-link>
</span>
<span v-else>&lt;empty&gt;</span>
</span>
<span v-else>
<span v-if="invoice.Description">{{ invoice.Description }}</span>
<span v-else>&lt;empty&gt;</span>
</span>
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// TODO validate callback url
const callbackUrl = ref('/')
let pollCount = 0
const INVOICE_POLL = 2000
const poll = async () => {
pollCount++
const url = window.origin + '/api/invoice/' + route.params.id
const res = await fetch(url)
const body = await res.json()
if (body.ConfirmedAt) {
success.value = true
clearInterval(interval)
if (pollCount > 1) {
// only redirect if the invoice was not immediately paid
setInterval(() => {
if (--redirectTimeout.value === 0) {
router.push(callbackUrl.value)
}
}, 1000)
} else {
redirectTimeout.value = -1
}
}
}
let interval
const invoice = ref(undefined)
const redirectTimeout = ref(3)
const success = ref(null)
const error = ref(null)
const label = ref('copy')
let copyTimeout = null
const copy = () => {
navigator.clipboard?.writeText(invoice.value.PaymentRequest)
label.value = 'copied'
if (copyTimeout) clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => { label.value = 'copy' }, 1500)
}
await (async () => {
const url = window.origin + '/api/invoice/' + route.params.id
const res = await fetch(url)
if (res.status === 404) {
error.value = 'invoice not found'
return
}
const body = await res.json()
if (body.Description) {
const regexp = /\[market:(?<id>[0-9]+)\]/
const m = body.Description.match(regexp)
const marketId = m?.groups?.id
if (marketId) {
body.DescriptionMarketId = marketId
body.Description = body.Description.replace(regexp, '')
callbackUrl.value = '/market/' + marketId
}
}
invoice.value = body
interval = setInterval(poll, INVOICE_POLL)
})()
</script>
<style scoped>
img {
width: 256px;
height: auto;
}
figcaption {
margin: 0.75em auto;
width: 256px;
}
.label {
margin: 1em auto;
}
a.label {
text-decoration: none;
}
div.grid {
grid-template-columns: auto auto;
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<div class="flex flex-col">
<router-link v-if="success" to="/" class="label success font-mono">
<div>Authenticated</div>
<small>Redirecting in {{ redirectTimeout }} ...</small>
</router-link>
<div class="font-mono my-3">
LNURL-auth
</div>
<div v-if="error" class="label error font-mono">
<div>Authentication error</div>
<small>{{ error }}</small>
</div>
<figure v-if="lnurl && qr" class="flex flex-col m-auto">
<a class="m-auto" :href="'lightning:' + lnurl">
<img :src="'data:image/png;base64,' + qr" />
</a>
<figcaption class="flex flex-row my-3 font-mono text-xs">
<span class="w-[80%] text-ellipsis overflow-hidden">{{ lnurl }}</span>
<button @click.prevent="copy">{{ label }}</button>
</figcaption>
</figure>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSession } from '@/stores/session'
const router = useRouter()
const session = useSession()
const qr = ref(null)
const lnurl = ref(null)
let interval = null
const LOGIN_POLL = 2000
const redirectTimeout = ref(3)
const success = ref(null)
const error = ref(null)
const label = ref('copy')
let copyTimeout = null
const copy = () => {
navigator.clipboard?.writeText(lnurl.value)
label.value = 'copied'
if (copyTimeout) clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => { label.value = 'copy' }, 1500)
}
const poll = async () => {
try {
await session.checkSession()
if (session.isAuthenticated) {
success.value = true
clearInterval(interval)
setInterval(() => {
if (--redirectTimeout.value === 0) {
router.push('/')
}
}, 1000)
}
} catch (err) {
// ignore 404 errors
if (err.reason !== 'session not found') {
console.error(err)
error.value = err.reason
}
}
}
const login = async () => {
const s = await session.login()
qr.value = s.qr
lnurl.value = s.lnurl
interval = setInterval(poll, LOGIN_POLL)
}
await (async () => {
// redirect to / if session already exists
if (session.initialized) {
if (session.isAuthenticated) return router.push('/')
return login()
}
// else subscribe to changes
return session.$subscribe(() => {
if (session.initialized) {
// for some reason, computed property is only updated when accessing the store directly
// it is not updated inside the second argument
if (session.isAuthenticated) return router.push('/')
return login()
}
})
})()
</script>
<style scoped>
img {
width: 256px;
height: auto;
}
figcaption {
margin: 0.75em auto;
width: 256px;
}
.label {
margin: 1em auto;
}
a.label {
text-decoration: none;
}
</style>

View File

@ -0,0 +1,140 @@
<template>
<!-- eslint-disable -->
<div class="my-3">
<pre>
_ _
_ __ ___ __ _ _ __| | _____| |_
| '_ ` _ \ / _` | '__| |/ / _ \ __|
| | | | | | (_| | | | ( __/ |_
|_| |_| |_|\__,_|_| |_|\_\___|\__|</pre>
</div>
<div class="font-mono">{{ market.Description }}</div>
<!-- eslint-enable -->
<button type="button" :class="yesClass" class="label success font-mono mx-1 my-3"
@click.prevent="toggleYes">YES</button>
<button type="button" :class="noClass" class="label error font-mono mx-1 my-3" @click.prevent="toggleNo">NO</button>
<form v-show="showForm" @submit.prevent="submitForm">
<label for="stake">how much?</label>
<input name="stake" v-model="stake" type="number" min="0" placeholder="sats" required />
<label for="certainty">how sure?</label>
<input name="certainty" v-model="certainty" type="number" min="0.01" max="0.99" step="0.01" required />
<label>you receive:</label>
<label>{{ format(shares) }} {{ selected }} shares @ {{ format(price) }} sats</label>
<label>you pay:</label>
<label>{{ format(cost) }} sats</label>
<label>if you win:</label>
<label>{{ format(profit) }} sats</label>
<button class="col-span-2" type="submit">submit order</button>
</form>
<div v-if="err" class="red text-center">{{ err }}</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const marketId = route.params.id
const selected = ref(null)
const showForm = computed(() => selected.value !== null)
const yesClass = computed(() => selected.value === 'YES' ? ['active'] : [])
const noClass = computed(() => selected.value === 'NO' ? ['active'] : [])
const err = ref(null)
// how much wants the user bet?
const stake = ref(null)
// how sure is the user he will win?
const certainty = ref(null)
// price per share: more risk, lower price, higher reward
const price = computed(() => certainty.value * 100)
// how many (full) shares can be bought?
const shares = computed(() => {
const val = price.value > 0 ? stake.value / price.value : null
// only full shares can be bought
return Math.round(val)
})
// how much does this order cost?
const cost = computed(() => {
return shares.value * price.value
})
// how high is the potential reward?
const profit = computed(() => {
// shares expire at 10 or 0 sats
const val = (100 * shares.value) - cost.value
return isNaN(val) ? 0 : val
})
const format = (x, i = 3) => x === null ? null : x >= 1 ? Math.round(x) : x === 0 ? x : x.toFixed(i)
const market = ref(null)
const url = '/api/market/' + marketId
await fetch(url)
.then(r => r.json())
.then(body => {
market.value = body
})
.catch(console.error)
// Currently, we only support binary markets.
// (only events which can be answered with YES and NO)
const yesShareId = computed(() => {
return market?.value.Shares.find(s => s.Description === 'YES').Id
})
const noShareId = computed(() => {
return market?.value.Shares.find(s => s.Description === 'NO').Id
})
const shareId = computed(() => {
return selected.value === 'YES' ? yesShareId.value : noShareId.value
})
const toggleYes = () => {
selected.value = selected.value === 'YES' ? null : 'YES'
}
const toggleNo = () => {
selected.value = selected.value === 'NO' ? null : 'NO'
}
const submitForm = async () => {
// TODO validate form
const url = window.origin + '/api/order'
const body = JSON.stringify({
sid: shareId.value,
quantity: shares.value,
price: price.value,
// TODO support selling
side: 'BUY'
})
const res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
const resBody = await res.json()
if (res.status !== 402) {
err.value = `error: server responded with HTTP ${resBody.status}`
return
}
const invoiceId = resBody.id
router.push('/invoice/' + invoiceId)
}
</script>
<style scoped>
.success.active {
background-color: #35df8d;
color: white;
}
.error.active {
background-color: #ff7386;
color: white;
}
form {
margin: 0 auto;
display: grid;
grid-template-columns: auto auto;
}
form>* {
margin: 0.5em 1em;
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<form ref="form" class="flex flex-col mx-auto text-left" method="post" action="/api/market"
@submit.prevent="submitForm">
<label for="desc">event description</label>
<textarea v-model="description" class="mb-1" id="desc" name="desc" type="text"></textarea>
<label for="endDate">end date</label>
<input v-model="endDate" class="mb-3" id="endDate" name="endDate" type="date" />
<div class="flex flex-row justify-center">
<button type="button" class="me-1" @click.prevent="$props.onCancel">cancel</button>
<button type="submit">submit</button>
</div>
</form>
</template>
<script setup>
import { ref, defineProps } from 'vue'
import { useRouter } from 'vue-router'
defineProps(['onCancel'])
const router = useRouter()
const form = ref(null)
const description = ref(null)
const endDate = ref(null)
const parseEndDate = endDate => {
const [yyyy, mm, dd] = endDate.split('-')
return `${yyyy}-${mm}-${dd}T00:00:00.000Z`
}
const submitForm = async () => {
const url = window.origin + '/api/market'
const body = JSON.stringify({ description: description.value, endDate: parseEndDate(endDate.value) })
const res = await fetch(url, { method: 'post', headers: { 'Content-type': 'application/json' }, body })
const resBody = await res.json()
const invoiceId = resBody.id
router.push('/invoice/' + invoiceId)
}
</script>
<style scoped>
textarea {
color: #000;
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<ul>
<li class="my-3" v-for="market in markets" :key="market.id">
<router-link :to="'/market/' + market.id">{{ market.description }}</router-link>
</li>
</ul>
<button v-if="!showForm" @click.prevent="toggleForm">+ create market</button>
<div v-else class="flex flex-col justify-center">
<MarketForm :onCancel="toggleForm" />
</div>
</template>
<script setup>
import MarketForm from './MarketForm'
import { ref } from 'vue'
import { useSession } from '@/stores/session'
import { useRouter } from 'vue-router'
const session = useSession()
const router = useRouter()
const markets = ref([])
const showForm = ref(false)
// TODO only load markets once per session
const url = window.origin + '/api/markets'
await fetch(url).then(async r => {
const body = await r.json()
markets.value = body
})
const toggleForm = () => {
if (!session.isAuthenticated) {
return router.push('/login')
}
showForm.value = !showForm.value
}
</script>
<style scoped>
a {
padding: 0 1em;
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<router-link to="/">market</router-link>
<router-link to="/user" v-if="session.isAuthenticated">user</router-link>
<router-link to="/login" v-else-if="session.isAuthenticated === false" href="/login">login</router-link>
<router-link disabled to="/" v-else>...</router-link>
</nav>
</header>
</template>
<script setup>
import { useSession } from '@/stores/session'
const session = useSession()
</script>
<style scoped>
nav {
display: flex;
justify-content: center;
}
nav>a {
margin: 0 3px;
}
</style>

70
vue/src/index.css Normal file
View File

@ -0,0 +1,70 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
button {
color: #ffffff;
border: solid 1px #8787A4;
padding: 0 1em;
}
button:hover {
color: #ffffff;
background: #8787A4;
}
input {
color: #000;
}
a {
color: #8787a4;
text-decoration: underline;
}
a:hover {
color: #ffffff;
background: #8787A4;
}
a.selected {
color: #ffffff;
background: #8787A4;
}
.label {
border: none;
width: fit-content;
padding: 0.5em 3em;
cursor: pointer;
}
.label:hover {
color: white;
}
.success {
background-color: rgba(20, 158, 97, .24);
color: #35df8d;
}
.success:hover {
background-color: #35df8d;
}
.red {
color: #ff7386;
}
.error {
background-color: rgba(245, 57, 94, .24);
color: #ff7386;
}
.error:hover {
background-color: #ff7386;
}
.text-muted {
opacity: 0.67
}

41
vue/src/main.js Normal file
View File

@ -0,0 +1,41 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import * as VueRouter from 'vue-router'
import App from './App.vue'
import './registerServiceWorker'
import './index.css'
import HomeView from '@/views/HomeView'
import LoginView from '@/views/LoginView'
import UserView from '@/views/UserView'
import MarketView from '@/views/MarketView'
import InvoiceView from '@/views/InvoiceView'
const routes = [
{
path: '/', component: HomeView
},
{
path: '/login', component: LoginView
},
{
path: '/user', component: UserView
},
{
path: '/market/:id', component: MarketView
},
{
path: '/invoice/:id', component: InvoiceView
}
]
const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(),
routes
})
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
app.mount('#app')

View File

@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready () {
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered () {
console.log('Service worker has been registered.')
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated () {
console.log('New content is available; please refresh.')
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}

44
vue/src/stores/session.js Normal file
View File

@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useSession = defineStore('session', () => {
const pubkey = ref(null)
const initialized = ref(false)
const isAuthenticated = computed(() => initialized.value ? !!pubkey.value : undefined)
function checkSession () {
const url = window.origin + '/api/session'
return fetch(url, {
credentials: 'include'
})
.then(async r => {
const body = await r.json()
if (body.pubkey) {
pubkey.value = body.pubkey
console.log('authenticated as', body.pubkey)
} else console.log('unauthenticated')
initialized.value = true
return body
}).catch(err => {
console.error('error:', err.reason || err)
})
}
function login () {
const url = window.origin + '/api/login'
return fetch(url, { credentials: 'include' }).then(r => r.json())
}
function logout () {
const url = window.origin + '/api/logout'
return fetch(url, { method: 'POST', credentials: 'include' })
.then(async r => {
const body = await r.json()
if (body.status === 'OK') {
pubkey.value = null
}
})
}
return { pubkey, isAuthenticated, initialized, checkSession, login, logout }
})

View File

@ -0,0 +1,21 @@
<template>
<!-- eslint-disable -->
<div class="my-3">
<pre>
_ _ _ _
__| | ___| |_ __ | |__ (_)
/ _` |/ _ \ | '_ \| '_ \| |
| (_| | __/ | |_) | | | | |
\__,_|\___|_| .__/|_| |_|_|
|_|.market </pre>
</div>
<!-- eslint-enable -->
<Suspense>
<MarketList />
</Suspense>
</template>
<script setup>
import MarketList from '@/components/MarketList'
</script>

View File

@ -0,0 +1,19 @@
<template>
<!-- eslint-disable -->
<div class="my-3">
<pre>
_ _ ___ ____
| || | / _ \___ \
| || |_| | | |__) |
|__ _| |_| / __/
|_| \___/_____|</pre>
</div>
<!-- eslint-enable -->
<Suspense>
<Invoice />
</Suspense>
</template>
<script setup>
import Invoice from '@/components/Invoice'
</script>

View File

@ -0,0 +1,20 @@
<template>
<!-- eslint-disable -->
<div class="my-3">
<pre>
_ _
| | ___ __ _(_)_ __
| |/ _ \ / _` | | '_ \
| | (_) | (_| | | | | |
|_|\___/ \__, |_|_| |_|
|___/ </pre>
</div>
<!-- eslint-enable -->
<Suspense>
<LoginQRCode class="flex justify-center m-3" />
</Suspense>
</template>
<script setup>
import LoginQRCode from '@/components/LoginQRCode'
</script>

View File

@ -0,0 +1,9 @@
<template>
<Suspense>
<Market />
</Suspense>
</template>
<script setup>
import Market from '@/components/Market'
</script>

View File

@ -0,0 +1,29 @@
<template>
<!-- eslint-disable -->
<div class="my-3">
<pre>
_ _ ___ ___ _ __
| | | / __|/ _ \ '__|
| |_| \__ \ __/ |
\__,_|___/\___|_| </pre>
</div>
<!-- eslint-enable -->
<div v-if="session.pubkey">
<div>authenticated as {{ session.pubkey.slice(0, 8) }}</div>
<button class="my-3" @click="logout">logout</button>
</div>
</template>
<script setup>
import { useSession } from '@/stores/session'
import { useRouter } from 'vue-router'
const session = useSession()
const router = useRouter()
const logout = async () => {
await session.logout()
router.push('/')
}
</script>

10
vue/tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{html,js,vue}'
],
theme: {
extend: {}
},
plugins: []
}

14
vue/vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

4
vue/vue.config.js Normal file
View File

@ -0,0 +1,4 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})

4733
vue/yarn.lock Normal file

File diff suppressed because it is too large Load Diff