Merge branch 'vue-rewrite' into develop
This commit is contained in:
commit
b39878c495
@ -47,7 +47,8 @@ CREATE TABLE orders(
|
|||||||
side ORDER_SIDE NOT NULL,
|
side ORDER_SIDE NOT NULL,
|
||||||
quantity BIGINT NOT NULL,
|
quantity BIGINT NOT NULL,
|
||||||
price BIGINT NOT NULL,
|
price BIGINT NOT NULL,
|
||||||
invoice_id UUID NOT NULL REFERENCES invoices(id)
|
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
||||||
|
order_id UUID REFERENCES orders(id)
|
||||||
);
|
);
|
||||||
ALTER TABLE orders ADD CONSTRAINT order_price CHECK(price > 0 AND price < 100);
|
ALTER TABLE orders ADD CONSTRAINT order_price CHECK(price > 0 AND price < 100);
|
||||||
ALTER TABLE orders ADD CONSTRAINT order_quantity CHECK(quantity > 0);
|
ALTER TABLE orders ADD CONSTRAINT order_quantity CHECK(quantity > 0);
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (db *DB) CreateInvoice(invoice *Invoice) error {
|
func (db *DB) CreateInvoice(tx *sql.Tx, ctx context.Context, invoice *Invoice) error {
|
||||||
if err := db.QueryRow(""+
|
if err := tx.QueryRowContext(ctx, ""+
|
||||||
"INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at, description) "+
|
"INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at, description) "+
|
||||||
"VALUES($1, $2, $3, $4, $5, $6, $7, $8) "+
|
"VALUES($1, $2, $3, $4, $5, $6, $7, $8) "+
|
||||||
"RETURNING id",
|
"RETURNING id",
|
||||||
@ -70,6 +71,33 @@ func (db *DB) FetchInvoices(where *FetchInvoicesWhere, invoices *[]Invoice) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) FetchUserInvoices(pubkey string, invoices *[]Invoice) error {
|
||||||
|
var (
|
||||||
|
rows *sql.Rows
|
||||||
|
invoice Invoice
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
var (
|
||||||
|
query = "" +
|
||||||
|
"SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since, COALESCE(description, ''), " +
|
||||||
|
"CASE WHEN confirmed_at IS NOT NULL THEN 'PAID' WHEN expires_at < CURRENT_TIMESTAMP THEN 'EXPIRED' ELSE 'WAITING' END AS status " +
|
||||||
|
"FROM invoices " +
|
||||||
|
"WHERE pubkey = $1 " +
|
||||||
|
"ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
if rows, err = db.Query(query, pubkey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
rows.Scan(
|
||||||
|
&invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash,
|
||||||
|
&invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince, &invoice.Description, &invoice.Status)
|
||||||
|
*invoices = append(*invoices, invoice)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) ConfirmInvoice(hash string, confirmedAt time.Time, msatsReceived int) error {
|
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
|
||||||
|
63
db/market.go
63
db/market.go
@ -1,6 +1,9 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import "database/sql"
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
type FetchOrdersWhere struct {
|
type FetchOrdersWhere struct {
|
||||||
MarketId int
|
MarketId int
|
||||||
@ -8,15 +11,15 @@ type FetchOrdersWhere struct {
|
|||||||
Confirmed bool
|
Confirmed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CreateMarket(market *Market) error {
|
func (db *DB) CreateMarket(tx *sql.Tx, ctx context.Context, market *Market) error {
|
||||||
if err := db.QueryRow(""+
|
if err := tx.QueryRowContext(ctx, ""+
|
||||||
"INSERT INTO markets(description, end_date, invoice_id) "+
|
"INSERT INTO markets(description, end_date, invoice_id) "+
|
||||||
"VALUES($1, $2, $3) "+
|
"VALUES($1, $2, $3) "+
|
||||||
"RETURNING id", market.Description, market.EndDate, market.InvoiceId).Scan(&market.Id); err != nil {
|
"RETURNING id", market.Description, market.EndDate, market.InvoiceId).Scan(&market.Id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// For now, we only support binary markets.
|
// 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 {
|
if _, err := tx.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -62,8 +65,8 @@ func (db *DB) FetchShares(marketId int, shares *[]Share) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) FetchShare(shareId string, share *Share) error {
|
func (db *DB) FetchShare(tx *sql.Tx, ctx context.Context, shareId string, share *Share) error {
|
||||||
return db.QueryRow("SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description)
|
return tx.QueryRowContext(ctx, "SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
|
func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
|
||||||
@ -99,8 +102,8 @@ func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CreateOrder(order *Order) error {
|
func (db *DB) CreateOrder(tx *sql.Tx, ctx context.Context, order *Order) error {
|
||||||
if _, err := db.Exec(""+
|
if _, err := tx.ExecContext(ctx, ""+
|
||||||
"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)",
|
||||||
order.ShareId, order.Pubkey, order.Side, order.Quantity, order.Price, order.InvoiceId); err != nil {
|
order.ShareId, order.Pubkey, order.Side, order.Quantity, order.Price, order.InvoiceId); err != nil {
|
||||||
@ -108,3 +111,47 @@ func (db *DB) CreateOrder(order *Order) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) FetchUserOrders(pubkey string, orders *[]Order) error {
|
||||||
|
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, " +
|
||||||
|
"CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' ELSE 'WAITING' END AS status " +
|
||||||
|
"FROM orders o " +
|
||||||
|
"JOIN invoices i ON o.invoice_id = i.id " +
|
||||||
|
"JOIN shares s ON o.share_id = s.id " +
|
||||||
|
"WHERE o.pubkey = $1 AND i.confirmed_at IS NOT NULL " +
|
||||||
|
"ORDER BY o.created_at DESC"
|
||||||
|
rows, err := db.Query(query, pubkey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var order Order
|
||||||
|
rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.ShareDescription, &order.Share.MarketId, &order.Invoice.ConfirmedAt, &order.Status)
|
||||||
|
*orders = append(*orders, order)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) FetchMarketOrders(marketId int64, orders *[]Order) error {
|
||||||
|
query := "" +
|
||||||
|
"SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, " +
|
||||||
|
"CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' ELSE 'WAITING' END AS status " +
|
||||||
|
"FROM orders o " +
|
||||||
|
"JOIN shares s ON o.share_id = s.id " +
|
||||||
|
"JOIN invoices i ON i.id = o.invoice_id " +
|
||||||
|
"WHERE s.market_id = $1 AND i.confirmed_at IS NOT NULL " +
|
||||||
|
"ORDER BY o.created_at DESC"
|
||||||
|
rows, err := db.Query(query, marketId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var order Order
|
||||||
|
rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.ShareDescription, &order.Share.MarketId, &order.Status)
|
||||||
|
*orders = append(*orders, order)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -47,11 +47,13 @@ type (
|
|||||||
ConfirmedAt null.Time
|
ConfirmedAt null.Time
|
||||||
HeldSince null.Time
|
HeldSince null.Time
|
||||||
Description string
|
Description string
|
||||||
|
Status string
|
||||||
}
|
}
|
||||||
Order struct {
|
Order struct {
|
||||||
Id UUID
|
Id UUID
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
ShareId string `json:"sid"`
|
ShareId string `json:"sid"`
|
||||||
|
ShareDescription string
|
||||||
Share
|
Share
|
||||||
Pubkey string
|
Pubkey string
|
||||||
Side string `json:"side"`
|
Side string `json:"side"`
|
||||||
|
@ -2,6 +2,7 @@ package lnd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ import (
|
|||||||
"github.com/lightningnetwork/lnd/lnwire"
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, description string) (*db.Invoice, error) {
|
func (lnd *LNDClient) CreateInvoice(tx *sql.Tx, ctx context.Context, 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
|
||||||
@ -26,14 +27,14 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hash = preimage.Hash()
|
hash = preimage.Hash()
|
||||||
if paymentRequest, err = lnd.Invoices.AddHoldInvoice(context.TODO(), &invoicesrpc.AddInvoiceData{
|
if paymentRequest, err = lnd.Invoices.AddHoldInvoice(ctx, &invoicesrpc.AddInvoiceData{
|
||||||
Hash: &hash,
|
Hash: &hash,
|
||||||
Value: lnwire.MilliSatoshi(msats),
|
Value: lnwire.MilliSatoshi(msats),
|
||||||
Expiry: int64(expiry / time.Millisecond),
|
Expiry: int64(expiry / time.Millisecond),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if lnInvoice, err = lnd.Client.LookupInvoice(context.TODO(), hash); err != nil {
|
if lnInvoice, err = lnd.Client.LookupInvoice(ctx, hash); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dbInvoice = &db.Invoice{
|
dbInvoice = &db.Invoice{
|
||||||
@ -46,7 +47,7 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri
|
|||||||
ExpiresAt: lnInvoice.CreationDate.Add(expiry),
|
ExpiresAt: lnInvoice.CreationDate.Add(expiry),
|
||||||
Description: description,
|
Description: description,
|
||||||
}
|
}
|
||||||
if err := d.CreateInvoice(dbInvoice); err != nil {
|
if err := d.CreateInvoice(tx, ctx, dbInvoice); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return dbInvoice, nil
|
return dbInvoice, nil
|
||||||
|
@ -93,3 +93,18 @@ func HandleInvoice(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
return sc.Render(c, http.StatusOK, "invoice.html", data)
|
return sc.Render(c, http.StatusOK, "invoice.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HandleInvoices(sc context.ServerContext) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
u db.User
|
||||||
|
invoices []db.Invoice
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
u = c.Get("session").(db.User)
|
||||||
|
if err = sc.Db.FetchUserInvoices(u.Pubkey, &invoices); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, invoices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
context_ "context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
@ -50,6 +52,7 @@ func HandleMarket(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
|
func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
|
tx *sql.Tx
|
||||||
u db.User
|
u db.User
|
||||||
m db.Market
|
m db.Market
|
||||||
invoice *db.Invoice
|
invoice *db.Invoice
|
||||||
@ -64,26 +67,41 @@ func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
return echo.NewHTTPError(http.StatusBadRequest)
|
return echo.NewHTTPError(http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transaction start
|
||||||
|
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Commit()
|
||||||
|
|
||||||
u = c.Get("session").(db.User)
|
u = c.Get("session").(db.User)
|
||||||
msats = 1000
|
msats = 1000
|
||||||
// TODO: add [market:<id>] for redirect after payment
|
// TODO: add [market:<id>] for redirect after payment
|
||||||
invDescription = fmt.Sprintf("create market \"%s\" (%s)", m.Description, m.EndDate)
|
invDescription = fmt.Sprintf("create market \"%s\"", m.Description)
|
||||||
if invoice, err = sc.Lnd.CreateInvoice(sc.Db, u.Pubkey, msats, invDescription); err != nil {
|
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, u.Pubkey, msats, invDescription); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
|
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
|
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
|
||||||
|
|
||||||
m.InvoiceId = invoice.Id
|
m.InvoiceId = invoice.Id
|
||||||
if err := sc.Db.CreateMarket(&m); err != nil {
|
if err := sc.Db.CreateMarket(tx, ctx, &m); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// need to commit before starting to poll invoice status
|
||||||
|
tx.Commit()
|
||||||
|
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
||||||
|
|
||||||
data = map[string]any{
|
data = map[string]any{
|
||||||
"id": invoice.Id,
|
"id": invoice.Id,
|
||||||
"bolt11": invoice.PaymentRequest,
|
"bolt11": invoice.PaymentRequest,
|
||||||
@ -94,9 +112,27 @@ func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HandleMarketOrders(sc context.ServerContext) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
marketId int64
|
||||||
|
orders []db.Order
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Bad Request")
|
||||||
|
}
|
||||||
|
if err = sc.Db.FetchMarketOrders(marketId, &orders); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, orders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
|
tx *sql.Tx
|
||||||
u db.User
|
u db.User
|
||||||
o db.Order
|
o db.Order
|
||||||
s db.Share
|
s db.Share
|
||||||
@ -122,7 +158,18 @@ func HandleOrder(sc context.ServerContext) 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 {
|
|
||||||
|
// transaction start
|
||||||
|
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Commit()
|
||||||
|
|
||||||
|
if err = sc.Db.FetchShare(tx, ctx, o.ShareId, &s); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
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)
|
description = fmt.Sprintf("%s %d %s shares @ %d sats [market:%d]", strings.ToUpper(o.Side), o.Quantity, s.Description, o.Price, s.MarketId)
|
||||||
@ -130,24 +177,29 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
// 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 = sc.Lnd.CreateInvoice(sc.Db, o.Pubkey, msats, description); err != nil {
|
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, o.Pubkey, msats, description); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Create QR code to pay HODL invoice
|
// Create QR code to pay HODL invoice
|
||||||
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
|
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
|
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create (unconfirmed) order
|
// Create (unconfirmed) order
|
||||||
o.InvoiceId = invoice.Id
|
o.InvoiceId = invoice.Id
|
||||||
if err := sc.Db.CreateOrder(&o); err != nil {
|
if err := sc.Db.CreateOrder(tx, ctx, &o); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start goroutine to poll status and update invoice in background
|
// need to commit before startign to poll invoice status
|
||||||
|
tx.Commit()
|
||||||
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
||||||
|
|
||||||
// TODO: find matching orders
|
// TODO: find matching orders
|
||||||
@ -161,3 +213,18 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
|||||||
return c.JSON(http.StatusPaymentRequired, data)
|
return c.JSON(http.StatusPaymentRequired, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HandleOrders(sc context.ServerContext) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
u db.User
|
||||||
|
orders []db.Order
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
u = c.Get("session").(db.User)
|
||||||
|
if err = sc.Db.FetchUserOrders(u.Pubkey, &orders); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, orders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -46,10 +46,14 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
|
|||||||
middleware.SessionGuard,
|
middleware.SessionGuard,
|
||||||
middleware.LNDGuard)
|
middleware.LNDGuard)
|
||||||
GET(e, sc, "/api/market/:id", handler.HandleMarket)
|
GET(e, sc, "/api/market/:id", handler.HandleMarket)
|
||||||
|
GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders)
|
||||||
POST(e, sc, "/api/order",
|
POST(e, sc, "/api/order",
|
||||||
handler.HandleOrder,
|
handler.HandleOrder,
|
||||||
middleware.SessionGuard,
|
middleware.SessionGuard,
|
||||||
middleware.LNDGuard)
|
middleware.LNDGuard)
|
||||||
|
GET(e, sc, "/api/orders",
|
||||||
|
handler.HandleOrders,
|
||||||
|
middleware.SessionGuard)
|
||||||
GET(e, sc, "/api/login", handler.HandleLogin)
|
GET(e, sc, "/api/login", handler.HandleLogin)
|
||||||
GET(e, sc, "/api/login/callback", handler.HandleLoginCallback)
|
GET(e, sc, "/api/login/callback", handler.HandleLoginCallback)
|
||||||
POST(e, sc, "/api/logout", handler.HandleLogout)
|
POST(e, sc, "/api/logout", handler.HandleLogout)
|
||||||
@ -57,6 +61,9 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
|
|||||||
GET(e, sc, "/api/invoice/:id",
|
GET(e, sc, "/api/invoice/:id",
|
||||||
handler.HandleInvoiceStatus,
|
handler.HandleInvoiceStatus,
|
||||||
middleware.SessionGuard)
|
middleware.SessionGuard)
|
||||||
|
GET(e, sc, "/api/invoices",
|
||||||
|
handler.HandleInvoices,
|
||||||
|
middleware.SessionGuard)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
"core-js": "^3.8.3",
|
"core-js": "^3.8.3",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
|
"s-ago": "^2.2.0",
|
||||||
"vite": "^4.5.0",
|
"vite": "^4.5.0",
|
||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
"vue-router": "4"
|
"vue-router": "4"
|
||||||
|
@ -22,14 +22,18 @@
|
|||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="grid text-muted text-xs">
|
<div class="grid text-muted text-xs">
|
||||||
|
<span v-if="faucet" class="mx-3 my-1">faucet</span>
|
||||||
|
<span v-if="faucet" class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||||
|
<a href="https://faucet.mutinynet.com/" target="_blank">faucet.mutinynet.com</a>
|
||||||
|
</span>
|
||||||
<span class="mx-3 my-1">payment hash</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
<span class="mx-3 my-1">payment hash</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||||
{{ invoice.Hash }}
|
{{ invoice.Hash }}
|
||||||
</span>
|
</span>
|
||||||
<span class="mx-3 my-1">created at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
<span class="mx-3 my-1">created at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||||
{{ invoice.CreatedAt }}
|
{{ invoice.CreatedAt }} ({{ ago(new Date(invoice.CreatedAt)) }})
|
||||||
</span>
|
</span>
|
||||||
<span class="mx-3 my-1">expires at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
<span class="mx-3 my-1">expires at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||||
{{ invoice.ExpiresAt }}
|
{{ invoice.ExpiresAt }} ({{ ago(new Date(invoice.ExpiresAt)) }})
|
||||||
</span>
|
</span>
|
||||||
<span class="mx-3 my-1">sats</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
<span class="mx-3 my-1">sats</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||||
{{ invoice.Msats / 1000 }}
|
{{ invoice.Msats / 1000 }}
|
||||||
@ -54,8 +58,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import ago from 's-ago'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -120,6 +125,11 @@ await (async () => {
|
|||||||
invoice.value = body
|
invoice.value = body
|
||||||
interval = setInterval(poll, INVOICE_POLL)
|
interval = setInterval(poll, INVOICE_POLL)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
onUnmounted(() => { clearInterval(interval) })
|
||||||
|
|
||||||
|
const faucet = window.location.hostname === 'delphi.market' ? 'https://faucet.mutinynet.com' : ''
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -10,62 +10,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="font-mono">{{ market.Description }}</div>
|
<div class="font-mono">{{ market.Description }}</div>
|
||||||
<!-- eslint-enable -->
|
<!-- eslint-enable -->
|
||||||
<button type="button" :class="yesClass" class="label success font-mono mx-1 my-3"
|
<header class="flex flex-row text-center justify-center pt-1">
|
||||||
@click.prevent="toggleYes">YES</button>
|
<nav>
|
||||||
<button type="button" :class="noClass" class="label error font-mono mx-1 my-3" @click.prevent="toggleNo">NO</button>
|
<StyledLink :to="'/market/' + marketId + '/form'">form</StyledLink>
|
||||||
<form v-show="showForm" @submit.prevent="submitForm">
|
<StyledLink :to="'/market/' + marketId + '/orders'">orders</StyledLink>
|
||||||
<label for="stake">how much?</label>
|
</nav>
|
||||||
<input name="stake" v-model="stake" type="number" min="0" placeholder="sats" required />
|
</header>
|
||||||
<label for="certainty">how sure?</label>
|
<Suspense>
|
||||||
<input name="certainty" v-model="certainty" type="number" min="0.01" max="0.99" step="0.01" required />
|
<router-view class="m-3" />
|
||||||
<label>you receive:</label>
|
</Suspense>
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import StyledLink from '@/components/StyledLink'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const marketId = route.params.id
|
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 market = ref(null)
|
||||||
const url = '/api/market/' + marketId
|
const url = '/api/market/' + marketId
|
||||||
@ -75,66 +37,16 @@ await fetch(url)
|
|||||||
market.value = body
|
market.value = body
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.success.active {
|
nav {
|
||||||
background-color: #35df8d;
|
display: flex;
|
||||||
color: white;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error.active {
|
nav>a {
|
||||||
background-color: #ff7386;
|
margin: 0 3px;
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
margin: 0 auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
form>* {
|
|
||||||
margin: 0.5em 1em;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
<button type="submit">submit</button>
|
<button type="submit">submit</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<div v-if="err" class="red text-center">{{ err }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -22,6 +23,7 @@ const router = useRouter()
|
|||||||
const form = ref(null)
|
const form = ref(null)
|
||||||
const description = ref(null)
|
const description = ref(null)
|
||||||
const endDate = ref(null)
|
const endDate = ref(null)
|
||||||
|
const err = ref(null)
|
||||||
|
|
||||||
const parseEndDate = endDate => {
|
const parseEndDate = endDate => {
|
||||||
const [yyyy, mm, dd] = endDate.split('-')
|
const [yyyy, mm, dd] = endDate.split('-')
|
||||||
@ -33,6 +35,10 @@ const submitForm = async () => {
|
|||||||
const body = JSON.stringify({ description: description.value, endDate: parseEndDate(endDate.value) })
|
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 res = await fetch(url, { method: 'post', headers: { 'Content-type': 'application/json' }, body })
|
||||||
const resBody = await res.json()
|
const resBody = await res.json()
|
||||||
|
if (res.status !== 402) {
|
||||||
|
err.value = `error: server responded with HTTP ${resBody.status}`
|
||||||
|
return
|
||||||
|
}
|
||||||
const invoiceId = resBody.id
|
const invoiceId = resBody.id
|
||||||
router.push('/invoice/' + invoiceId)
|
router.push('/invoice/' + invoiceId)
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<ul>
|
<ul>
|
||||||
<li class="my-3" v-for="market in markets" :key="market.id">
|
<li class="my-3" v-for="market in markets" :key="market.id">
|
||||||
<router-link :to="'/market/' + market.id">{{ market.description }}</router-link>
|
<router-link :to="'/market/' + market.id + '/form'">{{ market.description }}</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button v-if="!showForm" @click.prevent="toggleForm">+ create market</button>
|
<button v-if="!showForm" @click.prevent="toggleForm">+ create market</button>
|
||||||
|
57
vue/src/components/MarketOrders.vue
Normal file
57
vue/src/components/MarketOrders.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text w-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<th>description</th>
|
||||||
|
<th class="hidden-sm">created at</th>
|
||||||
|
<th>status</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<OrderRow :order="o" v-for="o in orders" :key="o.id" />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import OrderRow from './OrderRow.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const marketId = route.params.id
|
||||||
|
|
||||||
|
const orders = ref([])
|
||||||
|
const url = `/api/market/${marketId}/orders`
|
||||||
|
await fetch(url)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(body => {
|
||||||
|
orders.value = body.map(o => {
|
||||||
|
// remove market column
|
||||||
|
delete o.MarketId
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
th {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-sm {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
129
vue/src/components/OrderForm.vue
Normal file
129
vue/src/components/OrderForm.vue
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<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(100)
|
||||||
|
// how sure is the user he will win?
|
||||||
|
const certainty = ref(0.5)
|
||||||
|
// 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>
|
22
vue/src/components/OrderRow.vue
Normal file
22
vue/src/components/OrderRow.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<tr :class="className">
|
||||||
|
<td v-if="order.MarketId"><router-link :to="/market/ + order.MarketId">{{ order.MarketId }}</router-link></td>
|
||||||
|
<td>{{ order.side }} {{ order.quantity }} {{ order.ShareDescription }} @ {{ order.price }} sats</td>
|
||||||
|
<td :title="order.CreatedAt" class="hidden-sm">{{ ago(new Date(order.CreatedAt)) }}</td>
|
||||||
|
<td class="font-mono">{{ order.Status }}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, defineProps, computed } from 'vue'
|
||||||
|
import ago from 's-ago'
|
||||||
|
|
||||||
|
const props = defineProps(['order'])
|
||||||
|
|
||||||
|
const order = ref(props.order)
|
||||||
|
|
||||||
|
const className = computed(() => {
|
||||||
|
return order.value.side === 'BUY' ? 'success' : 'error'
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
16
vue/src/components/StyledLink.vue
Normal file
16
vue/src/components/StyledLink.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<router-link :to="to" :class="{ selected }">
|
||||||
|
<slot></slot>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
|
||||||
|
import { computed, defineProps } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const props = defineProps(['to'])
|
||||||
|
const selected = computed(() => props.to !== '/' ? route.path.startsWith(props.to) : route.path === props.to, [route.path])
|
||||||
|
|
||||||
|
</script>
|
60
vue/src/components/UserInvoices.vue
Normal file
60
vue/src/components/UserInvoices.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text w-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<th>sats</th>
|
||||||
|
<th>created at</th>
|
||||||
|
<th class="hidden-sm">expires at</th>
|
||||||
|
<th>status</th>
|
||||||
|
<th>details</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="i in invoices " :key="i.id">
|
||||||
|
<td>{{ i.Msats / 1000 }}</td>
|
||||||
|
<td :title="i.CreatedAt">{{ ago(new Date(i.CreatedAt)) }}</td>
|
||||||
|
<td :title="i.ExpiresAt" class="hidden-sm">{{ ago(new Date(i.ExpiresAt)) }}</td>
|
||||||
|
<td class="font-mono">{{ i.Status }}</td>
|
||||||
|
<td>
|
||||||
|
<router-link :to="/invoice/ + i.Id">open</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ago from 's-ago'
|
||||||
|
|
||||||
|
const invoices = ref(null)
|
||||||
|
|
||||||
|
const url = '/api/invoices'
|
||||||
|
await fetch(url)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(body => {
|
||||||
|
invoices.value = body
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
table {
|
||||||
|
width: fit-content;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
th {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-sm {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
51
vue/src/components/UserOrders.vue
Normal file
51
vue/src/components/UserOrders.vue
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text w-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<th>market</th>
|
||||||
|
<th>description</th>
|
||||||
|
<th class="hidden-sm">created at</th>
|
||||||
|
<th>status</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<OrderRow :order="o" v-for="o in orders" :key="o.id" />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import OrderRow from './OrderRow.vue'
|
||||||
|
|
||||||
|
const orders = ref(null)
|
||||||
|
|
||||||
|
const url = '/api/orders'
|
||||||
|
await fetch(url)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(body => {
|
||||||
|
orders.value = body
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
table {
|
||||||
|
width: fit-content;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
th {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-sm {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
18
vue/src/components/UserSettings.vue
Normal file
18
vue/src/components/UserSettings.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="session.pubkey" class="flex flex-row items-center">
|
||||||
|
<div>authenticated as {{ session.pubkey.slice(0, 8) }}</div>
|
||||||
|
<button class="ms-2 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>
|
@ -50,6 +50,7 @@ a.selected {
|
|||||||
|
|
||||||
.success:hover {
|
.success:hover {
|
||||||
background-color: #35df8d;
|
background-color: #35df8d;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.red {
|
.red {
|
||||||
@ -63,6 +64,7 @@ a.selected {
|
|||||||
|
|
||||||
.error:hover {
|
.error:hover {
|
||||||
background-color: #ff7386;
|
background-color: #ff7386;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
|
@ -10,6 +10,11 @@ import LoginView from '@/views/LoginView'
|
|||||||
import UserView from '@/views/UserView'
|
import UserView from '@/views/UserView'
|
||||||
import MarketView from '@/views/MarketView'
|
import MarketView from '@/views/MarketView'
|
||||||
import InvoiceView from '@/views/InvoiceView'
|
import InvoiceView from '@/views/InvoiceView'
|
||||||
|
import UserSettings from '@/components/UserSettings'
|
||||||
|
import UserInvoices from '@/components/UserInvoices'
|
||||||
|
import UserOrders from '@/components/UserOrders'
|
||||||
|
import OrderForm from '@/components/OrderForm'
|
||||||
|
import MarketOrders from '@/components/MarketOrders'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@ -19,10 +24,21 @@ const routes = [
|
|||||||
path: '/login', component: LoginView
|
path: '/login', component: LoginView
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/user', component: UserView
|
path: '/user',
|
||||||
|
component: UserView,
|
||||||
|
children: [
|
||||||
|
{ path: 'settings', name: 'user', component: UserSettings },
|
||||||
|
{ path: 'invoices', name: 'invoices', component: UserInvoices },
|
||||||
|
{ path: 'orders', name: 'orders', component: UserOrders }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/market/:id', component: MarketView
|
path: '/market/:id',
|
||||||
|
component: MarketView,
|
||||||
|
children: [
|
||||||
|
{ path: 'form', name: 'form', component: OrderForm },
|
||||||
|
{ path: 'orders', name: 'market-orders', component: MarketOrders }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/invoice/:id', component: InvoiceView
|
path: '/invoice/:id', component: InvoiceView
|
||||||
|
@ -9,21 +9,29 @@
|
|||||||
\__,_|___/\___|_| </pre>
|
\__,_|___/\___|_| </pre>
|
||||||
</div>
|
</div>
|
||||||
<!-- eslint-enable -->
|
<!-- eslint-enable -->
|
||||||
<div v-if="session.pubkey">
|
<header class="flex flex-row text-center justify-center pt-1">
|
||||||
<div>authenticated as {{ session.pubkey.slice(0, 8) }}</div>
|
<nav>
|
||||||
<button class="my-3" @click="logout">logout</button>
|
<StyledLink to="/user/settings">settings</StyledLink>
|
||||||
</div>
|
<StyledLink to="/user/invoices">invoices</StyledLink>
|
||||||
|
<StyledLink to="/user/orders">orders</StyledLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<Suspense>
|
||||||
|
<router-view class="m-3" />
|
||||||
|
</Suspense>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useSession } from '@/stores/session'
|
import StyledLink from '@/components/StyledLink'
|
||||||
import { useRouter } from 'vue-router'
|
</script>
|
||||||
const session = useSession()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const logout = async () => {
|
<style scoped>
|
||||||
await session.logout()
|
nav {
|
||||||
router.push('/')
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
nav>a {
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -4012,6 +4012,11 @@ run-parallel@^1.1.9:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask "^1.2.2"
|
queue-microtask "^1.2.2"
|
||||||
|
|
||||||
|
s-ago@^2.2.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/s-ago/-/s-ago-2.2.0.tgz#4143a9d0176b3100dcf649c78e8a1ec8a59b1312"
|
||||||
|
integrity sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==
|
||||||
|
|
||||||
safe-array-concat@^1.0.1:
|
safe-array-concat@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c"
|
resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user