Implement withdrawal
This commit is contained in:
parent
2af9ac29df
commit
c7ae559777
@ -54,3 +54,11 @@ CREATE TABLE orders(
|
|||||||
);
|
);
|
||||||
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);
|
||||||
|
CREATE TABLE withdrawals(
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
||||||
|
bolt11 TEXT NOT NULL UNIQUE,
|
||||||
|
paid_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
@ -66,4 +66,12 @@ type (
|
|||||||
Invoice
|
Invoice
|
||||||
OrderId null.String
|
OrderId null.String
|
||||||
}
|
}
|
||||||
|
Withdrawal struct {
|
||||||
|
Id UUID
|
||||||
|
CreatedAt time.Time
|
||||||
|
DeletedAt null.Time
|
||||||
|
Pubkey string
|
||||||
|
Bolt11 string `json:"bolt11"`
|
||||||
|
PaidAt null.Time
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
13
db/withdrawal.go
Normal file
13
db/withdrawal.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) CreateWithdrawal(tx *sql.Tx, c context.Context, w *Withdrawal) error {
|
||||||
|
if _, err := tx.ExecContext(c, "INSERT INTO withdrawals(pubkey, bolt11) VALUES ($1, $2)", w.Pubkey, w.Bolt11); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
30
lnd/withdrawal.go
Normal file
30
lnd/withdrawal.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package lnd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/btcutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (lnd *LNDClient) PayInvoice(tx *sql.Tx, bolt11 string) error {
|
||||||
|
maxFeeSats := btcutil.Amount(10)
|
||||||
|
ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
log.Printf("attempting to pay bolt11 %s ...\n", bolt11)
|
||||||
|
payChan := lnd.Client.PayInvoice(ctx, bolt11, maxFeeSats, nil)
|
||||||
|
res := <-payChan
|
||||||
|
if res.Err != nil {
|
||||||
|
log.Printf("error paying bolt11: %s -- %s\n", bolt11, res.Err)
|
||||||
|
tx.Rollback()
|
||||||
|
return res.Err
|
||||||
|
}
|
||||||
|
log.Printf("successfully paid bolt11: %s\n", bolt11)
|
||||||
|
if _, err := tx.ExecContext(ctx, "UPDATE withdrawals SET paid_at = CURRENT_TIMESTAMP WHERE bolt11 = $1", bolt11); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return res.Err
|
||||||
|
}
|
@ -13,7 +13,7 @@ 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
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "violates check constraint") {
|
if strings.Contains(err.Error(), "violates check constraint") || strings.Contains(err.Error(), "violates unique constraint") {
|
||||||
code = 400
|
code = 400
|
||||||
}
|
}
|
||||||
c.JSON(code, map[string]any{"status": code})
|
c.JSON(code, map[string]any{"status": code})
|
||||||
|
79
server/router/handler/withdrawal.go
Normal file
79
server/router/handler/withdrawal.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
context_ "context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/db"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/lightningnetwork/lnd/zpay32"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleWithdrawal(sc context.ServerContext) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
w db.Withdrawal
|
||||||
|
u db.User
|
||||||
|
inv *zpay32.Invoice
|
||||||
|
tx *sql.Tx
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if err := c.Bind(&w); err != nil {
|
||||||
|
code := http.StatusBadRequest
|
||||||
|
return c.JSON(code, map[string]any{"status": code, "reason": "bolt11 required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if inv, err = zpay32.Decode(w.Bolt11, sc.Lnd.ChainParams); err != nil {
|
||||||
|
code := http.StatusBadRequest
|
||||||
|
return c.JSON(code, map[string]any{"status": code, "reason": "zpay32 decode error"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// transaction start
|
||||||
|
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Commit()
|
||||||
|
|
||||||
|
u = c.Get("session").(db.User)
|
||||||
|
w.Pubkey = u.Pubkey
|
||||||
|
|
||||||
|
// TODO deduct network fee from user balance
|
||||||
|
if u.Msats < int64(*inv.MilliSat) {
|
||||||
|
tx.Rollback()
|
||||||
|
code := http.StatusBadRequest
|
||||||
|
return c.JSON(code, map[string]any{"status": code, "reason": "insufficient balance"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// create withdrawal
|
||||||
|
if err = sc.Db.CreateWithdrawal(tx, ctx, &w); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
if strings.Contains(err.Error(), "violates unique constraint") {
|
||||||
|
code := http.StatusBadRequest
|
||||||
|
return c.JSON(code, map[string]any{"status": code, "reason": "bolt11 already submitted"})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// pay invoice via LND
|
||||||
|
if err = sc.Lnd.PayInvoice(tx, w.Bolt11); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// deduct balance from user
|
||||||
|
if _, err = tx.ExecContext(ctx, "UPDATE users SET msats = msats - $1 WHERE pubkey = $2", int64(*inv.MilliSat), u.Pubkey); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
}
|
@ -68,6 +68,10 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
|
|||||||
GET(e, sc, "/api/invoices",
|
GET(e, sc, "/api/invoices",
|
||||||
handler.HandleInvoices,
|
handler.HandleInvoices,
|
||||||
middleware.SessionGuard)
|
middleware.SessionGuard)
|
||||||
|
POST(e, sc, "/api/withdrawal",
|
||||||
|
handler.HandleWithdrawal,
|
||||||
|
middleware.SessionGuard,
|
||||||
|
middleware.LNDGuard)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
<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>
|
|
80
vue/src/components/UserWallet.vue
Normal file
80
vue/src/components/UserWallet.vue
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="session.pubkey" class="grid flex-row items-center">
|
||||||
|
<div>authenticated as {{ session.pubkey.slice(0, 8) }}</div>
|
||||||
|
<button class="ms-2 my-3" @click="logout">logout</button>
|
||||||
|
<div>you have {{ session.msats / 1000 }} sats</div>
|
||||||
|
<button class="ms-2 my-3" @click="toggleWithdrawalForm">
|
||||||
|
<span v-if="showWithdrawalForm">cancel</span>
|
||||||
|
<span v-else>withdraw</span></button>
|
||||||
|
</div>
|
||||||
|
<form v-show="showWithdrawalForm" @submit.prevent="submitWithdrawal">
|
||||||
|
<label for="bolt11">bolt11</label>
|
||||||
|
<input name="bolt11" id="bolt11" type="text" required v-model="bolt11" />
|
||||||
|
<button type="submit" class="col-span-2">submit withdrawal</button>
|
||||||
|
</form>
|
||||||
|
<div v-if="err" class="red text-center">{{ err }}</div>
|
||||||
|
<div v-if="success" class="green text-center">{{ success }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useSession } from '@/stores/session'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const session = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await session.logout()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const showWithdrawalForm = ref(false)
|
||||||
|
const bolt11 = ref(null)
|
||||||
|
const toggleWithdrawalForm = () => {
|
||||||
|
showWithdrawalForm.value = !showWithdrawalForm.value
|
||||||
|
}
|
||||||
|
const err = ref(null)
|
||||||
|
const success = ref(null)
|
||||||
|
const submitWithdrawal = async () => {
|
||||||
|
success.value = null
|
||||||
|
err.value = null
|
||||||
|
const url = '/api/withdrawal'
|
||||||
|
const body = JSON.stringify({ bolt11: bolt11.value })
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
|
||||||
|
if (res.status === 200) {
|
||||||
|
success.value = 'invoice paid'
|
||||||
|
return session.checkSession()
|
||||||
|
}
|
||||||
|
const resBody = await res.json()
|
||||||
|
err.value = resBody.reason || `error: server responded with HTTP ${res.status}`
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
form>* {
|
||||||
|
margin: 0.5em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
@ -11,7 +11,7 @@ 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 UserWallet from '@/components/UserWallet'
|
||||||
import UserInvoices from '@/components/UserInvoices'
|
import UserInvoices from '@/components/UserInvoices'
|
||||||
import UserOrders from '@/components/UserOrders'
|
import UserOrders from '@/components/UserOrders'
|
||||||
import OrderForm from '@/components/OrderForm'
|
import OrderForm from '@/components/OrderForm'
|
||||||
@ -32,7 +32,7 @@ const routes = [
|
|||||||
path: '/user',
|
path: '/user',
|
||||||
component: UserView,
|
component: UserView,
|
||||||
children: [
|
children: [
|
||||||
{ path: 'settings', name: 'user', component: UserSettings },
|
{ path: 'wallet', name: 'user', component: UserWallet },
|
||||||
{ path: 'invoices', name: 'invoices', component: UserInvoices },
|
{ path: 'invoices', name: 'invoices', component: UserInvoices },
|
||||||
{ path: 'orders', name: 'orders', component: UserOrders }
|
{ path: 'orders', name: 'orders', component: UserOrders }
|
||||||
]
|
]
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
<!-- eslint-enable -->
|
<!-- eslint-enable -->
|
||||||
<header class="flex flex-row text-center justify-center pt-1">
|
<header class="flex flex-row text-center justify-center pt-1">
|
||||||
<nav>
|
<nav>
|
||||||
<StyledLink to="/user/settings">settings</StyledLink>
|
<StyledLink to="/user/wallet">wallet</StyledLink>
|
||||||
<StyledLink to="/user/invoices">invoices</StyledLink>
|
<StyledLink to="/user/invoices">invoices</StyledLink>
|
||||||
<StyledLink to="/user/orders">orders</StyledLink>
|
<StyledLink to="/user/orders">orders</StyledLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user