mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
fix: fee limits for LND and LDK, isolated balance calculation (#937)
This commit is contained in:
parent
83006f2b2d
commit
188e7c6fa6
11 changed files with 145 additions and 42 deletions
|
|
@ -71,7 +71,7 @@ type App struct {
|
|||
BudgetUsage uint64 `json:"budgetUsage"`
|
||||
BudgetRenewal string `json:"budgetRenewal"`
|
||||
Isolated bool `json:"isolated"`
|
||||
Balance uint64 `json:"balance"`
|
||||
Balance int64 `json:"balance"`
|
||||
Metadata Metadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import (
|
|||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func GetIsolatedBalance(tx *gorm.DB, appId uint) uint64 {
|
||||
func GetIsolatedBalance(tx *gorm.DB, appId uint) int64 {
|
||||
var received struct {
|
||||
Sum uint64
|
||||
Sum int64
|
||||
}
|
||||
tx.
|
||||
Table("transactions").
|
||||
|
|
@ -15,7 +15,7 @@ func GetIsolatedBalance(tx *gorm.DB, appId uint) uint64 {
|
|||
Where("app_id = ? AND type = ? AND state = ?", appId, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).Scan(&received)
|
||||
|
||||
var spent struct {
|
||||
Sum uint64
|
||||
Sum int64
|
||||
}
|
||||
|
||||
tx.
|
||||
|
|
|
|||
69
db/queries/get_isolated_balance_test.go
Normal file
69
db/queries/get_isolated_balance_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package queries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/getAlby/hub/constants"
|
||||
"github.com/getAlby/hub/db"
|
||||
"github.com/getAlby/hub/tests"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetIsolatedBalance_PendingNoOverflow(t *testing.T) {
|
||||
defer tests.RemoveTestService()
|
||||
svc, err := tests.CreateTestService()
|
||||
require.NoError(t, err)
|
||||
|
||||
app, _, err := tests.CreateApp(svc)
|
||||
assert.NoError(t, err)
|
||||
app.Isolated = true
|
||||
svc.DB.Save(&app)
|
||||
|
||||
paymentAmount := uint64(1000) // 1 sat
|
||||
|
||||
tx := db.Transaction{
|
||||
AppId: &app.ID,
|
||||
RequestEventId: nil,
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
FeeReserveMsat: uint64(10000),
|
||||
AmountMsat: paymentAmount,
|
||||
PaymentRequest: tests.MockInvoice,
|
||||
PaymentHash: tests.MockPaymentHash,
|
||||
SelfPayment: true,
|
||||
}
|
||||
svc.DB.Save(&tx)
|
||||
|
||||
balance := GetIsolatedBalance(svc.DB, app.ID)
|
||||
assert.Equal(t, int64(-11000), balance)
|
||||
}
|
||||
|
||||
func TestGetIsolatedBalance_SettledNoOverflow(t *testing.T) {
|
||||
defer tests.RemoveTestService()
|
||||
svc, err := tests.CreateTestService()
|
||||
require.NoError(t, err)
|
||||
|
||||
app, _, err := tests.CreateApp(svc)
|
||||
assert.NoError(t, err)
|
||||
app.Isolated = true
|
||||
svc.DB.Save(&app)
|
||||
|
||||
paymentAmount := uint64(1000) // 1 sat
|
||||
|
||||
tx := db.Transaction{
|
||||
AppId: &app.ID,
|
||||
RequestEventId: nil,
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
State: constants.TRANSACTION_STATE_SETTLED,
|
||||
FeeReserveMsat: uint64(0),
|
||||
AmountMsat: paymentAmount,
|
||||
PaymentRequest: tests.MockInvoice,
|
||||
PaymentHash: tests.MockPaymentHash,
|
||||
SelfPayment: true,
|
||||
}
|
||||
svc.DB.Save(&tx)
|
||||
|
||||
balance := GetIsolatedBalance(svc.DB, app.ID)
|
||||
assert.Equal(t, int64(-1000), balance)
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import (
|
|||
"github.com/getAlby/hub/logger"
|
||||
"github.com/getAlby/hub/lsp"
|
||||
"github.com/getAlby/hub/service/keys"
|
||||
"github.com/getAlby/hub/transactions"
|
||||
"github.com/getAlby/hub/utils"
|
||||
)
|
||||
|
||||
|
|
@ -414,6 +415,12 @@ func (ls *LDKService) Shutdown() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func getMaxTotalRoutingFeeLimit(amountMsat uint64) ldk_node.MaxTotalRoutingFeeLimit {
|
||||
return ldk_node.MaxTotalRoutingFeeLimitSome{
|
||||
AmountMsat: transactions.CalculateFeeReserveMsat(amountMsat),
|
||||
}
|
||||
}
|
||||
|
||||
func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string, amount *uint64) (*lnclient.PayInvoiceResponse, error) {
|
||||
paymentRequest, err := decodepay.Decodepay(invoice)
|
||||
if err != nil {
|
||||
|
|
@ -424,13 +431,13 @@ func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string, amoun
|
|||
return nil, err
|
||||
}
|
||||
|
||||
paymentAmount := uint64(paymentRequest.MSatoshi)
|
||||
paymentAmountMsat := uint64(paymentRequest.MSatoshi)
|
||||
if amount != nil {
|
||||
paymentAmount = *amount
|
||||
paymentAmountMsat = *amount
|
||||
}
|
||||
|
||||
maxSpendable := ls.getMaxSpendable()
|
||||
if paymentAmount > maxSpendable {
|
||||
if paymentAmountMsat > maxSpendable {
|
||||
ls.eventPublisher.Publish(&events.Event{
|
||||
Event: "nwc_outgoing_liquidity_required",
|
||||
Properties: map[string]interface{}{
|
||||
|
|
@ -447,10 +454,15 @@ func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string, amoun
|
|||
defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription)
|
||||
|
||||
var paymentHash string
|
||||
maxTotalRoutingFeeMsat := getMaxTotalRoutingFeeLimit(paymentAmountMsat)
|
||||
sendingParams := &ldk_node.SendingParameters{
|
||||
MaxTotalRoutingFeeMsat: &maxTotalRoutingFeeMsat,
|
||||
}
|
||||
|
||||
if amount == nil {
|
||||
paymentHash, err = ls.node.Bolt11Payment().Send(invoice, nil)
|
||||
paymentHash, err = ls.node.Bolt11Payment().Send(invoice, sendingParams)
|
||||
} else {
|
||||
paymentHash, err = ls.node.Bolt11Payment().SendUsingAmount(invoice, *amount, nil)
|
||||
paymentHash, err = ls.node.Bolt11Payment().SendUsingAmount(invoice, *amount, sendingParams)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("SendPayment failed")
|
||||
|
|
@ -539,7 +551,12 @@ func (ls *LDKService) SendKeysend(ctx context.Context, amount uint64, destinatio
|
|||
ldkEventSubscription := ls.ldkEventBroadcaster.Subscribe()
|
||||
defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription)
|
||||
|
||||
paymentHash, err := ls.node.SpontaneousPayment().Send(amount, destination, nil, customTlvs, &preimage)
|
||||
maxTotalRoutingFeeMsat := getMaxTotalRoutingFeeLimit(amount)
|
||||
sendingParams := &ldk_node.SendingParameters{
|
||||
MaxTotalRoutingFeeMsat: &maxTotalRoutingFeeMsat,
|
||||
}
|
||||
|
||||
paymentHash, err := ls.node.SpontaneousPayment().Send(amount, destination, sendingParams, customTlvs, &preimage)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Keysend failed")
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/getAlby/hub/lnclient"
|
||||
"github.com/getAlby/hub/lnclient/lnd/wrapper"
|
||||
"github.com/getAlby/hub/logger"
|
||||
"github.com/getAlby/hub/transactions"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
// "gorm.io/gorm"
|
||||
|
|
@ -314,7 +315,24 @@ func (svc *LNDService) LookupInvoice(ctx context.Context, paymentHash string) (t
|
|||
}
|
||||
|
||||
func (svc *LNDService) SendPaymentSync(ctx context.Context, payReq string, amount *uint64) (*lnclient.PayInvoiceResponse, error) {
|
||||
sendRequest := &lnrpc.SendRequest{PaymentRequest: payReq}
|
||||
paymentRequest, err := decodepay.Decodepay(payReq)
|
||||
if err != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"bolt11": payReq,
|
||||
}).WithError(err).Error("Failed to decode bolt11 invoice")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
paymentAmountMsat := uint64(paymentRequest.MSatoshi)
|
||||
if amount != nil {
|
||||
paymentAmountMsat = *amount
|
||||
}
|
||||
sendRequest := &lnrpc.SendRequest{PaymentRequest: payReq, FeeLimit: &lnrpc.FeeLimit{
|
||||
Limit: &lnrpc.FeeLimit_FixedMsat{
|
||||
FixedMsat: int64(transactions.CalculateFeeReserveMsat(paymentAmountMsat)),
|
||||
},
|
||||
}}
|
||||
|
||||
if amount != nil {
|
||||
sendRequest.AmtMsat = int64(*amount)
|
||||
|
|
@ -378,6 +396,11 @@ func (svc *LNDService) SendKeysend(ctx context.Context, amount uint64, destinati
|
|||
PaymentHash: paymentHashBytes,
|
||||
DestFeatures: []lnrpc.FeatureBit{lnrpc.FeatureBit_TLV_ONION_REQ},
|
||||
DestCustomRecords: destCustomRecords,
|
||||
FeeLimit: &lnrpc.FeeLimit{
|
||||
Limit: &lnrpc.FeeLimit_FixedMsat{
|
||||
FixedMsat: int64(transactions.CalculateFeeReserveMsat(amount)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.client.SendPaymentSync(ctx, sendPaymentRequest)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const (
|
|||
)
|
||||
|
||||
type getBalanceResponse struct {
|
||||
Balance uint64 `json:"balance"`
|
||||
Balance int64 `json:"balance"`
|
||||
// MaxAmount int `json:"max_amount"`
|
||||
// BudgetRenewal string `json:"budget_renewal"`
|
||||
}
|
||||
|
|
@ -29,12 +29,12 @@ func (controller *nip47Controller) HandleGetBalanceEvent(ctx context.Context, ni
|
|||
"request_event_id": requestEventId,
|
||||
}).Debug("Getting balance")
|
||||
|
||||
balance := uint64(0)
|
||||
balance := int64(0)
|
||||
if app.Isolated {
|
||||
balance = queries.GetIsolatedBalance(controller.db, app.ID)
|
||||
} else {
|
||||
balances, err := controller.lnClient.GetBalances(ctx)
|
||||
balance = uint64(balances.Lightning.TotalSpendable)
|
||||
balance = balances.Lightning.TotalSpendable
|
||||
if err != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"request_event_id": requestEventId,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func TestHandleGetBalanceEvent(t *testing.T) {
|
|||
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
|
||||
HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
|
||||
|
||||
assert.Equal(t, uint64(21000), publishedResponse.Result.(*getBalanceResponse).Balance)
|
||||
assert.Equal(t, int64(21000), publishedResponse.Result.(*getBalanceResponse).Balance)
|
||||
assert.Nil(t, publishedResponse.Error)
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ func TestHandleGetBalanceEvent_IsolatedApp_NoTransactions(t *testing.T) {
|
|||
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
|
||||
HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
|
||||
|
||||
assert.Equal(t, uint64(0), publishedResponse.Result.(*getBalanceResponse).Balance)
|
||||
assert.Equal(t, int64(0), publishedResponse.Result.(*getBalanceResponse).Balance)
|
||||
assert.Nil(t, publishedResponse.Error)
|
||||
}
|
||||
func TestHandleGetBalanceEvent_IsolatedApp_Transactions(t *testing.T) {
|
||||
|
|
@ -132,6 +132,6 @@ func TestHandleGetBalanceEvent_IsolatedApp_Transactions(t *testing.T) {
|
|||
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
|
||||
HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
|
||||
|
||||
assert.Equal(t, uint64(1000), publishedResponse.Result.(*getBalanceResponse).Balance)
|
||||
assert.Equal(t, int64(1000), publishedResponse.Result.(*getBalanceResponse).Balance)
|
||||
assert.Nil(t, publishedResponse.Error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,14 +323,9 @@ func TestSendPaymentSync_IsolatedApp_BalanceSufficient_FailedPayment(t *testing.
|
|||
}
|
||||
|
||||
func TestCalculateFeeReserve(t *testing.T) {
|
||||
defer tests.RemoveTestService()
|
||||
svc, err := tests.CreateTestService()
|
||||
require.NoError(t, err)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
assert.Equal(t, uint64(10_000), transactionsService.calculateFeeReserveMsat(0))
|
||||
assert.Equal(t, uint64(10_000), transactionsService.calculateFeeReserveMsat(10_000))
|
||||
assert.Equal(t, uint64(10_000), transactionsService.calculateFeeReserveMsat(100_000))
|
||||
assert.Equal(t, uint64(10_000), transactionsService.calculateFeeReserveMsat(1000_000))
|
||||
assert.Equal(t, uint64(20_000), transactionsService.calculateFeeReserveMsat(2000_000))
|
||||
assert.Equal(t, uint64(10_000), CalculateFeeReserveMsat(0))
|
||||
assert.Equal(t, uint64(10_000), CalculateFeeReserveMsat(10_000))
|
||||
assert.Equal(t, uint64(10_000), CalculateFeeReserveMsat(100_000))
|
||||
assert.Equal(t, uint64(10_000), CalculateFeeReserveMsat(1000_000))
|
||||
assert.Equal(t, uint64(20_000), CalculateFeeReserveMsat(2000_000))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ func TestSendKeysend_IsolatedAppToNoApp(t *testing.T) {
|
|||
result := svc.DB.Find(&transactions)
|
||||
assert.Equal(t, int64(3), result.RowsAffected)
|
||||
// expect balance to be decreased
|
||||
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
assert.Equal(t, int64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
}
|
||||
|
||||
func TestSendKeysend_IsolatedAppToIsolatedApp(t *testing.T) {
|
||||
|
|
@ -510,10 +510,10 @@ func TestSendKeysend_IsolatedAppToIsolatedApp(t *testing.T) {
|
|||
result := svc.DB.Find(&transactions)
|
||||
assert.Equal(t, int64(3), result.RowsAffected)
|
||||
// expect balance to be decreased
|
||||
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
assert.Equal(t, int64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
|
||||
// expect app2 to receive the payment
|
||||
assert.Equal(t, uint64(123000), queries.GetIsolatedBalance(svc.DB, app2.ID))
|
||||
assert.Equal(t, int64(123000), queries.GetIsolatedBalance(svc.DB, app2.ID))
|
||||
|
||||
// check notifications
|
||||
assert.Equal(t, 2, len(mockEventConsumer.GetConsumedEvents()))
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ func TestSendPaymentSync_SelfPayment_NoAppToIsolatedApp(t *testing.T) {
|
|||
result := svc.DB.Find(&transactions)
|
||||
assert.Equal(t, int64(2), result.RowsAffected)
|
||||
// expect balance to be increased
|
||||
assert.Equal(t, uint64(123000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
assert.Equal(t, int64(123000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
}
|
||||
|
||||
func TestSendPaymentSync_SelfPayment_NoAppToApp(t *testing.T) {
|
||||
|
|
@ -229,7 +229,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToNoApp(t *testing.T) {
|
|||
result := svc.DB.Find(&transactions)
|
||||
assert.Equal(t, int64(3), result.RowsAffected)
|
||||
// expect balance to be decreased
|
||||
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
assert.Equal(t, int64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
}
|
||||
|
||||
func TestSendPaymentSync_SelfPayment_IsolatedAppToApp(t *testing.T) {
|
||||
|
|
@ -305,7 +305,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToApp(t *testing.T) {
|
|||
result := svc.DB.Find(&transactions)
|
||||
assert.Equal(t, int64(3), result.RowsAffected)
|
||||
// expect balance to be decreased
|
||||
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
assert.Equal(t, int64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
}
|
||||
|
||||
func TestSendPaymentSync_SelfPayment_IsolatedAppToIsolatedApp(t *testing.T) {
|
||||
|
|
@ -387,7 +387,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToIsolatedApp(t *testing.T) {
|
|||
result := svc.DB.Find(&transactions)
|
||||
assert.Equal(t, int64(3), result.RowsAffected)
|
||||
// expect balance to be decreased
|
||||
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
assert.Equal(t, int64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
|
||||
// check notifications
|
||||
assert.Equal(t, 2, len(mockEventConsumer.GetConsumedEvents()))
|
||||
|
|
@ -473,5 +473,5 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToSelf(t *testing.T) {
|
|||
assert.Equal(t, int64(3), result.RowsAffected)
|
||||
|
||||
// expect balance to be unchanged
|
||||
assert.Equal(t, uint64(133000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
assert.Equal(t, int64(133000), queries.GetIsolatedBalance(svc.DB, app.ID))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ func (svc *transactionsService) SendPaymentSync(ctx context.Context, payReq stri
|
|||
RequestEventId: requestEventId,
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
FeeReserveMsat: svc.calculateFeeReserveMsat(paymentAmount),
|
||||
FeeReserveMsat: CalculateFeeReserveMsat(paymentAmount),
|
||||
AmountMsat: paymentAmount,
|
||||
PaymentRequest: payReq,
|
||||
PaymentHash: paymentRequest.PaymentHash,
|
||||
|
|
@ -363,7 +363,7 @@ func (svc *transactionsService) SendKeysend(ctx context.Context, amount uint64,
|
|||
RequestEventId: requestEventId,
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
FeeReserveMsat: svc.calculateFeeReserveMsat(uint64(amount)),
|
||||
FeeReserveMsat: CalculateFeeReserveMsat(uint64(amount)),
|
||||
AmountMsat: amount,
|
||||
Metadata: datatypes.JSON(metadataBytes),
|
||||
Boostagram: datatypes.JSON(boostagramBytes),
|
||||
|
|
@ -796,7 +796,7 @@ func (svc *transactionsService) interceptSelfPayment(paymentHash string) (*lncli
|
|||
}
|
||||
|
||||
func (svc *transactionsService) validateCanPay(tx *gorm.DB, appId *uint, amount uint64, description string) error {
|
||||
amountWithFeeReserve := amount + svc.calculateFeeReserveMsat(amount)
|
||||
amountWithFeeReserve := amount + CalculateFeeReserveMsat(amount)
|
||||
|
||||
// ensure balance for isolated apps
|
||||
if appId != nil {
|
||||
|
|
@ -820,7 +820,7 @@ func (svc *transactionsService) validateCanPay(tx *gorm.DB, appId *uint, amount
|
|||
if app.Isolated {
|
||||
balance := queries.GetIsolatedBalance(tx, appPermission.AppId)
|
||||
|
||||
if amountWithFeeReserve > balance {
|
||||
if int64(amountWithFeeReserve) > balance {
|
||||
message := NewInsufficientBalanceError().Error()
|
||||
if description != "" {
|
||||
message += " " + description
|
||||
|
|
@ -862,9 +862,8 @@ func (svc *transactionsService) validateCanPay(tx *gorm.DB, appId *uint, amount
|
|||
}
|
||||
|
||||
// max of 1% or 10000 millisats (10 sats)
|
||||
func (svc *transactionsService) calculateFeeReserveMsat(amount uint64) uint64 {
|
||||
// NOTE: LDK defaults to 1% of the payment amount + 50 sats
|
||||
return uint64(math.Max(math.Ceil(float64(amount)*0.01), 10000))
|
||||
func CalculateFeeReserveMsat(amountMsat uint64) uint64 {
|
||||
return uint64(math.Max(math.Ceil(float64(amountMsat)*0.01), 10000))
|
||||
}
|
||||
|
||||
func makePreimageHex() ([]byte, error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue