Skip to content

Commit 017c8bf

Browse files
authored
Merge pull request #1377 from lightninglabs/rfqmath-fix-tolerance
rfqmath: Improve WithinTolerance doc and handle zero coefficient edge case
2 parents e696148 + a95197d commit 017c8bf

File tree

3 files changed

+155
-19
lines changed

3 files changed

+155
-19
lines changed

rfq/negotiator.go

+44-2
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,30 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept,
652652
tolerance := rfqmath.NewBigIntFromUint64(
653653
n.cfg.AcceptPriceDeviationPpm,
654654
)
655-
acceptablePrice := msg.AssetRate.Rate.WithinTolerance(
655+
acceptablePrice, err := msg.AssetRate.Rate.WithinTolerance(
656656
assetRate.Rate, tolerance,
657657
)
658+
if err != nil {
659+
// The tolerance check failed. We will return without
660+
// calling the quote accept callback.
661+
err = fmt.Errorf("failed to check tolerance: %w", err)
662+
log.Errorf("Error checking tolerance: %v", err)
663+
664+
// Construct an invalid quote response event so that we
665+
// can inform the peer that the quote response has not
666+
// validated successfully.
667+
invalidQuoteRespEvent := NewInvalidQuoteRespEvent(
668+
&msg, InvalidAssetRatesQuoteRespStatus,
669+
)
670+
finalise(
671+
msg, fn.Some[InvalidQuoteRespEvent](
672+
*invalidQuoteRespEvent,
673+
),
674+
)
675+
676+
return
677+
}
678+
658679
if !acceptablePrice {
659680
// The price is not within the acceptable tolerance.
660681
// We will return without calling the quote accept
@@ -791,9 +812,30 @@ func (n *Negotiator) HandleIncomingSellAccept(msg rfqmsg.SellAccept,
791812
tolerance := rfqmath.NewBigIntFromUint64(
792813
n.cfg.AcceptPriceDeviationPpm,
793814
)
794-
acceptablePrice := msg.AssetRate.Rate.WithinTolerance(
815+
acceptablePrice, err := msg.AssetRate.Rate.WithinTolerance(
795816
assetRate.Rate, tolerance,
796817
)
818+
if err != nil {
819+
// The tolerance check failed. We will return without
820+
// calling the quote accept callback.
821+
err = fmt.Errorf("failed to check tolerance: %w", err)
822+
log.Errorf("Error checking tolerance: %v", err)
823+
824+
// Construct an invalid quote response event so that we
825+
// can inform the peer that the quote response has not
826+
// validated successfully.
827+
invalidQuoteRespEvent := NewInvalidQuoteRespEvent(
828+
&msg, InvalidAssetRatesQuoteRespStatus,
829+
)
830+
finalise(
831+
msg, fn.Some[InvalidQuoteRespEvent](
832+
*invalidQuoteRespEvent,
833+
),
834+
)
835+
836+
return
837+
}
838+
797839
if !acceptablePrice {
798840
// The price is not within the acceptable bounds.
799841
// We will return without calling the quote accept

rfqmath/fixed_point.go

+24-3
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,29 @@ func (f FixedPoint[T]) Equals(other FixedPoint[T]) bool {
119119
}
120120

121121
// WithinTolerance returns true if the two FixedPoint values are within the
122-
// given tolerance (in parts per million (PPM)).
122+
// given tolerance, specified in parts per million (PPM).
123+
//
124+
// The tolerance is applied relative to the larger of the two values to ensure
125+
// that a 100% tolerance (1,000,000 PPM) always results in a match for any two
126+
// nonzero values.
127+
//
128+
// This max-based approach (e.g. ask) is more lenient than using the smaller
129+
// value (e.g., bid) as the reference. For example, if the two values are 100
130+
// and 105, a 5% tolerance (50,000 PPM) allows a maximum difference of:
131+
// - Bid-based: 100 × 0.05 = 5 (so 105 is the limit)
132+
// - Max-based: 105 × 0.05 = 5.25 (so 105 is still within tolerance)
133+
//
134+
// This means max-based tolerance accepts slightly wider spreads, making it
135+
// less strict in bid-ask scenarios.
123136
func (f FixedPoint[T]) WithinTolerance(
124-
other FixedPoint[T], tolerancePpm T) bool {
137+
other FixedPoint[T], tolerancePpm T) (bool, error) {
138+
139+
// If either of the coefficients are zero, we cannot check tolerance.
140+
zero := NewInt[T]().FromUint64(0)
141+
if f.Coefficient.Equals(zero) || other.Coefficient.Equals(zero) {
142+
return false, fmt.Errorf("cannot check tolerance with zero " +
143+
"value")
144+
}
125145

126146
// Determine the larger scale between the two fixed-point numbers.
127147
// Both values will be scaled to this larger scale to ensure a
@@ -162,7 +182,8 @@ func (f FixedPoint[T]) WithinTolerance(
162182
// Compare the scaled delta to the product of the maximum coefficient
163183
// and the tolerance.
164184
toleranceCoefficient := maxCoefficient.Mul(tolerancePpm)
165-
return toleranceCoefficient.Gte(scaledDelta)
185+
result := toleranceCoefficient.Gte(scaledDelta)
186+
return result, nil
166187
}
167188

168189
// FixedPointFromUint64 creates a new FixedPoint from the given integer and

rfqmath/fixed_point_test.go

+87-14
Original file line numberDiff line numberDiff line change
@@ -309,12 +309,26 @@ func testCasesWithinTolerance[N Int[N]](t *testing.T) {
309309
},
310310
}
311311

312+
// Create a zero coefficient to test the error case.
313+
zeroCoefficient := NewInt[N]().FromUint64(0)
314+
312315
// Run the test cases.
313316
for idx, tc := range testCases {
314-
result := tc.firstFp.WithinTolerance(
317+
result, err := tc.firstFp.WithinTolerance(
315318
tc.secondFp, NewInt[N]().FromUint64(tc.tolerancePpm),
316319
)
317320

321+
// If either of the coefficients are zero, we expect an error.
322+
if tc.firstFp.Coefficient.Equals(zeroCoefficient) ||
323+
tc.secondFp.Coefficient.Equals(zeroCoefficient) {
324+
325+
require.Error(t, err)
326+
require.False(t, result)
327+
continue
328+
}
329+
330+
require.NoError(t, err, "Test case %d failed", idx)
331+
318332
// Compare bounds check result with expected test case within
319333
// bounds flag.
320334
require.Equal(
@@ -330,7 +344,7 @@ func testWithinToleranceEqualValues(t *rapid.T) {
330344
tolerancePpm := rapid.Int64Min(0).Draw(t, "tolerance")
331345

332346
// Generate a random coefficient and scale.
333-
coefficient := rapid.Int64Min(0).Draw(t, "coefficient")
347+
coefficient := rapid.Int64Min(1).Draw(t, "coefficient")
334348
scale := rapid.Uint8Range(0, 18).Draw(t, "scale")
335349

336350
// Create two identical FixedPoint[BigInt] values.
@@ -348,7 +362,9 @@ func testWithinToleranceEqualValues(t *rapid.T) {
348362

349363
// The result should always be true when the fixed-point values
350364
// are equal.
351-
result := f1.WithinTolerance(f2, tolerancePpmBigInt)
365+
result, err := f1.WithinTolerance(f2, tolerancePpmBigInt)
366+
require.NoError(t, err)
367+
352368
if !result {
353369
t.Fatalf("WithinTolerance should be true when values " +
354370
"are equal, but got false")
@@ -363,7 +379,7 @@ func testWithinToleranceZeroTolerance(t *rapid.T) {
363379
tolerancePpmBigInt := NewBigInt(big.NewInt(0))
364380

365381
// Generate two equal fixed-points.
366-
coefficient := rapid.Int64Min(0).Draw(t, "coefficient")
382+
coefficient := rapid.Int64Min(1).Draw(t, "coefficient")
367383
scale := rapid.Uint8Range(0, 18).Draw(t, "scale")
368384
f1 := FixedPoint[BigInt]{
369385
Coefficient: NewBigInt(big.NewInt(coefficient)),
@@ -375,16 +391,18 @@ func testWithinToleranceZeroTolerance(t *rapid.T) {
375391
Scale: scale,
376392
}
377393

378-
require.True(t, f1.WithinTolerance(f2, tolerancePpmBigInt))
394+
result, err := f1.WithinTolerance(f2, tolerancePpmBigInt)
395+
require.NoError(t, err)
396+
require.True(t, result)
379397
}
380398

381399
// testWithinToleranceSymmetric is a property-based test which ensures that the
382400
// WithinTolerance method is symmetric (swapping the order of the fixed-point
383401
// values does not change the result).
384402
func testWithinToleranceSymmetric(t *rapid.T) {
385403
// Generate random coefficients, scales, and tolerance
386-
coefficient := rapid.Int64Min(0).Draw(t, "coefficient_1")
387-
coefficient2 := rapid.Int64Min(0).Draw(t, "coefficient_2")
404+
coefficient := rapid.Int64Min(1).Draw(t, "coefficient_1")
405+
coefficient2 := rapid.Int64Min(1).Draw(t, "coefficient_2")
388406
scale1 := rapid.Uint8Range(0, 18).Draw(t, "scale_1")
389407
scale2 := rapid.Uint8Range(0, 18).Draw(t, "scale_2")
390408
tolerancePpm := rapid.Int64Range(0, 1_000_000).Draw(t, "tolerance")
@@ -401,8 +419,11 @@ func testWithinToleranceSymmetric(t *rapid.T) {
401419

402420
tolerancePpmBigInt := NewBigInt(big.NewInt(tolerancePpm))
403421

404-
result1 := f1.WithinTolerance(f2, tolerancePpmBigInt)
405-
result2 := f2.WithinTolerance(f1, tolerancePpmBigInt)
422+
result1, err := f1.WithinTolerance(f2, tolerancePpmBigInt)
423+
require.NoError(t, err)
424+
425+
result2, err := f2.WithinTolerance(f1, tolerancePpmBigInt)
426+
require.NoError(t, err)
406427

407428
if result1 != result2 {
408429
t.Fatalf("WithinTolerance is not symmetric: "+
@@ -420,14 +441,14 @@ func testWithinToleranceMaxTolerance(t *rapid.T) {
420441
tolerancePpmBigInt := NewBigInt(big.NewInt(tolerancePpm))
421442

422443
// Generate random fixed-point values.
423-
coefficient1 := rapid.Int64Min(0).Draw(t, "coefficient_1")
444+
coefficient1 := rapid.Int64Min(1).Draw(t, "coefficient_1")
424445
scale1 := rapid.Uint8Range(0, 18).Draw(t, "scale_1")
425446
f1 := FixedPoint[BigInt]{
426447
Coefficient: NewBigInt(big.NewInt(coefficient1)),
427448
Scale: scale1,
428449
}
429450

430-
coefficient2 := rapid.Int64Min(0).Draw(t, "coefficient_2")
451+
coefficient2 := rapid.Int64Min(1).Draw(t, "coefficient_2")
431452
scale2 := rapid.Uint8Range(0, 18).Draw(t, "scale_2")
432453
f2 := FixedPoint[BigInt]{
433454
Coefficient: NewBigInt(big.NewInt(coefficient2)),
@@ -436,13 +457,59 @@ func testWithinToleranceMaxTolerance(t *rapid.T) {
436457

437458
// The result should always be true when tolerancePpm is at its max or
438459
// larger.
439-
result := f1.WithinTolerance(f2, tolerancePpmBigInt)
460+
result, err := f1.WithinTolerance(f2, tolerancePpmBigInt)
461+
require.NoError(t, err)
462+
440463
if !result {
441464
t.Fatalf("WithinTolerance should be true when tolerancePpm " +
442465
"is large, but got false")
443466
}
444467
}
445468

469+
// testWithinToleranceCoefficientZeroError is a property-based test which
470+
// ensures that the WithinTolerance method returns an error when either or both
471+
// of the fixed-point values have a coefficient of zero.
472+
func testWithinToleranceCoefficientZeroError(t *rapid.T) {
473+
// Set tolerancePpm to any value above 100%.
474+
tolerancePpm := rapid.Int64Min(1_000_000).Draw(t, "tolerance")
475+
tolerancePpmBigInt := NewBigInt(big.NewInt(tolerancePpm))
476+
477+
// Generate a random mode to determine which coefficient(s) to set to
478+
// zero.
479+
mode := rapid.Int64Range(0, 2).Draw(t, "mode")
480+
481+
var coefficient1, coefficient2 int64
482+
switch mode {
483+
case 0:
484+
coefficient1 = 0
485+
coefficient2 = rapid.Int64Min(1).Draw(t, "coefficient_2")
486+
case 1:
487+
coefficient1 = rapid.Int64Min(1).Draw(t, "coefficient_1")
488+
coefficient2 = 0
489+
case 2:
490+
coefficient1 = 0
491+
coefficient2 = 0
492+
}
493+
494+
// Generate random fixed-point values from the coefficients.
495+
scale1 := rapid.Uint8Range(0, 18).Draw(t, "scale_1")
496+
f1 := FixedPoint[BigInt]{
497+
Coefficient: NewBigInt(big.NewInt(coefficient1)),
498+
Scale: scale1,
499+
}
500+
501+
scale2 := rapid.Uint8Range(0, 18).Draw(t, "scale_2")
502+
f2 := FixedPoint[BigInt]{
503+
Coefficient: NewBigInt(big.NewInt(coefficient2)),
504+
Scale: scale2,
505+
}
506+
507+
// We always expect an error when either or both coefficients are zero.
508+
result, err := f1.WithinTolerance(f2, tolerancePpmBigInt)
509+
require.Error(t, err)
510+
require.False(t, result)
511+
}
512+
446513
// testWithinToleranceFloatReproduce is a property-based test that verifies
447514
// the reproducibility of the WithinTolerance method's result using calculations
448515
// on float64 values.
@@ -452,7 +519,7 @@ func testWithinToleranceFloatReproduce(t *rapid.T) {
452519
tolerancePpmBigInt := NewBigInt(big.NewInt(tolerancePpm))
453520

454521
// Generate random fixed-point values.
455-
coefficientsRange := rapid.Int64Range(0, 1_000_000_000)
522+
coefficientsRange := rapid.Int64Range(1, 1_000_000_000)
456523

457524
coefficient1 := coefficientsRange.Draw(t, "coefficient_1")
458525
scale1 := rapid.Uint8Range(0, 9).Draw(t, "scale_1")
@@ -469,7 +536,8 @@ func testWithinToleranceFloatReproduce(t *rapid.T) {
469536
}
470537

471538
// Compute the result using the WithinTolerance method.
472-
result := f1.WithinTolerance(f2, tolerancePpmBigInt)
539+
result, err := f1.WithinTolerance(f2, tolerancePpmBigInt)
540+
require.NoError(t, err)
473541

474542
// Compute expected result using float64.
475543
f1Float := f1.ToFloat64()
@@ -523,6 +591,11 @@ func testWithinTolerance(t *testing.T) {
523591
rapid.MakeCheck(testWithinToleranceMaxTolerance),
524592
)
525593

594+
t.Run(
595+
"within_tolerance_coefficient_zero_error",
596+
rapid.MakeCheck(testWithinToleranceCoefficientZeroError),
597+
)
598+
526599
t.Run(
527600
"within_tolerance_float_reproduce",
528601
rapid.MakeCheck(testWithinToleranceFloatReproduce),

0 commit comments

Comments
 (0)