2021-03-02 12:42:02 +00:00
|
|
|
package liquidity
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
|
|
|
|
"github.com/btcsuite/btcutil"
|
|
|
|
"github.com/lightninglabs/loop"
|
|
|
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// defaultSwapFeePPM is the default limit we place on swap fees,
|
|
|
|
// expressed as parts per million of swap volume, 0.5%.
|
|
|
|
defaultSwapFeePPM = 5000
|
|
|
|
|
|
|
|
// defaultRoutingFeePPM is the default limit we place on routing fees
|
|
|
|
// for the swap invoice, expressed as parts per million of swap volume,
|
|
|
|
// 1%.
|
|
|
|
defaultRoutingFeePPM = 10000
|
|
|
|
|
|
|
|
// defaultRoutingFeePPM is the default limit we place on routing fees
|
|
|
|
// for the prepay invoice, expressed as parts per million of prepay
|
|
|
|
// volume, 0.5%.
|
|
|
|
defaultPrepayRoutingFeePPM = 5000
|
|
|
|
|
|
|
|
// defaultMaximumMinerFee is the default limit we place on miner fees
|
|
|
|
// per swap. We apply a multiplier to this default fee to guard against
|
|
|
|
// the case where we have broadcast the preimage, then fees spike and
|
|
|
|
// we need to sweep the preimage.
|
|
|
|
defaultMaximumMinerFee = 15000 * 100
|
|
|
|
|
|
|
|
// defaultMaximumPrepay is the default limit we place on prepay
|
|
|
|
// invoices.
|
|
|
|
defaultMaximumPrepay = 30000
|
|
|
|
|
|
|
|
// defaultSweepFeeRateLimit is the default limit we place on estimated
|
|
|
|
// sweep fees, (750 * 4 /1000 = 3 sat/vByte).
|
|
|
|
defaultSweepFeeRateLimit = chainfee.SatPerKWeight(750)
|
2021-03-02 12:42:03 +00:00
|
|
|
|
|
|
|
// minerMultiplier is a multiplier we use to scale our miner fee to
|
|
|
|
// ensure that we will still be able to complete our swap in the case
|
|
|
|
// of a severe fee spike.
|
|
|
|
minerMultiplier = 100
|
2021-03-02 12:42:06 +00:00
|
|
|
|
|
|
|
// defaultFeePPM is the default percentage of swap amount that we
|
|
|
|
// allocate to fees, 2%.
|
|
|
|
defaultFeePPM = 20000
|
2021-03-02 12:42:02 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
// ErrZeroMinerFee is returned if a zero maximum miner fee is set.
|
|
|
|
ErrZeroMinerFee = errors.New("maximum miner fee must be non-zero")
|
|
|
|
|
|
|
|
// ErrZeroSwapFeePPM is returned if a zero server fee ppm is set.
|
|
|
|
ErrZeroSwapFeePPM = errors.New("swap fee PPM must be non-zero")
|
|
|
|
|
|
|
|
// ErrZeroRoutingPPM is returned if a zero routing fee ppm is set.
|
|
|
|
ErrZeroRoutingPPM = errors.New("routing fee PPM must be non-zero")
|
|
|
|
|
|
|
|
// ErrZeroPrepayPPM is returned if a zero prepay routing fee ppm is set.
|
|
|
|
ErrZeroPrepayPPM = errors.New("prepay routing fee PPM must be non-zero")
|
|
|
|
|
|
|
|
// ErrZeroPrepay is returned if a zero maximum prepay is set.
|
|
|
|
ErrZeroPrepay = errors.New("maximum prepay must be non-zero")
|
|
|
|
|
2021-03-02 12:42:03 +00:00
|
|
|
// ErrInvalidPPM is returned is the parts per million for a fee rate
|
|
|
|
// are invalid.
|
|
|
|
ErrInvalidPPM = errors.New("invalid ppm")
|
|
|
|
|
2021-03-02 12:42:02 +00:00
|
|
|
// ErrInvalidSweepFeeRateLimit is returned if an invalid sweep fee limit
|
|
|
|
// is set.
|
|
|
|
ErrInvalidSweepFeeRateLimit = fmt.Errorf("sweep fee rate limit must "+
|
|
|
|
"be > %v sat/vByte",
|
|
|
|
satPerKwToSatPerVByte(chainfee.AbsoluteFeePerKwFloor))
|
|
|
|
)
|
|
|
|
|
|
|
|
// Compile time assertion that FeeCategoryLimit implements FeeLimit.
|
|
|
|
var _ FeeLimit = (*FeeCategoryLimit)(nil)
|
|
|
|
|
|
|
|
// FeeCategoryLimit is an implementation of the fee limit interface which sets
|
|
|
|
// a specific fee limit per fee category.
|
|
|
|
type FeeCategoryLimit struct {
|
|
|
|
// MaximumPrepay is the maximum prepay amount we are willing to pay per
|
|
|
|
// swap.
|
|
|
|
MaximumPrepay btcutil.Amount
|
|
|
|
|
|
|
|
// MaximumSwapFeePPM is the maximum server fee we are willing to pay per
|
|
|
|
// swap expressed as parts per million of the swap volume.
|
|
|
|
MaximumSwapFeePPM uint64
|
|
|
|
|
|
|
|
// MaximumRoutingFeePPM is the maximum off-chain routing fee we
|
|
|
|
// are willing to pay for off chain invoice routing fees per swap,
|
|
|
|
// expressed as parts per million of the swap amount.
|
|
|
|
MaximumRoutingFeePPM uint64
|
|
|
|
|
|
|
|
// MaximumPrepayRoutingFeePPM is the maximum off-chain routing fee we
|
|
|
|
// are willing to pay for off chain prepay routing fees per swap,
|
|
|
|
// expressed as parts per million of the prepay amount.
|
|
|
|
MaximumPrepayRoutingFeePPM uint64
|
|
|
|
|
|
|
|
// MaximumMinerFee is the maximum on chain fee that we cap our miner
|
|
|
|
// fee at in case where we need to claim on chain because we have
|
|
|
|
// revealed the preimage, but fees have spiked. We will not initiate a
|
|
|
|
// swap if we estimate that the sweep cost will be above our sweep
|
|
|
|
// fee limit, and we use fee estimates at time of sweep to set our fees,
|
|
|
|
// so this is just a sane cap covering the special case where we need to
|
|
|
|
// sweep during a fee spike.
|
|
|
|
MaximumMinerFee btcutil.Amount
|
|
|
|
|
|
|
|
// SweepFeeRateLimit is the limit that we place on our estimated sweep
|
|
|
|
// fee. A swap will not be suggested if estimated fee rate is above this
|
|
|
|
// value.
|
|
|
|
SweepFeeRateLimit chainfee.SatPerKWeight
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewFeeCategoryLimit created a new fee limit struct which sets individual
|
|
|
|
// fee limits per category.
|
|
|
|
func NewFeeCategoryLimit(swapFeePPM, routingFeePPM, prepayFeePPM uint64,
|
|
|
|
minerFee, prepay btcutil.Amount,
|
|
|
|
sweepLimit chainfee.SatPerKWeight) *FeeCategoryLimit {
|
|
|
|
|
|
|
|
return &FeeCategoryLimit{
|
|
|
|
MaximumPrepay: prepay,
|
|
|
|
MaximumSwapFeePPM: swapFeePPM,
|
|
|
|
MaximumRoutingFeePPM: routingFeePPM,
|
|
|
|
MaximumPrepayRoutingFeePPM: prepayFeePPM,
|
|
|
|
MaximumMinerFee: minerFee,
|
|
|
|
SweepFeeRateLimit: sweepLimit,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func defaultFeeCategoryLimit() *FeeCategoryLimit {
|
|
|
|
return NewFeeCategoryLimit(defaultSwapFeePPM, defaultRoutingFeePPM,
|
|
|
|
defaultPrepayRoutingFeePPM, defaultMaximumMinerFee,
|
|
|
|
defaultMaximumPrepay, defaultSweepFeeRateLimit)
|
|
|
|
}
|
|
|
|
|
|
|
|
// String returns the string representation of our fee category limits.
|
|
|
|
func (f *FeeCategoryLimit) String() string {
|
|
|
|
return fmt.Sprintf("fee categories: maximum prepay: %v, maximum "+
|
|
|
|
"miner fee: %v, maximum swap fee ppm: %v, maximum "+
|
|
|
|
"routing fee ppm: %v, maximum prepay routing fee ppm: %v,"+
|
|
|
|
"sweep fee limit: %v", f.MaximumPrepay, f.MaximumMinerFee,
|
|
|
|
f.MaximumSwapFeePPM, f.MaximumRoutingFeePPM,
|
|
|
|
f.MaximumPrepayRoutingFeePPM, f.SweepFeeRateLimit,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (f *FeeCategoryLimit) validate() error {
|
|
|
|
// Check that we have non-zero fee limits.
|
|
|
|
if f.MaximumSwapFeePPM == 0 {
|
|
|
|
return ErrZeroSwapFeePPM
|
|
|
|
}
|
|
|
|
|
|
|
|
if f.MaximumRoutingFeePPM == 0 {
|
|
|
|
return ErrZeroRoutingPPM
|
|
|
|
}
|
|
|
|
|
|
|
|
if f.MaximumPrepayRoutingFeePPM == 0 {
|
|
|
|
return ErrZeroPrepayPPM
|
|
|
|
}
|
|
|
|
|
|
|
|
if f.MaximumPrepay == 0 {
|
|
|
|
return ErrZeroPrepay
|
|
|
|
}
|
|
|
|
|
|
|
|
if f.MaximumMinerFee == 0 {
|
|
|
|
return ErrZeroMinerFee
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check that our sweep limit is above our minimum fee rate. We use
|
|
|
|
// absolute fee floor rather than kw floor because we will allow users
|
|
|
|
// to specify fee rate is sat/vByte and want to allow 1 sat/vByte.
|
|
|
|
if f.SweepFeeRateLimit < chainfee.AbsoluteFeePerKwFloor {
|
|
|
|
return ErrInvalidSweepFeeRateLimit
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// mayLoopOut checks our estimated loop out sweep fee against our sweep limit.
|
|
|
|
func (f *FeeCategoryLimit) mayLoopOut(estimate chainfee.SatPerKWeight) error {
|
|
|
|
if estimate > f.SweepFeeRateLimit {
|
|
|
|
log.Debugf("Current fee estimate to sweep: %v sat/vByte "+
|
|
|
|
"exceeds limit of: %v sat/vByte",
|
|
|
|
satPerKwToSatPerVByte(estimate),
|
|
|
|
satPerKwToSatPerVByte(f.SweepFeeRateLimit))
|
|
|
|
|
|
|
|
return newReasonError(ReasonSweepFees)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// loopOutLimits checks whether the quote provided is within our fee limits.
|
|
|
|
func (f *FeeCategoryLimit) loopOutLimits(amount btcutil.Amount,
|
|
|
|
quote *loop.LoopOutQuote) error {
|
|
|
|
|
|
|
|
maxFee := ppmToSat(amount, f.MaximumSwapFeePPM)
|
|
|
|
|
|
|
|
if quote.SwapFee > maxFee {
|
|
|
|
log.Debugf("quoted swap fee: %v > maximum swap fee: %v",
|
|
|
|
quote.SwapFee, maxFee)
|
|
|
|
|
|
|
|
return newReasonError(ReasonSwapFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
if quote.MinerFee > f.MaximumMinerFee {
|
|
|
|
log.Debugf("quoted miner fee: %v > maximum miner "+
|
|
|
|
"fee: %v", quote.MinerFee, f.MaximumMinerFee)
|
|
|
|
|
|
|
|
return newReasonError(ReasonMinerFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
if quote.PrepayAmount > f.MaximumPrepay {
|
|
|
|
log.Debugf("quoted prepay: %v > maximum prepay: %v",
|
|
|
|
quote.PrepayAmount, f.MaximumPrepay)
|
|
|
|
|
|
|
|
return newReasonError(ReasonPrepay)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-11-30 11:18:31 +00:00
|
|
|
func (f *FeeCategoryLimit) loopInLimits(amount btcutil.Amount,
|
|
|
|
quote *loop.LoopInQuote) error {
|
|
|
|
|
|
|
|
maxServerFee := ppmToSat(amount, f.MaximumSwapFeePPM)
|
|
|
|
if quote.SwapFee > maxServerFee {
|
|
|
|
log.Debugf("quoted swap fee: %v > maximum swap fee: %v",
|
|
|
|
quote.SwapFee, maxServerFee)
|
|
|
|
|
|
|
|
return newReasonError(ReasonSwapFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
if quote.MinerFee > f.MaximumMinerFee {
|
|
|
|
log.Debugf("quoted miner fee: %v > maximum miner "+
|
|
|
|
"fee: %v", quote.MinerFee, f.MaximumMinerFee)
|
|
|
|
|
|
|
|
return newReasonError(ReasonMinerFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-03-02 12:42:02 +00:00
|
|
|
// loopOutFees returns the prepay and routing and miner fees we are willing to
|
|
|
|
// pay for a loop out swap.
|
|
|
|
func (f *FeeCategoryLimit) loopOutFees(amount btcutil.Amount,
|
|
|
|
quote *loop.LoopOutQuote) (btcutil.Amount, btcutil.Amount,
|
|
|
|
btcutil.Amount) {
|
|
|
|
|
|
|
|
prepayMaxFee := ppmToSat(
|
|
|
|
quote.PrepayAmount, f.MaximumPrepayRoutingFeePPM,
|
|
|
|
)
|
|
|
|
|
|
|
|
routeMaxFee := ppmToSat(amount, f.MaximumRoutingFeePPM)
|
|
|
|
|
|
|
|
return prepayMaxFee, routeMaxFee, f.MaximumMinerFee
|
|
|
|
}
|
2021-03-02 12:42:03 +00:00
|
|
|
|
|
|
|
// Compile time assertion that FeePortion implements FeeLimit interface.
|
|
|
|
var _ FeeLimit = (*FeePortion)(nil)
|
|
|
|
|
|
|
|
// FeePortion is a fee limitation which limits fees to a set portion of
|
|
|
|
// the swap amount.
|
|
|
|
type FeePortion struct {
|
|
|
|
// PartsPerMillion is the total portion of the swap amount that the
|
|
|
|
// swap may consume.
|
|
|
|
PartsPerMillion uint64
|
|
|
|
}
|
|
|
|
|
2021-03-02 12:42:06 +00:00
|
|
|
func defaultFeePortion() *FeePortion {
|
|
|
|
return &FeePortion{
|
|
|
|
PartsPerMillion: defaultFeePPM,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-02 12:42:03 +00:00
|
|
|
// NewFeePortion creates a fee limit based on a flat percentage of swap amount.
|
|
|
|
func NewFeePortion(ppm uint64) *FeePortion {
|
|
|
|
return &FeePortion{
|
|
|
|
PartsPerMillion: ppm,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// String returns a string representation of the fee limit.
|
|
|
|
func (f *FeePortion) String() string {
|
|
|
|
return fmt.Sprintf("parts per million: %v", f.PartsPerMillion)
|
|
|
|
}
|
|
|
|
|
|
|
|
// validate returns an error if the values provided are invalid.
|
|
|
|
func (f *FeePortion) validate() error {
|
|
|
|
if f.PartsPerMillion <= 0 {
|
|
|
|
return ErrInvalidPPM
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// mayLoopOut checks our estimated loop out sweep fee against our sweep limit.
|
|
|
|
// For fee percentage, we do not check anything because we need the full quote
|
|
|
|
// to determine whether we can perform a swap.
|
|
|
|
func (f *FeePortion) mayLoopOut(_ chainfee.SatPerKWeight) error {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// loopOutLimits checks whether the quote provided is within our fee
|
|
|
|
// limits for the swap amount.
|
|
|
|
func (f *FeePortion) loopOutLimits(swapAmt btcutil.Amount,
|
|
|
|
quote *loop.LoopOutQuote) error {
|
|
|
|
|
|
|
|
// First, check whether any of the individual fee categories provided
|
|
|
|
// by the server are more than our total limit. We do this so that we
|
|
|
|
// can provide more specific reasons for not executing swaps.
|
|
|
|
feeLimit := ppmToSat(swapAmt, f.PartsPerMillion)
|
|
|
|
minerFee := scaleMinerFee(quote.MinerFee)
|
|
|
|
|
|
|
|
if minerFee > feeLimit {
|
|
|
|
log.Debugf("miner fee: %v greater than fee limit: %v, at "+
|
|
|
|
"%v ppm", minerFee, feeLimit, f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonMinerFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
if quote.SwapFee > feeLimit {
|
|
|
|
log.Debugf("swap fee: %v greater than fee limit: %v, at "+
|
|
|
|
"%v ppm", quote.SwapFee, feeLimit, f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonSwapFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
if quote.PrepayAmount > feeLimit {
|
|
|
|
log.Debugf("prepay amount: %v greater than fee limit: %v, at "+
|
|
|
|
"%v ppm", quote.PrepayAmount, feeLimit, f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonPrepay)
|
|
|
|
}
|
|
|
|
|
|
|
|
// If our miner and swap fee equal our limit, we will have nothing left
|
|
|
|
// for off-chain fees, so we fail out early.
|
|
|
|
if minerFee+quote.SwapFee >= feeLimit {
|
|
|
|
log.Debugf("no budget for off-chain routing with miner fee: "+
|
|
|
|
"%v, swap fee: %v and fee limit: %v, at %v ppm",
|
|
|
|
minerFee, quote.SwapFee, feeLimit, f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonFeePPMInsufficient)
|
|
|
|
}
|
|
|
|
|
|
|
|
prepay, route, miner := f.loopOutFees(swapAmt, quote)
|
|
|
|
|
|
|
|
// Calculate the worst case fees that we could pay for this swap,
|
|
|
|
// ensuring that we are within our fee limit even if the swap fails.
|
|
|
|
fees := worstCaseOutFees(
|
|
|
|
prepay, route, quote.SwapFee, miner, quote.PrepayAmount,
|
|
|
|
)
|
|
|
|
|
|
|
|
if fees > feeLimit {
|
|
|
|
log.Debugf("total fees for swap: %v > fee limit: %v, at "+
|
|
|
|
"%v ppm", fees, feeLimit, f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonFeePPMInsufficient)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// loopOutFees return the maximum prepay and invoice routing fees for a swap
|
|
|
|
// amount and quote. Note that the fee portion implementation just returns
|
|
|
|
// the quote's miner fee, assuming that this value has already been validated.
|
|
|
|
// We also assume that the quote's minerfee + swapfee < fee limit, so that we
|
|
|
|
// have some fees left for off-chain routing.
|
|
|
|
func (f *FeePortion) loopOutFees(amount btcutil.Amount,
|
|
|
|
quote *loop.LoopOutQuote) (btcutil.Amount, btcutil.Amount,
|
|
|
|
btcutil.Amount) {
|
|
|
|
|
|
|
|
// Calculate the total amount we can spend in fees, and subtract the
|
|
|
|
// amounts provided by the quote to get the total available for
|
|
|
|
// off-chain fees.
|
|
|
|
feeLimit := ppmToSat(amount, f.PartsPerMillion)
|
|
|
|
minerFee := scaleMinerFee(quote.MinerFee)
|
|
|
|
|
|
|
|
available := feeLimit - minerFee - quote.SwapFee
|
|
|
|
|
|
|
|
prepayMaxFee, routeMaxFee := splitOffChain(
|
|
|
|
available, quote.PrepayAmount, amount,
|
|
|
|
)
|
|
|
|
|
|
|
|
return prepayMaxFee, routeMaxFee, minerFee
|
|
|
|
}
|
|
|
|
|
|
|
|
// splitOffChain takes an available fee budget and divides it among our prepay
|
|
|
|
// and swap payments proportional to their volume.
|
|
|
|
func splitOffChain(available, prepayAmt,
|
|
|
|
swapAmt btcutil.Amount) (btcutil.Amount, btcutil.Amount) {
|
|
|
|
|
|
|
|
total := swapAmt + prepayAmt
|
|
|
|
|
|
|
|
prepayMaxFee := available * prepayAmt / total
|
|
|
|
routeMaxFee := available * swapAmt / total
|
|
|
|
|
|
|
|
return prepayMaxFee, routeMaxFee
|
|
|
|
}
|
|
|
|
|
|
|
|
// scaleMinerFee scales our miner fee by our constant multiplier.
|
|
|
|
func scaleMinerFee(estimate btcutil.Amount) btcutil.Amount {
|
|
|
|
return estimate * btcutil.Amount(minerMultiplier)
|
|
|
|
}
|
2021-11-30 11:18:31 +00:00
|
|
|
|
|
|
|
func (f *FeePortion) loopInLimits(amount btcutil.Amount,
|
|
|
|
quote *loop.LoopInQuote) error {
|
|
|
|
|
|
|
|
// Calculate the total amount that this swap may spend in fees, as a
|
|
|
|
// portion of the swap amount.
|
|
|
|
totalFeeSpend := ppmToSat(amount, f.PartsPerMillion)
|
|
|
|
|
|
|
|
// Check individual fee components so that we can give more specific
|
|
|
|
// feedback.
|
|
|
|
if quote.MinerFee > totalFeeSpend {
|
|
|
|
log.Debugf("miner fee: %v greater than fee limit: %v, at "+
|
|
|
|
"%v ppm", quote.MinerFee, totalFeeSpend,
|
|
|
|
f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonMinerFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
if quote.SwapFee > totalFeeSpend {
|
|
|
|
log.Debugf("swap fee: %v greater than fee limit: %v, at "+
|
|
|
|
"%v ppm", quote.SwapFee, totalFeeSpend,
|
|
|
|
f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonSwapFee)
|
|
|
|
}
|
|
|
|
|
|
|
|
fees := worstCaseInFees(
|
|
|
|
quote.MinerFee, quote.SwapFee, defaultLoopInSweepFee,
|
|
|
|
)
|
|
|
|
|
|
|
|
if fees > totalFeeSpend {
|
|
|
|
log.Debugf("total fees for swap: %v > fee limit: %v, at "+
|
|
|
|
"%v ppm", fees, totalFeeSpend, f.PartsPerMillion)
|
|
|
|
|
|
|
|
return newReasonError(ReasonFeePPMInsufficient)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|