decdn-node 0.1.0

The deCDN node daemon (decdn-node): runtime, request handlers, payment settlement, and the admin RPC server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
//! On-chain `PaymentPool` seller-settlement service (ADR 003).
//!
//! The node is the *provider* (payee) on its voucher lanes. This service closes
//! the on-chain half of the paid-delivery loop that the off-chain voucher layer
//! ([`decdn_incentive`]) and the `cdn/client/v1` handler leave open. A pool is
//! owner-owned and owner-closed: the node NEVER closes, disputes, or settles a
//! pool. Its only settlement primitive is redemption — `redeem` for a single
//! lane, `redeemMany` for a batch — plus register-once of each signer's
//! capability on that signer's first redemption.
//!
//! - **Paid-watermark watcher.** The paid side of every lane is driven by
//!   consuming `PoolRedeemed` events filtered on this node's own `provider`
//!   address (ADR 003 § Tracking owed vs. paid): each event sets the lane's paid
//!   cumulative to `newPaidCumulative` and mirrors it onto the durable
//!   `LaneState::paid_cumulative` row. `PoolToppedUp` re-drives a pool's lanes
//!   (a dry pool may have left `owed > paid`), and `PoolReclaimed` forgets the
//!   pool's lanes. The watcher reconciles like every other chain watcher —
//!   enumerate `PoolRedeemed` from a pinned block, then tail live, resyncing on a
//!   missed range — so paid is rebuilt from the event log, never guessed. That
//!   forward rebuild starts at the poller's persisted cursor, so bootstrap first
//!   rehydrates the paid cache from the durable lane rows: a restart carries
//!   forward every redemption behind the cursor instead of re-submitting those
//!   lanes for silent on-chain no-ops (#2052). The same
//!   scan also folds the serve path's pool solvency/funder projection
//!   ([`crate::pool_view::PoolProjection`]): `PoolOpened`/`PoolToppedUp` set a
//!   pool's `{owner, deposit}`, `PoolRedeemed` for every provider draws down its
//!   pool-wide `totalRedeemed`, and `PoolReclaimed` drops it — so a serve request
//!   reads `{owner, remaining}` in-memory instead of through a `getPool` `eth_call`.
//! - **Redemption (per-chunk floor + on-shutdown).** On a redeem hint (a
//!   [`LaneKey`]) emitted by the voucher-accept path, the node reads the lane's
//!   owed voucher and its cached paid watermark and plans the lane for
//!   redemption — with no chain read. A signer this node has not yet registered
//!   attaches a `CapabilityReg` from its held `owner_sig`; on-chain registration
//!   is idempotent, and the first landed redemption persists `registered_until`
//!   so later passes attach nothing. A low-frequency
//!   self-tick sweeps every persisted lane, packs the planned lanes into
//!   chunks, and submits a chunk — one `redeemMany` — only once the aggregate
//!   unredeemed value across that chunk's lanes clears a configurable floor,
//!   so a dropped hint never strands a lane whose chunk has cleared the floor.
//!   The same sweep also runs at each serve cutoff: one second after the voucher
//!   and preimage paths start to refuse a lane whose capability expiry is near.
//!   The lane's claim is final there, and the sweep has about one redeem interval
//!   to start the redemption and the landing slack to land it before the
//!   contract stops paying the capability. On a restart, a lane whose cutoff
//!   passed while the node was down is swept at once. A value that stays below
//!   the floor is not redeemed by the cutoff sweep; a later sweep before the
//!   landing slack can still pack it with other lanes.
//!   The node flushes the lane store durable after planning a chunk and before
//!   submitting it, so post-crash on-disk `owed ≥ submitted`.
//! - **Redeem before reclaim.** A pool is owner-owned and owner-closed; the node
//!   never closes, disputes, or settles. An owner's `closePool` only starts the
//!   grace window (ADR 003 § Owner reclaims before a node redeems). The node runs
//!   no force-redeem on close: the self-tick sweep runs far inside the 48h grace
//!   floor (`redeem_interval_secs` is capped well below it at config load), so it
//!   redeems every lane worth the gas before the owner can `reclaim`. A lane below
//!   the per-chunk floor is left for the owner to reclaim — spending more gas than
//!   it recovers is not worth it — and a node offline for the whole grace window
//!   forfeits its unredeemed vouchers, a node-ops failure, not a protocol gap.
//!   The sink does fold `PoolCloseInitiated` into the projection (marking the pool
//!   `Closing` with its deadline) so the redeemer's solvency gate drops a drained
//!   lane, or one whose deadline falls within the landing slack, instead of
//!   submitting a `redeemMany` that reverts `PoolClosed`.
//!
//! Buyer-side `openPool`/`topUp`/`reclaim` (node→node cache-miss pulls) is out of
//! scope here. The paid-watermark watcher is a [`Route`] the runtime registers on
//! the shared multiplexed poller (which owns the loop, cursor, and shutdown); the
//! service itself owns only the redeemer [`JoinHandle`], aborted on shutdown or
//! drop.

use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use alloy::primitives::{Address, B256, Bytes, Signature, TxHash, U256};
use alloy::providers::Provider;
use alloy::rpc::types::Log;
use alloy::sol_types::SolEvent;
use anyhow::{Context, Result};
use decdn_common::config::{REDEEM_LANDING_SLACK_SECS, capability_expiry_margin_secs};
use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
use decdn_incentive::payment_pool::{PaymentPool, to_pool_u64};
use decdn_incentive::sig_canon::is_high_s;
use decdn_incentive::{
    CheckpointKey, KeyedCheckpointStore, LaneKey, LaneState, PoolId, PoolStateStore, StoreError,
};
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{Instrument as _, debug, error, info, warn};

use crate::chain_events::boot_retry::BootRetry;
use crate::chain_events::multiplexed_poller::{Route, SinkSource};
use crate::chain_events::resumable_watcher::{Checkpoint, ColdStart, CursorStart, LogSink};
use crate::chain_events::{REORG_MARGIN_BLOCKS, timed};
use crate::handlers::client::ClientHandler;
use crate::metrics::{Metrics, metric_hook};
use crate::onchain_tx::{TxOutcome, send_and_await_receipt};
use crate::pool_view::{Lifecycle, PoolProjection, PoolStatus};
use decdn_incentive::payment_pool::SignerAuthorization;

/// Capacity of the redeem-hint channel. Hints are advisory (a missed hint only
/// delays a redemption until the next voucher or self-tick sweep), so a bounded
/// channel that drops on overflow is acceptable — sized for a burst of concurrent
/// lanes without backpressuring the voucher-accept path.
pub const REDEEM_HINT_CAPACITY: usize = 256;

/// How long the admit path suppresses a repeat `getPool` for a pool it just
/// found not-servable (nonexistent / `Closed`). A not-servable pool never folds
/// into the projection, so its `snapshot` stays `None`; without this a client
/// re-sending its capability on every request would drive one `getPool` per
/// request against the same dead pool. On expiry the pool is re-checked once
/// — the window is short enough that a pool opened after a negative result still
/// becomes servable within it, long enough to collapse a request flood to ~one
/// call per pool per window.
const RESOLVE_VERDICT_TTL: Duration = Duration::from_mins(1);

/// How long the admit path suppresses a repeat `getPool` for a pool whose read
/// faulted (an RPC error or a `timed` timeout). A fault says nothing about the
/// pool, so the window is far shorter than [`RESOLVE_VERDICT_TTL`]: a live pool
/// is admitted again a few seconds after the RPC recovers. It is not zero: in an
/// outage a request inside the window refuses at once instead of waiting out a
/// read of its own. The window starts when the read ends; while a read is in
/// flight, other requests for that pool wait on it (see [`ReadSlot`]), so an
/// outage costs one read per pool at a time.
const RESOLVE_FAULT_TTL: Duration = Duration::from_secs(5);

/// Why a pool sits in the admit-path negative cache. The reason picks the
/// suppression window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NegativeReason {
    /// `getPool` answered: the pool is absent or `Closed`.
    Verdict,
    /// `getPool` failed or timed out; nothing is known about the pool.
    Fault,
}

impl NegativeReason {
    /// How long an entry with this reason suppresses a repeat `getPool`.
    const fn ttl(self) -> Duration {
        match self {
            Self::Verdict => RESOLVE_VERDICT_TTL,
            Self::Fault => RESOLVE_FAULT_TTL,
        }
    }
}

/// Cap on the admit-path negative cache, bounding its memory against a flood of
/// distinct nonexistent pool ids. At the cap an insert first prunes lapsed
/// entries; if the cache is still full it skips the insert, so a flood of
/// distinct in-window negatives past the cap pays one `getPool` per admit rather
/// than growing the cache without limit.
const RESOLVE_NEGATIVE_CACHE_MAX: usize = 4096;

/// How long an admit-path `Unregistered` read stays fresh in the signer-auth
/// cache. Any provider's next redemption registers the signer, and a
/// registration that pays no lane emits no event the projection can fold, so
/// only a re-read sees it. Inside the window the node admits the signer on its
/// presented capability; the on-chain `redeemMany` pays `min(desired, cap −
/// spent)` and never over-cashes. A `Registered` read has no window: see
/// [`AuthRead`].
const UNREGISTERED_AUTH_TTL: Duration = Duration::from_mins(1);

/// Cap on the admit-path signer-auth cache, bounding its memory against a flood of
/// distinct `(pool, signer)` pairs. At the cap an insert first prunes lapsed
/// `Unregistered` reads, then evicts the oldest read, so the map never grows
/// without bound and an evicted signer pays one `getAuthorization` on its next
/// admit.
const AUTH_CACHE_MAX: usize = 4096;

/// One admit-path `getAuthorization` read held in the signer-auth cache.
///
/// A `Registered` read never goes stale. Its `cap` and `expiry` are write-once,
/// and its `spent` moves only by redemptions, which the projection folds from
/// every provider's `PoolRedeemed`. The live `spent` is the read's `spent` plus
/// what the projection folded for the signer since the read. The baseline is
/// taken before the read is sent, so a redemption folded while the read is in
/// flight may count twice: the error makes `spent` high, which refuses near the
/// cap rather than over-admits.
#[derive(Clone, Copy, Debug)]
struct AuthRead {
    /// What the chain answered.
    auth: SignerAuthorization,
    /// The projection's folded `spent` for the signer when the read was sent.
    folded_at_read: u64,
    /// When the read landed.
    at: Instant,
}

/// What the signer-auth cache holds for one `(pool, signer)` at an admit.
#[derive(Debug, PartialEq, Eq)]
enum CachedAuth {
    /// A usable answer, with a registered `spent` brought up to the fold.
    Fresh(SignerAuthorization),
    /// An `Unregistered` read past [`UNREGISTERED_AUTH_TTL`], or one the
    /// projection has since seen the signer redeem: the signer may be
    /// registered now.
    Lapsed,
    /// No read is held for the pair.
    Absent,
}

/// Classify a held read against the projection's current fold for the signer.
/// Pure, so the freshness rules are unit-testable without a provider.
fn cached_auth(read: Option<&AuthRead>, folded_now: u64) -> CachedAuth {
    let Some(read) = read else {
        return CachedAuth::Absent;
    };
    let folded_since = folded_now.saturating_sub(read.folded_at_read);
    match read.auth {
        SignerAuthorization::Registered { cap, expiry, spent } => {
            CachedAuth::Fresh(SignerAuthorization::Registered {
                cap,
                expiry,
                spent: spent.saturating_add(folded_since),
            })
        }
        SignerAuthorization::Unregistered
            if folded_since == 0 && read.at.elapsed() < UNREGISTERED_AUTH_TTL =>
        {
            CachedAuth::Fresh(SignerAuthorization::Unregistered)
        }
        SignerAuthorization::Unregistered => CachedAuth::Lapsed,
    }
}

/// Store `read` for `key`. At [`AUTH_CACHE_MAX`], prune lapsed `Unregistered`
/// reads first; if the cache is still full, evict the oldest read. Pure, so the
/// bound is unit-testable.
fn remember_auth(
    cache: &mut HashMap<(B256, Address), AuthRead>,
    key: (B256, Address),
    read: AuthRead,
) {
    if cache.len() >= AUTH_CACHE_MAX && !cache.contains_key(&key) {
        cache.retain(|_, r| {
            matches!(r.auth, SignerAuthorization::Registered { .. })
                || r.at.elapsed() < UNREGISTERED_AUTH_TTL
        });
        if cache.len() >= AUTH_CACHE_MAX
            && let Some(oldest) = cache.iter().min_by_key(|(_, r)| r.at).map(|(k, _)| *k)
        {
            cache.remove(&oldest);
        }
    }
    cache.insert(key, read);
}

/// Bounded receipt wait for a redemption transaction. A stuck/dropped/replaced tx
/// must not wedge a background tick. A lapse first fetches the receipt by hash,
/// which adds at most 37 s (`RESOLVE_*` in `onchain_tx`); when that finds nothing
/// it yields [`TxOutcome::Timeout`], a non-fatal outcome (the tx may still mine
/// later; the claim is at worst deferred, never double-spent, because the
/// on-chain lane watermark is monotone).
const REDEEM_RECEIPT_TIMEOUT: Duration = Duration::from_mins(3);

/// Bound on the reactive halve-and-retry when a chunk fails to send oversized.
/// A default-sized chunk (300) sits far under the block-gas ceiling, so a real
/// oversize needs at most one or two halvings; this caps the worst-case fan-out
/// (a transient error mis-flagged as oversize) at 2^6 doomed sub-sends.
const MAX_SPLIT_DEPTH: u32 = 6;

/// Lanes per pre-redeem `getWatermarks` read ([`reconcile_onchain_watermarks`]).
/// [`plan_lanes`] plans every persisted lane owed more than it is paid, so the
/// count is bounded by nothing the redeemer controls — the per-transaction
/// voucher cap chunks the *submit*, after this read. 512 triples is ~49 KB of
/// calldata and ~32 KB of return, and under 3M gas — `test_getWatermarks_fullSizeBatch`
/// measures a batch this size and the gas snapshot tracks it, so a change that
/// makes the read too expensive shows up as a snapshot diff. That is well inside
/// the `eth_call` ceilings providers commonly set. An endpoint that rejects a
/// batch anyway surfaces as `redemption_reconcile_failures`, not as a wrong
/// answer. A plain const, not a config knob: no operator knowledge makes a
/// better choice here, and reusing the per-tx voucher cap would refragment the
/// read whenever an operator lowered it.
const WATERMARK_READ_BATCH_MAX: usize = 512;

