mirror of
https://github.com/RoboSats/robosats.git
synced 2025-07-20 01:33:15 +00:00
Add advanced options to LN payout form (#326)
* Add advanced options to LN payout form * Complete amount calcs * Temporary working solution for lnproxy web only (uses text instead of json) * Update LNpayment model and logics to use user's routing budget * Add handle lnproxyserver networks (i2p, tor, clearnet) / (mainnet,testnet) * Small fixes
This commit is contained in:
@ -258,7 +258,7 @@ class LNNode:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_ln_invoice(cls, invoice, num_satoshis):
|
def validate_ln_invoice(cls, invoice, num_satoshis, routing_budget_ppm):
|
||||||
"""Checks if the submited LN invoice comforms to expectations"""
|
"""Checks if the submited LN invoice comforms to expectations"""
|
||||||
|
|
||||||
payout = {
|
payout = {
|
||||||
@ -283,10 +283,17 @@ class LNNode:
|
|||||||
route_hints = payreq_decoded.route_hints
|
route_hints = payreq_decoded.route_hints
|
||||||
|
|
||||||
# Max amount RoboSats will pay for routing
|
# Max amount RoboSats will pay for routing
|
||||||
max_routing_fee_sats = max(
|
# Start deprecate after v0.3.1 (only else max_routing_fee_sats will remain)
|
||||||
num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
|
if routing_budget_ppm == 0:
|
||||||
float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")),
|
max_routing_fee_sats = max(
|
||||||
)
|
num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
|
||||||
|
float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# End deprecate
|
||||||
|
max_routing_fee_sats = int(
|
||||||
|
float(num_satoshis) * float(routing_budget_ppm) / 1000000
|
||||||
|
)
|
||||||
|
|
||||||
if route_hints:
|
if route_hints:
|
||||||
routes_cost = []
|
routes_cost = []
|
||||||
@ -306,7 +313,7 @@ class LNNode:
|
|||||||
# If the cheapest possible private route is more expensive than what RoboSats is willing to pay
|
# If the cheapest possible private route is more expensive than what RoboSats is willing to pay
|
||||||
if min(routes_cost) >= max_routing_fee_sats:
|
if min(routes_cost) >= max_routing_fee_sats:
|
||||||
payout["context"] = {
|
payout["context"] = {
|
||||||
"bad_invoice": "The invoice submitted only has a trick on the routing hints, you might be using an incompatible wallet (probably Muun? Use an onchain address instead!). Check the wallet compatibility guide at wallets.robosats.com"
|
"bad_invoice": "The invoice hinted private routes are not payable within the submitted routing budget."
|
||||||
}
|
}
|
||||||
return payout
|
return payout
|
||||||
|
|
||||||
|
@ -721,7 +721,7 @@ class Logics:
|
|||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_invoice(cls, order, user, invoice):
|
def update_invoice(cls, order, user, invoice, routing_budget_ppm):
|
||||||
|
|
||||||
# Empty invoice?
|
# Empty invoice?
|
||||||
if not invoice:
|
if not invoice:
|
||||||
@ -754,7 +754,11 @@ class Logics:
|
|||||||
cls.cancel_onchain_payment(order)
|
cls.cancel_onchain_payment(order)
|
||||||
|
|
||||||
num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"]
|
num_satoshis = cls.payout_amount(order, user)[1]["invoice_amount"]
|
||||||
payout = LNNode.validate_ln_invoice(invoice, num_satoshis)
|
routing_budget_sats = float(num_satoshis) * (
|
||||||
|
float(routing_budget_ppm) / 1000000
|
||||||
|
)
|
||||||
|
num_satoshis = int(num_satoshis - routing_budget_sats)
|
||||||
|
payout = LNNode.validate_ln_invoice(invoice, num_satoshis, routing_budget_ppm)
|
||||||
|
|
||||||
if not payout["valid"]:
|
if not payout["valid"]:
|
||||||
return False, payout["context"]
|
return False, payout["context"]
|
||||||
@ -765,6 +769,8 @@ class Logics:
|
|||||||
sender=User.objects.get(username=ESCROW_USERNAME),
|
sender=User.objects.get(username=ESCROW_USERNAME),
|
||||||
order_paid_LN=order, # In case this user has other payouts, update the one related to this order.
|
order_paid_LN=order, # In case this user has other payouts, update the one related to this order.
|
||||||
receiver=user,
|
receiver=user,
|
||||||
|
routing_budget_ppm=routing_budget_ppm,
|
||||||
|
routing_budget_sats=routing_budget_sats,
|
||||||
# if there is a LNPayment matching these above, it updates that one with defaults below.
|
# if there is a LNPayment matching these above, it updates that one with defaults below.
|
||||||
defaults={
|
defaults={
|
||||||
"invoice": invoice,
|
"invoice": invoice,
|
||||||
@ -1679,7 +1685,9 @@ class Logics:
|
|||||||
else:
|
else:
|
||||||
summary["received_sats"] = order.payout.num_satoshis
|
summary["received_sats"] = order.payout.num_satoshis
|
||||||
summary["trade_fee_sats"] = round(
|
summary["trade_fee_sats"] = round(
|
||||||
order.last_satoshis - summary["received_sats"]
|
order.last_satoshis
|
||||||
|
- summary["received_sats"]
|
||||||
|
- order.payout.routing_budget_sats
|
||||||
)
|
)
|
||||||
# Only add context for swap costs if the user is the swap recipient. Peer should not know whether it was a swap
|
# Only add context for swap costs if the user is the swap recipient. Peer should not know whether it was a swap
|
||||||
if users[order_user] == user and order.is_swap:
|
if users[order_user] == user and order.is_swap:
|
||||||
@ -1716,11 +1724,20 @@ class Logics:
|
|||||||
order.contract_finalization_time - order.last_satoshis_time
|
order.contract_finalization_time - order.last_satoshis_time
|
||||||
)
|
)
|
||||||
if not order.is_swap:
|
if not order.is_swap:
|
||||||
|
platform_summary["routing_budget_sats"] = order.payout.routing_budget_sats
|
||||||
|
# Start Deprecated after v0.3.1
|
||||||
platform_summary["routing_fee_sats"] = order.payout.fee
|
platform_summary["routing_fee_sats"] = order.payout.fee
|
||||||
|
# End Deprecated after v0.3.1
|
||||||
platform_summary["trade_revenue_sats"] = int(
|
platform_summary["trade_revenue_sats"] = int(
|
||||||
order.trade_escrow.num_satoshis
|
order.trade_escrow.num_satoshis
|
||||||
- order.payout.num_satoshis
|
- order.payout.num_satoshis
|
||||||
- order.payout.fee
|
# Start Deprecated after v0.3.1 (will be `- order.payout.routing_budget_sats`)
|
||||||
|
- (
|
||||||
|
order.payout.fee
|
||||||
|
if order.payout.routing_budget_sats == 0
|
||||||
|
else order.payout.routing_budget_sats
|
||||||
|
)
|
||||||
|
# End Deprecated after v0.3.1
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
platform_summary["routing_fee_sats"] = 0
|
platform_summary["routing_fee_sats"] = 0
|
||||||
|
@ -126,6 +126,19 @@ class LNPayment(models.Model):
|
|||||||
MaxValueValidator(1.5 * MAX_TRADE),
|
MaxValueValidator(1.5 * MAX_TRADE),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
# Routing budget in PPM
|
||||||
|
routing_budget_ppm = models.PositiveBigIntegerField(
|
||||||
|
default=0,
|
||||||
|
null=False,
|
||||||
|
validators=[
|
||||||
|
MinValueValidator(0),
|
||||||
|
MaxValueValidator(100000),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Routing budget in Sats. Only for reporting summaries.
|
||||||
|
routing_budget_sats = models.DecimalField(
|
||||||
|
max_digits=10, decimal_places=3, default=0, null=False, blank=False
|
||||||
|
)
|
||||||
# Fee in sats with mSats decimals fee_msat
|
# Fee in sats with mSats decimals fee_msat
|
||||||
fee = models.DecimalField(
|
fee = models.DecimalField(
|
||||||
max_digits=10, decimal_places=3, default=0, null=False, blank=False
|
max_digits=10, decimal_places=3, default=0, null=False, blank=False
|
||||||
|
@ -489,6 +489,14 @@ class UpdateOrderSerializer(serializers.Serializer):
|
|||||||
invoice = serializers.CharField(
|
invoice = serializers.CharField(
|
||||||
max_length=2000, allow_null=True, allow_blank=True, default=None
|
max_length=2000, allow_null=True, allow_blank=True, default=None
|
||||||
)
|
)
|
||||||
|
routing_budget_ppm = serializers.IntegerField(
|
||||||
|
default=0,
|
||||||
|
min_value=0,
|
||||||
|
max_value=100000,
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
help_text="Max budget to allocate for routing in PPM",
|
||||||
|
)
|
||||||
address = serializers.CharField(
|
address = serializers.CharField(
|
||||||
max_length=100, allow_null=True, allow_blank=True, default=None
|
max_length=100, allow_null=True, allow_blank=True, default=None
|
||||||
)
|
)
|
||||||
|
22
api/tasks.py
22
api/tasks.py
@ -86,12 +86,23 @@ def follow_send_payment(hash):
|
|||||||
from api.models import LNPayment, Order
|
from api.models import LNPayment, Order
|
||||||
|
|
||||||
lnpayment = LNPayment.objects.get(payment_hash=hash)
|
lnpayment = LNPayment.objects.get(payment_hash=hash)
|
||||||
fee_limit_sat = int(
|
# Start deprecate after v0.3.1 (only else max_routing_fee_sats will remain)
|
||||||
max(
|
if lnpayment.routing_budget_ppm == 0:
|
||||||
lnpayment.num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
|
fee_limit_sat = int(
|
||||||
float(config("MIN_FLAT_ROUTING_FEE_LIMIT")),
|
max(
|
||||||
|
lnpayment.num_satoshis
|
||||||
|
* float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")),
|
||||||
|
float(config("MIN_FLAT_ROUTING_FEE_LIMIT")),
|
||||||
|
)
|
||||||
|
) # 1000 ppm or 10 sats
|
||||||
|
else:
|
||||||
|
# End deprecate
|
||||||
|
# Defaults is 0ppm. Set by the user over API. Defaults to 1000 ppm on ReactJS frontend.
|
||||||
|
fee_limit_sat = int(
|
||||||
|
float(lnpayment.num_satoshis)
|
||||||
|
* float(lnpayment.routing_budget_ppm)
|
||||||
|
/ 1000000
|
||||||
)
|
)
|
||||||
) # 1000 ppm or 10 sats
|
|
||||||
timeout_seconds = int(config("PAYOUT_TIMEOUT_SECONDS"))
|
timeout_seconds = int(config("PAYOUT_TIMEOUT_SECONDS"))
|
||||||
|
|
||||||
request = LNNode.routerrpc.SendPaymentRequest(
|
request = LNNode.routerrpc.SendPaymentRequest(
|
||||||
@ -145,7 +156,6 @@ def follow_send_payment(hash):
|
|||||||
],
|
],
|
||||||
"IN_FLIGHT": False,
|
"IN_FLIGHT": False,
|
||||||
}
|
}
|
||||||
print(context)
|
|
||||||
|
|
||||||
# If failed due to not route, reset mission control. (This won't scale well, just a temporary fix)
|
# If failed due to not route, reset mission control. (This won't scale well, just a temporary fix)
|
||||||
# ResetMC deactivate temporary for tests
|
# ResetMC deactivate temporary for tests
|
||||||
|
@ -501,6 +501,7 @@ class OrderView(viewsets.ViewSet):
|
|||||||
# 5.b)'update_address' 6)'submit_statement' (in dispute), 7)'rate_user' , 8)'rate_platform'
|
# 5.b)'update_address' 6)'submit_statement' (in dispute), 7)'rate_user' , 8)'rate_platform'
|
||||||
action = serializer.data.get("action")
|
action = serializer.data.get("action")
|
||||||
invoice = serializer.data.get("invoice")
|
invoice = serializer.data.get("invoice")
|
||||||
|
routing_budget_ppm = serializer.data.get("routing_budget_ppm", 0)
|
||||||
address = serializer.data.get("address")
|
address = serializer.data.get("address")
|
||||||
mining_fee_rate = serializer.data.get("mining_fee_rate")
|
mining_fee_rate = serializer.data.get("mining_fee_rate")
|
||||||
statement = serializer.data.get("statement")
|
statement = serializer.data.get("statement")
|
||||||
@ -543,7 +544,9 @@ class OrderView(viewsets.ViewSet):
|
|||||||
|
|
||||||
# 2) If action is 'update invoice'
|
# 2) If action is 'update invoice'
|
||||||
elif action == "update_invoice":
|
elif action == "update_invoice":
|
||||||
valid, context = Logics.update_invoice(order, request.user, invoice)
|
valid, context = Logics.update_invoice(
|
||||||
|
order, request.user, invoice, routing_budget_ppm
|
||||||
|
)
|
||||||
if not valid:
|
if not valid:
|
||||||
return Response(context, status.HTTP_400_BAD_REQUEST)
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TOR_PROXY_IP: 127.0.0.1
|
TOR_PROXY_IP: 127.0.0.1
|
||||||
TOR_PROXY_PORT: 9050
|
TOR_PROXY_PORT: 9050
|
||||||
ROBOSATS_ONION: robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion
|
ROBOSATS_ONION: robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion
|
||||||
network_mode: service:tor
|
network_mode: service:tor
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/static:/usr/src/robosats/static
|
- ./frontend/static:/usr/src/robosats/static
|
||||||
|
@ -181,7 +181,7 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => {
|
|||||||
return await data;
|
return await data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchInfo = function () {
|
const fetchInfo = function (setNetwork?: boolean) {
|
||||||
setInfo({ ...info, loading: true });
|
setInfo({ ...info, loading: true });
|
||||||
apiClient.get(baseUrl, '/api/info/').then((data: Info) => {
|
apiClient.get(baseUrl, '/api/info/').then((data: Info) => {
|
||||||
const versionInfo: any = checkVer(data.version.major, data.version.minor, data.version.patch);
|
const versionInfo: any = checkVer(data.version.major, data.version.minor, data.version.patch);
|
||||||
@ -192,12 +192,16 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => {
|
|||||||
clientVersion: versionInfo.clientVersion,
|
clientVersion: versionInfo.clientVersion,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
|
// Sets Setting network from coordinator API param if accessing via web
|
||||||
|
if (setNetwork) {
|
||||||
|
setSettings({ ...settings, network: data.network });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open.stats || open.coordinator || info.coordinatorVersion == 'v?.?.?') {
|
if (open.stats || open.coordinator || info.coordinatorVersion == 'v?.?.?') {
|
||||||
fetchInfo();
|
fetchInfo(info.coordinatorVersion == 'v?.?.?');
|
||||||
}
|
}
|
||||||
}, [open.stats, open.coordinator]);
|
}, [open.stats, open.coordinator]);
|
||||||
|
|
||||||
@ -424,6 +428,7 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => {
|
|||||||
<OrderPage
|
<OrderPage
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
setOrder={setOrder}
|
setOrder={setOrder}
|
||||||
setCurrentOrder={setCurrentOrder}
|
setCurrentOrder={setCurrentOrder}
|
||||||
badOrder={badOrder}
|
badOrder={badOrder}
|
||||||
|
@ -7,12 +7,13 @@ import TradeBox from '../../components/TradeBox';
|
|||||||
import OrderDetails from '../../components/OrderDetails';
|
import OrderDetails from '../../components/OrderDetails';
|
||||||
|
|
||||||
import { Page } from '../NavBar';
|
import { Page } from '../NavBar';
|
||||||
import { Order } from '../../models';
|
import { Order, Settings } from '../../models';
|
||||||
import { apiClient } from '../../services/api';
|
import { apiClient } from '../../services/api';
|
||||||
|
|
||||||
interface OrderPageProps {
|
interface OrderPageProps {
|
||||||
windowSize: { width: number; height: number };
|
windowSize: { width: number; height: number };
|
||||||
order: Order;
|
order: Order;
|
||||||
|
settings: Settings;
|
||||||
setOrder: (state: Order) => void;
|
setOrder: (state: Order) => void;
|
||||||
setCurrentOrder: (state: number) => void;
|
setCurrentOrder: (state: number) => void;
|
||||||
fetchOrder: () => void;
|
fetchOrder: () => void;
|
||||||
@ -27,6 +28,7 @@ interface OrderPageProps {
|
|||||||
const OrderPage = ({
|
const OrderPage = ({
|
||||||
windowSize,
|
windowSize,
|
||||||
order,
|
order,
|
||||||
|
settings,
|
||||||
setOrder,
|
setOrder,
|
||||||
setCurrentOrder,
|
setCurrentOrder,
|
||||||
badOrder,
|
badOrder,
|
||||||
@ -128,6 +130,7 @@ const OrderPage = ({
|
|||||||
>
|
>
|
||||||
<TradeBox
|
<TradeBox
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
setOrder={setOrder}
|
setOrder={setOrder}
|
||||||
setBadOrder={setBadOrder}
|
setBadOrder={setBadOrder}
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
@ -170,6 +173,7 @@ const OrderPage = ({
|
|||||||
<div style={{ display: tab == 'contract' ? '' : 'none' }}>
|
<div style={{ display: tab == 'contract' ? '' : 'none' }}>
|
||||||
<TradeBox
|
<TradeBox
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
setOrder={setOrder}
|
setOrder={setOrder}
|
||||||
setBadOrder={setBadOrder}
|
setBadOrder={setBadOrder}
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { StringIfPlural, useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Alert,
|
Alert,
|
||||||
@ -134,7 +134,7 @@ const Notifications = ({
|
|||||||
title: t('Order has expired'),
|
title: t('Order has expired'),
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
onClick: moveToOrderPage,
|
onClick: moveToOrderPage,
|
||||||
sound: undefined,
|
sound: audio.ding,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
pageTitle: `${t('😪 Expired!')} - ${basePageTitle}`,
|
pageTitle: `${t('😪 Expired!')} - ${basePageTitle}`,
|
||||||
},
|
},
|
||||||
@ -262,7 +262,6 @@ const Notifications = ({
|
|||||||
} else if (order?.is_seller && status > 7 && oldStatus < 7) {
|
} else if (order?.is_seller && status > 7 && oldStatus < 7) {
|
||||||
message = Messages.escrowLocked;
|
message = Messages.escrowLocked;
|
||||||
} else if ([9, 10].includes(status) && oldStatus < 9) {
|
} else if ([9, 10].includes(status) && oldStatus < 9) {
|
||||||
console.log('yoooo');
|
|
||||||
message = Messages.chat;
|
message = Messages.chat;
|
||||||
} else if (order?.is_seller && [13, 14, 15].includes(status) && oldStatus < 13) {
|
} else if (order?.is_seller && [13, 14, 15].includes(status) && oldStatus < 13) {
|
||||||
message = Messages.successful;
|
message = Messages.successful;
|
||||||
@ -333,7 +332,6 @@ const Notifications = ({
|
|||||||
return (
|
return (
|
||||||
<StyledTooltip
|
<StyledTooltip
|
||||||
open={show}
|
open={show}
|
||||||
style={{ padding: 0, backgroundColor: 'black' }}
|
|
||||||
placement={windowWidth > 60 ? 'left' : 'bottom'}
|
placement={windowWidth > 60 ? 'left' : 'bottom'}
|
||||||
title={
|
title={
|
||||||
<Alert
|
<Alert
|
||||||
|
@ -29,7 +29,7 @@ const LinearDeterminate = ({ expiresAt, totalSecsExp }: Props): JSX.Element => {
|
|||||||
sx={{ height: '0.4em' }}
|
sx={{ height: '0.4em' }}
|
||||||
variant='determinate'
|
variant='determinate'
|
||||||
value={progress}
|
value={progress}
|
||||||
color={progress < 20 ? 'secondary' : 'primary'}
|
color={progress < 25 ? 'secondary' : 'primary'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Collapse,
|
Collapse,
|
||||||
useTheme,
|
useTheme,
|
||||||
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import Countdown, { CountdownRenderProps, zeroPad } from 'react-countdown';
|
import Countdown, { CountdownRenderProps, zeroPad } from 'react-countdown';
|
||||||
@ -86,25 +87,25 @@ const OrderDetails = ({
|
|||||||
// Render a completed state
|
// Render a completed state
|
||||||
return <span> {t('The order has expired')}</span>;
|
return <span> {t('The order has expired')}</span>;
|
||||||
} else {
|
} else {
|
||||||
let col = 'inherit';
|
let color = 'inherit';
|
||||||
const fraction_left = total / 1000 / order.total_secs_exp;
|
const fraction_left = total / 1000 / order.total_secs_exp;
|
||||||
// Make orange at 25% of time left
|
// Make orange at 25% of time left
|
||||||
if (fraction_left < 0.25) {
|
if (fraction_left < 0.25) {
|
||||||
col = 'orange';
|
color = theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
// Make red at 10% of time left
|
// Make red at 10% of time left
|
||||||
if (fraction_left < 0.1) {
|
if (fraction_left < 0.1) {
|
||||||
col = 'red';
|
color = theme.palette.error.main;
|
||||||
}
|
}
|
||||||
// Render a countdown, bold when less than 25%
|
// Render a countdown, bold when less than 25%
|
||||||
return fraction_left < 0.25 ? (
|
return fraction_left < 0.25 ? (
|
||||||
<b>
|
<Typography color={color}>
|
||||||
<span style={{ color: col }}>
|
<b>{`${hours}h ${zeroPad(minutes)}m ${zeroPad(seconds)}s `}</b>
|
||||||
{`${hours}h ${zeroPad(minutes)}m ${zeroPad(seconds)}s `}
|
</Typography>
|
||||||
</span>
|
|
||||||
</b>
|
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: col }}>{`${hours}h ${zeroPad(minutes)}m ${zeroPad(seconds)}s `}</span>
|
<Typography color={color}>
|
||||||
|
{`${hours}h ${zeroPad(minutes)}m ${zeroPad(seconds)}s `}
|
||||||
|
</Typography>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -77,7 +77,7 @@ const ChatHeader: React.FC<Props> = ({ connected, peerConnected, turtleMode, set
|
|||||||
>
|
>
|
||||||
<Typography align='center' variant='caption' sx={{ color: connectedTextColor }}>
|
<Typography align='center' variant='caption' sx={{ color: connectedTextColor }}>
|
||||||
{t('Peer') + ': '}
|
{t('Peer') + ': '}
|
||||||
{peerConnected ? t('connected') : t('disconnected')}
|
{connected ? (peerConnected ? t('connected') : t('disconnected')) : '...waiting'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
@ -72,6 +72,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (![9, 10].includes(status)) {
|
if (![9, 10].includes(status)) {
|
||||||
connection?.close();
|
connection?.close();
|
||||||
|
setConnection(undefined);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
@ -1,28 +1,71 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Grid, Typography, TextField } from '@mui/material';
|
import {
|
||||||
import { Order } from '../../../models';
|
Box,
|
||||||
|
Grid,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
useTheme,
|
||||||
|
Collapse,
|
||||||
|
Switch,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
InputAdornment,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
IconButton,
|
||||||
|
FormHelperText,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Order, Settings } from '../../../models';
|
||||||
import WalletsButton from '../WalletsButton';
|
import WalletsButton from '../WalletsButton';
|
||||||
import { LoadingButton } from '@mui/lab';
|
import { LoadingButton } from '@mui/lab';
|
||||||
import { pn } from '../../../utils';
|
import { pn } from '../../../utils';
|
||||||
|
|
||||||
|
import { ContentCopy, RoundaboutRight, Route, SelfImprovement } from '@mui/icons-material';
|
||||||
|
import { apiClient } from '../../../services/api';
|
||||||
|
|
||||||
|
import lnproxies from '../../../../static/lnproxies.json';
|
||||||
|
import { systemClient } from '../../../services/System';
|
||||||
|
|
||||||
export interface LightningForm {
|
export interface LightningForm {
|
||||||
invoice: string;
|
invoice: string;
|
||||||
routingBudget: number;
|
amount: number;
|
||||||
|
advancedOptions: boolean;
|
||||||
|
useCustomBudget: boolean;
|
||||||
|
routingBudgetUnit: 'PPM' | 'Sats';
|
||||||
|
routingBudgetPPM: number;
|
||||||
|
routingBudgetSats: number | undefined;
|
||||||
badInvoice: string;
|
badInvoice: string;
|
||||||
useLnproxy: boolean;
|
useLnproxy: boolean;
|
||||||
lnproxyServer: string;
|
lnproxyInvoice: string;
|
||||||
lnproxyBudget: number;
|
lnproxyAmount: number;
|
||||||
|
lnproxyServer: number;
|
||||||
|
lnproxyBudgetUnit: 'PPM' | 'Sats';
|
||||||
|
lnproxyBudgetPPM: number;
|
||||||
|
lnproxyBudgetSats: number;
|
||||||
badLnproxy: string;
|
badLnproxy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultLightning: LightningForm = {
|
export const defaultLightning: LightningForm = {
|
||||||
invoice: '',
|
invoice: '',
|
||||||
routingBudget: 0,
|
amount: 0,
|
||||||
|
advancedOptions: false,
|
||||||
|
useCustomBudget: false,
|
||||||
|
routingBudgetUnit: 'PPM',
|
||||||
|
routingBudgetPPM: 1000,
|
||||||
|
routingBudgetSats: undefined,
|
||||||
badInvoice: '',
|
badInvoice: '',
|
||||||
useLnproxy: false,
|
useLnproxy: false,
|
||||||
lnproxyServer: '',
|
lnproxyInvoice: '',
|
||||||
lnproxyBudget: 0,
|
lnproxyAmount: 0,
|
||||||
|
lnproxyServer: 0,
|
||||||
|
lnproxyBudgetUnit: 'Sats',
|
||||||
|
lnproxyBudgetPPM: 0,
|
||||||
|
lnproxyBudgetSats: 0,
|
||||||
badLnproxy: '',
|
badLnproxy: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -32,6 +75,7 @@ interface LightningPayoutFormProps {
|
|||||||
lightning: LightningForm;
|
lightning: LightningForm;
|
||||||
setLightning: (state: LightningForm) => void;
|
setLightning: (state: LightningForm) => void;
|
||||||
onClickSubmit: (invoice: string) => void;
|
onClickSubmit: (invoice: string) => void;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LightningPayoutForm = ({
|
export const LightningPayoutForm = ({
|
||||||
@ -40,48 +84,551 @@ export const LightningPayoutForm = ({
|
|||||||
onClickSubmit,
|
onClickSubmit,
|
||||||
lightning,
|
lightning,
|
||||||
setLightning,
|
setLightning,
|
||||||
|
settings,
|
||||||
}: LightningPayoutFormProps): JSX.Element => {
|
}: LightningPayoutFormProps): JSX.Element => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const [loadingLnproxy, setLoadingLnproxy] = useState<boolean>(false);
|
||||||
|
const [badLnproxyServer, setBadLnproxyServer] = useState<string>('');
|
||||||
|
|
||||||
|
const computeInvoiceAmount = function () {
|
||||||
|
const tradeAmount = order.trade_satoshis;
|
||||||
|
return Math.floor(tradeAmount - tradeAmount * (lightning.routingBudgetPPM / 1000000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateInvoice = function (invoice: string, targetAmount: number) {
|
||||||
|
const invoiceAmount = Number(invoice.substring(4, 5 + Math.floor(Math.log10(targetAmount))));
|
||||||
|
if (targetAmount != invoiceAmount && invoice.length > 20) {
|
||||||
|
return 'Invalid invoice amount';
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const amount = computeInvoiceAmount();
|
||||||
|
setLightning({
|
||||||
|
...lightning,
|
||||||
|
amount,
|
||||||
|
lnproxyAmount: amount - lightning.lnproxyBudgetSats,
|
||||||
|
routingBudgetSats:
|
||||||
|
lightning.routingBudgetSats == undefined
|
||||||
|
? Math.ceil((amount / 1000000) * lightning.routingBudgetPPM)
|
||||||
|
: lightning.routingBudgetSats,
|
||||||
|
});
|
||||||
|
}, [lightning.routingBudgetPPM]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightning.invoice != '') {
|
||||||
|
setLightning({
|
||||||
|
...lightning,
|
||||||
|
badInvoice: validateInvoice(lightning.invoice, lightning.amount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [lightning.invoice, lightning.amount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightning.lnproxyInvoice != '') {
|
||||||
|
setLightning({
|
||||||
|
...lightning,
|
||||||
|
badLnproxy: validateInvoice(lightning.lnproxyInvoice, lightning.lnproxyAmount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [lightning.lnproxyInvoice, lightning.lnproxyAmount]);
|
||||||
|
|
||||||
|
const lnproxyUrl = function () {
|
||||||
|
console.log(settings);
|
||||||
|
const bitcoinNetwork = settings?.network ?? 'mainnet';
|
||||||
|
let internetNetwork: 'Clearnet' | 'I2P' | 'TOR' = 'Clearnet';
|
||||||
|
if (settings.host?.includes('.i2p')) {
|
||||||
|
internetNetwork = 'I2P';
|
||||||
|
} else if (settings.host?.includes('.onion') || window.NativeRobosats != undefined) {
|
||||||
|
internetNetwork = 'TOR';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = lnproxies[lightning.lnproxyServer][`${bitcoinNetwork}${internetNetwork}`];
|
||||||
|
if (url != 'undefined') {
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
setBadLnproxyServer(
|
||||||
|
t(`Server not available for {{bitcoinNetwork}} bitcoin over {{internetNetwork}}`, {
|
||||||
|
bitcoinNetwork,
|
||||||
|
internetNetwork: t(internetNetwork),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBadLnproxyServer('');
|
||||||
|
lnproxyUrl();
|
||||||
|
}, [lightning.lnproxyServer]);
|
||||||
|
|
||||||
|
// const fetchLnproxy = function () {
|
||||||
|
// setLoadingLnproxy(true);
|
||||||
|
// apiClient
|
||||||
|
// .get(
|
||||||
|
// lnproxyUrl(),
|
||||||
|
// `/api/${lightning.lnproxyInvoice}${lightning.lnproxyBudgetSats > 0 ? `?routing_msat=${lightning.lnproxyBudgetSats * 1000}` : ''}`,
|
||||||
|
// )
|
||||||
|
// };
|
||||||
|
|
||||||
|
// Lnproxy API does not return JSON, therefore not compatible with current apiClient service
|
||||||
|
// Does not work on Android robosats!
|
||||||
|
const fetchLnproxy = function () {
|
||||||
|
setLoadingLnproxy(true);
|
||||||
|
fetch(
|
||||||
|
lnproxyUrl() +
|
||||||
|
`/api/${lightning.lnproxyInvoice}${
|
||||||
|
lightning.lnproxyBudgetSats > 0
|
||||||
|
? `?routing_msat=${lightning.lnproxyBudgetSats * 1000}`
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((text) => {
|
||||||
|
if (text.includes('lnproxy error')) {
|
||||||
|
setLightning({ ...lightning, badLnproxy: text });
|
||||||
|
} else {
|
||||||
|
setLightning({ ...lightning, invoice: text, badLnproxy: '' });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLightning({ ...lightning, badLnproxy: 'Lnproxy server uncaught error' });
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingLnproxy(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProxyBudgetChange = function (e) {
|
||||||
|
if (isFinite(e.target.value) && e.target.value >= 0) {
|
||||||
|
let lnproxyBudgetSats;
|
||||||
|
let lnproxyBudgetPPM;
|
||||||
|
|
||||||
|
if (lightning.lnproxyBudgetUnit === 'Sats') {
|
||||||
|
lnproxyBudgetSats = Math.floor(e.target.value);
|
||||||
|
lnproxyBudgetPPM = Math.round((lnproxyBudgetSats * 1000000) / lightning.amount);
|
||||||
|
} else {
|
||||||
|
lnproxyBudgetPPM = e.target.value;
|
||||||
|
lnproxyBudgetSats = Math.ceil((lightning.amount / 1000000) * lnproxyBudgetPPM);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lnproxyBudgetPPM < 99999) {
|
||||||
|
const lnproxyAmount = lightning.amount - lnproxyBudgetSats;
|
||||||
|
setLightning({ ...lightning, lnproxyBudgetSats, lnproxyBudgetPPM, lnproxyAmount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRoutingBudgetChange = function (e) {
|
||||||
|
const tradeAmount = order.trade_satoshis;
|
||||||
|
if (isFinite(e.target.value) && e.target.value >= 0) {
|
||||||
|
let routingBudgetSats;
|
||||||
|
let routingBudgetPPM;
|
||||||
|
|
||||||
|
if (lightning.routingBudgetUnit === 'Sats') {
|
||||||
|
routingBudgetSats = Math.floor(e.target.value);
|
||||||
|
routingBudgetPPM = Math.round((routingBudgetSats * 1000000) / tradeAmount);
|
||||||
|
} else {
|
||||||
|
routingBudgetPPM = e.target.value;
|
||||||
|
routingBudgetSats = Math.ceil((lightning.amount / 1000000) * routingBudgetPPM);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routingBudgetPPM < 99999) {
|
||||||
|
const amount = Math.floor(
|
||||||
|
tradeAmount - tradeAmount * (lightning.routingBudgetPPM / 1000000),
|
||||||
|
);
|
||||||
|
setLightning({ ...lightning, routingBudgetSats, routingBudgetPPM, amount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lnProxyBudgetHelper = function () {
|
||||||
|
let text = '';
|
||||||
|
if (lightning.lnproxyBudgetSats < 0) {
|
||||||
|
text = 'Must be positive';
|
||||||
|
} else if (lightning.lnproxyBudgetPPM > 10000) {
|
||||||
|
text = 'Too high! (That is more than 1%)';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const routingBudgetHelper = function () {
|
||||||
|
let text = '';
|
||||||
|
if (lightning.routingBudgetSats < 0) {
|
||||||
|
text = 'Must be positive';
|
||||||
|
} else if (lightning.routingBudgetPPM > 10000) {
|
||||||
|
text = 'Too high! (That is more than 1%)';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container direction='column' justifyContent='flex-start' alignItems='center' spacing={1}>
|
<Grid container direction='column' justifyContent='flex-start' alignItems='center' spacing={1}>
|
||||||
<Grid item xs={12}>
|
<div style={{ height: '0.3em' }} />
|
||||||
<Typography variant='body2'>
|
<Grid
|
||||||
{t('Submit a valid invoice for {{amountSats}} Satoshis.', {
|
item
|
||||||
amountSats: pn(order.invoice_amount),
|
style={{
|
||||||
})}
|
display: 'flex',
|
||||||
</Typography>
|
alignItems: 'center',
|
||||||
</Grid>
|
height: '1.1em',
|
||||||
|
}}
|
||||||
<Grid item xs={12}>
|
>
|
||||||
<WalletsButton />
|
<Typography color='text.primary'>{t('Advanced options')}</Typography>
|
||||||
</Grid>
|
<Switch
|
||||||
|
size='small'
|
||||||
<Grid item xs={12}>
|
checked={lightning.advancedOptions}
|
||||||
<TextField
|
onChange={(e) => {
|
||||||
fullWidth={true}
|
const checked = e.target.checked;
|
||||||
error={lightning.badInvoice != ''}
|
setLightning({
|
||||||
helperText={lightning.badInvoice ? t(lightning.badInvoice) : ''}
|
...lightning,
|
||||||
label={t('Payout Lightning Invoice')}
|
advancedOptions: checked,
|
||||||
required
|
});
|
||||||
value={lightning.invoice}
|
|
||||||
inputProps={{
|
|
||||||
style: { textAlign: 'center', maxHeight: '14.28em' },
|
|
||||||
}}
|
}}
|
||||||
multiline
|
|
||||||
minRows={4}
|
|
||||||
maxRows={8}
|
|
||||||
onChange={(e) => setLightning({ ...lightning, invoice: e.target.value ?? '' })}
|
|
||||||
/>
|
/>
|
||||||
|
<SelfImprovement sx={{ color: 'text.primary' }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
|
||||||
<LoadingButton
|
<Grid item>
|
||||||
loading={loading}
|
<Box
|
||||||
onClick={() => onClickSubmit(lightning.invoice)}
|
sx={{
|
||||||
variant='outlined'
|
backgroundColor: 'background.paper',
|
||||||
color='primary'
|
border: '1px solid',
|
||||||
|
width: '18em',
|
||||||
|
borderRadius: '0.3em',
|
||||||
|
borderColor: theme.palette.mode === 'dark' ? '#434343' : '#c4c4c4',
|
||||||
|
padding: '1em',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('Submit')}
|
<Grid
|
||||||
</LoadingButton>
|
container
|
||||||
|
direction='column'
|
||||||
|
justifyContent='flex-start'
|
||||||
|
alignItems='center'
|
||||||
|
spacing={0.5}
|
||||||
|
>
|
||||||
|
<Collapse in={lightning.advancedOptions}>
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction='column'
|
||||||
|
justifyContent='flex-start'
|
||||||
|
alignItems='center'
|
||||||
|
spacing={0.5}
|
||||||
|
padding={0.5}
|
||||||
|
>
|
||||||
|
<Grid item>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: '14em' }}
|
||||||
|
disabled={!lightning.advancedOptions}
|
||||||
|
error={routingBudgetHelper() != ''}
|
||||||
|
helperText={routingBudgetHelper()}
|
||||||
|
label={t('Routing Budget')}
|
||||||
|
required={true}
|
||||||
|
value={
|
||||||
|
lightning.routingBudgetUnit == 'PPM'
|
||||||
|
? lightning.routingBudgetPPM
|
||||||
|
: lightning.routingBudgetSats
|
||||||
|
}
|
||||||
|
variant='outlined'
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position='end'>
|
||||||
|
<Button
|
||||||
|
variant='text'
|
||||||
|
onClick={() => {
|
||||||
|
setLightning({
|
||||||
|
...lightning,
|
||||||
|
routingBudgetUnit:
|
||||||
|
lightning.routingBudgetUnit == 'PPM' ? 'Sats' : 'PPM',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lightning.routingBudgetUnit}
|
||||||
|
</Button>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
style: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={onRoutingBudgetChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{window.NativeRobosats === undefined ? (
|
||||||
|
<Grid item>
|
||||||
|
<Tooltip
|
||||||
|
enterTouchDelay={0}
|
||||||
|
leaveTouchDelay={4000}
|
||||||
|
placement='top'
|
||||||
|
title={t(
|
||||||
|
`Wrap this invoice using a Lnproxy server to protect your privacy (hides the receiving wallet).`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<FormControlLabel
|
||||||
|
onChange={(e) =>
|
||||||
|
setLightning({
|
||||||
|
...lightning,
|
||||||
|
useLnproxy: e.target.checked,
|
||||||
|
invoice: e.target.checked ? '' : lightning.invoice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
checked={lightning.useLnproxy}
|
||||||
|
control={<Checkbox />}
|
||||||
|
label={
|
||||||
|
<Typography color={lightning.useLnproxy ? 'primary' : 'text.secondary'}>
|
||||||
|
{t('Use Lnproxy')}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid item>
|
||||||
|
<Collapse in={lightning.useLnproxy}>
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction='column'
|
||||||
|
justifyContent='flex-start'
|
||||||
|
alignItems='center'
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
<Grid item>
|
||||||
|
<FormControl error={badLnproxyServer != ''}>
|
||||||
|
<InputLabel id='select-label'>{t('Server')}</InputLabel>
|
||||||
|
<Select
|
||||||
|
sx={{ width: '14em' }}
|
||||||
|
label={t('Server')}
|
||||||
|
labelId='select-label'
|
||||||
|
value={lightning.lnproxyServer}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLightning({ ...lightning, lnproxyServer: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lnproxies.map((lnproxyServer, index) => (
|
||||||
|
<MenuItem key={index} value={index}>
|
||||||
|
<Typography>{lnproxyServer.name}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{badLnproxyServer != '' ? (
|
||||||
|
<FormHelperText>{t(badLnproxyServer)}</FormHelperText>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: '14em' }}
|
||||||
|
disabled={!lightning.useLnproxy}
|
||||||
|
error={lnProxyBudgetHelper() != ''}
|
||||||
|
helperText={lnProxyBudgetHelper()}
|
||||||
|
label={t('Proxy Budget')}
|
||||||
|
value={
|
||||||
|
lightning.lnproxyBudgetUnit == 'PPM'
|
||||||
|
? lightning.lnproxyBudgetPPM
|
||||||
|
: lightning.lnproxyBudgetSats
|
||||||
|
}
|
||||||
|
variant='outlined'
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position='end'>
|
||||||
|
<Button
|
||||||
|
variant='text'
|
||||||
|
onClick={() => {
|
||||||
|
setLightning({
|
||||||
|
...lightning,
|
||||||
|
lnproxyBudgetUnit:
|
||||||
|
lightning.lnproxyBudgetUnit == 'PPM' ? 'Sats' : 'PPM',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lightning.lnproxyBudgetUnit}
|
||||||
|
</Button>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
style: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={onProxyBudgetChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Collapse>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Grid item>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Typography align='center' variant='body2'>
|
||||||
|
{t('Submit invoice for {{amountSats}} Sats', {
|
||||||
|
amountSats: pn(
|
||||||
|
lightning.useLnproxy ? lightning.lnproxyAmount : lightning.amount,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!')}>
|
||||||
|
<IconButton
|
||||||
|
sx={{ height: '0.5em' }}
|
||||||
|
onClick={() =>
|
||||||
|
systemClient.copyToClipboard(
|
||||||
|
lightning.useLnproxy ? lightning.lnproxyAmount : lightning.amount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ContentCopy sx={{ width: '0.8em' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item>
|
||||||
|
{lightning.useLnproxy ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth={true}
|
||||||
|
disabled={!lightning.useLnproxy}
|
||||||
|
error={lightning.badLnproxy != ''}
|
||||||
|
helperText={lightning.badLnproxy ? t(lightning.badLnproxy) : ''}
|
||||||
|
label={t('Invoice to wrap')}
|
||||||
|
required
|
||||||
|
value={lightning.lnproxyInvoice}
|
||||||
|
inputProps={{
|
||||||
|
style: { textAlign: 'center' },
|
||||||
|
}}
|
||||||
|
variant='outlined'
|
||||||
|
onChange={(e) =>
|
||||||
|
setLightning({ ...lightning, lnproxyInvoice: e.target.value ?? '' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth={true}
|
||||||
|
sx={lightning.useLnproxy ? { borderRadius: 0 } : {}}
|
||||||
|
disabled={lightning.useLnproxy}
|
||||||
|
error={lightning.badInvoice != ''}
|
||||||
|
helperText={lightning.badInvoice ? t(lightning.badInvoice) : ''}
|
||||||
|
label={lightning.useLnproxy ? t('Wrapped invoice') : t('Payout Lightning Invoice')}
|
||||||
|
required
|
||||||
|
value={lightning.invoice}
|
||||||
|
inputProps={{
|
||||||
|
style: { textAlign: 'center', maxHeight: '8em' },
|
||||||
|
}}
|
||||||
|
variant={lightning.useLnproxy ? 'filled' : 'standard'}
|
||||||
|
multiline={lightning.useLnproxy ? false : true}
|
||||||
|
minRows={3}
|
||||||
|
maxRows={5}
|
||||||
|
onChange={(e) => setLightning({ ...lightning, invoice: e.target.value ?? '' })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item>
|
||||||
|
{lightning.useLnproxy ? (
|
||||||
|
<LoadingButton
|
||||||
|
loading={loadingLnproxy}
|
||||||
|
disabled={
|
||||||
|
lightning.lnproxyInvoice.length < 20 || badLnproxyServer || lightning.badLnproxy
|
||||||
|
}
|
||||||
|
onClick={fetchLnproxy}
|
||||||
|
variant='outlined'
|
||||||
|
color='primary'
|
||||||
|
>
|
||||||
|
{t('Wrap')}
|
||||||
|
</LoadingButton>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<LoadingButton
|
||||||
|
loading={loading}
|
||||||
|
disabled={lightning.invoice == ''}
|
||||||
|
onClick={() => onClickSubmit(lightning.invoice)}
|
||||||
|
variant='outlined'
|
||||||
|
color='primary'
|
||||||
|
>
|
||||||
|
{t('Submit')}
|
||||||
|
</LoadingButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* <Grid item>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderRadius: '0.3em',
|
||||||
|
width: '18em',
|
||||||
|
borderColor: theme.palette.mode === 'dark' ? '#434343' : '#c4c4c4',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: theme.palette.mode === 'dark' ? '#ffffff' : '#2f2f2f',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction='column'
|
||||||
|
justifyContent='flex-start'
|
||||||
|
alignItems='center'
|
||||||
|
spacing={0.5}
|
||||||
|
padding={0.5}
|
||||||
|
>
|
||||||
|
<Collapse in={lightning.advancedOptions}>
|
||||||
|
<Tooltip
|
||||||
|
enterTouchDelay={0}
|
||||||
|
leaveTouchDelay={4000}
|
||||||
|
placement='top'
|
||||||
|
title={t(
|
||||||
|
`Set custom routing budget for the payout. If you don't know what this is, simply do not touch.`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<FormControlLabel
|
||||||
|
checked={lightning.useCustomBudget}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLightning({
|
||||||
|
...lightning,
|
||||||
|
useCustomBudget: e.target.checked,
|
||||||
|
routingBudgetSats: defaultLightning.routingBudgetSats,
|
||||||
|
routingBudgetPPM: defaultLightning.routingBudgetPPM,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
control={<Checkbox />}
|
||||||
|
label={
|
||||||
|
<Typography
|
||||||
|
style={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
color={lightning.useCustomBudget ? 'primary' : 'text.secondary'}
|
||||||
|
>
|
||||||
|
{t('Use custom routing budget')}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Grid> */}
|
||||||
|
|
||||||
|
<Grid item>
|
||||||
|
<WalletsButton />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
@ -4,7 +4,7 @@ import { Grid, Typography, ToggleButtonGroup, ToggleButton } from '@mui/material
|
|||||||
|
|
||||||
import currencies from '../../../../static/assets/currencies.json';
|
import currencies from '../../../../static/assets/currencies.json';
|
||||||
|
|
||||||
import { Order } from '../../../models';
|
import { Order, Settings } from '../../../models';
|
||||||
import { pn } from '../../../utils';
|
import { pn } from '../../../utils';
|
||||||
import { Bolt, Link } from '@mui/icons-material';
|
import { Bolt, Link } from '@mui/icons-material';
|
||||||
import { LightningPayoutForm, LightningForm, OnchainPayoutForm, OnchainForm } from '../Forms';
|
import { LightningPayoutForm, LightningForm, OnchainPayoutForm, OnchainForm } from '../Forms';
|
||||||
@ -19,6 +19,7 @@ interface PayoutPrompProps {
|
|||||||
onchain: OnchainForm;
|
onchain: OnchainForm;
|
||||||
setOnchain: (state: OnchainForm) => void;
|
setOnchain: (state: OnchainForm) => void;
|
||||||
loadingOnchain: boolean;
|
loadingOnchain: boolean;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PayoutPrompt = ({
|
export const PayoutPrompt = ({
|
||||||
@ -31,6 +32,7 @@ export const PayoutPrompt = ({
|
|||||||
loadingOnchain,
|
loadingOnchain,
|
||||||
onchain,
|
onchain,
|
||||||
setOnchain,
|
setOnchain,
|
||||||
|
settings,
|
||||||
}: PayoutPrompProps): JSX.Element => {
|
}: PayoutPrompProps): JSX.Element => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currencyCode: string = currencies[`${order.currency}`];
|
const currencyCode: string = currencies[`${order.currency}`];
|
||||||
@ -65,9 +67,9 @@ export const PayoutPrompt = ({
|
|||||||
size='small'
|
size='small'
|
||||||
value={tab}
|
value={tab}
|
||||||
exclusive
|
exclusive
|
||||||
onChange={(mouseEvent, value: string) => setTab(value)}
|
onChange={(mouseEvent, value) => setTab(value == null ? tab : value)}
|
||||||
>
|
>
|
||||||
<ToggleButton value='lightning' disableRipple={true}>
|
<ToggleButton value='lightning'>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -97,6 +99,7 @@ export const PayoutPrompt = ({
|
|||||||
<Grid item style={{ display: tab == 'lightning' ? '' : 'none' }}>
|
<Grid item style={{ display: tab == 'lightning' ? '' : 'none' }}>
|
||||||
<LightningPayoutForm
|
<LightningPayoutForm
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
loading={loadingLightning}
|
loading={loadingLightning}
|
||||||
lightning={lightning}
|
lightning={lightning}
|
||||||
setLightning={setLightning}
|
setLightning={setLightning}
|
||||||
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Box, CircularProgress, Grid, Typography, useTheme } from '@mui/material';
|
import { Box, CircularProgress, Grid, Typography, useTheme } from '@mui/material';
|
||||||
import Countdown, { CountdownRenderProps, zeroPad } from 'react-countdown';
|
import Countdown, { CountdownRenderProps, zeroPad } from 'react-countdown';
|
||||||
|
|
||||||
import { Order } from '../../../models';
|
import { Order, Settings } from '../../../models';
|
||||||
import { LightningForm, LightningPayoutForm } from '../Forms';
|
import { LightningForm, LightningPayoutForm } from '../Forms';
|
||||||
|
|
||||||
interface RoutingFailedPromptProps {
|
interface RoutingFailedPromptProps {
|
||||||
@ -12,6 +12,7 @@ interface RoutingFailedPromptProps {
|
|||||||
lightning: LightningForm;
|
lightning: LightningForm;
|
||||||
loadingLightning: boolean;
|
loadingLightning: boolean;
|
||||||
setLightning: (state: LightningForm) => void;
|
setLightning: (state: LightningForm) => void;
|
||||||
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FailureReasonProps {
|
interface FailureReasonProps {
|
||||||
@ -28,6 +29,7 @@ const FailureReason = ({ failureReason }: FailureReasonProps): JSX.Element => {
|
|||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
borderRadius: '0.3em',
|
borderRadius: '0.3em',
|
||||||
border: `1px solid ${theme.palette.text.secondary}`,
|
border: `1px solid ${theme.palette.text.secondary}`,
|
||||||
|
padding: '0.5em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant='body2' align='center'>
|
<Typography variant='body2' align='center'>
|
||||||
@ -46,6 +48,7 @@ export const RoutingFailedPrompt = ({
|
|||||||
loadingLightning,
|
loadingLightning,
|
||||||
lightning,
|
lightning,
|
||||||
setLightning,
|
setLightning,
|
||||||
|
settings,
|
||||||
}: RoutingFailedPromptProps): JSX.Element => {
|
}: RoutingFailedPromptProps): JSX.Element => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -95,6 +98,7 @@ export const RoutingFailedPrompt = ({
|
|||||||
<Grid item>
|
<Grid item>
|
||||||
<LightningPayoutForm
|
<LightningPayoutForm
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
loading={loadingLightning}
|
loading={loadingLightning}
|
||||||
lightning={lightning}
|
lightning={lightning}
|
||||||
setLightning={setLightning}
|
setLightning={setLightning}
|
||||||
|
@ -263,7 +263,7 @@ const TradeSummary = ({
|
|||||||
primary={t('{{revenueSats}} Sats', {
|
primary={t('{{revenueSats}} Sats', {
|
||||||
revenueSats: pn(platformSummary.trade_revenue_sats),
|
revenueSats: pn(platformSummary.trade_revenue_sats),
|
||||||
})}
|
})}
|
||||||
secondary={t('Platform trade revenue')}
|
secondary={t('Coordinator trade revenue')}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@ -273,9 +273,9 @@ const TradeSummary = ({
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={t('{{routingFeeSats}} MiliSats', {
|
primary={t('{{routingFeeSats}} MiliSats', {
|
||||||
routingFeeSats: pn(platformSummary.routing_fee_sats),
|
routingFeeSats: pn(platformSummary.routing_budget_sats),
|
||||||
})}
|
})}
|
||||||
secondary={t('Platform covered routing fee')}
|
secondary={t('Routing budget')}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ import {
|
|||||||
defaultDispute,
|
defaultDispute,
|
||||||
} from './Forms';
|
} from './Forms';
|
||||||
|
|
||||||
import { Order } from '../../models';
|
import { Order, Settings } from '../../models';
|
||||||
import { EncryptedChatMessage } from './EncryptedChat';
|
import { EncryptedChatMessage } from './EncryptedChat';
|
||||||
import { systemClient } from '../../services/System';
|
import { systemClient } from '../../services/System';
|
||||||
import CollabCancelAlert from './CollabCancelAlert';
|
import CollabCancelAlert from './CollabCancelAlert';
|
||||||
@ -96,12 +96,14 @@ interface TradeBoxProps {
|
|||||||
setBadOrder: (state: string | undefined) => void;
|
setBadOrder: (state: string | undefined) => void;
|
||||||
onRenewOrder: () => void;
|
onRenewOrder: () => void;
|
||||||
onStartAgain: () => void;
|
onStartAgain: () => void;
|
||||||
|
settings: Settings;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TradeBox = ({
|
const TradeBox = ({
|
||||||
order,
|
order,
|
||||||
setOrder,
|
setOrder,
|
||||||
|
settings,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
setBadOrder,
|
setBadOrder,
|
||||||
onRenewOrder,
|
onRenewOrder,
|
||||||
@ -134,6 +136,7 @@ const TradeBox = ({
|
|||||||
| 'submit_statement'
|
| 'submit_statement'
|
||||||
| 'rate_platform';
|
| 'rate_platform';
|
||||||
invoice?: string;
|
invoice?: string;
|
||||||
|
routing_budget_ppm?: number;
|
||||||
address?: string;
|
address?: string;
|
||||||
mining_fee_rate?: number;
|
mining_fee_rate?: number;
|
||||||
statement?: string;
|
statement?: string;
|
||||||
@ -143,6 +146,7 @@ const TradeBox = ({
|
|||||||
const submitAction = function ({
|
const submitAction = function ({
|
||||||
action,
|
action,
|
||||||
invoice,
|
invoice,
|
||||||
|
routing_budget_ppm,
|
||||||
address,
|
address,
|
||||||
mining_fee_rate,
|
mining_fee_rate,
|
||||||
statement,
|
statement,
|
||||||
@ -152,6 +156,7 @@ const TradeBox = ({
|
|||||||
.post(baseUrl, '/api/order/?order_id=' + order.id, {
|
.post(baseUrl, '/api/order/?order_id=' + order.id, {
|
||||||
action,
|
action,
|
||||||
invoice,
|
invoice,
|
||||||
|
routing_budget_ppm,
|
||||||
address,
|
address,
|
||||||
mining_fee_rate,
|
mining_fee_rate,
|
||||||
statement,
|
statement,
|
||||||
@ -201,7 +206,11 @@ const TradeBox = ({
|
|||||||
|
|
||||||
const updateInvoice = function (invoice: string) {
|
const updateInvoice = function (invoice: string) {
|
||||||
setLoadingButtons({ ...noLoadingButtons, submitInvoice: true });
|
setLoadingButtons({ ...noLoadingButtons, submitInvoice: true });
|
||||||
submitAction({ action: 'update_invoice', invoice });
|
submitAction({
|
||||||
|
action: 'update_invoice',
|
||||||
|
invoice,
|
||||||
|
routing_budget_ppm: lightning.routingBudgetPPM,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAddress = function () {
|
const updateAddress = function () {
|
||||||
@ -252,7 +261,7 @@ const TradeBox = ({
|
|||||||
setWaitingWebln(true);
|
setWaitingWebln(true);
|
||||||
setOpen({ ...open, webln: true });
|
setOpen({ ...open, webln: true });
|
||||||
webln
|
webln
|
||||||
.makeInvoice(order.trade_satoshis)
|
.makeInvoice(() => lightning.amount)
|
||||||
.then((invoice: any) => {
|
.then((invoice: any) => {
|
||||||
if (invoice) {
|
if (invoice) {
|
||||||
updateInvoice(invoice.paymentRequest);
|
updateInvoice(invoice.paymentRequest);
|
||||||
@ -377,6 +386,7 @@ const TradeBox = ({
|
|||||||
return (
|
return (
|
||||||
<PayoutPrompt
|
<PayoutPrompt
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
onClickSubmitInvoice={updateInvoice}
|
onClickSubmitInvoice={updateInvoice}
|
||||||
loadingLightning={loadingButtons.submitInvoice}
|
loadingLightning={loadingButtons.submitInvoice}
|
||||||
lightning={lightning}
|
lightning={lightning}
|
||||||
@ -424,6 +434,7 @@ const TradeBox = ({
|
|||||||
return (
|
return (
|
||||||
<PayoutPrompt
|
<PayoutPrompt
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
onClickSubmitInvoice={updateInvoice}
|
onClickSubmitInvoice={updateInvoice}
|
||||||
loadingLightning={loadingButtons.submitInvoice}
|
loadingLightning={loadingButtons.submitInvoice}
|
||||||
lightning={lightning}
|
lightning={lightning}
|
||||||
@ -549,6 +560,7 @@ const TradeBox = ({
|
|||||||
return (
|
return (
|
||||||
<RoutingFailedPrompt
|
<RoutingFailedPrompt
|
||||||
order={order}
|
order={order}
|
||||||
|
settings={settings}
|
||||||
onClickSubmitInvoice={updateInvoice}
|
onClickSubmitInvoice={updateInvoice}
|
||||||
loadingLightning={loadingButtons.submitInvoice}
|
loadingLightning={loadingButtons.submitInvoice}
|
||||||
lightning={lightning}
|
lightning={lightning}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export interface Coordinator {
|
export interface Coordinator {
|
||||||
alias: string;
|
alias: string;
|
||||||
|
enabled: boolean;
|
||||||
description: string | undefined;
|
description: string | undefined;
|
||||||
coverLetter: string | undefined;
|
coverLetter: string | undefined;
|
||||||
logo: string;
|
logo: string;
|
||||||
|
@ -15,7 +15,7 @@ export interface TradeCoordinatorSummary {
|
|||||||
contract_timestamp: Date;
|
contract_timestamp: Date;
|
||||||
contract_total_time: number;
|
contract_total_time: number;
|
||||||
contract_exchange_rate: number;
|
contract_exchange_rate: number;
|
||||||
routing_fee_sats: number;
|
routing_budget_sats: number;
|
||||||
trade_revenue_sats: number;
|
trade_revenue_sats: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"alias": "Inception",
|
"alias": "Inception",
|
||||||
|
"enabled": "true",
|
||||||
"description": "RoboSats original and experimental coordinator",
|
"description": "RoboSats original and experimental coordinator",
|
||||||
"coverLetter": "N/A",
|
"coverLetter": "N/A",
|
||||||
"contact_methods": {
|
"contact": {
|
||||||
"email": "robosats@protonmail.com",
|
"email": "robosats@protonmail.com",
|
||||||
"telegram": "@robosats",
|
"telegram": "@robosats",
|
||||||
"twitter": "@robosats",
|
"twitter": "@robosats",
|
||||||
|
11
frontend/static/lnproxies.json
Normal file
11
frontend/static/lnproxies.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "↬ Lnproxy Dev",
|
||||||
|
"mainnetClearnet": "lnproxy.org",
|
||||||
|
"mainnetTOR": "rdq6tvulanl7aqtupmoboyk2z3suzkdwurejwyjyjf4itr3zhxrm2lad.onion",
|
||||||
|
"mainnetI2P": "undefined",
|
||||||
|
"testnetClearnet": "undefined",
|
||||||
|
"testnetTOR": "undefined",
|
||||||
|
"testnetI2P": "undefined"
|
||||||
|
}
|
||||||
|
]
|
Reference in New Issue
Block a user