UI works well

This commit is contained in:
ben
2022-09-16 13:20:42 +01:00
parent ae06636293
commit cf849b260c
14 changed files with 1600 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# Cashu
## Create ecash mint for pegging in/out of ecash
### Usage
1. Enable extension
2. Create a Mint
3. Share wallet

View File

@@ -0,0 +1,25 @@
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_cashu")
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["TPoS"])
def cashu_renderer():
return template_renderer(["lnbits/extensions/cashu/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def cashu_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@@ -0,0 +1,6 @@
{
"name": "Cashu Ecash",
"short_description": "Ecash mints with LN peg in/out",
"icon": "approval",
"contributors": ["shinobi", "arcbtc", "calle"]
}

View File

@@ -0,0 +1,50 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Cashu, Pegs
async def create_cashu(wallet_id: str, data: Cashu) -> Cashu:
cashu_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
cashu_id,
wallet_id,
data.name,
data.tickershort,
data.fraction,
data.maxsats,
data.coins
),
)
cashu = await get_cashu(cashu_id)
assert cashu, "Newly created cashu couldn't be retrieved"
return cashu
async def get_cashu(cashu_id: str) -> Optional[Cashu]:
row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,))
return Cashu(**row) if row else None
async def get_cashus(wallet_ids: Union[str, List[str]]) -> List[Cashu]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM cashu.cashu WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Cashu(**row) for row in rows]
async def delete_cashu(cashu_id: str) -> None:
await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,))

View File

@@ -0,0 +1,33 @@
async def m001_initial(db):
"""
Initial cashu table.
"""
await db.execute(
"""
CREATE TABLE cashu.cashu (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
tickershort TEXT NOT NULL,
fraction BOOL,
maxsats INT,
coins INT
);
"""
)
"""
Initial cashus table.
"""
await db.execute(
"""
CREATE TABLE cashu.pegs (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
inout BOOL NOT NULL,
amount INT
);
"""
)

View File

@@ -0,0 +1,34 @@
from sqlite3 import Row
from typing import Optional
from fastapi import Query
from pydantic import BaseModel
class Cashu(BaseModel):
id: str = Query(None)
name: str = Query(None)
wallet: str = Query(None)
tickershort: str
fraction: bool = Query(None)
maxsats: int = Query(0)
coins: int = Query(0)
@classmethod
def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row))
class Pegs(BaseModel):
id: str
wallet: str
inout: str
amount: str
@classmethod
def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str

View File

@@ -0,0 +1,70 @@
import asyncio
import json
from lnbits.core import db as core_db
from lnbits.core.crud import create_payment
from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from .crud import get_cashu
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") == "cashu" and payment.extra.get("tipSplitted"):
# already splitted, ignore
return
# now we make some special internal transfers (from no one to the receiver)
cashu = await get_cashu(payment.extra.get("cashuId"))
tipAmount = payment.extra.get("tipAmount")
if tipAmount is None:
# no tip amount
return
tipAmount = tipAmount * 1000
amount = payment.amount - tipAmount
# mark the original payment with one extra key, "splitted"
# (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer
await core_db.execute(
"""
UPDATE apipayments
SET extra = ?, amount = ?
WHERE hash = ?
AND checking_id NOT LIKE 'internal_%'
""",
(
json.dumps(dict(**payment.extra, tipSplitted=True)),
amount,
payment.payment_hash,
),
)
# perform the internal transfer using the same payment_hash
internal_checking_id = f"internal_{urlsafe_short_hash()}"
await create_payment(
wallet_id=cashu.tip_wallet,
checking_id=internal_checking_id,
payment_request="",
payment_hash=payment.payment_hash,
amount=tipAmount,
memo=f"Tip for {payment.memo}",
pending=False,
extra={"tipSplitted": True},
)
# manually send this for now
await internal_invoice_queue.put(internal_checking_id)
return

View File

@@ -0,0 +1,79 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/cashu"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List TPoS">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /cashu/api/v1/cashus</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;cashu_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}cashu/api/v1/cashus -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /cashu/api/v1/cashus</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}cashu/api/v1/cashus -d '{"name":
&lt;string&gt;, "currency": &lt;string&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a TPoS"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/cashu/api/v1/cashus/&lt;cashu_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}cashu/api/v1/cashus/&lt;cashu_id&gt; -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@@ -0,0 +1,15 @@
<q-expansion-item group="extras" icon="info" label="About TPoS">
<q-card>
<q-card-section>
<p>
Make Ecash mints with peg in/out to a wallet, that can create and manage ecash.
</p>
<small
>Created by
<a href="https://github.com/calle" target="_blank"
>Calle</a
>.</small
>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@@ -0,0 +1,262 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true">New Mint</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Mints</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table dense flat :data="cashus" row-key="id" :columns="cashusTable.columns"
:pagination.sync="cashusTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn unelevated dense size="xs" icon="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'wallet/?tsh=' + props.row.tickershort + '&mnt=' + hostname + props.row.id + '&nme=' + props.row.name"
target="_blank"><q-tooltip>Shareable wallet page</q-tooltip></q-btn>
<q-btn unelevated dense size="xs" icon="account_balance" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'mint/' + props.row.id"
target="_blank"><q-tooltip>Shareable mint page</q-tooltip></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ (col.name == 'tip_options' && col.value ?
JSON.parse(col.value).join(", ") : col.value) }}
</q-td>
<q-td auto-width>
<q-btn flat dense size="xs" @click="deleteMint(props.row.id)" icon="cancel" color="pink"></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Cashu extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "cashu/_api_docs.html" %}
<q-separator></q-separator>
{% include "cashu/_cashu.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="createMint" class="q-gutter-md">
<q-input filled dense v-model.trim="formDialog.data.name" label="Mint Name" placeholder="Cashu Mint"></q-input>
<q-input filled dense v-model.trim="formDialog.data.tickershort" label="Ticker shorthand" placeholder="CC"
#></q-input>
<q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions"
label="Wallet *" ></q-select>
<q-toggle v-model="toggleAdvanced" label="Show advanced options"></q-toggle>
<div v-show="toggleAdvanced">
<div class="row">
<div class="col-5">
<q-checkbox v-model="formDialog.data.fraction" color="primary" label="sats/coins?">
<q-tooltip>Use with hedging extension to create a stablecoin!</q-tooltip>
</q-checkbox>
</div>
<div class="col-7">
<q-input v-if="!formDialog.data.fraction" filled dense type="number" v-model.trim="formDialog.data.cost" label="Sat coin cost (optional)"
value="1" type="number"></q-input>
</div>
</div>
<q-input class="q-mt-md" filled dense type="number" v-model.trim="formDialog.data.maxsats"
label="Maximum mint liquidity (optional)" placeholder="∞"></q-input>
<q-input class="q-mt-md" filled dense type="number" v-model.trim="formDialog.data.coins"
label="Coins that 'exist' in mint (optional)" placeholder="∞"></q-input>
</div>
<div class="row q-mt-md">
<q-btn unelevated color="primary"
:disable="formDialog.data.tickershort == null || formDialog.data.name == null" type="submit">Create Mint
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapMint = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.cashu = ['/cashu/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
cashus: [],
hostname: location.protocol + "//" + location.host + "/cashu/mint/",
toggleAdvanced: false,
cashusTable: {
columns: [
{ name: 'id', align: 'left', label: 'ID', field: 'id' },
{ name: 'name', align: 'left', label: 'Name', field: 'name' },
{
name: 'tickershort',
align: 'left',
label: 'tickershort',
field: 'tickershort'
},
{
name: 'wallet',
align: 'left',
label: 'Wallet',
field: 'wallet'
},
{
name: 'fraction',
align: 'left',
label: 'Using fraction',
field: 'fraction'
},
{
name: 'maxsats',
align: 'left',
label: 'Max Sats',
field: 'maxsats'
},
{
name: 'coins',
align: 'left',
label: 'No. of coins',
field: 'coins'
},
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: { fraction: false }
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
},
getMints: function () {
var self = this
LNbits.api
.request(
'GET',
'/cashu/api/v1/cashus?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.cashus = response.data.map(function (obj) {
return mapMint(obj)
})
})
},
createMint: function () {
if (this.formDialog.data.maxliquid == null) {
this.formDialog.data.maxliquid = 0
}
var data = {
name: this.formDialog.data.name,
tickershort: this.formDialog.data.tickershort,
maxliquid: this.formDialog.data.maxliquid,
}
var self = this
LNbits.api
.request(
'POST',
'/cashu/api/v1/cashus',
_.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet })
.inkey,
data
)
.then(function (response) {
self.cashus.push(mapMint(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteMint: function (cashuId) {
var self = this
var cashu = _.findWhere(this.cashus, { id: cashuId })
console.log(cashu)
LNbits.utils
.confirmDialog('Are you sure you want to delete this Mint? It will suck for users.')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/cashu/api/v1/cashus/' + cashuId,
_.findWhere(self.g.user.wallets, { id: cashu.wallet }).adminkey
)
.then(function (response) {
self.cashus = _.reject(self.cashus, function (obj) {
return obj.id == cashuId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.cashusTable.columns, this.cashus)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getMints()
}
}
})
</script>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<q-icon
name="account_balance"
class="text-grey"
style="font-size: 10rem"
></q-icon>
<h3 class="q-my-none">{{ mint_name }}</h3>
<br />
</center>
<h5 class="q-my-none">Some data about mint here: <br/>* whether its online <br/>* Who to contact for support <br/>* etc...</h5>
</q-card-section>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
}
})
</script>
{% endblock %}
</div>

View File

@@ -0,0 +1,753 @@
{% extends "public.html" %} {% block toolbar_title %} {% raw %} {{name}} Wallet {% endraw %}
{% endblock %} {% block footer %}{% endblock %} {% block page_container %}
<q-page-container>
<q-page>
<div class="row q-col-gutter-md justify-center q-pt-lg">
<div class="col-12 col-sm-8 col-md-9 col-lg-7 text-center q-gutter-y-md ">
<q-card>
<q-card-section>
<h3 class="q-my-none">
<center><strong>{% raw %} {{balanceAmount}}
</strong> {{tickershort}}{% endraw %}</center>
</h3>
</q-card-section>
</q-card>
<div class="row q-pb-md q-px-md justify-center q-col-gutter-sm gt-sm q-pt-lg">
<div class="col">
<q-btn size="18px" icon="arrow_downward" rounded color="primary" class="full-width" @click="showParseDialog">Receive</q-btn>
</div>
<div class="col">
<q-btn size="18px" icon="arrow_upward" rounded color="primary" class="full-width" @click="showSendDialog">Send</q-btn>
</div>
<div class="col">
<q-btn size="18px" rounded color="secondary" icon="sync_alt" class="full-width" @click="showCamera">Peg in/out
</q-btn>
</div>
<div class="col">
<q-btn size="18px" rounded color="secondary" icon="photo_camera" class="full-width" @click="showCamera">scan
</q-btn>
</div>
</div>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
</div>
<div class="col-auto">{% raw %}
<q-btn dense flat round icon="approval" color="grey" type="a" :href="mint" target="_blank">{% endraw %}
<q-tooltip>Mint details</q-tooltip>
</q-btn>
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart">
<q-tooltip>Show chart</q-tooltip>
</q-btn>
</div>
</div>
<q-input v-if="payments.length > 10" filled dense clearable v-model="paymentsTable.filter" debounce="300"
placeholder="Search by tag, memo, amount" class="q-mb-md">
</q-input>
<q-table dense flat :data="filteredPayments" :row-key="paymentTableRowKey" :columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination" no-data-label="No transactions made yet"
:filter="paymentsTable.filter">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">{{ col.label }}</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon v-if="props.row.isPaid" size="14px" :name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'" @click="props.expand = !props.expand"></q-icon>
<q-icon v-else name="settings_ethernet" color="grey" @click="props.expand = !props.expand">
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
<q-td key="memo" :props="props" style="white-space: normal; word-break: break-all">
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a class="inherit" :href="['/', props.row.tag, '/?usr=', user.id].join('')">
#{{ props.row.tag }}
</a>
</q-badge>
{{ props.row.memo }}
</q-td>
<q-td auto-width key="date" :props="props">
<q-tooltip>{{ props.row.date }}</q-tooltip>
{{ props.row.dateFrom }}
</q-td>
{% endraw %}
<q-td auto-width key="sat" v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" :props="props">{% raw %} {{
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
}}
</q-td>
<q-td auto-width key="sat" v-else :props="props">
{{ props.row.fsat }}
</q-td>
<q-td auto-width key="fee" :props="props">
{{ props.row.fee }}
</q-td>
</q-tr>
<q-dialog v-model="props.expand" :props="props">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
Invoice waiting to be paid
<lnbits-payment-details :payment="props.row"></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode :value="props.row.bolt11" :options="{width: 340}" class="rounded-borders">
</qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(props.row.bolt11)">Copy invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon size="18px" :name="'call_received'" :color="'green'"></q-icon>
Payment Received
<lnbits-payment-details :payment="props.row"></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon size="18px" :name="'call_made'" :color="'pink'"></q-icon>
Payment Sent
<lnbits-payment-details :payment="props.row"></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
Outgoing payment pending
<lnbits-payment-details :payment="props.row"></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card v-if="!receive.paymentReq" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
<q-input filled dense v-model.number="receive.data.amount" label="Amount ({{LNBITS_DENOMINATION}}) *"
mask="#.##" fill-mask="0" reverse-fill-mask :min="receive.minMax[0]" :max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"></q-input>
{% else %}
<q-select filled dense v-model="receive.unit" type="text" label="Unit" :options="receive.units"></q-select>
<q-input ref="setAmount" filled dense v-model.number="receive.data.amount"
:label="'Amount (' + receive.unit + ') *'" :mask="receive.unit != 'sat' ? '#.##' : '#'" fill-mask="0"
reverse-fill-mask :step="receive.unit != 'sat' ? '0.01' : '1'" :min="receive.minMax[0]"
:max="receive.minMax[1]" :readonly="receive.lnurl && receive.lnurl.fixed"></q-input>
{% endif %}
<q-input filled dense v-model.trim="receive.data.memo" label="Memo"></q-input>
{% raw %}
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn unelevated color="primary" :disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit">
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<q-spinner v-if="receive.status == 'loading'" color="primary" size="2.55em"></q-spinner>
</q-form>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode :value="receive.paymentReq" :options="{width: 340}" class="rounded-borders"></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)">Copy invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
{% endraw %}
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<h6 v-else class="q-my-none">
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {%
raw %}
</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black">Not enough funds!</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
<div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair
will be deterministically generated so your identity can't be
tied to your LNbits wallet or linked across websites. No other
data will be shared with {{ parse.lnurlauth.domain }}.
</p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-form v-if="!parse.camera.show" @submit="decodeRequest" class="q-gutter-md">
<q-input filled dense v-model.trim="parse.data.request" type="textarea" label="Paste coins">
</q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" :disable="parse.data.request == ''" type="submit">Read</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="parse.camera.show">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
<q-tabs class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top" active-class="px-0"
indicator-color="transparent">
<q-tab icon="arrow_downward" label="receive" @click="showParseDialog">
</q-tab>
<q-tab icon="arrow_upward" label="Send" @click="showSendDialog"></q-tab>
<q-tab icon="sync_alt" label="Peg in/out" @click="showSendDialog"></q-tab>
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
</q-tabs>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Warning</h6>
<p>
<strong>BOOKMARK THIS PAGE! If only mobile you can also click the 3 dots
and "Save to homescreen"/"Install app"</strong>!
</p>
<p>
Ecash is a bearer asset, meaning you have the funds saved on this
page, losing the page without exporting the page will mean you will
lose the funds.
</p>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(disclaimerDialog.location.href)">Copy wallet URL</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">I understand</q-btn>
</div>
</q-card>
</q-dialog>
</div>
</q-page>
</q-page-container>
{% endblock %} {% block styles %}
<style>
* {
touch-action: manipulation;
}
.keypad {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
}
.keypad .btn {
height: 100%;
}
.keypad .btn-confirm {
grid-area: 1 / 4 / 5 / 4;
}
</style>
{% endblock %} {% block scripts %}
<script>
var mapMint = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.cashu = ['/cashu/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
balanceAmount:"",
tickershort:"",
name:"",
receive: {
show: false,
status: 'pending',
paymentReq: null,
paymentHash: null,
minMax: [0, 2100000000000000],
lnurl: null,
units: ['sat'],
unit: 'sat',
data: {
amount: null,
memo: ''
}
},
parse: {
show: false,
invoice: null,
lnurlpay: null,
lnurlauth: null,
data: {
request: '',
amount: 0,
comment: ''
},
paymentChecker: null,
camera: {
show: false,
camera: 'auto'
}
},
payments: [],
paymentsTable: {
columns: [
{
name: 'note',
align: 'left',
label: 'Note',
field: 'note'
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
{
name: 'amount',
align: 'right',
label: 'Amount',
field: 'amount',
sortable: true
}
],
pagination: {
rowsPerPage: 10
},
filter: null
},
paymentsChart: {
show: false
},
disclaimerDialog: {
show: false,
location: window.location
},
balance: 0,
credit: 0,
newName: ''
}
},
computed: {
formattedBalance: function () {
return this.balance / 100
},
filteredPayments: function () {
var q = this.paymentsTable.filter
if (!q || q === '') return this.payments
return LNbits.utils.search(this.payments, q)
},
canPay: function () {
if (!this.parse.invoice) return false
return this.parse.invoice.sat <= this.balance
},
pendingPaymentsExist: function () {
return this.payments.findIndex(payment => payment.pending) !== -1
}
},
filters: {
msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000)
}
},
methods: {
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
closeCamera: function () {
this.parse.camera.show = false
},
showCamera: function () {
this.parse.camera.show = true
},
showChart: function () {
this.paymentsChart.show = true
this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments)
})
},
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
},
showReceiveDialog: function () {
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.unit = 'sat'
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
this.focusInput('setAmount')
},
showParseDialog: function () {
this.parse.show = true
this.parse.invoice = null
this.parse.lnurlpay = null
this.parse.lnurlauth = null
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
updateBalance: function (credit) {
this.balance = this.balance // update balance
},
closeReceiveDialog: function () {
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 10000)
},
closeParseDialog: function () {
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 10000)
},
onPaymentReceived: function (paymentHash) {
this.fetchPayments()
this.fetchBalance()
if (this.receive.paymentHash === paymentHash) {
this.receive.show = false
this.receive.paymentHash = null
clearInterval(this.receive.paymentChecker)
}
},
createInvoice: function () {
this.receive.status = 'loading'
if (LNBITS_DENOMINATION != 'sats') {
this.receive.data.amount = this.receive.data.amount * 100
}
LNbits.api
.createInvoice(
this.receive.data.amount,
this.receive.data.memo,
this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentHash = response.data.payment_hash
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
})
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
}
clearInterval(this.receive.paymentChecker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 40000)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.receive.status = 'pending'
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.decodeRequest()
this.parse.camera.show = false
},
decodeRequest: function () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
}
if (
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
return
}
let invoice
try {
invoice = decode(this.parse.data.request)
} catch (error) {
this.$q.notify({
timeout: 3000,
type: 'warning',
message: error + '.',
caption: '400 BAD REQUEST'
})
this.parse.show = false
return
}
let cleanInvoice = {
msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
}
_.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value
} else if (tag.description === 'description') {
cleanInvoice.description = tag.value
} else if (tag.description === 'expiry') {
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expired = false // TODO
}
}
})
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
},
authLnurl: function () {
let dismissAuthMsg = this.$q.notify({
timeout: 10,
message: 'Performing authentication...'
})
},
deleteWallet: function (walletId, user) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(() => {
LNbits.href.deleteWallet(walletId, user)
})
},
fetchPayments: function () {
return
},
fetchBalance: function () {
},
exportCSV: function () {
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
let columns = this.paymentsTable.columns
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
})
LNbits.utils.exportCSV(columns, this.payments)
}
},
watch: {
payments: function () {
this.fetchBalance()
}
},
created: function () {
this.fetchBalance()
this.fetchPayments()
LNbits.api
.request('GET', '/api/v1/currencies')
.then(response => {
this.receive.units = ['sat', ...response.data]
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
created: function () {
let params = (new URL(document.location)).searchParams
// get ticker
if(!params.get('tsh') && !this.$q.localStorage.getItem('cashu.tickershort')){
this.$q.localStorage.set('cashu.tickershort', "CE")
this.tickershort = "CE"
}
else if(params.get('tsh')){
this.$q.localStorage.set('cashu.tickershort', params.get('tsh'))
this.tickershort = params.get('tsh')
}
else if(this.$q.localStorage.getItem('cashu.tickershort')){
this.tickershort = this.$q.localStorage.getItem('cashu.tickershort')
}
if (!this.$q.localStorage.getItem('cashu.amount')) {
this.balanceAmount = 0
}
// get mint
if (params.get('mnt')) {
this.mint = params.get('mnt')
this.$q.localStorage.set('cashu.mint', params.get('mnt'))
}
else if(this.$q.localStorage.getItem('cashu.mint')){
this.mint = this.$q.localStorage.getItem('cashu.mint')
}
else{
this.$q.notify({
color: 'red',
message: 'No mint set!'
})
}
// get name
if (params.get('nme')) {
this.name = params.get('nme')
this.$q.localStorage.set('cashu.name', params.get('nme'))
}
else if(this.$q.localStorage.getItem('cashu.name')){
this.name = this.$q.localStorage.getItem('cashu.name')
}
}
})
</script>
{% endblock %}

View File

@@ -0,0 +1,69 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.settings import LNBITS_CUSTOM_LOGO, LNBITS_SITE_TITLE
from . import cashu_ext, cashu_renderer
from .crud import get_cashu
templates = Jinja2Templates(directory="templates")
@cashu_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return cashu_renderer().TemplateResponse(
"cashu/index.html", {"request": request, "user": user.dict()}
)
@cashu_ext.get("/wallet")
async def cashu(request: Request):
return cashu_renderer().TemplateResponse("cashu/wallet.html",{"request": request})
@cashu_ext.get("/mint/{mintID}")
async def cashu(request: Request, mintID):
cashu = await get_cashu(mintID)
return cashu_renderer().TemplateResponse("cashu/mint.html",{"request": request, "mint_name": cashu.name})
@cashu_ext.get("/manifest/{cashu_id}.webmanifest")
async def manifest(cashu_id: str):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
return {
"short_name": LNBITS_SITE_TITLE,
"name": cashu.name + " - " + LNBITS_SITE_TITLE,
"icons": [
{
"src": LNBITS_CUSTOM_LOGO
if LNBITS_CUSTOM_LOGO
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
"type": "image/png",
"sizes": "900x900",
}
],
"start_url": "/cashu/" + cashu_id,
"background_color": "#1F2234",
"description": "Bitcoin Lightning tPOS",
"display": "standalone",
"scope": "/cashu/" + cashu_id,
"theme_color": "#1F2234",
"shortcuts": [
{
"name": cashu.name + " - " + LNBITS_SITE_TITLE,
"short_name": cashu.name,
"description": cashu.name + " - " + LNBITS_SITE_TITLE,
"url": "/cashu/" + cashu_id,
}
],
}

View File

@@ -0,0 +1,160 @@
from http import HTTPStatus
import httpx
from fastapi import Query
from fastapi.params import Depends
from lnurl import decode as decode_lnurl
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import cashu_ext
from .crud import create_cashu, delete_cashu, get_cashu, get_cashus
from .models import Cashu, Pegs, PayLnurlWData
@cashu_ext.get("/api/v1/cashus", status_code=HTTPStatus.OK)
async def api_cashus(
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [cashu.dict() for cashu in await get_cashus(wallet_ids)]
@cashu_ext.post("/api/v1/cashus", status_code=HTTPStatus.CREATED)
async def api_cashu_create(
data: Cashu, wallet: WalletTypeInfo = Depends(get_key_type)
):
cashu = await create_cashu(wallet_id=wallet.wallet.id, data=data)
logger.debug(cashu)
return cashu.dict()
@cashu_ext.delete("/api/v1/cashus/{cashu_id}")
async def api_cashu_delete(
cashu_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
if cashu.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.")
await delete_cashu(cashu_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@cashu_ext.post("/api/v1/cashus/{cashu_id}/invoices", status_code=HTTPStatus.CREATED)
async def api_cashu_create_invoice(
amount: int = Query(..., ge=1), tipAmount: int = None, cashu_id: str = None
):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
if tipAmount:
amount += tipAmount
try:
payment_hash, payment_request = await create_invoice(
wallet_id=cashu.wallet,
amount=amount,
memo=f"{cashu.name}",
extra={"tag": "cashu", "tipAmount": tipAmount, "cashuId": cashu_id},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request}
@cashu_ext.post(
"/api/v1/cashus/{cashu_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
)
async def api_cashu_pay_invoice(
lnurl_data: PayLnurlWData, payment_request: str = None, cashu_id: str = None
):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
lnurl = (
lnurl_data.lnurl.replace("lnurlw://", "")
.replace("lightning://", "")
.replace("LIGHTNING://", "")
.replace("lightning:", "")
.replace("LIGHTNING:", "")
)
if lnurl.lower().startswith("lnurl"):
lnurl = decode_lnurl(lnurl)
else:
lnurl = "https://" + lnurl
async with httpx.AsyncClient() as client:
try:
r = await client.get(lnurl, follow_redirects=True)
if r.is_error:
lnurl_response = {"success": False, "detail": "Error loading"}
else:
resp = r.json()
if resp["tag"] != "withdrawRequest":
lnurl_response = {"success": False, "detail": "Wrong tag type"}
else:
r2 = await client.get(
resp["callback"],
follow_redirects=True,
params={
"k1": resp["k1"],
"pr": payment_request,
},
)
resp2 = r2.json()
if r2.is_error:
lnurl_response = {
"success": False,
"detail": "Error loading callback",
}
elif resp2["status"] == "ERROR":
lnurl_response = {"success": False, "detail": resp2["reason"]}
else:
lnurl_response = {"success": True, "detail": resp2}
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
return lnurl_response
@cashu_ext.get(
"/api/v1/cashus/{cashu_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_cashu_check_invoice(cashu_id: str, payment_hash: str):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status