/// Current Unix time in seconds, compared against a lane's cached
/// `registered_until` to skip the on-chain authorization read while the
/// registration is still live (`registered_until > now`). A broken system clock
/// (time before the epoch) yields `0`, so any non-zero `registered_until` looks
/// live and the read is skipped. This is benign: if the capability has actually
/// expired on-chain, the redemption simply no-ops as transient-empty at the
/// contract, which stays the authoritative backstop.
pub(crate) fn unix_now() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_secs())
}

/// Debounce thresholds for the watcher scan checkpoint (#784). The persisted
/// checkpoint is only a *floor* for the resume backfill: the resumable watcher's
/// `resolve_persisted_start` rounds it down by `REORG_MARGIN_BLOCKS` and every
/// sink is idempotent, so a checkpoint that lags the true scan position by a
/// bounded amount only ever *widens* the next rescan, never narrows it. `512` is
/// chosen so a backlog drain fsyncs ~once per 512 blocks instead of per block.
const CHECKPOINT_FLUSH_BLOCKS: u64 = 512;

/// Time-based companion to [`CHECKPOINT_FLUSH_BLOCKS`]: even a slow trickle of
/// events persists at least this often, bounding how many blocks a crash
/// re-scans when block-cadence alone would defer the write indefinitely.
const CHECKPOINT_FLUSH_INTERVAL: Duration = Duration::from_secs(30);

/// In-memory index of every lane's paid-cumulative watermark, keyed by
/// [`LaneKey`], read by the redeemer to compute `unredeemed = owed − paid`
/// (ADR 003 § Tracking owed vs. paid). Written by the `PoolRedeemed` watcher,
/// which mirrors every update onto the durable `LaneState::paid_cumulative` row
/// in the same breath. The durable field is the source of truth; this cache is
/// rehydrated from it at bootstrap ([`PoolSettlementService::bootstrap`]) so a
/// restart carries forward redemptions that landed before the log-poller's
/// persisted cursor, instead of re-submitting those lanes for silent on-chain
/// no-ops (#2052).
///
/// A `std::sync::Mutex`: the guard is only ever held to read/insert a single
/// entry, never across an `.await`.
#[derive(Clone, Default)]
pub struct PaidWatermarks {
    inner: Arc<Mutex<HashMap<LaneKey, U256>>>,
}

impl std::fmt::Debug for PaidWatermarks {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let len = self
            .inner
            .lock()
            .map_or_else(|p| p.into_inner().len(), |m| m.len());
        f.debug_struct("PaidWatermarks")
            .field("lanes", &len)
            .finish()
    }
}

impl PaidWatermarks {
    /// Set a lane's paid cumulative to the `PoolRedeemed` event's
    /// `newPaidCumulative`. Monotone on-chain, so this only ever advances.
    fn set(&self, key: LaneKey, paid: U256) {
        self.inner
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .insert(key, paid);
    }

    /// A lane's paid cumulative, or `U256::ZERO` if none is cached — no
    /// `PoolRedeemed` has landed for it this run and the bootstrap rehydration
    /// found no durable watermark (a never-redeemed lane). `ZERO` is the safe
    /// over-estimate of `unredeemed`: the on-chain redeem caps the increment and
    /// the event then corrects both the cache and the durable row.
    fn get(&self, key: &LaneKey) -> U256 {
        self.inner
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(key)
            .copied()
            .unwrap_or(U256::ZERO)
    }

    /// Drop a reclaimed pool's lane from the cache.
    fn forget(&self, key: &LaneKey) {
        self.inner
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .remove(key);
    }
}

/// Seller-side `PaymentPool` settlement service. Generic over the alloy
/// [`Provider`] (a wallet-filled provider is required for the `redeem` /
/// `redeemMany` write path). Cheap to construct; owns its background tasks.
pub struct PoolSettlementService<P: Provider + Clone + 'static> {
    contract: PaymentPool::PaymentPoolInstance<P>,
    redeem_tx: mpsc::Sender<LaneKey>,
    store: Arc<dyn PoolStateStore>,
    paid: PaidWatermarks,
    self_address: Address,
    redeem_threshold: U256,
    redeem_max_vouchers_per_tx: usize,
    metrics: Arc<Metrics>,
    pool_view: PoolProjection,
    /// The redemption task handle. Held so shutdown can abort+await it before a
    /// final redeem sweep. `take()`n by [`Self::quiesce_redeemer`]; the [`Drop`]
    /// impl aborts whatever remains. A `std::sync::Mutex` (not `tokio`): the guard
    /// is only ever held to `take()` the handle, never across an `.await`.
    redeemer: std::sync::Mutex<Option<JoinHandle<()>>>,
}

/// Startup self-check: a cheap immutable view confirms the configured address
/// actually hosts the `PaymentPool` contract. Transient errors retry on
/// `boot`'s budget; no contract at the address decodes as `ZeroData`, which is
/// permanent and fails at once.
async fn usdc_self_check<P: Provider + Clone>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    boot: &BootRetry,
) -> Result<Address> {
    let payment_pool_addr = *contract.address();
    boot.run("PaymentPool.usdc() self-check", || async {
        timed(None, "PaymentPool.usdc()", contract.usdc().call())
            .await
            .with_context(|| format!("PaymentPool.usdc() self-check at {payment_pool_addr}"))
    })
    .await
}

impl<P: Provider + Clone + 'static> PoolSettlementService<P> {
    /// Bootstrap the service: self-check the contract, spawn the redemption
    /// task, and return the service alongside the paid-watermark [`Route`] the
    /// runtime registers on the shared multiplexed poller.
    ///
    /// # Errors
    ///
    /// Returns an error if the `usdc()` self-check fails deterministically — a
    /// bad `payment_pool_address` is fatal at bring-up — or if `boot`'s budget
    /// runs out on transient RPC failures.
    #[allow(clippy::too_many_arguments)]
    pub async fn bootstrap(
        provider: P,
        payment_pool_addr: Address,
        self_address: Address,
        store: Arc<dyn PoolStateStore>,
        checkpoint_store: Arc<dyn KeyedCheckpointStore>,
        handler: Arc<ClientHandler>,
        redeem_threshold: U256,
        redeem_max_vouchers_per_tx: usize,
        redeem_interval: Duration,
        metrics: Arc<Metrics>,
        boot: &BootRetry,
        pool_view: PoolProjection,
        redeem_tx: mpsc::Sender<LaneKey>,
        redeem_rx: mpsc::Receiver<LaneKey>,
    ) -> Result<(Self, Route)> {
        let contract = PaymentPool::new(payment_pool_addr, provider);

        let usdc_token = usdc_self_check(&contract, boot).await?;
        info!(
            %payment_pool_addr,
            %usdc_token,
            %self_address,
            "PaymentPool settlement service bootstrap complete"
        );

        // Rehydrate the cache from the durable lane records BEFORE the redeemer
        // starts, so a restart does not forget redemptions that landed before the
        // log-poller's persisted cursor (#2052).
        let paid = rehydrate_paid_watermarks(store.as_ref(), self_address);

        // Paid-watermark watcher on the resumable `eth_getLogs` poller. The
        // backfill floor and downtime-gap resume are the cursor start's job: a
        // persisted `PoolRedeemed` checkpoint resumes across restarts; a
        // first-ever boot (cold store) anchors at head. `PoolRedeemed` is the
        // single write path for the paid side, so a drained-pool partial pay is
        // recorded exactly — the node never assumes its voucher cleared.
        let sink = PoolSettlementSink {
            self_address,
            store: Arc::clone(&store),
            handler,
            paid: paid.clone(),
            redeem_tx: redeem_tx.clone(),
            metrics: Arc::clone(&metrics),
            pool_view: pool_view.clone(),
        };
        let route = Route {
            addresses: vec![payment_pool_addr],
            topic0s: settlement_route_topic0s(),
            // The durable `PoolOpened` checkpoint resumes across restarts; a
            // first-ever boot (cold store) anchors at head. The poller flushes
            // this checkpoint on shutdown (via `CursorStart::flush`).
            start: cursor_start(Arc::clone(&checkpoint_store)),
            // This sink observes no shutdown token, so it registers a `Ready`
            // sink; its own follow-up reads/writes ride the wallet contract it
            // holds, independent of the poller's read-only get_logs provider.
            sink: SinkSource::Ready(Box::new(sink)),
            label: "settlement",
            on_established: Some(metric_hook(
                &metrics,
                Metrics::settlement_watcher_cycle_established,
            )),
            on_backoff: Some(metric_hook(
                &metrics,
                Metrics::settlement_watcher_backoff_started,
            )),
            on_tick_success: Some(metric_hook(&metrics, Metrics::settlement_watcher_tick)),
            on_task_panic: Some(metric_hook(
                &metrics,
                Metrics::settlement_watcher_task_panicked,
            )),
        };

        let redeemer = tokio::spawn(redeemer_loop(
            contract.clone(),
            Arc::clone(&store),
            paid.clone(),
            self_address,
            redeem_threshold,
            redeem_max_vouchers_per_tx,
            redeem_interval,
            capability_expiry_margin_secs(redeem_interval.as_secs()),
            redeem_rx,
            Arc::clone(&metrics),
            pool_view.clone(),
        ));

        Ok((
            Self {
                contract,
                redeem_tx,
                store,
                paid,
                self_address,
                redeem_threshold,
                redeem_max_vouchers_per_tx,
                metrics,
                pool_view,
                redeemer: std::sync::Mutex::new(Some(redeemer)),
            },
            route,
        ))
    }

    /// Sender the voucher-accept path uses to hint that a lane's accrued claim
    /// may be ready to plan into a chunk whose aggregate clears the redemption
    /// floor. Cloneable; dropping all senders simply ends the redemption task
    /// cleanly.
    #[must_use]
    pub fn redeem_hint_sender(&self) -> mpsc::Sender<LaneKey> {
        self.redeem_tx.clone()
    }

    /// Graceful shutdown: quiesce the redeemer, then run one final best-effort
    /// redeem sweep bounded by `deadline` so a lane whose chunk has cleared the
    /// floor is not left un-redeemed across the stop. A pool is owner-closed only,
    /// so there is nothing to close here — only redeem.
    ///
    /// The paid-watermark watcher is now the shared multiplexed poller's route,
    /// not a service-owned task: the runtime cancels the poller (which triggers an
    /// async, best-effort flush of this route's `PoolOpened` scan checkpoint via
    /// `CursorStart::flush`) before calling this, but only cancels the token — it
    /// does not await the poller's exit — so that flush is not guaranteed to
    /// complete before the final sweep below runs. This is fine: the scan
    /// checkpoint is independent of the lane-state redeem sweep and idempotent to
    /// re-scan, so the two can race without a correctness impact.
    pub async fn shutdown(&self, deadline: Duration) {
        self.quiesce_redeemer().await;
        if tokio::time::timeout(deadline, self.final_redeem_sweep())
            .await
            .is_err()
        {
            warn!(
                deadline_secs = deadline.as_secs(),
                "shutdown redeem deadline elapsed; some lanes left un-redeemed (redeem later)"
            );
        }
    }

    /// One last pass of `redeemMany` chunks over every lane whose chunk clears the
    /// floor, so shutdown secures earnings the next boot would otherwise wait a
    /// hint/sweep to collect.
    async fn final_redeem_sweep(&self) {
        // Shutdown redeems regardless of a flush failure (`strict_flush` false):
        // forfeiting the claim across the stop is worse than a bounded re-serve
        // risk. It still applies the configured per-chunk floor, so sub-floor
        // dust stays unredeemed across the stop.
        let Some(states) = load_lanes(self.store.as_ref()) else {
            return;
        };
        // No later sweep follows and no lane is parked, so the chain-fault flag
        // has no use here.
        let _chain_fault = redeem_sweep(
            &self.contract,
            &self.store,
            states,
            &self.paid,
            self.self_address,
            self.redeem_threshold,
            self.redeem_max_vouchers_per_tx,
            false,
            &self.metrics,
            &self.pool_view,
        )
        .await;
    }

    /// Abort and await the redemption task so it issues no *further* `redeem` into
    /// the shutdown sweep. Idempotent: once the handle is taken, later calls — and
    /// the [`Drop`] safety net — find `None` and no-op.
    async fn quiesce_redeemer(&self) {
        let handle = self
            .redeemer
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .take();
        if let Some(handle) = handle {
            handle.abort();
            let _ = handle.await;
        }
    }
}

impl<P: Provider + Clone + 'static> std::fmt::Debug for PoolSettlementService<P> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PoolSettlementService")
            .field("address", self.contract.address())
            .finish_non_exhaustive()
    }
}

impl<P: Provider + Clone + 'static> Drop for PoolSettlementService<P> {
    fn drop(&mut self) {
        if let Some(handle) = self
            .redeemer
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .take()
        {
            handle.abort();
        }
    }
}

/// Fold a stored voucher signature (`r‖s‖v`, 65 bytes) into the EIP-2098
/// compact pair `(r, vs)` the contract's `LaneVoucher` carries: `vs` is `s`
/// with the recovery bit in its top bit. Halving the signature is what makes
/// the voucher a static ABI struct, and so what makes a lane 160 calldata
/// bytes instead of 288.
///
/// # Errors
///
/// Errors on a signature that is not 65 bytes, on a malformed `r`/`s`/`v`, or
/// on a non-canonical high-`s` signature.
///
/// The canonicality check is [`is_high_s`], not "is the top bit of `s` free".
/// Those are not the same test: `secp256k1n / 2` sits below `2^255`, so about
/// `2^128` values have a clear top bit and are still high-`s`. Compaction would
/// happily fold the recovery bit into such a signature and the contract's
/// `ECDSA.tryRecover` would then reject it — reverting the whole `redeemMany`,
/// so one hostile lane would sink every honest lane batched with it. The node's
/// voucher-accept path already refuses high-`s` (#836), so this is the second
/// gate on a value that should never have been stored; a lane it rejects is
/// dropped from the batch rather than allowed to sink it.
fn compact_voucher_signature(sig: &[u8]) -> Result<(B256, B256)> {
    let raw: [u8; 65] = sig
        .try_into()
        .map_err(|_| anyhow::anyhow!("voucher signature is not 65 bytes (r‖s‖v)"))?;
    let parsed = Signature::from_raw(&raw).context("voucher signature is malformed")?;
    if is_high_s(&parsed) {
        anyhow::bail!("voucher signature is non-canonical (high `s`) and would revert on-chain");
    }

    let r = B256::from(parsed.r().to_be_bytes::<32>());
    let mut vs = parsed.s().to_be_bytes::<32>();
    // Canonical `s` is at most `n / 2`, which is below `2^255`, so the top bit
    // is free for the recovery bit. The `is_high_s` gate above is what
    // guarantees that.
    let Some(top) = vs.first_mut() else {
        anyhow::bail!("voucher signature `s` is empty");
    };
    *top |= u8::from(parsed.v()) << 7;

    Ok((r, B256::from(vs)))
}

/// The settlement watcher's cursor start: resume the durable
/// [`CheckpointKey::PoolOpened`] floor; a first-ever boot (cold store) anchors at
/// **head** ([`ColdStart::Head`]) — no `PoolRedeemed` toward this node can
/// predate the node itself, so there is no history to replay.
fn cursor_start(store: Arc<dyn KeyedCheckpointStore>) -> CursorStart {
    CursorStart::FromCheckpoint {
        checkpoint: Checkpoint {
            store,
            key: CheckpointKey::PoolOpened,
        },
        reorg_margin: REORG_MARGIN_BLOCKS,
        cold_start: ColdStart::Head,
    }
}

/// The settlement route's demux key: every `PaymentPool` lifecycle event the
/// paid-watermark cache and pool projection need. Split out from
/// [`PoolSettlementService::bootstrap`] so the exact topic0 set is
/// unit-testable without a provider — dropping `PoolOpened` here, for example,
/// would silently blind the projection to newly opened pools.
fn settlement_route_topic0s() -> Vec<B256> {
    vec![
        PaymentPool::PoolOpened::SIGNATURE_HASH,
        PaymentPool::PoolRedeemed::SIGNATURE_HASH,
        PaymentPool::PoolToppedUp::SIGNATURE_HASH,
        PaymentPool::PoolCloseInitiated::SIGNATURE_HASH,
        PaymentPool::PoolReclaimed::SIGNATURE_HASH,
    ]
}

/// Applies `PaymentPool` settlement logs to the paid-watermark cache and pool
/// projection. One per settlement watcher; the resumable `eth_getLogs`
/// poller feeds it block-ordered logs and advances + persists the scan checkpoint
/// per window on a clean tick.
///
/// Failure policy: paid-watermark updates are in-memory and always `Ok`. A
/// `PoolReclaimed` forget failure is logged and skipped (`Ok`) — the settled lane
/// owes nothing and the forget is idempotent. An undecodable log is log-and-skip
/// so a permanently undecodable log never hot-loops the deterministic re-scan.
struct PoolSettlementSink {
    self_address: Address,
    store: Arc<dyn PoolStateStore>,
    handler: Arc<ClientHandler>,
    paid: PaidWatermarks,
    redeem_tx: mpsc::Sender<LaneKey>,
    metrics: Arc<Metrics>,
    /// The serve path's pool solvency/funder view. This sink is its single writer:
    /// it folds `PoolOpened` (owner + deposit), `PoolToppedUp` (new deposit),
    /// `PoolRedeemed` for EVERY provider (pool-wide `totalRedeemed`),
    /// `PoolCloseInitiated` (mark `Closing` with its deadline), and `PoolReclaimed`
    /// (drop) into the projection so a serve request reads `{owner, remaining}`
    /// in-memory rather than through a `getPool` `eth_call`.
    pool_view: PoolProjection,
}

impl LogSink for PoolSettlementSink {
    #[allow(clippy::cognitive_complexity)]
    async fn apply(&mut self, log: Log) -> Result<()> {
        match log.topic0().copied() {
            Some(sig) if sig == PaymentPool::PoolOpened::SIGNATURE_HASH => {
                // Projection-only: `PoolOpened` seeds the serve path's owner +
                // deposit. It carries no settlement effect — this node holds no
                // lane against a freshly-opened pool until a voucher arrives.
                let event = match PaymentPool::PoolOpened::decode_log_data(&log.inner.data) {
                    Ok(event) => event,
                    Err(err) => {
                        warn!(error = %err, "skipping undecodable PoolOpened log");
                        return Ok(());
                    }
                };
                self.pool_view
                    .record_opened(event.poolId, event.owner, event.deposit);
                debug!(pool_id = %event.poolId, owner = %event.owner, "projected PoolOpened");
            }
            Some(sig) if sig == PaymentPool::PoolRedeemed::SIGNATURE_HASH => {
                let event = match PaymentPool::PoolRedeemed::decode_log_data(&log.inner.data) {
                    Ok(event) => event,
                    Err(err) => {
                        warn!(error = %err, "skipping undecodable PoolRedeemed log");
                        return Ok(());
                    }
                };
                // Pool-wide `totalRedeemed` folds EVERY provider's lanes, so the
                // solvency projection records this event before the own-provider
                // filter below — the serve path's `remaining` must reflect other
                // nodes' redemptions against the same pool, not just this node's.
                self.pool_view
                    .record_redeemed(event.poolId, event.provider, &event.lanes);
                // Not enough to filter on the event signature — `provider` is
                // an indexed topic but the OR-set filter cannot pin it, so
                // confirm it names this node before recording the paid watermark.
                if event.provider != self.self_address {
                    return Ok(());
                }
                // One event carries every lane the batch paid on this pool, so
                // the watermark write is per entry, not per log.
                for lane in &event.lanes {
                    let key = LaneKey {
                        pool_id: event.poolId,
                        signer: lane.signer,
                        provider: event.provider,
                    };
                    let paid_cumulative = U256::from(lane.newPaidCumulative);
                    self.paid.set(key, paid_cumulative);
                    // Mirror the watermark onto the durable lane record so it
                    // survives a restart (#2052). Buffered like `record`; the
                    // periodic lane flush fsyncs it. A no-op for a lane already
                    // forgotten, and the in-memory cache above stays authoritative
                    // for this run even if the durable write is refused, so a
                    // failure only lags durability — log it rather than fail the
                    // poller apply (whose failure policy is in-memory + always Ok).
                    if let Err(err) = self.store.set_paid_cumulative(key, paid_cumulative) {
                        self.metrics.watcher_persist_failure();
                        warn!(
                            error = %err,
                            pool_id = %event.poolId,
                            signer = %lane.signer,
                            "failed to persist lane paid watermark; in-memory cache updated, \
                             durable value lags until the next redemption"
                        );
                    }
                    debug!(
                        pool_id = %event.poolId,
                        signer = %lane.signer,
                        paid_cumulative = lane.newPaidCumulative,
                        "recorded lane paid watermark from PoolRedeemed"
                    );
                }
            }
            Some(sig) if sig == PaymentPool::PoolToppedUp::SIGNATURE_HASH => {
                let event = match PaymentPool::PoolToppedUp::decode_log_data(&log.inner.data) {
                    Ok(event) => event,
                    Err(err) => {
                        warn!(error = %err, "skipping undecodable PoolToppedUp log");
                        return Ok(());
                    }
                };
                // Raise the serve path's projected deposit so the solvency gate
                // reserves against the topped-up balance, not the stale one.
                self.pool_view.record_topup(event.poolId, event.newDeposit);
                // A top-up may re-open lanes a dry pool left `owed > paid`.
                // Re-drive by hinting the redeemer for each of the pool's lanes
                // this node provides.
                self.redrive_pool_lanes(event.poolId);
            }
            Some(sig) if sig == PaymentPool::PoolCloseInitiated::SIGNATURE_HASH => {
                let event = match PaymentPool::PoolCloseInitiated::decode_log_data(&log.inner.data)
                {
                    Ok(event) => event,
                    Err(err) => {
                        warn!(error = %err, "skipping undecodable PoolCloseInitiated log");
                        return Ok(());
                    }
                };
                // Mark the pool `Closing` in the projection so the redeemer's
                // solvency gate reads its dispute deadline: a lane whose pool is
                // drained or already past the deadline is dropped from planning
                // instead of submitting a `redeemMany` that reverts `PoolClosed`.
                // The node runs no force-redeem here — the periodic sweep redeems
                // every lane worth the gas well inside the grace window.
                self.pool_view.record_closing(
                    event.poolId,
                    u64::try_from(event.disputeDeadline).unwrap_or(u64::MAX),
                );
                if self.holds_unredeemed_on(event.poolId) {
                    self.metrics.pool_grace_close();
                }
            }
            Some(sig) if sig == PaymentPool::PoolReclaimed::SIGNATURE_HASH => {
                let event = match PaymentPool::PoolReclaimed::decode_log_data(&log.inner.data) {
                    Ok(event) => event,
                    Err(err) => {
                        warn!(error = %err, "skipping undecodable PoolReclaimed log");
                        return Ok(());
                    }
                };
                // The pool is `Closed` and refunded: drop it from the serve
                // projection so a later read fails open rather than reserving
                // against a stale remaining.
                self.pool_view.forget(event.poolId);
                self.forget_pool_lanes(event.poolId).await;
            }
            // Unreachable today (the filter's topic0 OR-set bounds the inputs);
            // don't panic (anti-panic policy), log so a future OR-set drift leaves
            // a greppable trail instead of a silently dropped event.
            _ => {
                debug!(topic0 = ?log.topic0(), "unmatched PaymentPool event in subscribed OR-set");
            }
        }
        Ok(())
    }
}

impl PoolSettlementSink {
    /// Whether this node provides a lane of `pool_id` that is owed more than the
    /// chain has paid it — revenue that must redeem inside the grace window. A
    /// store read failure answers `false`: the counter this feeds is advisory.
    fn holds_unredeemed_on(&self, pool_id: PoolId) -> bool {
        match self.store.load_all() {
            Ok(states) => holds_unredeemed(&states, pool_id, self.self_address),
            Err(err) => {
                warn!(error = %err, %pool_id, "grace-close check: failed to load lane state");
                false
            }
        }
    }

    /// Hint the redeemer for every lane of `pool_id` this node provides, so a
    /// top-up re-drives lanes a dry pool left `owed > paid`. Best-effort: a full
    /// hint channel drops the nudge (the self-tick sweep is the backstop).
    fn redrive_pool_lanes(&self, pool_id: PoolId) {
        let states = match self.store.load_all() {
            Ok(s) => s,
            Err(err) => {
                warn!(error = %err, %pool_id, "top-up re-drive: failed to load lane state");
                return;
            }
        };
        for st in states {
            if st.pool_id == pool_id && st.provider == self.self_address {
                let _ = self.redeem_tx.try_send(st.key());
            }
        }
    }

    /// Forget every lane of a reclaimed `pool_id` this node provides — the pool is
    /// `Closed`, so no further voucher can be redeemed against it.
    #[allow(clippy::cognitive_complexity)]
    async fn forget_pool_lanes(&self, pool_id: PoolId) {
        let states = match self.store.load_all() {
            Ok(s) => s,
            Err(err) => {
                warn!(error = %err, %pool_id, "reclaim cleanup: failed to load lane state");
                return;
            }
        };
        for st in states {
            if st.pool_id != pool_id || st.provider != self.self_address {
                continue;
            }
            let key = st.key();
            if let Err(err) = self.handler.forget_lane(key).await {
                self.metrics.watcher_persist_failure();
                warn!(error = %err, pool_id = %pool_id, signer = %key.signer, "failed to forget reclaimed lane");
            } else {
                self.paid.forget(&key);
                info!(pool_id = %pool_id, signer = %key.signer, "pool reclaimed; dropped tracked lane");
            }
        }
        self.handler.forget_pool_floor(pool_id);
    }
}

/// A [`PoolView`](crate::pool_view::PoolView) that confirms a pool on-chain
/// before a serve is admitted.
///
/// The serve gates read `{owner, remaining}` from the event-fed [`PoolProjection`]
/// the settlement watcher folds, so the common case costs no `eth_call`. A pool
/// the projection has not observed is the hard case: a pool opened before this
/// node's `ColdStart::Head` anchor never appears in the forward-only `PoolOpened`
/// scan, so its owner is absent from the projection even though the pool is live.
/// Rather than fail open on that gap, [`status`](crate::pool_view::PoolView::status) does ONE `getPool` at
/// admission — a read the admission path tolerates (it may block) — folds a
/// servable pool into the projection, and refuses an absent, closed, or faulted
/// pool. Every admit-path chain read (`getPool` and the signer's
/// `getAuthorization`) runs under `chain_events::timed`'s default bound, so a
/// hung read times out and takes the fault path instead of stalling admission.
/// The client re-sends its capability on its next request (the documented
/// lane recovery path), and by then the folded owner registers the lane.
///
/// The mid-stream re-check calls [`cached_status`](crate::pool_view::PoolView::cached_status), which reads the
/// projection ONLY and never blocks on a `getPool` — a chain read at a voucher
/// boundary would stall delivery.
///
/// A short-TTL negative cache suppresses a repeat `getPool` for a pool just
/// found not-servable (`RESOLVE_VERDICT_TTL`) or whose read faulted (the far
/// shorter `RESOLVE_FAULT_TTL`), so a client re-requesting the same dead pool
/// every request cannot drive one `getPool` per request, and one failed read does
/// not refuse a live pool for the full verdict window. Reads are coalesced per
/// pool: a request that finds a `getPool` in flight for its pool waits for it
/// and takes its answer.
///
/// The signer confirm reads `getAuthorization` once per `(pool, signer)` and
/// keeps a `Registered` answer for good (see `AuthRead`); only an
/// `Unregistered` answer is re-read. Those reads are coalesced per pair the same
/// way.
pub struct ResolvingPoolView<P: Provider + Clone> {
    /// The wallet/RPC-backed `PaymentPool` binding for the admit-path reads
    /// (`getPool`, `getAuthorization`).
    contract: PaymentPool::PaymentPoolInstance<P>,
    /// The event-fed projection this view reads first and folds a resolved pool
    /// into. Shared with the settlement watcher's sink (the authoritative writer).
    projection: PoolProjection,
    /// Pool id → (why, when) for pools recently found not-servable or whose read
    /// faulted. The reason picks the entry's window ([`NegativeReason::ttl`]).
    /// The guard is held only to read/insert one entry, never across the
    /// `getPool` await.
    negative: Mutex<HashMap<B256, (NegativeReason, Instant)>>,
    /// Pool id → a receiver for the in-flight admit `getPool` of that pool,
    /// which carries the read's answer. See [`ReadSlot`]. The guard is held only to
    /// read/insert one entry, never across the `getPool` await.
    inflight: InflightReads<B256, Option<PoolStatus>>,
    /// `(pool_id, signer)` → the last `getAuthorization` read of that pair, for
    /// the admit-path signer confirm. [`cached_auth`] decides whether a read
    /// still answers. The guard is held only to read/insert one entry, never
    /// across the `getAuthorization` await.
    auth_cache: Mutex<HashMap<(B256, Address), AuthRead>>,
    /// `(pool_id, signer)` → a receiver for the in-flight admit
    /// `getAuthorization` of that pair. See [`ReadSlot`].
    auth_inflight: InflightReads<(B256, Address), Option<SignerAuthorization>>,
    /// Counts each signer confirm by what the cache held for it.
    metrics: Arc<Metrics>,
}

