Merge branch 'vue-rewrite' into develop

This commit is contained in:
ekzyis 2023-11-27 17:45:34 +01:00
commit b39878c495
24 changed files with 631 additions and 150 deletions

View File

@ -47,7 +47,8 @@ CREATE TABLE orders(
side ORDER_SIDE NOT NULL,
quantity 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_quantity CHECK(quantity > 0);

View File

@ -1,12 +1,13 @@
package db
import (
"context"
"database/sql"
"time"
)
func (db *DB) CreateInvoice(invoice *Invoice) error {
if err := db.QueryRow(""+
func (db *DB) CreateInvoice(tx *sql.Tx, ctx context.Context, invoice *Invoice) error {
if err := tx.QueryRowContext(ctx, ""+
"INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at, description) "+
"VALUES($1, $2, $3, $4, $5, $6, $7, $8) "+
"RETURNING id",
@ -70,6 +71,33 @@ func (db *DB) FetchInvoices(where *FetchInvoicesWhere, invoices *[]Invoice) erro
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 {
if _, err := db.Exec("UPDATE invoices SET confirmed_at = $2, msats_received = $3 WHERE hash = $1", hash, confirmedAt, msatsReceived); err != nil {
return err

View File

@ -1,6 +1,9 @@
package db
import "database/sql"
import (
"context"
"database/sql"
)
type FetchOrdersWhere struct {
MarketId int
@ -8,15 +11,15 @@ type FetchOrdersWhere struct {
Confirmed bool
}
func (db *DB) CreateMarket(market *Market) error {
if err := db.QueryRow(""+
func (db *DB) CreateMarket(tx *sql.Tx, ctx context.Context, market *Market) error {
if err := tx.QueryRowContext(ctx, ""+
"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 {
if _, err := tx.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil {
return err
}
return nil
@ -62,8 +65,8 @@ func (db *DB) FetchShares(marketId int, shares *[]Share) error {
return nil
}
func (db *DB) FetchShare(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)
func (db *DB) FetchShare(tx *sql.Tx, ctx context.Context, shareId string, share *Share) error {
return tx.QueryRowContext(ctx, "SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description)
}
func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
@ -99,8 +102,8 @@ func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
return nil
}
func (db *DB) CreateOrder(order *Order) error {
if _, err := db.Exec(""+
func (db *DB) CreateOrder(tx *sql.Tx, ctx context.Context, order *Order) error {
if _, err := tx.ExecContext(ctx, ""+
"INSERT INTO orders(share_id, pubkey, side, quantity, price, invoice_id) "+
"VALUES ($1, $2, $3, $4, $5, $6)",
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
}
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
}

View File

@ -47,11 +47,13 @@ type (
ConfirmedAt null.Time
HeldSince null.Time
Description string
Status string
}
Order struct {
Id UUID
CreatedAt time.Time
ShareId string `json:"sid"`
Id UUID
CreatedAt time.Time
ShareId string `json:"sid"`
ShareDescription string
Share
Pubkey string
Side string `json:"side"`

View File

@ -2,6 +2,7 @@ package lnd
import (
"context"
"database/sql"
"log"
"time"
@ -12,7 +13,7 @@ import (
"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 (
expiry time.Duration = time.Hour
preimage lntypes.Preimage
@ -26,14 +27,14 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri
return nil, err
}
hash = preimage.Hash()
if paymentRequest, err = lnd.Invoices.AddHoldInvoice(context.TODO(), &invoicesrpc.AddInvoiceData{
if paymentRequest, err = lnd.Invoices.AddHoldInvoice(ctx, &invoicesrpc.AddInvoiceData{
Hash: &hash,
Value: lnwire.MilliSatoshi(msats),
Expiry: int64(expiry / time.Millisecond),
}); err != nil {
return nil, err
}
if lnInvoice, err = lnd.Client.LookupInvoice(context.TODO(), hash); err != nil {
if lnInvoice, err = lnd.Client.LookupInvoice(ctx, hash); err != nil {
return nil, err
}
dbInvoice = &db.Invoice{
@ -46,7 +47,7 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri
ExpiresAt: lnInvoice.CreationDate.Add(expiry),
Description: description,
}
if err := d.CreateInvoice(dbInvoice); err != nil {
if err := d.CreateInvoice(tx, ctx, dbInvoice); err != nil {
return nil, err
}
return dbInvoice, nil

View File

@ -93,3 +93,18 @@ func HandleInvoice(sc context.ServerContext) echo.HandlerFunc {
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)
}
}

View File

@ -1,11 +1,13 @@
package handler
import (
context_ "context"
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.ekzyis.com/ekzyis/delphi.market/db"
"git.ekzyis.com/ekzyis/delphi.market/lib"
@ -50,6 +52,7 @@ func HandleMarket(sc context.ServerContext) echo.HandlerFunc {
func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
return func(c echo.Context) error {
var (
tx *sql.Tx
u db.User
m db.Market
invoice *db.Invoice
@ -64,26 +67,41 @@ func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
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)
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 {
invDescription = fmt.Sprintf("create market \"%s\"", m.Description)
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, u.Pubkey, msats, invDescription); err != nil {
tx.Rollback()
return err
}
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
tx.Rollback()
return err
}
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
tx.Rollback()
return err
}
go sc.Lnd.CheckInvoice(sc.Db, hash)
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
}
// need to commit before starting to poll invoice status
tx.Commit()
go sc.Lnd.CheckInvoice(sc.Db, hash)
data = map[string]any{
"id": invoice.Id,
"bolt11": invoice.PaymentRequest,
@ -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 {
return func(c echo.Context) error {
var (
tx *sql.Tx
u db.User
o db.Order
s db.Share
@ -122,7 +158,18 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
u = c.Get("session").(db.User)
o.Pubkey = u.Pubkey
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
}
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
// 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
}
// Create QR code to pay HODL invoice
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
tx.Rollback()
return err
}
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
tx.Rollback()
return err
}
// Create (unconfirmed) order
o.InvoiceId = invoice.Id
if err := sc.Db.CreateOrder(&o); err != nil {
if err := sc.Db.CreateOrder(tx, ctx, &o); err != nil {
tx.Rollback()
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)
// TODO: find matching orders
@ -161,3 +213,18 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
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)
}
}

View File

@ -46,10 +46,14 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
middleware.SessionGuard,
middleware.LNDGuard)
GET(e, sc, "/api/market/:id", handler.HandleMarket)
GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders)
POST(e, sc, "/api/order",
handler.HandleOrder,
middleware.SessionGuard,
middleware.LNDGuard)
GET(e, sc, "/api/orders",
handler.HandleOrders,
middleware.SessionGuard)
GET(e, sc, "/api/login", handler.HandleLogin)
GET(e, sc, "/api/login/callback", handler.HandleLoginCallback)
POST(e, sc, "/api/logout", handler.HandleLogout)
@ -57,6 +61,9 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
GET(e, sc, "/api/invoice/:id",
handler.HandleInvoiceStatus,
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 {

View File

@ -12,6 +12,7 @@
"core-js": "^3.8.3",
"pinia": "^2.1.7",
"register-service-worker": "^1.7.2",
"s-ago": "^2.2.0",
"vite": "^4.5.0",
"vue": "^3.2.13",
"vue-router": "4"

View File

@ -22,14 +22,18 @@
</figcaption>
</figure>
<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">
{{ 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 }}
{{ invoice.CreatedAt }} ({{ ago(new Date(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 }}
{{ invoice.ExpiresAt }} ({{ ago(new Date(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 }}
@ -54,8 +58,9 @@
</template>
<script setup>
import { ref } from 'vue'
import { onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ago from 's-ago'
const router = useRouter()
const route = useRoute()
@ -120,6 +125,11 @@ await (async () => {
invoice.value = body
interval = setInterval(poll, INVOICE_POLL)
})()
onUnmounted(() => { clearInterval(interval) })
const faucet = window.location.hostname === 'delphi.market' ? 'https://faucet.mutinynet.com' : ''
</script>
<style scoped>

View File

@ -10,62 +10,24 @@
</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>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<StyledLink :to="'/market/' + marketId + '/form'">form</StyledLink>
<StyledLink :to="'/market/' + marketId + '/orders'">orders</StyledLink>
</nav>
</header>
<Suspense>
<router-view class="m-3" />
</Suspense>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import StyledLink from '@/components/StyledLink'
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
@ -75,66 +37,16 @@ await fetch(url)
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;
nav {
display: flex;
justify-content: center;
}
.error.active {
background-color: #ff7386;
color: white;
}
form {
margin: 0 auto;
display: grid;
grid-template-columns: auto auto;
}
form>* {
margin: 0.5em 1em;
nav>a {
margin: 0 3px;
}
</style>

View File

@ -10,6 +10,7 @@
<button type="submit">submit</button>
</div>
</form>
<div v-if="err" class="red text-center">{{ err }}</div>
</template>
<script setup>
@ -22,6 +23,7 @@ const router = useRouter()
const form = ref(null)
const description = ref(null)
const endDate = ref(null)
const err = ref(null)
const parseEndDate = endDate => {
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 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)
}

View File

@ -1,7 +1,7 @@
<template>
<ul>
<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>
</ul>
<button v-if="!showForm" @click.prevent="toggleForm">+ create market</button>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -50,6 +50,7 @@ a.selected {
.success:hover {
background-color: #35df8d;
color: white;
}
.red {
@ -63,6 +64,7 @@ a.selected {
.error:hover {
background-color: #ff7386;
color: white;
}
.text-muted {

View File

@ -10,6 +10,11 @@ import LoginView from '@/views/LoginView'
import UserView from '@/views/UserView'
import MarketView from '@/views/MarketView'
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 = [
{
@ -19,10 +24,21 @@ const routes = [
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

View File

@ -9,21 +9,29 @@
\__,_|___/\___|_| </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>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<StyledLink to="/user/settings">settings</StyledLink>
<StyledLink to="/user/invoices">invoices</StyledLink>
<StyledLink to="/user/orders">orders</StyledLink>
</nav>
</header>
<Suspense>
<router-view class="m-3" />
</Suspense>
</template>
<script setup>
import { useSession } from '@/stores/session'
import { useRouter } from 'vue-router'
const session = useSession()
const router = useRouter()
import StyledLink from '@/components/StyledLink'
</script>
const logout = async () => {
await session.logout()
router.push('/')
<style scoped>
nav {
display: flex;
justify-content: center;
}
</script>
nav>a {
margin: 0 3px;
}
</style>

View File

@ -4012,6 +4012,11 @@ run-parallel@^1.1.9:
dependencies:
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:
version "1.0.1"
resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c"