Implement withdrawal

This commit is contained in:
ekzyis 2023-12-03 06:21:57 +01:00
parent 2af9ac29df
commit c7ae559777
11 changed files with 226 additions and 22 deletions

View File

@ -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_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
);

View File

@ -66,4 +66,12 @@ type (
Invoice
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
View 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
View 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
}

View File

@ -13,7 +13,7 @@ func httpErrorHandler(err error, c echo.Context) {
if httpError, ok := err.(*echo.HTTPError); ok {
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
}
c.JSON(code, map[string]any{"status": code})

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

View File

@ -68,6 +68,10 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
GET(e, sc, "/api/invoices",
handler.HandleInvoices,
middleware.SessionGuard)
POST(e, sc, "/api/withdrawal",
handler.HandleWithdrawal,
middleware.SessionGuard,
middleware.LNDGuard)
}
func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {

View File

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

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

View File

@ -11,7 +11,7 @@ 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 UserWallet from '@/components/UserWallet'
import UserInvoices from '@/components/UserInvoices'
import UserOrders from '@/components/UserOrders'
import OrderForm from '@/components/OrderForm'
@ -32,7 +32,7 @@ const routes = [
path: '/user',
component: UserView,
children: [
{ path: 'settings', name: 'user', component: UserSettings },
{ path: 'wallet', name: 'user', component: UserWallet },
{ path: 'invoices', name: 'invoices', component: UserInvoices },
{ path: 'orders', name: 'orders', component: UserOrders }
]

View File

@ -11,7 +11,7 @@
<!-- eslint-enable -->
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<StyledLink to="/user/settings">settings</StyledLink>
<StyledLink to="/user/wallet">wallet</StyledLink>
<StyledLink to="/user/invoices">invoices</StyledLink>
<StyledLink to="/user/orders">orders</StyledLink>
</nav>