impl<P: Provider + Clone> std::fmt::Debug for ResolvingPoolView<P> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ResolvingPoolView")
            .field("address", self.contract.address())
            .field("projection", &self.projection)
            .finish_non_exhaustive()
    }
}

impl<P: Provider + Clone> ResolvingPoolView<P> {
    /// Wrap the event-fed `projection` with admit-path `getPool` and
    /// `getAuthorization` fallbacks against `contract`.
    #[must_use]
    pub fn new(
        contract: PaymentPool::PaymentPoolInstance<P>,
        projection: PoolProjection,
        metrics: Arc<Metrics>,
    ) -> Self {
        Self {
            contract,
            projection,
            negative: Mutex::new(HashMap::new()),
            inflight: Mutex::new(HashMap::new()),
            auth_cache: Mutex::new(HashMap::new()),
            auth_inflight: Mutex::new(HashMap::new()),
            metrics,
        }
    }

    /// What the signer-auth cache holds for `(pool_id, signer)` now.
    fn lookup_auth(&self, pool_id: B256, signer: Address) -> CachedAuth {
        let folded_now = self.projection.signer_spent(pool_id, signer);
        let guard = self
            .auth_cache
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        cached_auth(guard.get(&(pool_id, signer)), folded_now)
    }
}

impl<P: Provider + Clone + 'static> ResolvingPoolView<P> {
    /// Read `pool_id` on-chain once and record the result: fold a servable pool
    /// into the projection, or negative-cache an absent / `Closed` pool as a
    /// [`NegativeReason::Verdict`] and a failed read as a
    /// [`NegativeReason::Fault`]. The caller holds the pool's [`ReadSlot::Lead`].
    async fn resolve(&self, pool_id: B256) -> Option<PoolStatus> {
        // Bounded: the alloy HTTP provider sets no timeout of its own, so a hung
        // read times out here and takes the fault path below.
        let pool = match timed(None, "admit getPool", self.contract.getPool(pool_id).call()).await {
            Ok(pool) => pool,
            Err(err) => {
                warn!(
                    error = %sanitize_err_chain(&err),
                    %pool_id,
                    suppressed_for = ?RESOLVE_FAULT_TTL,
                    "admit getPool failed; refusing this pool until the negative cache lapses"
                );
                let mut guard = self
                    .negative
                    .lock()
                    .unwrap_or_else(std::sync::PoisonError::into_inner);
                remember_negative(&mut guard, pool_id, NegativeReason::Fault);
                return None;
            }
        };
        let Some(lifecycle) = resolved_lifecycle(&pool) else {
            debug!(%pool_id, "admit getPool: pool absent or closed; refusing");
            let mut guard = self
                .negative
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            remember_negative(&mut guard, pool_id, NegativeReason::Verdict);
            return None;
        };
        self.projection
            .record_resolved(pool_id, pool.owner, U256::from(pool.deposit), lifecycle);
        // The pool resolved: drop its lapsed negative entry. The projection
        // answers for it from here on.
        {
            let mut guard = self
                .negative
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            guard.remove(&pool_id);
        }
        self.projection.snapshot(pool_id)
    }
}

#[async_trait::async_trait]
impl<P: Provider + Clone + 'static> crate::pool_view::PoolView for ResolvingPoolView<P> {
    async fn status(&self, pool_id: B256) -> Option<PoolStatus> {
        loop {
            // Fast path: the event fold already knows this pool — no chain call.
            if let Some(status) = self.projection.snapshot(pool_id) {
                return Some(status);
            }
            // A pool recently found not-servable, or whose read faulted, is
            // suppressed for its reason's window, so a re-request flood cannot
            // storm `getPool`.
            {
                let guard = self
                    .negative
                    .lock()
                    .unwrap_or_else(std::sync::PoisonError::into_inner);
                if let Some(reason) = negative_cache_hit(&guard, pool_id) {
                    debug!(
                        %pool_id,
                        ?reason,
                        "admit getPool suppressed by the negative cache; refusing"
                    );
                    return None;
                }
            }
            match claim_read(&self.inflight, pool_id) {
                ReadSlot::Lead(read) => {
                    let status = self.resolve(pool_id).await;
                    read.finish(status);
                    return status;
                }
                // The reader sends its answer before it ends the read. A read
                // that ends with no answer was cancelled: re-check, and take
                // the read over if no other waiter has.
                ReadSlot::Wait(mut done) => {
                    if done.changed().await.is_ok() {
                        return *done.borrow();
                    }
                }
            }
        }
    }

    async fn cached_status(&self, pool_id: B256) -> Option<PoolStatus> {
        // MUST NOT block: read the projection only, never a `getPool`.
        self.projection.snapshot(pool_id)
    }

    async fn signer_spent_cached(&self, pool_id: B256, signer: Address) -> Option<u64> {
        // The mid-stream signer cap-headroom re-check: projection ONLY, never a
        // `getAuthorization`. A signer that drains its shared `cap` at another node
        // shows up here as the settlement watcher folds that node's `PoolRedeemed`.
        Some(self.projection.signer_spent(pool_id, signer))
    }

    async fn signer_authorization(
        &self,
        pool_id: B256,
        signer: Address,
    ) -> Option<SignerAuthorization> {
        match self.lookup_auth(pool_id, signer) {
            CachedAuth::Fresh(auth) => {
                self.metrics.serve_signer_auth_cached();
                return Some(auth);
            }
            CachedAuth::Absent => self.metrics.serve_signer_auth_first_read(),
            CachedAuth::Lapsed => self.metrics.serve_signer_auth_reread(),
        }
        loop {
            match claim_read(&self.auth_inflight, (pool_id, signer)) {
                ReadSlot::Lead(read) => {
                    let auth = self.read_authorization(pool_id, signer).await;
                    read.finish(auth);
                    return auth;
                }
                // A read that ends with no answer was cancelled: take the
                // cache's answer if it landed one, else take the read over.
                ReadSlot::Wait(mut done) => {
                    if done.changed().await.is_ok() {
                        return *done.borrow();
                    }
                    if let CachedAuth::Fresh(auth) = self.lookup_auth(pool_id, signer) {
                        return Some(auth);
                    }
                }
            }
        }
    }
}

impl<P: Provider + Clone + 'static> ResolvingPoolView<P> {
    /// Read `getAuthorization` for `(pool_id, signer)` and cache the answer. A
    /// fault (an error or a timeout) caches nothing and answers `None`, which
    /// refuses the signer as unconfirmed. A `Registered` read is cached for
    /// good, so a fault only ever meets a signer with no usable read. The caller
    /// holds the pair's [`ReadSlot::Lead`].
    async fn read_authorization(
        &self,
        pool_id: B256,
        signer: Address,
    ) -> Option<SignerAuthorization> {
        // The baseline is taken before the read is sent; see `AuthRead`.
        let folded_at_read = self.projection.signer_spent(pool_id, signer);
        // Bounded: a hung read times out here and takes the fault path below.
        let auth = match timed(
            None,
            "admit getAuthorization",
            self.contract.getAuthorization(pool_id, signer).call(),
        )
        .await
        {
            Ok(auth) => SignerAuthorization::from_onchain(&auth),
            Err(err) => {
                warn!(
                    error = %sanitize_err_chain(&err),
                    %pool_id,
                    %signer,
                    "admit getAuthorization failed; refusing this signer"
                );
                return None;
            }
        };
        let read = AuthRead {
            auth,
            folded_at_read,
            at: Instant::now(),
        };
        {
            let mut guard = self
                .auth_cache
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            remember_auth(&mut guard, (pool_id, signer), read);
        }
        // Answer as the next cache hit would, so a fold that landed during the
        // read already counts. An `Unregistered` read answers as read.
        match cached_auth(Some(&read), self.projection.signer_spent(pool_id, signer)) {
            CachedAuth::Fresh(fresh) => Some(fresh),
            CachedAuth::Lapsed | CachedAuth::Absent => Some(auth),
        }
    }
}

/// Why `pool_id` is suppressed, when its negative-cache entry is still inside its
/// reason's window — the admit path then skips the `getPool`. `None` for an
/// absent or lapsed entry. Provider-free, so the TTL gate is unit-testable.
fn negative_cache_hit(
    cache: &HashMap<B256, (NegativeReason, Instant)>,
    pool_id: B256,
) -> Option<NegativeReason> {
    cache
        .get(&pool_id)
        .filter(|(reason, at)| at.elapsed() < reason.ttl())
        .map(|(reason, _)| *reason)
}

/// Remember `pool_id` as recently not-servable or faulted, per `reason`. At the
/// cache cap, prune lapsed entries first; if the cache is still full, skip the
/// insert, so a flood of distinct ids cannot grow the map past
/// [`RESOLVE_NEGATIVE_CACHE_MAX`]. Provider-free, so the cap-prune is
/// unit-testable.
fn remember_negative(
    cache: &mut HashMap<B256, (NegativeReason, Instant)>,
    pool_id: B256,
    reason: NegativeReason,
) {
    if cache.len() >= RESOLVE_NEGATIVE_CACHE_MAX {
        cache.retain(|_, (reason, at)| at.elapsed() < reason.ttl());
    }
    if cache.len() < RESOLVE_NEGATIVE_CACHE_MAX || cache.contains_key(&pool_id) {
        cache.insert(pool_id, (reason, Instant::now()));
    }
}

/// Read key → a receiver for that key's in-flight admit chain read. The channel
/// starts at a seen `V::default()` and carries the read's answer once it ends.
type InflightReads<K, V> = Mutex<HashMap<K, watch::Receiver<V>>>;

/// A request's place in the admit chain read for one key. At most one request
/// per key reads at a time; the rest wait for it.
enum ReadSlot<'a, K: Eq + Hash, V> {
    /// No read is in flight: this request reads, and the guard ends the read.
    Lead(InflightRead<'a, K, V>),
    /// A read is in flight: the receiver's `changed` returns `Ok` with the
    /// read's answer, or an error when the read ends unanswered.
    Wait(watch::Receiver<V>),
}

/// A key's in-flight read. [`finish`](Self::finish) hands the answer to every
/// waiter. Dropping it ends the read: it removes the key's entry, then drops
/// the sender, which wakes every waiter. A cancelled reader ends the read with
/// no answer, so a waiter then re-checks and takes over the read.
struct InflightRead<'a, K: Eq + Hash, V> {
    /// The map this read is registered in.
    inflight: &'a InflightReads<K, V>,
    /// The key being read.
    key: K,
    /// Carries the answer to the waiters. Dropped after [`Drop::drop`] removes
    /// the entry, which closes the channel.
    done: watch::Sender<V>,
}

impl<K: Eq + Hash, V> InflightRead<'_, K, V> {
    /// Hand `answer` to every waiter, then end the read. The waiters take it
    /// as their own answer, so the read's outcome reaches them whether or not
    /// a cache had room to record it.
    fn finish(self, answer: V) {
        self.done.send_replace(answer);
    }
}

impl<K: Eq + Hash, V> Drop for InflightRead<'_, K, V> {
    fn drop(&mut self) {
        self.inflight
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .remove(&self.key);
    }
}

/// Join the in-flight read for `key`, or start one.
fn claim_read<K: Eq + Hash + Copy, V: Default>(
    inflight: &InflightReads<K, V>,
    key: K,
) -> ReadSlot<'_, K, V> {
    let mut guard = inflight
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    // A closed channel means its read already ended: start a new one rather
    // than wait on it, which would return at once and spin the caller's loop.
    if let Some(done) = guard.get(&key)
        && done.has_changed().is_ok()
    {
        return ReadSlot::Wait(done.clone());
    }
    let (done, waiters) = watch::channel(V::default());
    guard.insert(key, waiters);
    ReadSlot::Lead(InflightRead {
        inflight,
        key,
        done,
    })
}

/// The serve-path serve status a resolved `getPool` snapshot maps to, or `None`
/// when the pool is not worth seeding: a zero owner (the pool does not exist, or
/// a reorg unwound it) or a `Closed` (reclaimed / terminal) pool. Both leave the
/// serve gate fail-open `None` rather than register a lane against a pool that
/// can no longer be redeemed. Pure, so the mapping is unit-testable without a
/// provider.
fn resolved_lifecycle(pool: &PaymentPool::Pool) -> Option<Lifecycle> {
    if pool.owner == Address::ZERO {
        return None;
    }
    match pool.status {
        PaymentPool::Status::Open => Some(Lifecycle::Open),
        PaymentPool::Status::Closing => Some(Lifecycle::Closing {
            deadline: pool.disputeDeadline,
        }),
        _ => None,
    }
}

