2019-01-14 19:56:59 +03:00
|
|
|
package invoicesrpc
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"crypto/rand"
|
2018-10-05 11:14:56 +03:00
|
|
|
"errors"
|
2019-01-14 19:56:59 +03:00
|
|
|
"fmt"
|
|
|
|
"math"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/btcsuite/btcd/chaincfg"
|
|
|
|
"github.com/btcsuite/btcutil"
|
|
|
|
"github.com/davecgh/go-spew/spew"
|
2018-10-05 11:14:56 +03:00
|
|
|
|
2019-01-14 19:56:59 +03:00
|
|
|
"github.com/lightningnetwork/lnd/channeldb"
|
2018-10-05 11:14:56 +03:00
|
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
2019-01-14 19:56:59 +03:00
|
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
2018-10-05 11:14:56 +03:00
|
|
|
"github.com/lightningnetwork/lnd/netann"
|
|
|
|
"github.com/lightningnetwork/lnd/zpay32"
|
2019-01-14 19:56:59 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// AddInvoiceConfig contains dependencies for invoice creation.
|
|
|
|
type AddInvoiceConfig struct {
|
|
|
|
// AddInvoice is called to add the invoice to the registry.
|
|
|
|
AddInvoice func(invoice *channeldb.Invoice, paymentHash lntypes.Hash) (
|
|
|
|
uint64, error)
|
|
|
|
|
|
|
|
// IsChannelActive is used to generate valid hop hints.
|
|
|
|
IsChannelActive func(chanID lnwire.ChannelID) bool
|
|
|
|
|
|
|
|
// ChainParams are required to properly decode invoice payment requests
|
|
|
|
// that are marshalled over rpc.
|
|
|
|
ChainParams *chaincfg.Params
|
|
|
|
|
|
|
|
// NodeSigner is an implementation of the MessageSigner implementation
|
|
|
|
// that's backed by the identity private key of the running lnd node.
|
|
|
|
NodeSigner *netann.NodeSigner
|
|
|
|
|
|
|
|
// DefaultCLTVExpiry is the default invoice expiry if no values is
|
|
|
|
// specified.
|
|
|
|
DefaultCLTVExpiry uint32
|
|
|
|
|
|
|
|
// ChanDB is a global boltdb instance which is needed to access the
|
|
|
|
// channel graph.
|
|
|
|
ChanDB *channeldb.DB
|
2019-12-11 00:09:52 +03:00
|
|
|
|
|
|
|
// GenInvoiceFeatures returns a feature containing feature bits that
|
|
|
|
// should be advertised on freshly generated invoices.
|
|
|
|
GenInvoiceFeatures func() *lnwire.FeatureVector
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
2019-01-15 12:06:48 +03:00
|
|
|
// AddInvoiceData contains the required data to create a new invoice.
|
|
|
|
type AddInvoiceData struct {
|
|
|
|
// An optional memo to attach along with the invoice. Used for record
|
|
|
|
// keeping purposes for the invoice's creator, and will also be set in
|
|
|
|
// the description field of the encoded payment request if the
|
|
|
|
// description_hash field is not being used.
|
|
|
|
Memo string
|
|
|
|
|
|
|
|
// The preimage which will allow settling an incoming HTLC payable to
|
2018-10-05 11:14:56 +03:00
|
|
|
// this preimage. If Preimage is set, Hash should be nil. If both
|
|
|
|
// Preimage and Hash are nil, a random preimage is generated.
|
2019-01-15 12:06:48 +03:00
|
|
|
Preimage *lntypes.Preimage
|
|
|
|
|
2018-10-05 11:14:56 +03:00
|
|
|
// The hash of the preimage. If Hash is set, Preimage should be nil.
|
|
|
|
// This condition indicates that we have a 'hold invoice' for which the
|
|
|
|
// htlc will be accepted and held until the preimage becomes known.
|
|
|
|
Hash *lntypes.Hash
|
|
|
|
|
2019-11-15 10:59:14 +03:00
|
|
|
// The value of this invoice in millisatoshis.
|
|
|
|
Value lnwire.MilliSatoshi
|
2019-01-15 12:06:48 +03:00
|
|
|
|
|
|
|
// Hash (SHA-256) of a description of the payment. Used if the
|
|
|
|
// description of payment (memo) is too long to naturally fit within the
|
|
|
|
// description field of an encoded payment request.
|
|
|
|
DescriptionHash []byte
|
|
|
|
|
|
|
|
// Payment request expiry time in seconds. Default is 3600 (1 hour).
|
|
|
|
Expiry int64
|
|
|
|
|
|
|
|
// Fallback on-chain address.
|
|
|
|
FallbackAddr string
|
|
|
|
|
|
|
|
// Delta to use for the time-lock of the CLTV extended to the final hop.
|
|
|
|
CltvExpiry uint64
|
|
|
|
|
|
|
|
// Whether this invoice should include routing hints for private
|
|
|
|
// channels.
|
|
|
|
Private bool
|
2020-04-08 14:47:10 +03:00
|
|
|
|
|
|
|
// HodlInvoice signals that this invoice shouldn't be settled
|
|
|
|
// immediately upon receiving the payment.
|
|
|
|
HodlInvoice bool
|
2019-01-15 12:06:48 +03:00
|
|
|
}
|
|
|
|
|
2019-01-14 19:56:59 +03:00
|
|
|
// AddInvoice attempts to add a new invoice to the invoice database. Any
|
2018-10-05 11:14:56 +03:00
|
|
|
// duplicated invoices are rejected, therefore all invoices *must* have a
|
|
|
|
// unique payment preimage.
|
2019-01-14 19:56:59 +03:00
|
|
|
func AddInvoice(ctx context.Context, cfg *AddInvoiceConfig,
|
2019-01-15 12:06:48 +03:00
|
|
|
invoice *AddInvoiceData) (*lntypes.Hash, *channeldb.Invoice, error) {
|
2019-01-14 19:56:59 +03:00
|
|
|
|
2018-10-05 11:14:56 +03:00
|
|
|
var (
|
2020-04-08 14:47:10 +03:00
|
|
|
paymentPreimage *lntypes.Preimage
|
2018-10-05 11:14:56 +03:00
|
|
|
paymentHash lntypes.Hash
|
|
|
|
)
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
|
|
|
// Only either preimage or hash can be set.
|
|
|
|
case invoice.Preimage != nil && invoice.Hash != nil:
|
|
|
|
return nil, nil,
|
|
|
|
errors.New("preimage and hash both set")
|
|
|
|
|
|
|
|
// If no hash or preimage is given, generate a random preimage.
|
|
|
|
case invoice.Preimage == nil && invoice.Hash == nil:
|
2020-04-08 14:47:10 +03:00
|
|
|
paymentPreimage = &lntypes.Preimage{}
|
2019-01-14 19:56:59 +03:00
|
|
|
if _, err := rand.Read(paymentPreimage[:]); err != nil {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, err
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
2018-10-05 11:14:56 +03:00
|
|
|
paymentHash = paymentPreimage.Hash()
|
|
|
|
|
|
|
|
// If just a hash is given, we create a hold invoice by setting the
|
|
|
|
// preimage to unknown.
|
|
|
|
case invoice.Preimage == nil && invoice.Hash != nil:
|
|
|
|
paymentHash = *invoice.Hash
|
|
|
|
|
|
|
|
// A specific preimage was supplied. Use that for the invoice.
|
|
|
|
case invoice.Preimage != nil && invoice.Hash == nil:
|
2020-04-08 14:47:10 +03:00
|
|
|
preimage := *invoice.Preimage
|
|
|
|
paymentPreimage = &preimage
|
2018-10-05 11:14:56 +03:00
|
|
|
paymentHash = invoice.Preimage.Hash()
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// The size of the memo, receipt and description hash attached must not
|
|
|
|
// exceed the maximum values for either of the fields.
|
|
|
|
if len(invoice.Memo) > channeldb.MaxMemoSize {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, fmt.Errorf("memo too large: %v bytes "+
|
2019-01-14 19:56:59 +03:00
|
|
|
"(maxsize=%v)", len(invoice.Memo), channeldb.MaxMemoSize)
|
|
|
|
}
|
|
|
|
if len(invoice.DescriptionHash) > 0 && len(invoice.DescriptionHash) != 32 {
|
2019-09-11 17:09:52 +03:00
|
|
|
return nil, nil, fmt.Errorf("description hash is %v bytes, must be 32",
|
|
|
|
len(invoice.DescriptionHash))
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
2020-04-22 02:42:52 +03:00
|
|
|
// We set the max invoice amount to 100k BTC, which itself is several
|
|
|
|
// multiples off the current block reward.
|
|
|
|
maxInvoiceAmt := btcutil.Amount(btcutil.SatoshiPerBitcoin * 100000)
|
|
|
|
|
|
|
|
switch {
|
2019-01-14 19:56:59 +03:00
|
|
|
// The value of the invoice must not be negative.
|
2020-04-22 08:16:05 +03:00
|
|
|
case int64(invoice.Value) < 0:
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, fmt.Errorf("payments of negative value "+
|
2020-04-22 08:16:05 +03:00
|
|
|
"are not allowed, value is %v", int64(invoice.Value))
|
2020-04-22 02:42:52 +03:00
|
|
|
|
|
|
|
// Also ensure that the invoice is actually realistic, while preventing
|
|
|
|
// any issues due to underflow.
|
|
|
|
case invoice.Value.ToSatoshis() > maxInvoiceAmt:
|
|
|
|
return nil, nil, fmt.Errorf("invoice amount %v is "+
|
|
|
|
"too large, max is %v", invoice.Value.ToSatoshis(),
|
|
|
|
maxInvoiceAmt)
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
2019-11-15 10:59:14 +03:00
|
|
|
amtMSat := invoice.Value
|
2019-01-14 19:56:59 +03:00
|
|
|
|
|
|
|
// We also create an encoded payment request which allows the
|
|
|
|
// caller to compactly send the invoice to the payer. We'll create a
|
|
|
|
// list of options to be added to the encoded payment request. For now
|
|
|
|
// we only support the required fields description/description_hash,
|
|
|
|
// expiry, fallback address, and the amount field.
|
|
|
|
var options []func(*zpay32.Invoice)
|
|
|
|
|
|
|
|
// We only include the amount in the invoice if it is greater than 0.
|
|
|
|
// By not including the amount, we enable the creation of invoices that
|
|
|
|
// allow the payee to specify the amount of satoshis they wish to send.
|
|
|
|
if amtMSat > 0 {
|
|
|
|
options = append(options, zpay32.Amount(amtMSat))
|
|
|
|
}
|
|
|
|
|
|
|
|
// If specified, add a fallback address to the payment request.
|
|
|
|
if len(invoice.FallbackAddr) > 0 {
|
|
|
|
addr, err := btcutil.DecodeAddress(invoice.FallbackAddr,
|
|
|
|
cfg.ChainParams)
|
|
|
|
if err != nil {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, fmt.Errorf("invalid fallback address: %v",
|
2019-01-14 19:56:59 +03:00
|
|
|
err)
|
|
|
|
}
|
|
|
|
options = append(options, zpay32.FallbackAddr(addr))
|
|
|
|
}
|
|
|
|
|
|
|
|
// If expiry is set, specify it. If it is not provided, no expiry time
|
|
|
|
// will be explicitly added to this payment request, which will imply
|
|
|
|
// the default 3600 seconds.
|
|
|
|
if invoice.Expiry > 0 {
|
|
|
|
|
|
|
|
// We'll ensure that the specified expiry is restricted to sane
|
|
|
|
// number of seconds. As a result, we'll reject an invoice with
|
|
|
|
// an expiry greater than 1 year.
|
|
|
|
maxExpiry := time.Hour * 24 * 365
|
|
|
|
expSeconds := invoice.Expiry
|
|
|
|
|
|
|
|
if float64(expSeconds) > maxExpiry.Seconds() {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, fmt.Errorf("expiry of %v seconds "+
|
2019-01-14 19:56:59 +03:00
|
|
|
"greater than max expiry of %v seconds",
|
|
|
|
float64(expSeconds), maxExpiry.Seconds())
|
|
|
|
}
|
|
|
|
|
|
|
|
expiry := time.Duration(invoice.Expiry) * time.Second
|
|
|
|
options = append(options, zpay32.Expiry(expiry))
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the description hash is set, then we add it do the list of options.
|
|
|
|
// If not, use the memo field as the payment request description.
|
|
|
|
if len(invoice.DescriptionHash) > 0 {
|
|
|
|
var descHash [32]byte
|
|
|
|
copy(descHash[:], invoice.DescriptionHash[:])
|
|
|
|
options = append(options, zpay32.DescriptionHash(descHash))
|
|
|
|
} else {
|
|
|
|
// Use the memo field as the description. If this is not set
|
|
|
|
// this will just be an empty string.
|
|
|
|
options = append(options, zpay32.Description(invoice.Memo))
|
|
|
|
}
|
|
|
|
|
|
|
|
// We'll use our current default CLTV value unless one was specified as
|
|
|
|
// an option on the command line when creating an invoice.
|
|
|
|
switch {
|
|
|
|
case invoice.CltvExpiry > math.MaxUint16:
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, fmt.Errorf("CLTV delta of %v is too large, max "+
|
2019-01-14 19:56:59 +03:00
|
|
|
"accepted is: %v", invoice.CltvExpiry, math.MaxUint16)
|
|
|
|
case invoice.CltvExpiry != 0:
|
|
|
|
options = append(options,
|
|
|
|
zpay32.CLTVExpiry(invoice.CltvExpiry))
|
|
|
|
default:
|
|
|
|
// TODO(roasbeef): assumes set delta between versions
|
|
|
|
defaultDelta := cfg.DefaultCLTVExpiry
|
|
|
|
options = append(options, zpay32.CLTVExpiry(uint64(defaultDelta)))
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we were requested to include routing hints in the invoice, then
|
|
|
|
// we'll fetch all of our available private channels and create routing
|
|
|
|
// hints for them.
|
|
|
|
if invoice.Private {
|
|
|
|
openChannels, err := cfg.ChanDB.FetchAllChannels()
|
|
|
|
if err != nil {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, fmt.Errorf("could not fetch all channels")
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
graph := cfg.ChanDB.ChannelGraph()
|
|
|
|
|
|
|
|
numHints := 0
|
|
|
|
for _, channel := range openChannels {
|
|
|
|
// We'll restrict the number of individual route hints
|
|
|
|
// to 20 to avoid creating overly large invoices.
|
2019-07-12 09:51:30 +03:00
|
|
|
if numHints >= 20 {
|
2019-01-14 19:56:59 +03:00
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
// Since we're only interested in our private channels,
|
|
|
|
// we'll skip public ones.
|
|
|
|
isPublic := channel.ChannelFlags&lnwire.FFAnnounceChannel != 0
|
|
|
|
if isPublic {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Make sure the counterparty has enough balance in the
|
|
|
|
// channel for our amount. We do this in order to reduce
|
|
|
|
// payment errors when attempting to use this channel
|
|
|
|
// as a hint.
|
|
|
|
chanPoint := lnwire.NewChanIDFromOutPoint(
|
|
|
|
&channel.FundingOutpoint,
|
|
|
|
)
|
|
|
|
if amtMSat >= channel.LocalCommitment.RemoteBalance {
|
|
|
|
log.Debugf("Skipping channel %v due to "+
|
|
|
|
"not having enough remote balance",
|
|
|
|
chanPoint)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Make sure the channel is active.
|
|
|
|
if !cfg.IsChannelActive(chanPoint) {
|
|
|
|
log.Debugf("Skipping channel %v due to not "+
|
|
|
|
"being eligible to forward payments",
|
|
|
|
chanPoint)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// To ensure we don't leak unadvertised nodes, we'll
|
|
|
|
// make sure our counterparty is publicly advertised
|
|
|
|
// within the network. Otherwise, we'll end up leaking
|
|
|
|
// information about nodes that intend to stay
|
|
|
|
// unadvertised, like in the case of a node only having
|
|
|
|
// private channels.
|
|
|
|
var remotePub [33]byte
|
|
|
|
copy(remotePub[:], channel.IdentityPub.SerializeCompressed())
|
|
|
|
isRemoteNodePublic, err := graph.IsPublicNode(remotePub)
|
|
|
|
if err != nil {
|
|
|
|
log.Errorf("Unable to determine if node %x "+
|
|
|
|
"is advertised: %v", remotePub, err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if !isRemoteNodePublic {
|
|
|
|
log.Debugf("Skipping channel %v due to "+
|
|
|
|
"counterparty %x being unadvertised",
|
|
|
|
chanPoint, remotePub)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fetch the policies for each end of the channel.
|
|
|
|
chanID := channel.ShortChanID().ToUint64()
|
|
|
|
info, p1, p2, err := graph.FetchChannelEdgesByID(chanID)
|
|
|
|
if err != nil {
|
|
|
|
log.Errorf("Unable to fetch the routing "+
|
|
|
|
"policies for the edges of the channel "+
|
|
|
|
"%v: %v", chanPoint, err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Now, we'll need to determine which is the correct
|
|
|
|
// policy for HTLCs being sent from the remote node.
|
|
|
|
var remotePolicy *channeldb.ChannelEdgePolicy
|
|
|
|
if bytes.Equal(remotePub[:], info.NodeKey1Bytes[:]) {
|
|
|
|
remotePolicy = p1
|
|
|
|
} else {
|
|
|
|
remotePolicy = p2
|
|
|
|
}
|
|
|
|
|
|
|
|
// If for some reason we don't yet have the edge for
|
|
|
|
// the remote party, then we'll just skip adding this
|
|
|
|
// channel as a routing hint.
|
|
|
|
if remotePolicy == nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Finally, create the routing hint for this channel and
|
|
|
|
// add it to our list of route hints.
|
|
|
|
hint := zpay32.HopHint{
|
|
|
|
NodeID: channel.IdentityPub,
|
|
|
|
ChannelID: chanID,
|
|
|
|
FeeBaseMSat: uint32(remotePolicy.FeeBaseMSat),
|
|
|
|
FeeProportionalMillionths: uint32(
|
|
|
|
remotePolicy.FeeProportionalMillionths,
|
|
|
|
),
|
|
|
|
CLTVExpiryDelta: remotePolicy.TimeLockDelta,
|
|
|
|
}
|
|
|
|
|
|
|
|
// Include the route hint in our set of options that
|
|
|
|
// will be used when creating the invoice.
|
|
|
|
routeHint := []zpay32.HopHint{hint}
|
|
|
|
options = append(options, zpay32.RouteHint(routeHint))
|
|
|
|
|
|
|
|
numHints++
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2019-12-11 00:09:52 +03:00
|
|
|
// Set our desired invoice features and add them to our list of options.
|
|
|
|
invoiceFeatures := cfg.GenInvoiceFeatures()
|
2019-11-22 13:24:28 +03:00
|
|
|
options = append(options, zpay32.Features(invoiceFeatures))
|
|
|
|
|
2019-12-05 18:59:31 +03:00
|
|
|
// Generate and set a random payment address for this invoice. If the
|
|
|
|
// sender understands payment addresses, this can be used to avoid
|
|
|
|
// intermediaries probing the receiver.
|
|
|
|
var paymentAddr [32]byte
|
|
|
|
if _, err := rand.Read(paymentAddr[:]); err != nil {
|
|
|
|
return nil, nil, err
|
|
|
|
}
|
|
|
|
options = append(options, zpay32.PaymentAddr(paymentAddr))
|
|
|
|
|
2019-01-14 19:56:59 +03:00
|
|
|
// Create and encode the payment request as a bech32 (zpay32) string.
|
|
|
|
creationDate := time.Now()
|
|
|
|
payReq, err := zpay32.NewInvoice(
|
2018-10-05 11:14:56 +03:00
|
|
|
cfg.ChainParams, paymentHash, creationDate, options...,
|
2019-01-14 19:56:59 +03:00
|
|
|
)
|
|
|
|
if err != nil {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, err
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
payReqString, err := payReq.Encode(
|
|
|
|
zpay32.MessageSigner{
|
|
|
|
SignCompact: cfg.NodeSigner.SignDigestCompact,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
if err != nil {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, err
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
newInvoice := &channeldb.Invoice{
|
|
|
|
CreationDate: creationDate,
|
|
|
|
Memo: []byte(invoice.Memo),
|
|
|
|
PaymentRequest: []byte(payReqString),
|
|
|
|
Terms: channeldb.ContractTerm{
|
2019-11-22 13:25:02 +03:00
|
|
|
FinalCltvDelta: int32(payReq.MinFinalCLTVExpiry()),
|
|
|
|
Expiry: payReq.Expiry(),
|
2019-01-14 19:56:59 +03:00
|
|
|
Value: amtMSat,
|
|
|
|
PaymentPreimage: paymentPreimage,
|
2019-12-05 18:59:31 +03:00
|
|
|
PaymentAddr: paymentAddr,
|
2019-11-22 13:24:28 +03:00
|
|
|
Features: invoiceFeatures,
|
2019-01-14 19:56:59 +03:00
|
|
|
},
|
2020-04-08 14:47:10 +03:00
|
|
|
HodlInvoice: invoice.HodlInvoice,
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
log.Tracef("[addinvoice] adding new invoice %v",
|
|
|
|
newLogClosure(func() string {
|
|
|
|
return spew.Sdump(newInvoice)
|
|
|
|
}),
|
|
|
|
)
|
|
|
|
|
|
|
|
// With all sanity checks passed, write the invoice to the database.
|
2018-10-05 11:14:56 +03:00
|
|
|
_, err = cfg.AddInvoice(newInvoice, paymentHash)
|
2019-01-14 19:56:59 +03:00
|
|
|
if err != nil {
|
2019-01-15 12:06:48 +03:00
|
|
|
return nil, nil, err
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|
|
|
|
|
2018-10-05 11:14:56 +03:00
|
|
|
return &paymentHash, newInvoice, nil
|
2019-01-14 19:56:59 +03:00
|
|
|
}
|