| 1 | import 'dart:math'; |
| 2 | import "package:cw_bitcoin/coin_selection.dart"; |
| 3 | import "package:flutter_test/flutter_test.dart"; |
| 4 | void main() { |
| 5 | test('effectiveValue', () { |
| 6 | expect(effectiveValue(10000, 136), 9864); |
| 7 | expect(effectiveValue(100, 136), -36); |
| 8 | }); |
| 9 | test('BnB finds changeless match in window', () { |
| 10 | final r = branchAndBound([200,100,90,80], 300, 50); |
| 11 | expect(r, isNotNull); expect(r!.hasChange, isFalse); |
| 12 | final vals=[200,100,90,80]; final sum=r.indices.map((i)=>vals[i]).reduce((a,b)=>a+b); |
| 13 | expect(sum>=300 && sum<=350, isTrue); |
| 14 | }); |
| 15 | test('BnB null when no subset in window', () { |
| 16 | expect(branchAndBound([1000,900], 300, 5), isNull); |
| 17 | }); |
| 18 | test('SRD with change', () { |
| 19 | final r = singleRandomDraw([500,500,500,500], 700, 50, Random(1)); |
| 20 | expect(r, isNotNull); expect(r!.hasChange, isTrue); |
| 21 | }); |
| 22 | test('SRD randomizes across seeds', () { |
| 23 | final a = (singleRandomDraw([100,101,102,103,104,105],150,10,Random(1))!.indices..sort()); |
| 24 | final b = (singleRandomDraw([100,101,102,103,104,105],150,10,Random(9))!.indices..sort()); |
| 25 | expect(a, isNot(equals(b))); |
| 26 | }); |
| 27 | test('SRD null on insufficient funds', () { |
| 28 | expect(singleRandomDraw([100,100], 500, 10, Random(1)), isNull); |
| 29 | }); |
| 30 | test('selectCoins prefers changeless BnB, else SRD', () { |
| 31 | expect(selectCoins(values:[200,100,90],target:300,inputCost:0,costOfChange:20,minChange:10).hasChange, isFalse); |
| 32 | expect(selectCoins(values:[500,500,500],target:700,inputCost:0,costOfChange:5,minChange:10,rng:Random(1)).hasChange, isTrue); |
| 33 | }); |
| 34 | test('selectCoins drops non-positive effective values', () { |
| 35 | // coin of value 50 with inputCost 136 -> effective -86 -> dropped; only the 1000 usable |
| 36 | final r = selectCoins(values:[50,1000],target:500,inputCost:136,costOfChange:5,minChange:10,rng:Random(1)); |
| 37 | expect(r.indices, equals([1])); |
| 38 | }); |
| 39 | test('changelessMatch finds a set whose effective sum lands in the window', () { |
| 40 | // inputCost 100: values [50, 400, 300, 200] -> eff [dropped, 300, 200, 100] |
| 41 | // target 500, window 46: eff {300, 200} = 500, exact |
| 42 | final r = changelessMatch( |
| 43 | values: [50, 400, 300, 200], target: 500, inputCosts: [100, 100, 100, 100], window: 46); |
| 44 | expect(r, isNotNull); |
| 45 | expect(r!.hasChange, isFalse); |
| 46 | final effSum = r.indices.map((i) => [50, 400, 300, 200][i] - 100).reduce((a, b) => a + b); |
| 47 | expect(effSum >= 500 && effSum <= 546, isTrue); |
| 48 | expect(r.indices.contains(0), isFalse); // negative-eff coin never selected |
| 49 | }); |
| 50 | test('changelessMatch returns null when no subset lands in the window', () { |
| 51 | expect( |
| 52 | changelessMatch(values: [10000, 9000], target: 500, inputCosts: [100, 100], window: 46), |
| 53 | isNull); |
| 54 | }); |
| 55 | test('selectCoins throws when insufficient', () { |
| 56 | expect(() => selectCoins(values:[100,100],target:500,inputCost:0,costOfChange:5,minChange:10), |
| 57 | throwsA(isA<InsufficientFundsException>())); |
| 58 | }); |
| 59 | test('changeless pipeline: leftover absorbed into fee is non-negative and below dust', () { |
| 60 | // Mirrors the _createUTXOS / estimateTxForAmount arithmetic with the wallet's |
| 61 | // 68*inputs + 34*outputs + 10 vBytes model, at 10 sat/vB with 1 recipient output. |
| 62 | const feeRate = 10; |
| 63 | const amount = 50000; |
| 64 | final values = [60700, 30000, 21800, 9000, 5000]; |
| 65 | const target = amount + (34 * 1 + 10) * feeRate; |
| 66 | final r = changelessMatch( |
| 67 | values: values, |
| 68 | target: target, |
| 69 | inputCosts: List.filled(values.length, 68 * feeRate), |
| 70 | window: 546); |
| 71 | expect(r, isNotNull); |
| 72 | final inAmount = r!.indices.map((i) => values[i]).reduce((a, b) => a + b); |
| 73 | final feeNoChange = (68 * r.indices.length + 34 * 1 + 10) * feeRate; |
| 74 | final leftover = inAmount - amount - feeNoChange; |
| 75 | expect(leftover >= 0, isTrue); // the caller never recurses for more inputs |
| 76 | expect(leftover <= 546, isTrue); // fee overpay is bounded by the dust limit |
| 77 | }); |
| 78 | test('BnB terminates and returns null on large pools with no possible match', () { |
| 79 | // 300 even effective values, odd target, zero window: no subset can ever match, |
| 80 | // so the search must stop at maxTries instead of exploring 2^300 branches. |
| 81 | final values = List<int>.generate(300, (i) => 1000000 + i * 2); |
| 82 | final r = changelessMatch( |
| 83 | values: values, target: 1500001, inputCosts: List.filled(300, 10), window: 0); |
| 84 | expect(r, isNull); |
| 85 | }); |
| 86 | test('changelessMatch charges each input its own script-type cost', () { |
| 87 | // Legacy input (148 vB) vs segwit input (68 vB) at 10 sat/vB: same value, but |
| 88 | // the legacy coin's effective value is 800 lower. Target only reachable when |
| 89 | // the cheaper segwit coin is chosen: eff segwit = 10000-680 = 9320. |
| 90 | const feeRate = 10; |
| 91 | final r = changelessMatch( |
| 92 | values: [10000, 10000], |
| 93 | target: 9320, |
| 94 | inputCosts: [148 * feeRate, 68 * feeRate], |
| 95 | window: 0, |
| 96 | ); |
| 97 | expect(r, isNotNull); |
| 98 | expect(r!.indices, equals([1])); |
| 99 | }); |
| 100 | } |