/// Redemption task: chunk lanes' accrued claims and `redeemMany` a chunk once
/// its aggregate unredeemed value clears the floor, driven by three sources —
/// advisory hints ([`LaneKey`]) from the voucher-accept path, a low-frequency
/// self-tick that sweeps every lane into chunked `redeemMany` transactions so a
/// dropped hint can never strand a lane whose chunk has cleared the floor, and a
/// sweep at the earliest serve cutoff of a lane with a finite capability expiry
/// and value owed ([`serve_cutoff_wake`]). Ends cleanly when every hint sender
/// is dropped.
///
/// The cutoff sweep is what makes a delegated lane's final claim redeemable. The
/// claim stops growing at the serve cutoff, and the sweep then has about one
/// redeem interval of room before [`capability_expired`] skips the lane. A
/// self-tick alone leaves no room for delay: the last tick before the expiry can
/// fall just before the landing slack, and the loop is serial, so a receipt wait
/// that delays that tick forfeits the lane. The cutoff sweep waits behind an
/// in-flight redemption too, but the interval absorbs that wait.
/// `margin_secs` is the voucher path's capability-expiry margin.
///
/// A lane whose hint-path redemption hits a chain fault (an RPC fault or a
/// revert) is parked: each later hint for it is dropped and counted into
/// `redeem_hints_parked`. Each tick or cutoff sweep retries the parked lanes
/// with every other lane. Only a sweep that runs and reports no chain fault
/// clears the parked set. A failed lane load runs no sweep and keeps the set,
/// and a sweep with any chain fault keeps the whole set. So during an RPC
/// outage each lane costs one failed attempt per interval: the sweep's batched
/// retry. Hints for a parked lane stay dropped until a sweep finishes without a
/// chain fault. Parking delays only the hint path: the sweep retries every lane
/// each interval, and a serve cutoff still wakes a sweep, so no claim strands.
#[allow(clippy::too_many_arguments)]
async fn redeemer_loop<P: Provider + Clone>(
    contract: PaymentPool::PaymentPoolInstance<P>,
    store: Arc<dyn PoolStateStore>,
    paid: PaidWatermarks,
    self_address: Address,
    redeem_threshold: U256,
    max_vouchers: usize,
    redeem_interval: Duration,
    margin_secs: u64,
    mut redeem_rx: mpsc::Receiver<LaneKey>,
    metrics: Arc<Metrics>,
    pool_view: PoolProjection,
) {
    let mut ticker = tokio::time::interval(redeem_interval);
    // Skip the immediate first tick: new vouchers hint anyway, and the first
    // sweep waits one interval. A lane persisted before a restart whose claim
    // cannot wait that long gets its cutoff sweep instead: no scan has run yet,
    // so a cutoff that passed while the node was down is due at once.
    ticker.tick().await;
    let mut swept_at = 0;
    let mut parked: HashSet<LaneKey> = HashSet::new();
    let mut next_cutoff = load_lanes(store.as_ref()).and_then(|states| {
        next_serve_cutoff(
            &states,
            &paid,
            self_address,
            margin_secs,
            swept_at,
            unix_now(),
        )
    });
    loop {
        tokio::select! {
            hint = redeem_rx.recv() => match hint {
                Some(key) => {
                    if parked.contains(&key) {
                        metrics.redeem_hint_parked();
                        continue;
                    }
                    let outcome = redeem_one(
                        &contract, &store, &paid, self_address, margin_secs, swept_at,
                        redeem_threshold, max_vouchers, key, &metrics, &pool_view,
                    )
                    .await;
                    if outcome.chain_fault {
                        park_lane(&mut parked, key);
                    }
                    next_cutoff = next_cutoff.into_iter().chain(outcome.wake).min();
                    continue;
                }
                // All hint senders dropped — the service is going away.
                None => break,
            },
            _ = ticker.tick() => {}
            () = sleep_until_unix(next_cutoff) => {}
        }
        // A tick or a cutoff: sweep every lane, and schedule the next cutoff
        // from the same scan. A failed load keeps every cutoff still ahead and
        // drops one that has passed, so the loop does not re-fire it at once;
        // the next tick retries the scan. The sweep retries the parked lanes
        // with every other lane. A failed load runs no sweep, so the parked set
        // stays.
        let Some(states) = load_lanes(store.as_ref()) else {
            next_cutoff = next_cutoff.filter(|wake| *wake > unix_now());
            continue;
        };
        swept_at = unix_now();
        next_cutoff = next_serve_cutoff(
            &states,
            &paid,
            self_address,
            margin_secs,
            swept_at,
            swept_at,
        );
        let chain_fault = redeem_sweep(
            &contract,
            &store,
            states,
            &paid,
            self_address,
            redeem_threshold,
            max_vouchers,
            true,
            &metrics,
            &pool_view,
        )
        .await;
        // Release the parked lanes only after a sweep without a chain fault. A
        // sweep with a fault keeps the whole set: one failed attempt per lane
        // per interval, the sweep's own batched retry.
        if !chain_fault {
            parked.clear();
        }
    }
    debug!("PaymentPool redeemer loop ended (all hint senders dropped)");
}

/// One lane planned for redemption: its pool, its persistence key (so a landed
/// registration can be written back to `registered_until`), its full claim value
/// and its unredeemed value (for the per-chunk floor), the highest voucher to
/// submit, and — on the signer's first redemption — the owner-signed capability
/// to register.
struct PlannedLane {
    pool_id: PoolId,
    key: LaneKey,
    /// The claim's full on-chain-comparable value (`amount + verified frontier`).
    /// The contract pays `owed − w.amount`, so the pre-submit reconciliation
    /// ([`reconcile_onchain_watermarks`]) drops a lane whose on-chain watermark
    /// already reaches this, and recomputes `unredeemed` from the fresh read.
    owed: U256,
    unredeemed: U256,
    voucher: PaymentPool::LaneVoucher,
    register: Option<PaymentPool::CapabilityReg>,
}

/// Sum the unredeemed value across planned lanes — the raw USDC the node holds
/// in accepted vouchers it has not yet cashed on-chain. Saturating so a
/// pathological total can never wrap; in practice pool deposits bound it far
/// below `U256::MAX`. Feeds the `decdn_unredeemed_usdc` gauge once per sweep.
fn sum_unredeemed(plans: &[PlannedLane]) -> U256 {
    plans
        .iter()
        .fold(U256::ZERO, |acc, plan| acc.saturating_add(plan.unredeemed))
}

/// Sum the value still recoverable from the distinct pools behind `plans` —
/// `Σ (deposit − totalRedeemed)`, the ceiling those pools can still pay this
/// node. Feeds the `decdn_pool_deposit_usdc` gauge once per sweep.
///
/// `remaining`, not the raw deposit: already-redeemed funds have left the pool
/// and are not recoverable from it, so counting them would overstate what the
/// node can still collect. A pool the projection has not folded contributes
/// nothing rather than a guess — the same fail-quiet direction the planner
/// takes on an unknown pool.
///
/// Scoped to planned lanes, which is what bounds it to this node's own
/// counterparties: the projection also folds `PoolOpened` for pools this node
/// has no lane on, and summing those would report the network's escrow as its
/// own. The precise reading is therefore "pools that currently owe this node",
/// not every pool it has ever been paid by — a lane settles out of the planned
/// set once it is fully redeemed. The caller skips the publish on an empty plan
/// set so that settling out does not read as the escrow disappearing.
///
/// A pool the projection has not folded contributes nothing rather than a
/// guess, so the gauge under-reports in the window after a restart where the
/// planner has deliberately failed open on an unknown pool.
fn sum_recoverable_deposit(plans: &[PlannedLane], pool_view: &PoolProjection) -> U256 {
    let mut seen: HashSet<PoolId> = HashSet::new();
    plans
        .iter()
        .filter(|plan| seen.insert(plan.pool_id))
        .filter_map(|plan| pool_view.snapshot(plan.pool_id))
        .fold(U256::ZERO, |acc, status| {
            acc.saturating_add(status.remaining)
        })
}

/// Group planned lanes into one `PoolBatch` per distinct pool, in first-seen
/// order, so the contract amortizes each pool's status read and `totalRedeemed`
/// write across its lanes. A lane's capability registration (present only on a
/// signer's first redemption) rides in its pool's batch; each `(pool, signer)`
/// lane is distinct, so no capability is ever duplicated within a batch.
fn group_by_pool(lanes: &[PlannedLane]) -> Vec<PaymentPool::PoolBatch> {
    let mut order: Vec<PoolId> = Vec::new();
    let mut by_pool: HashMap<PoolId, PaymentPool::PoolBatch> = HashMap::new();
    for lane in lanes {
        let batch = by_pool.entry(lane.pool_id).or_insert_with(|| {
            order.push(lane.pool_id);
            PaymentPool::PoolBatch {
                poolId: lane.pool_id,
                capabilities: Vec::new(),
                vouchers: Vec::new(),
            }
        });
        if let Some(reg) = &lane.register {
            batch.capabilities.push(reg.clone());
        }
        batch.vouchers.push(lane.voucher.clone());
    }
    order
        .into_iter()
        .filter_map(|pool_id| by_pool.remove(&pool_id))
        .collect()
}

/// Partition planned lanes into gas-bounded redemption chunks (each an
/// independent `redeemMany`). Every returned chunk has at most `max_vouchers`
/// lanes and an aggregate unredeemed value `>= floor`; a chunk that cannot clear
/// the floor is dropped and its lanes defer to a later sweep (a zero floor
/// keeps every chunk). The chunk count is the minimum that
/// respects `max_vouchers`, and high-value lanes are dealt round-robin across the
/// chunks so dust rides alongside real value instead of segregating into a
/// below-floor chunk.
fn chunk_redemptions(
    mut plans: Vec<PlannedLane>,
    floor: U256,
    max_vouchers: usize,
) -> Vec<Vec<PlannedLane>> {
    if plans.is_empty() {
        return Vec::new();
    }
    let cap = max_vouchers.max(1);
    let k = plans.len().div_ceil(cap);
    // Sort by unredeemed descending so round-robin dealing balances value across
    // chunks (largest lanes land in distinct buckets first).
    plans.sort_by_key(|plan| Reverse(plan.unredeemed));
    let mut buckets: Vec<Vec<PlannedLane>> = (0..k).map(|_| Vec::new()).collect();
    for (i, plan) in plans.into_iter().enumerate() {
        if let Some(bucket) = buckets.get_mut(i % k) {
            bucket.push(plan);
        }
    }
    buckets
        .into_iter()
        .filter(|bucket| {
            let sum = bucket
                .iter()
                .map(|l| l.unredeemed)
                .fold(U256::ZERO, |a, b| a + b);
            sum >= floor
        })
        .collect()
}

/// Redemption policy: whether a lane whose pool has this `status` is worth
/// submitting now. Fails OPEN on an unknown pool (`None`) — a projection gap
/// (cold start, reorg, an unfolded event) must never hold a lane and strand real
/// money; only a positive zero-`remaining` holds. A drained `Open` pool is held
/// (a top-up re-drives it); a drained `Closing` pool is dropped (it cannot be
/// topped up, so `remaining == 0` is irreversible); a funded `Closing` pool is
/// redeemable only while its deadline lies more than [`REDEEM_LANDING_SLACK_SECS`]
/// ahead. Past the deadline `redeemMany` reverts `PoolClosed`, and that revert
/// takes the whole batch down with it.
fn pool_is_redeemable(status: Option<PoolStatus>, now: u64) -> bool {
    match status {
        None => true,
        Some(s) => {
            if s.remaining.is_zero() {
                return false;
            }
            match s.lifecycle {
                Lifecycle::Open => true,
                Lifecycle::Closing { deadline } => {
                    now.saturating_add(REDEEM_LANDING_SLACK_SECS) < deadline
                }
            }
        }
    }
}

/// Split candidate lanes into the ones whose pool can pay now and a count of the
/// ones held/dropped by [`pool_is_redeemable`]. A pool absent from `snapshot` is
/// `None` (fail open).
fn partition_redeemable(
    states: Vec<LaneState>,
    snapshot: &HashMap<PoolId, Option<PoolStatus>>,
    now: u64,
) -> (Vec<LaneState>, usize) {
    let mut kept = Vec::with_capacity(states.len());
    let mut skipped = 0usize;
    for st in states {
        let pool_status = snapshot.get(&st.pool_id).copied().flatten();
        if pool_is_redeemable(pool_status, now) {
            kept.push(st);
        } else {
            skipped += 1;
        }
    }
    (kept, skipped)
}

/// Whether a capability `expiry` has passed, or falls within
/// [`REDEEM_LANDING_SLACK_SECS`] of `now`, so a redemption planned now would land
/// on an expired capability and pay 0. `0` means no tracked expiry and never
/// expires.
const fn capability_expired(expiry: u64, now: u64) -> bool {
    expiry != 0 && now.saturating_add(REDEEM_LANDING_SLACK_SECS) >= expiry
}

/// When the redeemer wakes for one lane's serve cutoff, in Unix seconds, or
/// `None` when the lane needs no cutoff sweep.
///
/// The serve cutoff is `expiry − margin_secs`. From that second on, the voucher
/// and preimage paths refuse the lane's proofs with `CapabilityExpired`
/// ([`inside_capability_expiry_margin`](decdn_common::config::inside_capability_expiry_margin)),
/// so the lane's claim is final. The wake
/// is one second after the cutoff, because those paths read a coarse clock that
/// can lag the wall clock by up to one refresh. A sweep at the wake has one
/// redeem interval, less that second, to start the redemption before
/// [`capability_expired`] skips the lane, and [`REDEEM_LANDING_SLACK_SECS`] to
/// land it.
///
/// Only a lane this node provides, with a finite expiry and value owed beyond its
/// cached paid watermark, needs a wake.
fn lane_cutoff_wake(
    st: &LaneState,
    paid: &PaidWatermarks,
    self_address: Address,
    margin_secs: u64,
) -> Option<u64> {
    if st.provider != self_address || st.expiry == 0 || st.owed() <= paid.get(&st.key()) {
        return None;
    }
    Some(st.expiry.saturating_sub(margin_secs).saturating_add(1))
}

/// When the redeemer must sweep for one lane's cutoff, given that its last
/// sweep scanned the lane store at `swept_at` (`0` before the first scan).
///
/// A scan at or after the lane's [`lane_cutoff_wake`] already read the final
/// claim, so the lane needs no further cutoff sweep. Otherwise the sweep is due
/// at the wake, or at `now` when the wake has passed unscanned: the node was
/// down at the cutoff, or the lane's first hint waited behind redemption work
/// until after it. A lane that [`capability_expired`] skips at `now` cannot be
/// redeemed and needs no sweep.
fn serve_cutoff_wake(
    st: &LaneState,
    paid: &PaidWatermarks,
    self_address: Address,
    margin_secs: u64,
    swept_at: u64,
    now: u64,
) -> Option<u64> {
    let wake = lane_cutoff_wake(st, paid, self_address, margin_secs)?;
    (wake > swept_at && !capability_expired(st.expiry, now)).then_some(wake.max(now))
}

/// The earliest [`serve_cutoff_wake`] across `states`.
fn next_serve_cutoff(
    states: &[LaneState],
    paid: &PaidWatermarks,
    self_address: Address,
    margin_secs: u64,
    swept_at: u64,
    now: u64,
) -> Option<u64> {
    states
        .iter()
        .filter_map(|st| serve_cutoff_wake(st, paid, self_address, margin_secs, swept_at, now))
        .min()
}

/// Sleep until the Unix second `wake`, or forever when there is none.
async fn sleep_until_unix(wake: Option<u64>) {
    match wake {
        Some(wake) => {
            let wait = Duration::from_secs(wake.saturating_sub(unix_now()));
            tokio::time::sleep(wait).await;
        }
        None => std::future::pending().await,
    }
}

/// Whether a lane's observed registration is still live at `now`. `0`
/// (unknown/unregistered) and any past expiry are "not registered" — the safe
/// direction is to re-read rather than skip a possibly-absent registration.
const fn is_registered(registered_until: u64, now: u64) -> bool {
    registered_until > now
}

/// Whether this node has confirmed a lane's signer is registered on-chain,
/// resolved from the persisted `registered_until` watermark alone — no chain read.
enum RegistrationStatus {
    /// `registered_until > now`: this node has landed the signer's registration;
    /// attach no `CapabilityReg`.
    Registered,
    /// Not yet confirmed registered by this node: attach a `CapabilityReg` built
    /// from the lane's held `owner_sig`. On-chain registration is idempotent (a
    /// duplicate or already-registered signer is a no-op), so this is safe even
    /// when another provider already registered the signer; the first landed
    /// redemption persists `registered_until` and every later one skips the reg.
    Unregistered,
}

/// Seed a fresh paid-watermark cache from the durable lane rows this node
/// provides. Bootstrap calls this before the redeemer starts so a restart carries
/// forward redemptions that landed before the log-poller's persisted cursor,
/// instead of re-planning those already-redeemed lanes into a `redeemMany` the
/// contract silently no-ops (#2052).
///
/// A `load_all` failure is not fatal: the `PoolRedeemed` watcher still rebuilds
/// `paid` forward from the cursor. It only re-opens the restart-amnesia window for
/// pre-cursor lanes, so the failure is surfaced loudly rather than aborting
/// bring-up.
fn rehydrate_paid_watermarks(store: &dyn PoolStateStore, self_address: Address) -> PaidWatermarks {
    let paid = PaidWatermarks::default();
    match store.load_all() {
        Ok(states) => {
            let mut restored = 0usize;
            for st in &states {
                if st.provider == self_address && !st.paid_cumulative.is_zero() {
                    paid.set(st.key(), st.paid_cumulative);
                    restored += 1;
                }
            }
            if restored > 0 {
                info!(
                    restored,
                    "rehydrated paid-watermark cache from durable lane store"
                );
            }
        }
        Err(err) => warn!(
            error = %err,
            "failed to rehydrate paid-watermark cache from lane store; pre-cursor \
             redemptions may be re-submitted until re-observed on-chain"
        ),
    }
    paid
}

/// Plan one lane for redemption from its already-loaded state and a resolved
/// registration status. Pure and I/O-free — the caller ([`plan_lanes`]) resolves
/// the status from the persisted `registered_until` watermark alone, with no chain
/// read. Returns `Ok(None)` when the lane is not this node's, has no signed
/// voucher, has nothing unredeemed, or — a node-durability fault that should not
/// occur — is an unregistered signer whose lane has lost its `owner_sig`.
fn plan_lane(
    st: &LaneState,
    paid: &PaidWatermarks,
    self_address: Address,
    status: &RegistrationStatus,
) -> Result<Option<PlannedLane>> {
    if st.provider != self_address {
        return Ok(None);
    }
    let key = st.key();
    // The lane's claim: its latest signature PLUS the frontier the chain has
    // proved on top of it.
    //
    // That sum is a real choice rather than a formality. A lane's worth is
    // `amount + verified_index × chunk_price`, so counting only the signed
    // cumulative would understate it by up to one whole chain — a lane sitting
    // on 200 unredeemed reveals and no fresh signature would look worthless to
    // the redemption floor and never be swept.
    //
    // There is only ever one claim to weigh. A rollover that folded less than
    // the frontier it retires is refused outright (ADR 003 §Rollover), so the
    // lane never holds a retired voucher worth more than its live one.
    //
    // In the cooperative case the strongest claim is always a signed voucher at
    // index 0 — every rollover and every close emits one whose `amount` already
    // folds the chain it retires — so a finalized delivery submits a zero index,
    // a zero preimage, and walks nothing on-chain.
    let Some(claim) = st.live_claim() else {
        return Ok(None);
    };
    let owed = claim.value();
    if owed.is_zero() {
        return Ok(None);
    }
    let unredeemed = owed.saturating_sub(paid.get(&key));
    if unredeemed.is_zero() {
        return Ok(None);
    }

    let register = match status {
        RegistrationStatus::Registered => None,
        RegistrationStatus::Unregistered => {
            let Some(owner_sig) = st.owner_sig else {
                // `owner_sig` is written in the SAME fsynced row as the frontier
                // (#1906), so a crash loses the value and its `owner_sig` together —
                // a lane with redeemable value always carries the material to
                // register its signer. Reaching here means the durable row kept the
                // value but lost the `owner_sig`, a node-side durability fault. The
                // client already sent its capability once; it is not asked to
                // re-send. Skip the lane and surface the invariant break.
                error!(
                    pool_id = %key.pool_id,
                    signer = %key.signer,
                    "BUG: redeemable lane has no owner_sig, so its signer cannot be \
                     registered; skipping (node durability fault, not a client re-send)"
                );
                return Ok(None);
            };
            // Every field of the registration payload comes off the lane record
            // itself — the same durable row as the frontier being redeemed (#1906).
            // On-chain registration is idempotent, so attaching this when the signer
            // is already registered elsewhere is a harmless no-op; the first landed
            // redemption persists `registered_until` and every later one omits it.
            // `cap` recovers the `u64` spending cap the intake path zero-extended
            // into the lane's `U256`.
            Some(PaymentPool::CapabilityReg {
                signer: key.signer,
                spendingCap: to_pool_u64(st.cap, "capability spending cap")?,
                expiry: st.expiry,
                ownerSig: Bytes::from(owner_sig.to_vec()),
            })
        }
    };

    let (r, vs) = compact_voucher_signature(&claim.signature)
        .context("compact the lane's voucher signature")?;
    let voucher = PaymentPool::LaneVoucher {
        signer: key.signer,
        // The claim's SIGNED anchor, not its extended value: the contract
        // rebuilds the EIP-712 digest from these, then re-derives the extension
        // itself from the chain fields below. Submitting the extended value here
        // would recover the wrong signer.
        cumulative: to_pool_u64(claim.amount, "voucher cumulative")?,
        bytesDelivered: to_pool_u64(claim.bytes_delivered, "voucher bytes delivered")?,
        r,
        vs,
        chainRoot: claim.chain.chain_root,
        // The deepest preimage this node has verified. At index 0 the root is
        // its own preimage, so a settlement voucher needs no chain state at all.
        preimage: claim.chain.tip,
        chainMeter: claim
            .chain_meter()
            .context("pack the lane's chainMeter word")?,
    };
    Ok(Some(PlannedLane {
        pool_id: key.pool_id,
        key,
        owed,
        unredeemed,
        voucher,
        register,
    }))
}

/// Plan a set of candidate lanes for redemption. The only on-chain-derived gate
/// is pool solvency (the event-fed [`PoolProjection`], read below); a lane's
/// signer-registration status is resolved locally from its persisted
/// `registered_until` watermark — no `getAuthorization` read. A lane not yet
/// confirmed registered attaches a `CapabilityReg` from its held `owner_sig`
/// ([`plan_lane`]); on-chain registration is idempotent, so this is safe even for
/// a signer another provider already registered, and the first landed redemption
/// persists `registered_until` so later passes skip the reg.
///
/// A lane with value owed whose capability expires within
/// [`REDEEM_LANDING_SLACK_SECS`] of `now` is skipped and metered: the contract
/// pays 0 for an expired capability, so its redemption would only spend gas. A
/// lane with nothing owed is dropped silently, expired or not.
#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)]
fn plan_lanes(
    paid: &PaidWatermarks,
    self_address: Address,
    states: Vec<LaneState>,
    metrics: &Arc<Metrics>,
    pool_view: &PoolProjection,
    now: u64,
) -> Vec<PlannedLane> {
    // The redeemer's only on-chain-derived gate: hold/drop lanes whose pool cannot
    // pay, read from the event-fed projection. Fail open on an unknown pool. A
    // lane's signer-registration status is resolved locally below — no chain read.
    let mut snapshot: HashMap<PoolId, Option<PoolStatus>> = HashMap::new();
    for st in &states {
        snapshot
            .entry(st.pool_id)
            .or_insert_with(|| pool_view.snapshot(st.pool_id));
    }
    let (states, skipped) = partition_redeemable(states, &snapshot, now);
    if skipped > 0 {
        metrics.redemption_skipped_insolvent_by(skipped as u64);
    }
    let mut plans: Vec<PlannedLane> = Vec::new();
    for st in &states {
        if st.provider != self_address {
            continue;
        }
        // Registered iff this node has already landed the signer's registration
        // (persisted `registered_until` still live); otherwise attach a
        // `CapabilityReg` from the held `owner_sig` ([`plan_lane`]).
        let reg_status = if is_registered(st.registered_until, now) {
            RegistrationStatus::Registered
        } else {
            RegistrationStatus::Unregistered
        };
        match plan_lane(st, paid, self_address, &reg_status) {
            // The expiry check follows `plan_lane`, which drops a lane with
            // nothing unredeemed. A fully redeemed lane stays in the store until
            // its pool is reclaimed, so only a skip that strands value is metered.
            Ok(Some(_)) if capability_expired(st.expiry, now) => {
                metrics.redemption_skipped_expired();
                debug!(
                    pool_id = %st.pool_id,
                    signer = %st.signer,
                    expiry = st.expiry,
                    "redeemer: skipping a lane with value owed whose capability has expired"
                );
            }
            Ok(Some(lane)) => plans.push(lane),
            Ok(None) => {}
            Err(err) => {
                metrics.redemption_failure();
                warn!(error = %sanitize_err_chain(&err), pool_id = %st.pool_id, "redemption planning failed");
            }
        }
    }
    plans
}

/// Park `key` after a chain fault on its hint path, until a sweep finishes
/// without a chain fault ([`redeemer_loop`]).
fn park_lane(parked: &mut HashSet<LaneKey>, key: LaneKey) {
    debug!(
        pool_id = %key.pool_id,
        signer = %key.signer,
        provider = %key.provider,
        "lane parked after a chain fault until a sweep finishes without one"
    );
    parked.insert(key);
}

/// What one hint-path redemption tells [`redeemer_loop`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct HintOutcome {
    /// The lane's [`serve_cutoff_wake`], if it has one.
    wake: Option<u64>,
    /// The redemption hit a chain fault: a failed watermark read or a
    /// `redeemMany` that did not land. The loop parks the lane until the next
    /// sweep.
    chain_fault: bool,
}

/// Hint-path redemption: plan one lane and, if it clears the per-chunk `floor`,
/// submit it as a one-lane `redeemMany`. A sub-floor hint defers to the next
/// sweep, which packs it with other lanes, and reads nothing from the chain.
/// Returns the lane's [`serve_cutoff_wake`] against the last sweep scan at
/// `swept_at`, so a lane first seen between sweeps gets its cutoff sweep, even
/// when the hint waited behind redemption work until after the cutoff. Also
/// returns whether the redemption hit a chain fault. A lane the store cannot
/// load is a local fault, not a chain fault.
#[allow(clippy::too_many_arguments)]
async fn redeem_one<P: Provider + Clone>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    store: &Arc<dyn PoolStateStore>,
    paid: &PaidWatermarks,
    self_address: Address,
    margin_secs: u64,
    swept_at: u64,
    floor: U256,
    max_vouchers: usize,
    key: LaneKey,
    metrics: &Arc<Metrics>,
    pool_view: &PoolProjection,
) -> HintOutcome {
    let st = match store.get(key) {
        Ok(Some(st)) => st,
        Ok(None) => return HintOutcome::default(),
        Err(err) => {
            metrics.redemption_failure();
            warn!(error = %err, pool_id = %key.pool_id, "redemption planning failed to load lane");
            return HintOutcome::default();
        }
    };
    let now = unix_now();
    let wake = serve_cutoff_wake(&st, paid, self_address, margin_secs, swept_at, now);
    let plans = plan_lanes(paid, self_address, vec![st], metrics, pool_view, now);
    // Hint path: require the durability floor and skip the submit on a failed
    // flush (`strict_flush`); the lane defers to the next sweep.
    let chain_fault =
        redeem_planned_lanes(contract, store, plans, floor, max_vouchers, true, metrics).await;
    HintOutcome { wake, chain_fault }
}

/// Load every persisted lane for a sweep or for cutoff scheduling. A load
/// failure is logged and returns `None`.
fn load_lanes(store: &dyn PoolStateStore) -> Option<Vec<LaneState>> {
    store
        .load_all()
        .inspect_err(|err| warn!(error = %err, "redeemer sweep: failed to load lane state"))
        .ok()
}

/// Sweep: plan every lane in `states` (per-lane error isolation through the
/// planning phase), then chunk the survivors under the per-chunk floor +
/// voucher-count cap and submit each chunk as its own `redeemMany`. Value is
/// spread across chunks so dust settles alongside real value; a chunk that
/// cannot clear the floor defers to a later sweep.
///
/// Returns whether the sweep hit a chain fault, as [`redeem_planned_lanes`]
/// reports it. The periodic sweep in [`redeemer_loop`] releases the parked
/// lanes only when it returns `false`.
#[allow(clippy::too_many_arguments)]
async fn redeem_sweep<P: Provider + Clone>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    store: &Arc<dyn PoolStateStore>,
    states: Vec<LaneState>,
    paid: &PaidWatermarks,
    self_address: Address,
    floor: U256,
    max_vouchers: usize,
    strict_flush: bool,
    metrics: &Arc<Metrics>,
    pool_view: &PoolProjection,
) -> bool {
    let plans = plan_lanes(paid, self_address, states, metrics, pool_view, unix_now());
    // Publish the pending-redemption total once per sweep. This is the whole
    // planned set, before the per-chunk floor defers any dust — a lane below the
    // floor is still owed and rides a later sweep, so counting it here is what
    // makes the gauge "USDC waiting to be redeemed" rather than "redeeming now".
    metrics.set_unredeemed_usdc(sum_unredeemed(&plans));
    // Only when there is something to measure. An empty plan set is the HEALTHY
    // steady state — everything owed has been redeemed — and publishing zero for
    // it would sawtooth the gauge to 0 after every successful sweep, which reads
    // exactly like insolvent counterparties. Leaving the last value standing is
    // the same discipline the buyer-wallet read takes.
    if !plans.is_empty() {
        metrics.set_pool_deposit_usdc(sum_recoverable_deposit(&plans, pool_view));
    }
    // A sweep is the retry for every lane, so it parks nothing itself. The
    // caller decides from the chain-fault flag whether parked lanes go free.
    redeem_planned_lanes(
        contract,
        store,
        plans,
        floor,
        max_vouchers,
        strict_flush,
        metrics,
    )
    .await
}

/// Submit one chunk of planned lanes as a single `redeemMany`. On an oversize
/// send failure (`is_oversize_send_err`) with more than one lane, halve the chunk
/// and retry each half, bounded by `MAX_SPLIT_DEPTH`. A revert or a non-oversize
/// send error records one `redemption_failure`. An unconfirmed receipt (a failed
/// or lapsed wait that the by-hash fetch in `send_and_await_receipt` could not
/// resolve) is not a failure: the tx may have mined, and it counts only into its
/// `onchain_tx_*` bucket. Every non-landed outcome leaves the claims for the next
/// sweep (cumulative, monotone, retry-safe). Does NOT seed the paid cache — each
/// paid voucher emits its own `PoolRedeemed`.
///
/// Returns whether the chunk hit a chain fault: any outcome other than
/// `Landed`, the unconfirmed ones included, in the chunk itself or in either
/// half of a split. The hint path parks the lane on a fault ([`redeemer_loop`]).
///
/// Runs inside an `onchain_tx` span that records the transaction hash (`tx`)
/// and the `outcome`. A halved retry nests its two halves as child spans.
#[allow(clippy::cognitive_complexity)]
#[tracing::instrument(
    name = "onchain_tx",
    skip_all,
    fields(
        op = "redeemMany",
        depth = depth,
        voucher_count = lanes.len(),
        tx = tracing::field::Empty,
        outcome = tracing::field::Empty,
        otel.status_code = tracing::field::Empty,
    )
)]
async fn submit_chunk<P: Provider + Clone>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    store: &Arc<dyn PoolStateStore>,
    mut lanes: Vec<PlannedLane>,
    metrics: &Arc<Metrics>,
    depth: u32,
) -> bool {
    if lanes.is_empty() {
        return false;
    }
    let span = tracing::Span::current();
    let batches = group_by_pool(&lanes);
    let cap_count: usize = batches.iter().map(|b| b.capabilities.len()).sum();
    let voucher_count = lanes.len();
    let pool_count = batches.len();
    let sent = contract.redeemMany(batches).send().await;
    let outcome = send_and_await_receipt(sent, Some(REDEEM_RECEIPT_TIMEOUT), metrics).await;
    let failed = is_redemption_failure(&outcome);
    let chain_fault = is_chain_fault(&outcome);
    match outcome {
        TxOutcome::Landed(receipt) => {
            span.record("tx", tracing::field::display(receipt.transaction_hash));
            span.record("outcome", "landed");
            info!(
                pool_count,
                cap_count,
                voucher_count,
                tx = %receipt.transaction_hash,
                "batched lane redemption landed (redeemMany)"
            );
            record_landed_chunk(contract, store, &lanes, metrics).await;
            return false;
        }
        TxOutcome::SendErr(err)
            if lanes.len() >= 2
                && depth < MAX_SPLIT_DEPTH
                && is_oversize_send_err(&err.to_string()) =>
        {
            span.record("outcome", "split");
            let mid = lanes.len() / 2;
            let right = lanes.split_off(mid);
            warn!(
                voucher_count,
                depth, "redeemMany send rejected oversized; halving chunk and retrying"
            );
            let left_faulted =
                Box::pin(submit_chunk(contract, store, lanes, metrics, depth + 1)).await;
            let right_faulted =
                Box::pin(submit_chunk(contract, store, right, metrics, depth + 1)).await;
            // Each half counts its own outcome; the split itself is not a failure.
            return left_faulted || right_faulted;
        }
        TxOutcome::Reverted(receipt) => {
            record_tx_failure(&span, "reverted", Some(receipt.transaction_hash));
            warn!(voucher_count, tx = %receipt.transaction_hash, "redeemMany reverted on-chain; leaving claims for retry");
        }
        TxOutcome::SendErr(err) => {
            record_tx_failure(&span, "send_failed", None);
            warn!(error = %sanitize_error_sources(&err), voucher_count, "redeemMany send failed; leaving claims for retry");
        }
        TxOutcome::ReceiptErr {
            error,
            tx_hash,
            last_lookup,
        } => {
            record_tx_failure(&span, "receipt_failed", Some(tx_hash));
            warn!(error = %sanitize_error_sources(&error), %last_lookup, voucher_count, tx = %tx_hash, "redeemMany receipt wait failed and the by-hash lookup missed; unconfirmed, leaving claims for retry");
        }
        TxOutcome::Timeout {
            tx_hash,
            last_lookup,
        } => {
            record_tx_failure(&span, "timeout", Some(tx_hash));
            warn!(%last_lookup, voucher_count, tx = %tx_hash, timeout = ?REDEEM_RECEIPT_TIMEOUT, "redeemMany receipt timed out and the by-hash lookup missed; unconfirmed, leaving claims for retry");
        }
    }
    if failed {
        metrics.redemption_failure();
    }
    chain_fault
}

/// Whether a `redeemMany` outcome is a chain fault that parks a hinted lane
/// ([`redeemer_loop`]). Every outcome but `Landed` is one, the unconfirmed ones
/// included: the lane's claim stays unsettled either way, so a hint that retries
/// it at once meets the same fault. An oversize send that `submit_chunk` halves
/// takes the outcome of its halves instead.
const fn is_chain_fault(outcome: &TxOutcome) -> bool {
    match outcome {
        TxOutcome::Landed(_) => false,
        TxOutcome::Reverted(_)
        | TxOutcome::SendErr(_)
        | TxOutcome::ReceiptErr { .. }
        | TxOutcome::Timeout { .. } => true,
    }
}

/// Whether a `redeemMany` outcome counts into `decdn_redemption_failures_total`.
/// A revert and a refused send are failures. A landed chunk is not, and neither
/// is an unconfirmed one (`ReceiptErr` / `Timeout`): the by-hash lookup already
/// resolves every transaction the RPC can see mined, so what stays unconfirmed
/// counts into `decdn_onchain_tx_receipt_failed_total` /
/// `decdn_onchain_tx_timeout_total`, which `DecdnOnchainTxUnconfirmed` alerts
/// on. An oversize send that `submit_chunk` halves never reaches this count.
const fn is_redemption_failure(outcome: &TxOutcome) -> bool {
    match outcome {
        TxOutcome::Reverted(_) | TxOutcome::SendErr(_) => true,
        TxOutcome::Landed(_) | TxOutcome::ReceiptErr { .. } | TxOutcome::Timeout { .. } => false,
    }
}

/// Record a landed `redeemMany` chunk: count its vouchers into
/// `pool_redemptions`, then persist `registered_until` for every lane whose
/// `CapabilityReg` rode in the chunk, so the next sweep does not register it
/// again.
///
/// The persisted value is the expiry the chain registered, read back with one
/// batched `getAuthorizations`, not the attached `CapabilityReg`'s expiry.
/// On-chain registration is write-once: when another provider landed a
/// different capability for the signer first, the attached one was a no-op and
/// its expiry is not the signer's. A failed or short read persists nothing for
/// the unread lanes; the next sweep attaches their `CapabilityReg` again (a
/// no-op on-chain) and re-reads.
async fn record_landed_chunk<P: Provider + Clone>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    store: &Arc<dyn PoolStateStore>,
    lanes: &[PlannedLane],
    metrics: &Metrics,
) {
    metrics.pool_redemptions(u64::try_from(lanes.len()).unwrap_or(u64::MAX));
    let registering: Vec<LaneKey> = lanes
        .iter()
        .filter(|lane| lane.register.is_some())
        .map(|lane| lane.key)
        .collect();
    for chunk in registering.chunks(WATERMARK_READ_BATCH_MAX) {
        let pool_ids = chunk.iter().map(|key| key.pool_id).collect();
        let signers = chunk.iter().map(|key| key.signer).collect();
        let builder = contract.getAuthorizations(pool_ids, signers);
        match timed(None, "post-redeem getAuthorizations", builder.call()).await {
            Ok(auths) => persist_registered_expiries(store, chunk, &auths),
            Err(err) => {
                warn!(
                    error = %sanitize_err_chain(&err),
                    lanes = chunk.len(),
                    "post-redeem registration read failed; the next sweep re-registers and re-reads"
                );
                break;
            }
        }
    }
}

/// Persist each lane's registered expiry as its `registered_until`. `auths` is
/// the `getAuthorizations` result for `keys`, in the same order; a short result
/// is a positionally sound prefix and the unread tail is skipped. A signer the
/// chain still reports unregistered persists nothing.
fn persist_registered_expiries(
    store: &Arc<dyn PoolStateStore>,
    keys: &[LaneKey],
    auths: &[PaymentPool::Authorization],
) {
    if auths.len() != keys.len() {
        warn!(
            expected = keys.len(),
            got = auths.len(),
            "post-redeem registration read returned a mismatched count"
        );
    }
    for (key, auth) in keys.iter().zip(auths) {
        let SignerAuthorization::Registered { expiry, .. } =
            SignerAuthorization::from_onchain(auth)
        else {
            continue;
        };
        if let Err(err) = store.set_registered_until(*key, expiry) {
            warn!(error = %err, pool_id = %key.pool_id, signer = %key.signer,
                "failed to persist registered_until after a landed registration");
        }
    }
}

/// Flush the lane store durable off the async worker (the fsync must not block a
/// runtime worker). One flush lands the lane frontier AND the buffered
/// capability rows, so it is the durability floor for both. Returns `true` on
/// success; a failure is metered and logged.
///
/// `strict` says what the caller does with a `false`, and is carried here only
/// so the log reports the outcome the caller actually takes: the periodic sweep
/// and hint paths defer their submit, while the shutdown sweep redeems anyway —
/// with the residual that a `CapabilityReg` can go on-chain against material
/// that never reached disk.
async fn flush_store_durable(
    store: &Arc<dyn PoolStateStore>,
    metrics: &Arc<Metrics>,
    strict: bool,
) -> bool {
    let store = Arc::clone(store);
    match tokio::task::spawn_blocking(move || store.flush()).await {
        Ok(Ok(())) => true,
        Ok(Err(err)) => {
            metrics.lane_flush_failure();
            if strict {
                warn!(error = %err, "pre-redeem lane store flush failed; deferring redeem");
            } else {
                warn!(
                    error = %err,
                    "pre-redeem lane store flush failed; the shutdown sweep proceeds \
                     with un-flushed lane and capability state"
                );
            }
            false
        }
        Err(join_err) => {
            metrics.lane_flush_failure();
            warn!(%join_err, "pre-redeem lane store flush task join failed");
            false
        }
    }
}

/// Read the on-chain paid watermark for every planned lane and drop any lane the
/// chain already shows settled to its claim value — the last check before
/// spending gas on a `redeemMany` the contract would silently no-op (its own
/// `claimed <= w.amount` guard, `PaymentPool.redeem`). A surviving lane's
/// `unredeemed` is recomputed from the fresh on-chain paid, so the per-chunk
/// floor gates on the true delta.
///
/// This is the freshest possible signal and complements the durable paid cache:
/// the event-fed cache can lag the chain between poll ticks, and another actor
/// could have redeemed the same lane. The read is the contract's own
/// `getWatermarks` batch view: one `eth_call` per batch of at most
/// [`WATERMARK_READ_BATCH_MAX`] lanes, no per-lane fan-out and no dependency on
/// a contract outside the protocol's own deployment.
///
/// **Fail-open.** An RPC error, a timeout or a mismatched return length submits
/// the lanes it could not read unchanged: the contract's own no-op guard is the
/// backstop, and the only cost of a stale read is the gas this check saves. It
/// never holds up a redemption on a read it could not make. A failed batch stops
/// the read there, so later batches are not issued; the batches already read
/// still reconcile and keep their savings. Each batch meters itself through
/// `redemption_reconcile_ok` / `redemption_reconcile_failure`, because a zero
/// `redemption_reconciled_skip` alone cannot separate a healthy sweep with
/// nothing to drop from a read that never landed.
///
/// Returns the kept lanes and whether a batch failed: an RPC error, a timeout or
/// a mismatched return length, each also metered as a reconcile failure. The
/// hint path parks the lane on a failed read ([`redeemer_loop`]).
async fn reconcile_onchain_watermarks<P: Provider + Clone>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    plans: Vec<PlannedLane>,
    metrics: &Arc<Metrics>,
) -> (Vec<PlannedLane>, bool) {
    // An empty plan set is the healthy steady state, so short-circuit rather than
    // issue an `eth_call` with three empty arrays on every idle sweep.
    if plans.is_empty() {
        return (plans, false);
    }
    let mut read_failed = false;
    let mut onchain_paid: Vec<U256> = Vec::with_capacity(plans.len());
    for chunk in plans.chunks(WATERMARK_READ_BATCH_MAX) {
        let mut pool_ids = Vec::with_capacity(chunk.len());
        let mut signers = Vec::with_capacity(chunk.len());
        let mut providers = Vec::with_capacity(chunk.len());
        for plan in chunk {
            pool_ids.push(plan.pool_id);
            signers.push(plan.key.signer);
            providers.push(plan.key.provider);
        }
        // Bound the read: the alloy HTTP provider has no request timeout, so a hung
        // RPC would wedge the redeemer loop forever — the opposite of fail-open. A
        // timeout folds into the error arm and submits the unread tail unchanged
        // (degrade-and-continue, per `timed`); the default bound
        // (`DEFAULT_RPC_CALL_TIMEOUT`) applies.
        let builder = contract.getWatermarks(pool_ids, signers, providers);
        let lanes = match timed(None, "pre-redeem getWatermarks", builder.call()).await {
            Ok(lanes) => lanes,
            Err(err) => {
                warn!(
                    stage = "reconcile",
                    error = %sanitize_err_chain(&err),
                    lanes = chunk.len(),
                    "pre-redeem watermark reconciliation failed; submitting on the contract's own no-op guard"
                );
                metrics.redemption_reconcile_failure();
                read_failed = true;
                break;
            }
        };
        // A well-behaved contract returns one `Lane` per input triple, in input
        // order. Any other length means the decoder and the chain disagree about
        // the return shape, so the batch's values cannot be trusted to pair with
        // this chunk's plans — a long return may be offset, and dropping a lane
        // on a mis-paired watermark could forfeit real claim value at the next
        // shutdown sweep.
        // Keep only a short return's prefix, which is still positionally sound,
        // and stop either way; `reconcile_plans` keeps the untouched tail.
        if lanes.len() != chunk.len() {
            warn!(
                stage = "reconcile",
                expected = chunk.len(),
                got = lanes.len(),
                "watermark reconciliation returned a mismatched count; submitting the \
                 unreconciled lanes unchanged"
            );
            metrics.redemption_reconcile_failure();
            read_failed = true;
            if lanes.len() < chunk.len() {
                onchain_paid.extend(lanes.iter().map(|lane| U256::from(lane.amount)));
            }
            break;
        }
        metrics.redemption_reconcile_ok();
        onchain_paid.extend(lanes.iter().map(|lane| U256::from(lane.amount)));
    }
    let (kept, skipped) = reconcile_plans(plans, &onchain_paid);
    if skipped > 0 {
        metrics.redemption_reconciled_skip_by(skipped);
    }
    (kept, read_failed)
}

/// Pure core of [`reconcile_onchain_watermarks`]: given each plan's freshly-read
/// on-chain paid watermark (parallel to `plans`, same order), drop every lane the
/// chain already shows settled to its claim value and recompute `unredeemed` for
/// the survivors from that fresh paid. Returns the kept lanes and the count
/// dropped. `onchain_paid` is a prefix of `plans`, in the same order: the caller
/// supplies a shorter slice when a batch errors or under-returns, and the
/// untouched tail is conservatively kept.
fn reconcile_plans(plans: Vec<PlannedLane>, onchain_paid: &[U256]) -> (Vec<PlannedLane>, u64) {
    let mut kept = Vec::with_capacity(plans.len());
    let mut skipped = 0u64;
    for (idx, mut plan) in plans.into_iter().enumerate() {
        let Some(&onchain) = onchain_paid.get(idx) else {
            // No reading for this lane (short slice): keep it unchanged — the
            // contract's own no-op guard remains the backstop.
            kept.push(plan);
            continue;
        };
        if onchain >= plan.owed {
            skipped += 1;
            debug!(
                pool_id = %plan.pool_id,
                signer = %plan.key.signer,
                onchain_paid = %onchain,
                owed = %plan.owed,
                "reconciliation: lane already settled on-chain, dropping from redeem batch"
            );
            continue;
        }
        // Recompute against the fresh on-chain paid so a lagging cache cannot push
        // this lane over the per-chunk floor on value the chain has already paid.
        plan.unredeemed = plan.owed.saturating_sub(onchain);
        kept.push(plan);
    }
    (kept, skipped)
}

/// Record a failed transaction's `outcome`, its hash when one was issued, and
/// an error status on its `onchain_tx` span.
fn record_tx_failure(span: &tracing::Span, outcome: &'static str, tx: Option<TxHash>) {
    if let Some(tx) = tx {
        span.record("tx", tracing::field::display(tx));
    }
    span.record("outcome", outcome);
    span.record("otel.status_code", "ERROR");
}

/// Whether `provider` has a lane of `pool_id` in `states` that is owed more
/// than it has been paid.
fn holds_unredeemed(states: &[LaneState], pool_id: PoolId, provider: Address) -> bool {
    states.iter().any(|st| {
        st.pool_id == pool_id && st.provider == provider && st.owed() > st.paid_cumulative
    })
}

/// Chunk planned lanes under the per-chunk floor + voucher-count cap and submit
/// each chunk. Every caller passes the configured
/// `blockchain.redeem_threshold_micro_usdc`, which config keeps above zero, so a
/// sub-floor chunk defers on every path — the hint, the self-tick sweep, and the
/// graceful-shutdown sweep alike. The floor is per CHUNK, and value is spread
/// so dust rides alongside larger lanes: a sub-floor lane stays unredeemed
/// only until its chunk's aggregate clears the floor, or until the pool owner
/// reclaims it.
///
/// The floor gates the on-chain read too: when the whole set's cached
/// `unredeemed` is below the floor, it returns before
/// [`reconcile_onchain_watermarks`]. The cached paid watermark can only lag the
/// chain, so a fresh read can only lower `unredeemed`, and no chunk after the
/// read can hold more than the whole set. The gate is on the total, not per
/// chunk, because a lane the read drops can shrink the chunk count and re-pack
/// lanes from a failing chunk into one that clears. A sub-floor hint therefore
/// costs no `eth_call`. At `floor == 0` the gate always passes.
///
/// Floors the redeemed watermark first: flushes the lane store durable AFTER the
/// lanes were planned (their cumulative amounts already read) and BEFORE any
/// chunk goes on-chain, so a crash right after a submit still finds on-disk
/// `owed ≥ submitted` (`record` is monotone, so the flush persists at least every
/// value in the batch). The periodic sweep and hint path require this floor and
/// skip their submit on a failed flush (`strict_flush`); the shutdown sweep
/// redeems regardless, since forfeiting the claim across the stop is worse than
/// a bounded re-serve risk.
///
/// Returns whether the cycle hit a chain fault: a failed watermark read or a
/// chunk that did not land. A floor deferral, an empty chunk set and a failed
/// strict flush add no fault of their own; the flush is local to this node.
async fn redeem_planned_lanes<P: Provider + Clone>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    store: &Arc<dyn PoolStateStore>,
    plans: Vec<PlannedLane>,
    floor: U256,
    max_vouchers: usize,
    strict_flush: bool,
    metrics: &Arc<Metrics>,
) -> bool {
    // Apply the floor on the cached total before any chain read.
    if plans.is_empty() || sum_unredeemed(&plans) < floor {
        return false;
    }
    // Last check before spending gas: reconcile against the on-chain watermark
    // and drop lanes the chain already shows settled, so a lagging cache or a
    // concurrent redeemer can never cost a no-op `redeemMany`.
    let (plans, read_failed) = reconcile_onchain_watermarks(contract, plans, metrics).await;
    let chunks = chunk_redemptions(plans, floor, max_vouchers);
    if chunks.is_empty() {
        return read_failed;
    }
    let span = tracing::info_span!("redeem_cycle", chunks = chunks.len(), strict_flush);
    async move {
        if !flush_store_durable(store, metrics, strict_flush).await && strict_flush {
            return read_failed;
        }
        let mut faulted = read_failed;
        for chunk in chunks {
            faulted |= submit_chunk(contract, store, chunk, metrics, 0).await;
        }
        faulted
    }
    .instrument(span)
    .await
}

/// Mutable debounce bookkeeping for [`DebouncedCheckpointStore`], guarded by a
/// single `std::sync::Mutex`. The lock is only ever held for the brief duration
/// of a `record`/`flush` decision (no `.await` inside), so a sync mutex is the
/// right primitive.
struct DebounceState {
    /// The block last forwarded to a *durable* write on the inner store, or
    /// `None` if nothing has been persisted in this process yet.
    last_persisted: Option<u64>,
    /// Monotonic [`Instant`] of the last durable write, for the time-based
    /// threshold. Seeded at construction so the first interval is measured from
    /// service start.
    last_persist_at: Instant,
    /// The highest block buffered but not yet durably written. Monotonic. `None`
    /// once flushed/forwarded — i.e. equal to `last_persisted`.
    pending: Option<u64>,
    /// Whether the first in-process `record` has run. Until it does we lazily fold
    /// the inner store's existing checkpoint into `last_persisted`, and force that
    /// first record durable so a restart re-anchors the floor promptly.
    seeded: bool,
}

/// Debouncing decorator over a [`KeyedCheckpointStore`] (#784, keyed in #1092).
/// Buffers `record_checkpoint` in memory **per [`CheckpointKey`]** and forwards a
/// durable write for a key only when its buffered block is at least `flush_blocks`
/// ahead of that key's last persisted value *or* at least `flush_interval` has
/// elapsed since that key's last durable write — cutting the per-block fsync
/// amplification a watcher's live tail would otherwise pay on a backlog drain.
///
/// **Durability contract preserved (per key).** The persisted value is only ever
/// a *floor* for the resume backfill (the resumable watcher rewinds it by
/// `REORG_MARGIN_BLOCKS` and every sink is idempotent), so a
/// buffered-but-not-yet-fsynced advance that a crash loses merely widens the next
/// rescan — never narrows it. `record_checkpoint` only raises a key's `pending`,
/// so the forwarded value stays monotonic. [`Self::flush_checkpoint`] forces the
/// buffered value out and is wired into graceful shutdown.
pub struct DebouncedCheckpointStore {
    inner: Arc<dyn KeyedCheckpointStore>,
    flush_blocks: u64,
    flush_interval: Duration,
    /// Clock source, injectable so the time-based threshold is unit-testable
    /// without sleeping. Production uses [`Instant::now`].
    clock: Box<dyn Fn() -> Instant + Send + Sync>,
    /// Per-key debounce bookkeeping, created lazily on a key's first record/flush.
    state: std::sync::Mutex<HashMap<CheckpointKey, DebounceState>>,
}

impl std::fmt::Debug for DebouncedCheckpointStore {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let tracked: Vec<(CheckpointKey, Option<u64>, Option<u64>)> = {
            let map = self
                .state
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            map.iter()
                .map(|(k, s)| (*k, s.pending, s.last_persisted))
                .collect()
        };
        f.debug_struct("DebouncedCheckpointStore")
            .field("flush_blocks", &self.flush_blocks)
            .field("flush_interval", &self.flush_interval)
            .field("tracked", &tracked)
            .finish_non_exhaustive()
    }
}

impl DebouncedCheckpointStore {
    /// Wrap `inner` with the production debounce thresholds
    /// (`CHECKPOINT_FLUSH_BLOCKS` / `CHECKPOINT_FLUSH_INTERVAL`) and the real wall
    /// clock.
    #[must_use]
    pub fn new(inner: Arc<dyn KeyedCheckpointStore>) -> Self {
        Self::with_params(
            inner,
            CHECKPOINT_FLUSH_BLOCKS,
            CHECKPOINT_FLUSH_INTERVAL,
            Box::new(Instant::now),
        )
    }

    /// Construct with explicit thresholds and clock — the seam the unit tests
    /// drive to exercise the block-cadence and time-cadence paths deterministically.
    fn with_params(
        inner: Arc<dyn KeyedCheckpointStore>,
        flush_blocks: u64,
        flush_interval: Duration,
        clock: Box<dyn Fn() -> Instant + Send + Sync>,
    ) -> Self {
        Self {
            inner,
            flush_blocks,
            flush_interval,
            clock,
            state: std::sync::Mutex::new(HashMap::new()),
        }
    }

    /// Forward `block` to the inner store for `key` and reset that key's debounce
    /// window. Caller holds `state`. On a durable-write error the buffered
    /// `pending` is left intact so the next `record`/`flush` retries.
    fn persist_locked(
        &self,
        key: CheckpointKey,
        state: &mut DebounceState,
        block: u64,
    ) -> Result<(), StoreError> {
        self.inner.record_checkpoint(key, block)?;
        state.last_persisted = Some(block);
        state.last_persist_at = (self.clock)();
        state.pending = None;
        Ok(())
    }
}

impl KeyedCheckpointStore for DebouncedCheckpointStore {
    fn load_checkpoint(&self, key: CheckpointKey) -> Result<Option<u64>, StoreError> {
        let persisted = self.inner.load_checkpoint(key)?;
        let pending = self
            .state
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(&key)
            .and_then(|s| s.pending);
        Ok(match (persisted, pending) {
            (Some(a), Some(b)) => Some(a.max(b)),
            (a, b) => a.or(b),
        })
    }

    fn record_checkpoint(&self, key: CheckpointKey, block: u64) -> Result<(), StoreError> {
        let mut map = self
            .state
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let clock = &self.clock;
        let state = map.entry(key).or_insert_with(|| DebounceState {
            last_persisted: None,
            last_persist_at: clock(),
            pending: None,
            seeded: false,
        });
        // On the first in-process record for this key, fold the inner store's
        // existing checkpoint into `last_persisted` so a fresh key-state around an
        // already-populated inner store cannot regress the on-disk floor. `seeded`
        // flips only after the load *succeeds*.
        let first_record = !state.seeded;
        if first_record {
            state.last_persisted = self.inner.load_checkpoint(key)?;
            state.seeded = true;
        }
        let highest = state
            .pending
            .max(state.last_persisted)
            .unwrap_or(0)
            .max(block);
        state.pending = Some(highest);

        if state.last_persisted == Some(highest) {
            return Ok(());
        }

        let blocks_ahead = highest.saturating_sub(state.last_persisted.unwrap_or(0));
        let elapsed = (self.clock)().saturating_duration_since(state.last_persist_at);
        let due =
            first_record || blocks_ahead >= self.flush_blocks || elapsed >= self.flush_interval;
        if due {
            self.persist_locked(key, state, highest)
        } else {
            Ok(())
        }
    }

    fn flush_checkpoint(&self, key: CheckpointKey) -> Result<(), StoreError> {
        let mut map = self
            .state
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let Some(state) = map.get_mut(&key) else {
            return Ok(());
        };
        match state.pending {
            Some(block) if state.last_persisted != Some(block) => {
                self.persist_locked(key, state, block)
            }
            _ => Ok(()),
        }
    }
}

/// Whether a `redeemMany` send error looks like "the transaction is too big to
/// include" — exceeding the block gas limit or the node/mempool transaction-size
/// cap — rather than a revert or a transient RPC fault. Matched case-insensitively
/// against a small set of client markers; the caller also bounds retry depth, so a
/// false negative simply leaves the claim for the next sweep and a false positive
/// costs at most a bounded number of doomed smaller sends.
fn is_oversize_send_err(msg: &str) -> bool {
    const MARKERS: [&str; 5] = [
        "gas required exceeds",
        "exceeds block gas limit",
        "oversized data",
        "transaction too large",
        "request entity too large",
    ];
    let m = msg.to_ascii_lowercase();
    MARKERS.iter().any(|marker| m.contains(marker))
}

#[cfg(test)]
mod tests;