diff --git a/docs/performance/transfer-filter-preparation-20261004/README.md b/docs/performance/transfer-filter-preparation-20261004/README.md
new file mode 100644
index 0000000..b2cba58
--- /dev/null
+++ b/docs/performance/transfer-filter-preparation-20261004/README.md
@@ -0,0 +1,40 @@
+# Query-invariant transfer filter preparation
+
+Continuation of RemitFlow Frontend #281 on the existing PR #298. The earlier bounded-heap selector is retained; this comparison starts from that already-optimized implementation, not the old full sort.
+
+The status alias, folded search term and date-window cutoff are prepared once per invocation. Rows are still read and matched on every query; no result cache or stale-window reuse is introduced. Empty filters bypass the matcher. Actor visibility, returned row identity, stable top-k membership, totals and scope keys are retained.
+
+## Focused native execution
+
+Node v22.23.3; Vitest 4.1.10; Prettier 3.9.6; linux/x64; AMD EPYC 7763 64-Core Processor.
+The existing 11 utility cases and two new maintained regressions passed together: **13 passed, zero failures or skipped tests**. The new cases cover one clock read per query, inclusive date boundaries, fresh time on subsequent calls, unbounded presets and invalid/oversized windows.
+The original repository Vite configuration, jsdom setup and locked dependencies were used. Only these two utility test files ran; no full application suite, build, browser or provider execution is claimed.
+
+[Execution run](https://github.com/woahwhattheheck/RemitFlow-Frontend/actions/runs/37200367081). Candidate before formatting: b3af1368de7e2d043a79ae49fe912584375eadfa. Tests and timings below executed the formatted files committed with this report.
+
+## Complete-selector timings
+
+Synthetic histories; three warmups followed by nine paired samples with alternating order. Fixture creation, assertions, garbage collection and operation counting are outside the timed selector call. All paired outputs and row references matched; a further 10,368 untimed compatibility comparisons passed.
+
+| Workload | Baseline ms | Prepared ms | Time reduction |
+| ---------------------- | ----------: | ----------: | -------------: |
+| 5k all time | 3.753 | 3.100 | 17.40% |
+| 100k all time | 38.884 | 30.025 | 22.78% |
+| 100k date window | 77.677 | 41.512 | 46.56% |
+| 100k status and search | 23.599 | 20.468 | 13.27% |
+| 100k combined filters | 37.811 | 26.264 | 30.54% |
+
+All raw paired samples, output digests, source Git blobs and separately measured operation counts are in [results.json](results.json). Negative reductions, if present, are regressions, not speedups. These measurements exclude localStorage decoding, schema validation, the simulated API delay, React rendering and network/provider work; they are not whole-page or fleet-throughput results.
+
+An earlier local prototype without the empty-filter bypass regressed the 5,000-row all-time case by 15.93% in one paired measurement. That prototype was rejected. The revised local pass measured 1.536 versus 1.542 ms for that small workload (effectively unchanged), 69.875 versus 40.020 ms for the 100,000-row date window and 32.628 versus 22.354 ms for combined filters. Those exploratory Node 22.16.0 observations are separate from the source-bound execution above.
+
+## Reproduce
+
+```bash
+git show ecefe06d68ca6838434b755525cfc3cc6c701d4f:src/utils/transferSearch.js > src/utils/transferSearch.baseline.js
+node --expose-gc scripts/bench-transfer-filter-preparation.mjs src/utils/transferSearch.baseline.js
+npx vitest run test/unit/transfer-search.test.js test/unit/transfer-filter-preparation.test.js
+rm src/utils/transferSearch.baseline.js
+```
+
+No existing test, package manifest, lockfile, UI or API path was changed. The temporary execution workflow is not part of the product result. Original contribution and claim custody remain unchanged; this report establishes neither maintainer acceptance nor an award/payment.
diff --git a/docs/performance/transfer-filter-preparation-20261004/results.json b/docs/performance/transfer-filter-preparation-20261004/results.json
new file mode 100644
index 0000000..ab2568c
--- /dev/null
+++ b/docs/performance/transfer-filter-preparation-20261004/results.json
@@ -0,0 +1,383 @@
+{
+ "schema": "transfer-filter-preparation-benchmark/v1",
+ "executed_at": "2026-10-04T11:56:10.496Z",
+ "node": "v22.23.3",
+ "platform": "linux/x64",
+ "cpu": "AMD EPYC 7763 64-Core Processor",
+ "source": {
+ "baseline_blob": "a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274",
+ "candidate_blob": "f072d7879d4bceb74d3635f6bfda0797e7f549a0"
+ },
+ "method": "3 warmups; 9 paired samples, alternating order; synthetic fixed records; setup/assertions/GC/counting outside timed calls",
+ "scope": "Complete production applyTransferSearch and its actual dependency modules, not storage/API/React/browser/provider latency.",
+ "compatibility_checks": 10368,
+ "observations": [
+ {
+ "name": "5k all time",
+ "rows": 5000,
+ "query": {
+ "actorId": "demo"
+ },
+ "total_matched": 4288,
+ "returned": 100,
+ "baseline_median_ms": 3.7526440000000036,
+ "candidate_median_ms": 3.0997669999999857,
+ "reduction_percent": 17.397786733833996,
+ "operation_counts": {
+ "baseline": {
+ "date_constructions": 0,
+ "lowercase_calls": 4288,
+ "string_match_calls": 4288
+ },
+ "candidate": {
+ "date_constructions": 0,
+ "lowercase_calls": 1,
+ "string_match_calls": 1
+ }
+ },
+ "result_sha256": "af8fc4354239ad98cc6421adcdd67840ffcfd6f57d63e1397062a3115b9113b6",
+ "pairs": [
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 3.7526440000000036,
+ "candidate": 5.348464000000007
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 3.5111739999999827,
+ "baseline": 3.810030999999981
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 3.834937000000025,
+ "candidate": 3.110306000000037
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 3.0997669999999857,
+ "baseline": 3.839615999999978
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 2.1969440000000304,
+ "candidate": 1.7528870000000438
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 2.3584249999999543,
+ "baseline": 3.0319599999999696
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 2.705592000000024,
+ "candidate": 3.094547000000034
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 3.2347990000000095,
+ "baseline": 3.7058159999999702
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 3.8358690000000024,
+ "candidate": 3.0322309999999675
+ }
+ ]
+ },
+ {
+ "name": "100k all time",
+ "rows": 100000,
+ "query": {
+ "actorId": "demo"
+ },
+ "total_matched": 85714,
+ "returned": 100,
+ "baseline_median_ms": 38.88385699999981,
+ "candidate_median_ms": 30.024726999999984,
+ "reduction_percent": 22.783567998410923,
+ "operation_counts": {
+ "baseline": {
+ "date_constructions": 0,
+ "lowercase_calls": 85714,
+ "string_match_calls": 85714
+ },
+ "candidate": {
+ "date_constructions": 0,
+ "lowercase_calls": 1,
+ "string_match_calls": 1
+ }
+ },
+ "result_sha256": "e70cbd4515bd5b7a648f095dde799467c191ef6591ba8d60c8b88b342409cb90",
+ "pairs": [
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 41.91509599999995,
+ "candidate": 33.09540900000002
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 31.61123299999997,
+ "baseline": 43.19093299999997
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 36.904979000000026,
+ "candidate": 29.873694999999998
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 29.382939999999962,
+ "baseline": 37.878693999999996
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 37.82792900000004,
+ "candidate": 29.612397999999985
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 32.902851000000055,
+ "baseline": 41.05498299999999
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 36.653589999999895,
+ "candidate": 29.378081000000066
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 30.024726999999984,
+ "baseline": 38.88385699999981
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 40.884575999999925,
+ "candidate": 32.89763100000005
+ }
+ ]
+ },
+ {
+ "name": "100k date window",
+ "rows": 100000,
+ "query": {
+ "actorId": "demo",
+ "range": "30d"
+ },
+ "total_matched": 21445,
+ "returned": 100,
+ "baseline_median_ms": 77.67733499999986,
+ "candidate_median_ms": 41.512255000000096,
+ "reduction_percent": 46.55808544410004,
+ "operation_counts": {
+ "baseline": {
+ "date_constructions": 171428,
+ "lowercase_calls": 85714,
+ "string_match_calls": 85714
+ },
+ "candidate": {
+ "date_constructions": 85715,
+ "lowercase_calls": 1,
+ "string_match_calls": 1
+ }
+ },
+ "result_sha256": "74024fb5b745a035677c5a899ffca5d562db9816715203d1d2aacd66db985ad0",
+ "pairs": [
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 77.11932500000012,
+ "candidate": 42.58652699999993
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 39.15601400000014,
+ "baseline": 76.42492099999981
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 80.41769300000033,
+ "candidate": 43.91076500000008
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 38.1670610000001,
+ "baseline": 74.1527659999997
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 80.388508,
+ "candidate": 41.512255000000096
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 41.521818999999596,
+ "baseline": 77.67733499999986
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 78.02949099999978,
+ "candidate": 39.358372000000145
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 42.156524999999874,
+ "baseline": 74.95974699999988
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 77.83227399999987,
+ "candidate": 39.81588000000011
+ }
+ ]
+ },
+ {
+ "name": "100k status and search",
+ "rows": 100000,
+ "query": {
+ "actorId": "demo",
+ "status": "submitted",
+ "search": " ALICE "
+ },
+ "total_matched": 22857,
+ "returned": 100,
+ "baseline_median_ms": 23.599294999999984,
+ "candidate_median_ms": 20.46779400000014,
+ "reduction_percent": 13.269468431153753,
+ "operation_counts": {
+ "baseline": {
+ "date_constructions": 0,
+ "lowercase_calls": 68570,
+ "string_match_calls": 22857
+ },
+ "candidate": {
+ "date_constructions": 0,
+ "lowercase_calls": 34286,
+ "string_match_calls": 1
+ }
+ },
+ "result_sha256": "274de23d2629ae95bc92c3234c680e046caff8acb836956fe2f17e30c96736a7",
+ "pairs": [
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 29.14542900000015,
+ "candidate": 22.70290299999988
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 19.512673000000177,
+ "baseline": 29.16711899999973
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 22.40157100000033,
+ "candidate": 19.63978999999972
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 20.533166000000165,
+ "baseline": 23.599294999999984
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 22.065374000000247,
+ "candidate": 20.46779400000014
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 19.539984000000004,
+ "baseline": 21.87729300000001
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 28.005221999999776,
+ "candidate": 20.249667000000045
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 23.231468999999834,
+ "baseline": 24.938012000000526
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 21.595057000000452,
+ "candidate": 21.847137000000657
+ }
+ ]
+ },
+ {
+ "name": "100k combined filters",
+ "rows": 100000,
+ "query": {
+ "actorId": "demo",
+ "status": "settled",
+ "search": " ALICE ",
+ "range": "90d",
+ "limit": 500
+ },
+ "total_matched": 17145,
+ "returned": 500,
+ "baseline_median_ms": 37.81098099999963,
+ "candidate_median_ms": 26.264164999999593,
+ "reduction_percent": 30.53826082957263,
+ "operation_counts": {
+ "baseline": {
+ "date_constructions": 45714,
+ "lowercase_calls": 68572,
+ "string_match_calls": 22857
+ },
+ "candidate": {
+ "date_constructions": 22858,
+ "lowercase_calls": 34287,
+ "string_match_calls": 1
+ }
+ },
+ "result_sha256": "c18859b9b94bbbef72690fbdd332764c4fb3c1ace6e617a2c15e4e60118cce45",
+ "pairs": [
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 36.961607000000186,
+ "candidate": 25.90255100000013
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 25.181555000000117,
+ "baseline": 37.81098099999963
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 40.81570499999998,
+ "candidate": 30.698475999999573
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 26.264164999999593,
+ "baseline": 40.82644600000003
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 43.52538200000072,
+ "candidate": 28.478516000000127
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 24.604769999999917,
+ "baseline": 37.18445199999951
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 36.857842999999775,
+ "candidate": 25.36314500000026
+ },
+ {
+ "order": ["candidate", "baseline"],
+ "candidate": 35.77864199999931,
+ "baseline": 37.269722
+ },
+ {
+ "order": ["baseline", "candidate"],
+ "baseline": 43.32392400000026,
+ "candidate": 28.913426000000072
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/performance/transfer-search-20261004/README.md b/docs/performance/transfer-search-20261004/README.md
new file mode 100644
index 0000000..c2486f8
--- /dev/null
+++ b/docs/performance/transfer-search-20261004/README.md
@@ -0,0 +1,88 @@
+# Capped transfer search performance
+
+Issue: [RemitFlow Frontend #281](https://github.com/RemitFlow/RemitFlow-Frontend/issues/281)
+Existing submission: [PR #298](https://github.com/RemitFlow/RemitFlow-Frontend/pull/298)
+
+`applyTransferSearch` previously built two full arrays, sorted every matching
+transfer and then returned at most 500 rows. Its comparator reparsed each
+timestamp on every comparison. Large unsorted histories therefore spent most
+of their query time sorting rows that would never be returned.
+
+The selector now scans the history once and retains only the best requested
+rows in a bounded heap. It parses each matching timestamp once, sorts the
+retained rows, and still counts every match. The public `stableSortTransfers`
+helper and the actor, status, search and date predicates are unchanged.
+
+## Observed production-module timings
+
+Node **24.19.0**, Linux x64. Each row below is the median of five paired
+measurements, alternating execution order after one warmup per version.
+Fixtures, garbage collection and output assertions were outside the timed
+call. The timed function is the actual imported `applyTransferSearch`.
+
+| History | Result cap | Before, ms | After, ms | Speedup |
+| ------------------------------ | ---------: | ---------: | --------: | ------: |
+| 5,000 shuffled transfers | 100 | 22.827 | 2.484 | 9.19x |
+| 100,000 shuffled transfers | 100 | 776.075 | 26.912 | 28.84x |
+| 100,000 oldest-first transfers | 500 | 44.631 | 31.982 | 1.40x |
+| 100,000 newest-first transfers | 500 | 46.296 | 25.439 | 1.82x |
+
+Every timed result matched the baseline's complete return value and original
+row references. The 100,000-row shuffled case reduced `Date.parse` calls from
+3,031,310 to 100,000 and sorted 100 rows instead of 100,000. Operation counts
+were measured in separate, untimed calls. Full paired samples and source
+hashes are retained in [results.json](results.json).
+
+These measurements cover the repository's localStorage demo query selector.
+They exclude storage parsing, transfer-schema validation, the API's simulated
+400 ms delay, React rendering and external providers. They establish neither
+end-to-end page latency nor live-provider throughput. Heap retention is bounded
+by the normalized result limit; no measured peak-memory result is claimed.
+
+## Compatibility and focused validation
+
+The selector preserves descending creation time, descending exact ID-string
+order and original input order for identical time/ID keys. It returns the same
+`items`, `totalMatched`, `capped` and `scopeKey`, and preserves row references.
+It does not mutate inputs or change actor visibility, filters, query caps,
+navigation, debounce or request cancellation.
+
+The maintained `test/unit/transfer-search.test.js` file passed **11/11** cases
+under Vitest **4.1.10** and Node **24.19.0**: all nine existing cases plus two
+focused regressions. New coverage compares against the unchanged public sort
+across caps and filters, duplicate keys at the cap boundary, frozen inputs,
+row identity, sparse legacy histories, invalid/epoch/pre-1970 timestamps and
+distinct Unicode IDs. Independent source review found no blockers.
+
+The run used the installed Vitest package and a Node-only configuration for
+this pure utility file. No dependency installation or assertion substitution
+was used. The repository-wide test suite, production build and hosted CI were
+not rerun for this utility continuation; earlier PR evidence retains its own
+source revision and limitations. No full-suite or sponsor-acceptance claim is
+made here.
+
+## Reproduce
+
+From a checkout containing this change and the baseline commit, stage the old
+selector beside the current one so both use the same unchanged dependencies:
+
+```bash
+git show 09cad8dff6d55a35b39d6836d31d045cdc1b938b:src/utils/transferSearch.js > src/utils/transferSearch.baseline.js
+node --expose-gc scripts/bench-transfer-search.mjs src/utils/transferSearch.baseline.js
+npx vitest run test/unit/transfer-search.test.js
+```
+
+The benchmark needs Node only and writes JSON to stdout. An optional second
+path selects a different candidate module. It asserts complete baseline
+equivalence for every sample and reports both input source hashes. The
+baseline staging file is temporary and is not part of the contribution.
+
+| Executed source | Git blob |
+| -------------------- | ------------------------------------------ |
+| Baseline selector | `be8db651d5c2457323d6f8373a170a41d8b99b87` |
+| Updated selector | `a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274` |
+| Maintained unit file | `22be7a01462fd6a612d40cdc9e34f0c9f1f443ea` |
+
+The original contribution and claim remain unchanged. This is performance
+follow-through on the existing submission; it does not establish a new award
+or payment.
diff --git a/docs/performance/transfer-search-20261004/results.json b/docs/performance/transfer-search-20261004/results.json
new file mode 100644
index 0000000..7f62886
--- /dev/null
+++ b/docs/performance/transfer-search-20261004/results.json
@@ -0,0 +1,178 @@
+{
+ "runtime": {
+ "node": "v24.19.0",
+ "platform": "linux",
+ "arch": "x64"
+ },
+ "sources": {
+ "baselineBlob": "be8db651d5c2457323d6f8373a170a41d8b99b87",
+ "candidateBlob": "a75e2e4fe88e16cb0b03ed97aa0a1c18dbba1274"
+ },
+ "method": {
+ "rounds": 5,
+ "warmupsPerVersion": 1,
+ "order": "alternating before/after",
+ "gcBeforeEachSample": true,
+ "timingIncludes": "actual applyTransferSearch only; fixtures/assertions outside timing",
+ "operationCounts": "separate untimed production calls",
+ "providerRequests": 0
+ },
+ "scenarios": [
+ {
+ "count": 5000,
+ "order": "shuffled",
+ "limit": 100,
+ "samples": [
+ {
+ "before": 33.71209999999999,
+ "after": 2.137278000000009
+ },
+ {
+ "after": 2.483734999999996,
+ "before": 27.878434999999996
+ },
+ {
+ "before": 20.951795000000004,
+ "after": 3.445317999999986
+ },
+ {
+ "after": 2.557780999999977,
+ "before": 22.238653
+ },
+ {
+ "before": 22.827020000000005,
+ "after": 1.8687130000000138
+ }
+ ],
+ "baselineMedianMs": 22.827020000000005,
+ "candidateMedianMs": 2.483734999999996,
+ "speedup": 9.190602056982748,
+ "baselineOperations": {
+ "dateParseCalls": 110108,
+ "sortedLengths": [5000]
+ },
+ "candidateOperations": {
+ "dateParseCalls": 5000,
+ "sortedLengths": [100]
+ },
+ "identicalResults": true
+ },
+ {
+ "count": 100000,
+ "order": "shuffled",
+ "limit": 100,
+ "samples": [
+ {
+ "before": 768.0208469999998,
+ "after": 26.91173000000026
+ },
+ {
+ "after": 29.546558000000005,
+ "before": 922.863077
+ },
+ {
+ "before": 776.0745080000002,
+ "after": 25.353613999999652
+ },
+ {
+ "after": 27.637462000000596,
+ "before": 679.0042330000006
+ },
+ {
+ "before": 855.1001800000004,
+ "after": 23.976690999999846
+ }
+ ],
+ "baselineMedianMs": 776.0745080000002,
+ "candidateMedianMs": 26.91173000000026,
+ "speedup": 28.837778470577426,
+ "baselineOperations": {
+ "dateParseCalls": 3031310,
+ "sortedLengths": [100000]
+ },
+ "candidateOperations": {
+ "dateParseCalls": 100000,
+ "sortedLengths": [100]
+ },
+ "identicalResults": true
+ },
+ {
+ "count": 100000,
+ "order": "ascending",
+ "limit": 500,
+ "samples": [
+ {
+ "before": 44.33002699999997,
+ "after": 33.507582000000184
+ },
+ {
+ "after": 31.485877000000073,
+ "before": 44.50824499999999
+ },
+ {
+ "before": 46.68953899999997,
+ "after": 31.618967999999768
+ },
+ {
+ "after": 32.851283999999396,
+ "before": 44.630928999999924
+ },
+ {
+ "before": 45.76722100000006,
+ "after": 31.98176499999954
+ }
+ ],
+ "baselineMedianMs": 44.630928999999924,
+ "candidateMedianMs": 31.98176499999954,
+ "speedup": 1.3955117549015998,
+ "baselineOperations": {
+ "dateParseCalls": 199998,
+ "sortedLengths": [100000]
+ },
+ "candidateOperations": {
+ "dateParseCalls": 100000,
+ "sortedLengths": [500]
+ },
+ "identicalResults": true
+ },
+ {
+ "count": 100000,
+ "order": "descending",
+ "limit": 500,
+ "samples": [
+ {
+ "before": 51.15443300000061,
+ "after": 26.590165999999954
+ },
+ {
+ "after": 25.439112999999452,
+ "before": 46.29600699999992
+ },
+ {
+ "before": 65.55661699999928,
+ "after": 36.23268499999995
+ },
+ {
+ "after": 24.899862000000212,
+ "before": 44.26636099999996
+ },
+ {
+ "before": 43.51811400000042,
+ "after": 22.59087400000044
+ }
+ ],
+ "baselineMedianMs": 46.29600699999992,
+ "candidateMedianMs": 25.439112999999452,
+ "speedup": 1.8198750483163826,
+ "baselineOperations": {
+ "dateParseCalls": 199998,
+ "sortedLengths": [100000]
+ },
+ "candidateOperations": {
+ "dateParseCalls": 100000,
+ "sortedLengths": [500]
+ },
+ "identicalResults": true
+ }
+ ]
+}
diff --git a/docs/validation/actor-scoped-transfer-validation.md b/docs/validation/actor-scoped-transfer-validation.md
new file mode 100644
index 0000000..947f009
--- /dev/null
+++ b/docs/validation/actor-scoped-transfer-validation.md
@@ -0,0 +1,76 @@
+# Actor-scoped transfer validation
+
+Issue: [RemitFlow Frontend #281](https://github.com/RemitFlow/RemitFlow-Frontend/issues/281)
+Existing submission: [PR #298](https://github.com/RemitFlow/RemitFlow-Frontend/pull/298)
+
+## Problem and repair
+
+The simulated localStorage API shares a transfer collection across wallets.
+Previously, `listTransfers` validated that entire collection before selecting
+the requesting actor's rows. As a result, the validity of a different wallet's
+records could change this wallet's error behavior.
+
+| Stored rows | Before | Repaired behavior |
+| ------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------- |
+| Only a corrupt foreign-wallet row | Schema error for an empty current-wallet history | Empty list |
+| Valid current-wallet row and corrupt foreign-wallet row | Correct list, but foreign schema diagnostics were logged | Correct list without foreign diagnostics |
+| Corrupt current-wallet row and valid foreign-wallet row | Empty list concealed the current wallet's schema failure | `ContractViolationError` |
+
+The API now uses the existing `isVisibleToActor` predicate on raw rows before
+validating individual transfers. It still passes a non-array top-level response
+to the schema validator, so malformed response structure remains an error.
+Visible corrupt records retain the existing validation policy.
+
+The same visibility options are reused for the final search. Legacy records
+without an actor remain visible only to the demo actor. The query actor is
+normalized as before, while stored actor strings retain exact matching.
+Cancellation, filtering, stable order and result caps keep their existing paths.
+
+This changes the localStorage demonstration API; it does not establish a
+server-side authorization boundary for an external service.
+
+## Source and execution evidence
+
+The repaired source commit is
+[`935caeb37e3932f85c2555f9086bde2c771c20bd`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/935caeb37e3932f85c2555f9086bde2c771c20bd),
+a direct child of `21fccd57636a96d6c844289157e9c4693419a478`.
+The preceding navigation and capped-selector changes are preserved.
+
+| Executed file | Git blob |
+| ----------------------------------------- | ------------------------------------------ |
+| `src/services/api.js` | `1ec5dcd1f163d536c2f2cb6059fa6218c92f7d41` |
+| `test/unit/list-transfers-search.test.js` | `2d2c4753103589a1c53421a7e8a66734f566e0ef` |
+
+Two focused executions completed on 2026-10-04:
+
+- **Direct production API replay: 9 passed, 0 failed.** Node 24.19.0 imported
+ the changed API and actual contract modules, using synthetic JSON through an
+ in-memory localStorage interface and the API's real 400 ms timers. It covered
+ the three reproduced failures, current-wallet corruption alone, valid mixed
+ actors, demo and non-demo legacy visibility, a non-array response, and query
+ trimming with exact stored actor strings.
+- **Maintained unit file: 12 passed, 0 failed, 1.86 seconds.** Node 24.19.0 and
+ Vitest 4.1.10 ran all five existing cases and seven added regressions together
+ at the source commit above. No test cases were removed or weakened. The
+ retained installed runtime's package manifest, lockfile, Vite configuration
+ and test setup matched this checkout by Git blob; no source changes or new
+ dependency installation were needed for that run.
+
+Run the maintained selection from an installed checkout:
+
+```bash
+node node_modules/vitest/vitest.mjs run test/unit/list-transfers-search.test.js --maxWorkers=1 --fileParallelism=false
+```
+
+The observed run set `TMPDIR` to a writable task-owned tmpfs directory because
+the shared cloud disk was full. An earlier dependency-install attempt in a
+different checkout stopped with `ENOSPC`; it did not produce a test result or
+change dependencies or lockfiles. Reusing the compatible installed runtime
+closed that execution gap.
+
+Both edited JavaScript files also passed syntax checks, and the source diff
+passed whitespace checks. No repository-wide suite, production build, browser,
+wallet or external-provider run was repeated for this repair. Earlier
+[selector performance evidence](../performance/transfer-search-20261004/README.md)
+retains its own source revision and measurement scope. These results establish
+neither sponsor acceptance nor an award or payment.
diff --git a/docs/validation/rf298-existing-ci-relay17-20261004/README.md b/docs/validation/rf298-existing-ci-relay17-20261004/README.md
new file mode 100644
index 0000000..53c03b8
--- /dev/null
+++ b/docs/validation/rf298-existing-ci-relay17-20261004/README.md
@@ -0,0 +1,58 @@
+# PR298: required local checks pass on the composed source
+
+The complete package selection passes on [1e702e64](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/1e702e6437deee1bc1b0d75f5a3189abd178a226): **504 passed, zero failed or skipped, across all 53 files**. The final tree is `50b2704afb930440c23d30f6ab1dd7bbbf7fbf85`.
+
+[Issue281](https://github.com/RemitFlow/RemitFlow-Frontend/issues/281) requires existing tests and CI to remain passing without weakening unrelated tests. This continuation keeps the original [PR298](https://github.com/RemitFlow/RemitFlow-Frontend/pull/298), the earlier actor and navigation corrections, the bounded selector, and the later query-invariant filter preparation.
+
+## Final executed gates
+
+| Gate | Result |
+| ------------------------ | -------------------------------------------------------------------------------------------- |
+| Existing Prettier check | Passed; existing configuration and ignores unchanged |
+| Complete package tests | 504 passed / 0 failed / 0 skipped; 53/53 files passed |
+| Test duration | Vitest 69.54 s; complete process 70.096 s |
+| Production build | Passed; Vite 1.75 s; complete command 2.078 s |
+| Critical audit threshold | Exit 0; zero critical findings |
+| Remaining audit findings | 20 high, 12 moderate, 2 low |
+| Source identity | All 355 tracked blobs matched before execution and remained unchanged after testing/building |
+
+The final suite ran once with the complete package file selection, no retries, exclusions, altered timeouts or snapshot changes. It includes the original race, cancellation, actor-scope, pagination and scale cases and the new filter-preparation cases. Node 24.19.0 and Vitest 4.1.10 used the retained installation after package, lockfile, Vite configuration and test setup matched exactly.
+
+The audit executed once at 11:56:36–11:56:56 UTC on 2026-10-04. The final composition has identical package/lock bytes, so that dated result was reused. It passes the workflow's critical threshold while retaining the 34 findings above.
+
+## Changes and composition
+
+The four maintained send/quote fixtures now traverse the actual review, confirmation, result and receipt flow and assert the existing canonical-decimal contract. Their 22 original case titles remain. Exact amounts, fees, receipt reconciliation, negative/unquotable input, offline blocking, retry and transfer-creation assertions are retained.
+
+The only functional send-page change gives an otherwise valid amount with no available quote the pricing explanation expected by the maintained test. Required Prettier output covers the remaining flagged files. From the final parent 71746fa5, 19 paths change and 336 tracked paths remain unchanged.
+
+A first composition passed 502 tests across 52 files. Before its publication, a concurrent selector preparation contribution advanced the original branch. A non-force update rejected the outdated parent with 422. Its five files were then incorporated, the two new report files were formatted, and the final 504-case composition above was executed. Both raw runs remain separately identified in the archive; the earlier result is not represented as the final source.
+
+The peer selector `f072d7879d4bceb74d3635f6bfda0797e7f549a0`, benchmark script `1bcf4b3f4d44596fe3381d4631cde0481e85aca9`, and added test `cea83462b01de6fa1631a6bfeaa25fbdb522f980` are preserved byte for byte. Formatting of both historical benchmark JSON documents preserves every measurement and source pin.
+
+## Performance evidence retained
+
+The [earlier bounded-selector report](../../performance/transfer-search-20261004/README.md) and [query-invariant filter-preparation report](../../performance/transfer-filter-preparation-20261004/README.md) retain their own executed sources, environments, paired samples and scope. This continuation did not rerun either benchmark or combine their speedups.
+
+The final package's scale and performance tests pass. The validation-command durations in this report are not application or network throughput measurements.
+
+## Reproduce and inspect
+
+From an installed checkout of the source commit:
+
+```bash
+npm test -- --maxWorkers=2
+npm run format:check
+npm audit --audit-level=critical
+npm run build
+```
+
+The local test command used the default and JSON reporters and imported the actual repository Vite configuration with only `cacheDir` redirected to a task directory. `TMPDIR` and npm's cache/log locations were similarly redirected because the shared root filesystem was full. The exact wrappers/configuration are in the archive; dependencies were not reinstalled or changed.
+
+[receipt.json](receipt.json) records commands, source identities, timestamps, hosted status and archive/member hashes. [evidence.zip](evidence.zip) preserves raw Vitest output and JSON, formatter output, audit JSON, build output, source manifests, exact diffs and the separately identified intermediate execution. Raw execution files in the archive are unchanged.
+
+## Hosted and execution boundary
+
+At the published source, [CI 37201143157](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37201143157) and [Lighthouse CI 37201143084](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37201143084) report `action_required`. Local gate results do not establish hosted approval or maintainer acceptance.
+
+The app tests use the repository's demonstration wallet and transfer service in jsdom. The archive retains React/Vite warnings. No live-wallet, provider, settlement or whole-page performance result is established here. The original contribution and metadata handoff remain in place.
diff --git a/docs/validation/rf298-existing-ci-relay17-20261004/evidence.zip b/docs/validation/rf298-existing-ci-relay17-20261004/evidence.zip
new file mode 100644
index 0000000..6069ed2
Binary files /dev/null and b/docs/validation/rf298-existing-ci-relay17-20261004/evidence.zip differ
diff --git a/docs/validation/rf298-existing-ci-relay17-20261004/receipt.json b/docs/validation/rf298-existing-ci-relay17-20261004/receipt.json
new file mode 100644
index 0000000..950d669
--- /dev/null
+++ b/docs/validation/rf298-existing-ci-relay17-20261004/receipt.json
@@ -0,0 +1,303 @@
+{
+ "schema": "rf298-existing-ci/v1",
+ "recorded_at": "2026-10-04T12:12:51.298448+00:00",
+ "source_commit": "1e702e6437deee1bc1b0d75f5a3189abd178a226",
+ "parent": "71746fa5552f2dbb0ad3727637b52b9f64747410",
+ "tree": "50b2704afb930440c23d30f6ab1dd7bbbf7fbf85",
+ "source_files": 355,
+ "source_files_unchanged_after_execution": true,
+ "final_run": {
+ "repository": "woahwhattheheck/RemitFlow-Frontend",
+ "parent": "71746fa5552f2dbb0ad3727637b52b9f64747410",
+ "tested_tree": "50b2704afb930440c23d30f6ab1dd7bbbf7fbf85",
+ "source_files": 355,
+ "node": "v24.19.0",
+ "commands": [
+ {
+ "gate": "tests",
+ "command": [
+ "node",
+ "node_modules/vitest/vitest.mjs",
+ "run",
+ "test/integration",
+ "test/unit",
+ "test/components",
+ "test/services",
+ "test/lighthouse-config.test.js",
+ "test/security",
+ "--config",
+ "relay17.vitest.config.mjs",
+ "--maxWorkers=2",
+ "--reporter=default",
+ "--reporter=json",
+ "--outputFile.json=/dev/shm/relay17-rf298-evidence/vitest-results.json"
+ ],
+ "started_at": "2026-10-04T12:07:02.842160+00:00",
+ "finished_at": "2026-10-04T12:08:12.938097+00:00",
+ "elapsed_seconds": 70.09594242300227,
+ "exit_code": 0
+ },
+ {
+ "gate": "build",
+ "command": ["npm", "run", "build"],
+ "started_at": "2026-10-04T12:08:12.945742+00:00",
+ "finished_at": "2026-10-04T12:08:15.023921+00:00",
+ "elapsed_seconds": 2.078225809003925,
+ "exit_code": 0
+ }
+ ],
+ "source_unchanged_after": true
+ },
+ "tests": {
+ "passed": 504,
+ "failed": 0,
+ "skipped": 0,
+ "pending": 0,
+ "todo": 0,
+ "files_passed": 53,
+ "files_total": 53,
+ "vitest_seconds": 69.54
+ },
+ "audit": {
+ "gate": {
+ "gate": "audit",
+ "command": [
+ "npm",
+ "audit",
+ "--audit-level=critical",
+ "--json",
+ "--fetch-retries=0",
+ "--fetch-timeout=20000"
+ ],
+ "started_at": "2026-10-04T11:56:36.112260+00:00",
+ "finished_at": "2026-10-04T11:56:56.026595+00:00",
+ "elapsed_seconds": 19.914345624994894,
+ "exit_code": 0
+ },
+ "vulnerabilities": {
+ "info": 0,
+ "low": 2,
+ "moderate": 12,
+ "high": 20,
+ "critical": 0,
+ "total": 34
+ },
+ "reuse": "One audit on byte-identical package and lock in the first composition; not rerun after the selector-only concurrent source change.",
+ "package_blob": "625d0f3895fcc7afbfcfa131713249f5cd65dcd6",
+ "lock_blob": "70d29c1865dac7ce76ba7d4e6ca5ee454be54aae"
+ },
+ "format": {
+ "version": "3.9.6",
+ "result": "pass",
+ "initial_flagged_files": 14,
+ "additional_peer_report_files": 2,
+ "config_and_ignores_unchanged": true
+ },
+ "intermediate_run": {
+ "tested_tree": "a12311c454b4c6c1845437f651839212143c4971",
+ "passed": 502,
+ "failed": 0,
+ "files": 52,
+ "publication": "Not advanced after the non-force ref update returned422 for a concurrent source change; preserved only as intermediate evidence."
+ },
+ "hosted": [
+ {
+ "id": 37201143157,
+ "name": "CI",
+ "status": "completed",
+ "conclusion": "action_required",
+ "url": "https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37201143157",
+ "head": "1e702e6437deee1bc1b0d75f5a3189abd178a226"
+ },
+ {
+ "id": 37201143084,
+ "name": "Lighthouse CI",
+ "status": "completed",
+ "conclusion": "action_required",
+ "url": "https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37201143084",
+ "head": "1e702e6437deee1bc1b0d75f5a3189abd178a226"
+ }
+ ],
+ "archive": {
+ "path": "evidence.zip",
+ "bytes": 147628,
+ "sha256": "e853b730426ab43c783ec90a13f59210f62eb89ea62d18a27051e0f320800442",
+ "members": [
+ {
+ "path": "baseline-manifest.json",
+ "bytes": 56812,
+ "sha256": "b83aedae9147f858dfc208cdb2d4ddd4f7c7ec2170a6fa2fb9647ff61f8fc76e"
+ },
+ {
+ "path": "candidate-manifest.json",
+ "bytes": 56815,
+ "sha256": "14ddb3a4ac858a746ae8ca87d10d91c12f210c623243b183710c8fea89ca6fae"
+ },
+ {
+ "path": "changes.json",
+ "bytes": 3400,
+ "sha256": "1b78beab07c34a14c2bf9b7ea9557006cf1d37acf63669311c0fd23f558f4c5d"
+ },
+ {
+ "path": "candidate.patch",
+ "bytes": 46481,
+ "sha256": "9bdbb1666fa4ea58a4f2c0c92e7c745c2767c05726f3677dbf41306d7cb85767"
+ },
+ {
+ "path": "run-receipt.json",
+ "bytes": 1264,
+ "sha256": "55fe4e6f306e2de79c3e47c6fa3e12b6e8a61976a93ee7a222e24a64ec89efc8"
+ },
+ {
+ "path": "vitest-results.json",
+ "bytes": 152453,
+ "sha256": "26e84510765fb3958816674d2b80455813b70eca97eecfcc4aafa9bd7b0cfbd1"
+ },
+ {
+ "path": "vitest-output.log",
+ "bytes": 7382,
+ "sha256": "5f754c51ba442a5d808237d377160c94e12969cdceb77031a3de9036850da18b"
+ },
+ {
+ "path": "tests-stderr.log",
+ "bytes": 29742,
+ "sha256": "5f11c3a42cf601f1e9006f308a13354ba47b06265b74570e7dee4412047c83cc"
+ },
+ {
+ "path": "audit-results.json",
+ "bytes": 51078,
+ "sha256": "8b87a13f1e39c94f710de1e541d3ba88f8a47661af825c6004a41b2a812eb5a8"
+ },
+ {
+ "path": "audit-stderr.log",
+ "bytes": 307,
+ "sha256": "7d61cb2e2deb776dfec4d6f54693f9edeed0af5c4da52f091c53c995d862fb65"
+ },
+ {
+ "path": "build-output.log",
+ "bytes": 378,
+ "sha256": "a6a66c90918c89197a621a39ee35e68aba57a7291fe2b9a044213efe96900bae"
+ },
+ {
+ "path": "build-stderr.log",
+ "bytes": 99,
+ "sha256": "7125f194612fcff2f454c05d848e7cb71facc1967b4d5017e25b34fdee3ba74f"
+ },
+ {
+ "path": "format-before.log",
+ "bytes": 714,
+ "sha256": "2229fe8e82e708d83c8733a186f8ddc75984b53ec73f71356ab7214b5f0cd1e8"
+ },
+ {
+ "path": "format-write.log",
+ "bytes": 584,
+ "sha256": "0a4a70c0a7fc4fdcff48a70a198ba40f68857e2839b23754e23d9928f7e0b0fe"
+ },
+ {
+ "path": "format-after.log",
+ "bytes": 66,
+ "sha256": "17aa973d3f004560237d9a95171210b0671deff23d61628eecf7322ff5938f20"
+ },
+ {
+ "path": "final-format-before.log",
+ "bytes": 245,
+ "sha256": "31d8413491152aadf64d0be0232aad49eeb24c429c9f89591c1ad86cbab88fa7"
+ },
+ {
+ "path": "final-format-write.log",
+ "bytes": 142,
+ "sha256": "b89b060de24f16590e81f75fb6fa43bad31c2d4ad0345a447d8c58d5da17a3cd"
+ },
+ {
+ "path": "final-format-after.log",
+ "bytes": 66,
+ "sha256": "17aa973d3f004560237d9a95171210b0671deff23d61628eecf7322ff5938f20"
+ },
+ {
+ "path": "run-final.py",
+ "bytes": 3650,
+ "sha256": "b3bf2da62415507d43b02a0f95ff81fef4562e1e04ebf0bcb4ac5588add35fcf"
+ },
+ {
+ "path": "relay17.vitest.config.mjs",
+ "bytes": 107,
+ "sha256": "13a91081d6818081c932e1fcaba7c9e5fbfaa4f474de6abe2fe5f7680abfcdc1"
+ },
+ {
+ "path": "peer-integration-review.json",
+ "bytes": 2606,
+ "sha256": "97837abce4ce4b0af68b03930d265e8ad25c5cdd42ce0b821e444797ef4afa11"
+ },
+ {
+ "path": "initial-composition/baseline-manifest.json",
+ "bytes": 41306,
+ "sha256": "e57c83389ed09d29f80451177aec8506992ff431cbd8ee0c607aa15b03b89c2b"
+ },
+ {
+ "path": "initial-composition/candidate-manifest.json",
+ "bytes": 56071,
+ "sha256": "6ac9a037cc49f52a709cf965a263ff11db9aa75889e2660ba1622e956dccfa54"
+ },
+ {
+ "path": "initial-composition/changes.json",
+ "bytes": 3159,
+ "sha256": "49032fe72e060eb578b4a91b067516d353848fe00caa8aaba0f4adbcca7a361a"
+ },
+ {
+ "path": "initial-composition/candidate.patch",
+ "bytes": 33220,
+ "sha256": "cfcf0f271ac303e69b1e93c118a4034f33466ca6abc99388bfc87523f824aa40"
+ },
+ {
+ "path": "initial-composition/run-receipt.json",
+ "bytes": 1653,
+ "sha256": "33459b9b6a670e5c669892ca6ba3c091da8c5a18e31e8f76fe215163653a4912"
+ },
+ {
+ "path": "initial-composition/vitest-results.json",
+ "bytes": 151620,
+ "sha256": "e807f1c5466358dc55111d61f65624f0c3789cad9ae51d508b0a6fc46d5e76fb"
+ },
+ {
+ "path": "initial-composition/vitest-output.log",
+ "bytes": 7455,
+ "sha256": "5ebba564b9214b09c877e8d3d805d9614d116efdd8a294dd535fdc11ccdbaf24"
+ },
+ {
+ "path": "initial-composition/tests-stderr.log",
+ "bytes": 32477,
+ "sha256": "07b47e4028072e0f21fbf071ab4cab2ffb1c109d16587358efb6e9e191411288"
+ },
+ {
+ "path": "initial-composition/build-output.log",
+ "bytes": 378,
+ "sha256": "42fa9432279dbe2378736c21c5a1ae37f2e66873c498d73e53b862b01b2e7db1"
+ },
+ {
+ "path": "initial-composition/build-stderr.log",
+ "bytes": 99,
+ "sha256": "7125f194612fcff2f454c05d848e7cb71facc1967b4d5017e25b34fdee3ba74f"
+ },
+ {
+ "path": "initial-composition/run.py",
+ "bytes": 3593,
+ "sha256": "a7fdcb0db8477d15230848c7362a5fc4addf7d2edb82fed8ac130a43c9339c6b"
+ },
+ {
+ "path": "initial-composition/relay17.vitest.config.mjs",
+ "bytes": 107,
+ "sha256": "13a91081d6818081c932e1fcaba7c9e5fbfaa4f474de6abe2fe5f7680abfcdc1"
+ },
+ {
+ "path": "initial-composition/source-review.json",
+ "bytes": 1650,
+ "sha256": "6043c540de79c5f02383a3be5741b652e24d7451f331244b495ca69b3255427d"
+ },
+ {
+ "path": "initial-composition/SOURCE_REVIEW.md",
+ "bytes": 2412,
+ "sha256": "c69a2b239b635ed6712ab4a275c9a28fc73cdadfe5d8d6380a79fcd79c9ba5e7"
+ }
+ ]
+ }
+}
diff --git a/scripts/bench-transfer-filter-preparation.mjs b/scripts/bench-transfer-filter-preparation.mjs
new file mode 100644
index 0000000..1bcf4b3
--- /dev/null
+++ b/scripts/bench-transfer-filter-preparation.mjs
@@ -0,0 +1,277 @@
+#!/usr/bin/env node
+// Compare complete production selectors; no benchmark replacement of product code.
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { performance } from 'node:perf_hooks';
+import os from 'node:os';
+
+const [baselineArg, candidateArg = 'src/utils/transferSearch.js'] =
+ process.argv.slice(2);
+if (!baselineArg) {
+ console.error(
+ 'Usage: node --expose-gc scripts/bench-transfer-filter-preparation.mjs BASELINE [CANDIDATE]',
+ );
+ process.exit(2);
+}
+const baselinePath = resolve(baselineArg);
+const candidatePath = resolve(candidateArg);
+const baseline = await import(pathToFileURL(baselinePath));
+const candidate = await import(pathToFileURL(candidatePath));
+const gitBlob = (path) => {
+ const bytes = readFileSync(path);
+ return createHash('sha1')
+ .update(`blob ${bytes.length}\0`)
+ .update(bytes)
+ .digest('hex');
+};
+const now = new Date('2026-10-04T12:00:00.000Z');
+const options = { now, legacyActorId: 'demo' };
+let seed = 20261004;
+const random = () =>
+ (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32;
+function makeHistory(count) {
+ const statuses = ['pending', 'submitted', 'completed', 'settled', 'failed'];
+ const rows = Array.from({ length: count }, (_, index) => ({
+ id: String(index).padStart(8, '0'),
+ actorId: index % 7 === 0 ? 'other' : 'demo',
+ recipient: index % 3 === 0 ? 'BOB' : 'Alice Smith',
+ status: statuses[index % statuses.length],
+ createdAt: new Date(
+ now.getTime() - (index % 120) * 86400000 - (index % 1000),
+ ).toISOString(),
+ }));
+ for (let i = rows.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(random() * (i + 1));
+ [rows[i], rows[j]] = [rows[j], rows[i]];
+ }
+ return Object.freeze(rows.map(Object.freeze));
+}
+const large = makeHistory(100_000);
+const small = large.slice(0, 5_000);
+const cases = [
+ ['5k all time', small, { actorId: 'demo' }],
+ ['100k all time', large, { actorId: 'demo' }],
+ ['100k date window', large, { actorId: 'demo', range: '30d' }],
+ [
+ '100k status and search',
+ large,
+ { actorId: 'demo', status: 'submitted', search: ' ALICE ' },
+ ],
+ [
+ '100k combined filters',
+ large,
+ {
+ actorId: 'demo',
+ status: 'settled',
+ search: ' ALICE ',
+ range: '90d',
+ limit: 500,
+ },
+ ],
+];
+function compareResults(expected, actual) {
+ assert.deepEqual(actual, expected);
+ assert.equal(actual.items.length, expected.items.length);
+ actual.items.forEach((item, index) =>
+ assert.strictEqual(item, expected.items[index]),
+ );
+}
+const median = (samples) =>
+ [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)];
+function measure(selector, rows, query) {
+ globalThis.gc?.();
+ const start = performance.now();
+ const result = selector.applyTransferSearch(rows, query, options);
+ return { result, ms: performance.now() - start };
+}
+function operationCounts(selector, rows, query) {
+ // Instrumentation is separate from all timing calls and restored in finally.
+ const NativeDate = globalThis.Date;
+ const originalLower = String.prototype.toLowerCase;
+ const originalMatch = String.prototype.match;
+ const counts = {
+ date_constructions: 0,
+ lowercase_calls: 0,
+ string_match_calls: 0,
+ };
+ globalThis.Date = new Proxy(NativeDate, {
+ construct(target, args, newTarget) {
+ counts.date_constructions += 1;
+ return Reflect.construct(target, args, newTarget);
+ },
+ });
+ String.prototype.toLowerCase = function (...args) {
+ counts.lowercase_calls += 1;
+ return Reflect.apply(originalLower, this, args);
+ };
+ String.prototype.match = function (...args) {
+ counts.string_match_calls += 1;
+ return Reflect.apply(originalMatch, this, args);
+ };
+ try {
+ return {
+ counts,
+ result: selector.applyTransferSearch(rows, query, options),
+ };
+ } finally {
+ globalThis.Date = NativeDate;
+ String.prototype.toLowerCase = originalLower;
+ String.prototype.match = originalMatch;
+ }
+}
+const observations = [];
+for (const [name, rows, query] of cases) {
+ const expected = baseline.applyTransferSearch(rows, query, options);
+ for (let warmup = 0; warmup < 3; warmup += 1) {
+ compareResults(
+ expected,
+ baseline.applyTransferSearch(rows, query, options),
+ );
+ compareResults(
+ expected,
+ candidate.applyTransferSearch(rows, query, options),
+ );
+ }
+ const pairs = [];
+ for (let repeat = 0; repeat < 9; repeat += 1) {
+ const order =
+ repeat % 2 === 0 ? ['baseline', 'candidate'] : ['candidate', 'baseline'];
+ const pair = { order };
+ for (const key of order) {
+ const measured = measure(
+ key === 'baseline' ? baseline : candidate,
+ rows,
+ query,
+ );
+ compareResults(expected, measured.result);
+ pair[key] = measured.ms;
+ }
+ pairs.push(pair);
+ }
+ const oldCounts = operationCounts(baseline, rows, query);
+ const newCounts = operationCounts(candidate, rows, query);
+ compareResults(expected, oldCounts.result);
+ compareResults(expected, newCounts.result);
+ const baselineMedian = median(pairs.map((pair) => pair.baseline));
+ const candidateMedian = median(pairs.map((pair) => pair.candidate));
+ observations.push({
+ name,
+ rows: rows.length,
+ query,
+ total_matched: expected.totalMatched,
+ returned: expected.items.length,
+ baseline_median_ms: baselineMedian,
+ candidate_median_ms: candidateMedian,
+ reduction_percent: 100 * (1 - candidateMedian / baselineMedian),
+ operation_counts: {
+ baseline: oldCounts.counts,
+ candidate: newCounts.counts,
+ },
+ result_sha256: createHash('sha256')
+ .update(JSON.stringify(expected))
+ .digest('hex'),
+ pairs,
+ });
+}
+// Differential compatibility checks are untimed and use unchanged row identities.
+let compatibility = 0;
+const dates = [
+ undefined,
+ null,
+ '',
+ 'bad',
+ 0,
+ -1,
+ '0',
+ '1970-01-01T00:00:00Z',
+ '1969-12-31T23:59:59Z',
+ '2026-10-04T12:00:00Z',
+ '2026-09-27T12:00:00Z',
+ '2026-09-27T11:59:59.999Z',
+ new Date('2026-09-27T12:00:00Z'),
+ new Date(NaN),
+ '2027-01-01T00:00:00Z',
+];
+const ranges = [
+ '',
+ '0d',
+ '0007d',
+ '7d',
+ '30d',
+ '90d',
+ 'unknown',
+ '99999999999999999d',
+ '9'.repeat(400) + 'd',
+];
+const edgeRows = Object.freeze(
+ dates
+ .flatMap((createdAt, index) => [
+ {
+ id: `a${index}`,
+ createdAt,
+ recipient: 'Alice',
+ status: 'settled',
+ actorId: 'demo',
+ },
+ { id: `b${index}`, createdAt, recipient: 'BOB', status: 'submitted' },
+ {
+ id: `c${index}`,
+ createdAt,
+ recipient: 'Älice',
+ status: 'failed',
+ actorId: 'other',
+ },
+ ])
+ .map(Object.freeze),
+);
+for (const instant of [now, new Date(NaN)]) {
+ for (const range of ranges) {
+ for (const status of ['', 'settled', 'submitted', 'invalid']) {
+ for (const search of ['', ' ALICE ', 'nobody']) {
+ const filters = { range, status, search };
+ for (const row of edgeRows) {
+ assert.equal(
+ candidate.matchesTransferFilters(row, filters, instant),
+ baseline.matchesTransferFilters(row, filters, instant),
+ );
+ compatibility += 1;
+ }
+ for (const limit of [1, 10, 500]) {
+ const query = { actorId: 'demo', ...filters, limit };
+ const opt = { ...options, now: instant };
+ compareResults(
+ baseline.applyTransferSearch(edgeRows, query, opt),
+ candidate.applyTransferSearch(edgeRows, query, opt),
+ );
+ compatibility += 1;
+ }
+ }
+ }
+ }
+}
+console.log(
+ JSON.stringify(
+ {
+ schema: 'transfer-filter-preparation-benchmark/v1',
+ executed_at: new Date().toISOString(),
+ node: process.version,
+ platform: `${process.platform}/${process.arch}`,
+ cpu: os.cpus()[0]?.model,
+ source: {
+ baseline_blob: gitBlob(baselinePath),
+ candidate_blob: gitBlob(candidatePath),
+ },
+ method:
+ '3 warmups; 9 paired samples, alternating order; synthetic fixed records; setup/assertions/GC/counting outside timed calls',
+ scope:
+ 'Complete production applyTransferSearch and its actual dependency modules, not storage/API/React/browser/provider latency.',
+ compatibility_checks: compatibility,
+ observations,
+ },
+ null,
+ 2,
+ ),
+);
diff --git a/scripts/bench-transfer-search.mjs b/scripts/bench-transfer-search.mjs
new file mode 100644
index 0000000..94dde7f
--- /dev/null
+++ b/scripts/bench-transfer-search.mjs
@@ -0,0 +1,154 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { performance } from 'node:perf_hooks';
+import { pathToFileURL } from 'node:url';
+
+const args = process.argv.slice(2);
+const baselinePath = args[0];
+const candidatePath = args[1] ?? 'src/utils/transferSearch.js';
+if (!baselinePath) {
+ throw new Error(
+ 'Usage: node --expose-gc bench-transfer-search.mjs BASELINE [CANDIDATE]',
+ );
+}
+const before = await import(pathToFileURL(resolve(baselinePath)).href);
+const after = await import(pathToFileURL(resolve(candidatePath)).href);
+const now = new Date('2026-10-04T00:00:00Z');
+const rounds = 5;
+
+function blob(path) {
+ const data = readFileSync(path);
+ return createHash('sha1')
+ .update(`blob ${data.length}\0`)
+ .update(data)
+ .digest('hex');
+}
+
+function fixture(count, order) {
+ return Array.from({ length: count }, (_, i) => {
+ const rank =
+ order === 'shuffled'
+ ? (i * 7919) % count
+ : order === 'ascending'
+ ? i
+ : count - i - 1;
+ return {
+ id: `tx_${i}`,
+ actorId: 'GACTOR_A',
+ recipient: 'amina@example.com',
+ status: 'completed',
+ createdAt: new Date(1700000000000 + rank * 1000).toISOString(),
+ };
+ });
+}
+
+function measure(fn, rows, query) {
+ if (global.gc) global.gc();
+ const start = performance.now();
+ const result = fn(rows, query, { now });
+ return { ms: performance.now() - start, result };
+}
+
+function countOperations(fn, rows, query) {
+ const originalParse = Date.parse;
+ const originalSort = Array.prototype.sort;
+ let dateParseCalls = 0;
+ const sortedLengths = [];
+ Date.parse = (...values) => {
+ dateParseCalls += 1;
+ return originalParse(...values);
+ };
+ Array.prototype.sort = function (...values) {
+ sortedLengths.push(this.length);
+ return originalSort.apply(this, values);
+ };
+ try {
+ fn(rows, query, { now });
+ return { dateParseCalls, sortedLengths };
+ } finally {
+ Date.parse = originalParse;
+ Array.prototype.sort = originalSort;
+ }
+}
+
+function median(values) {
+ return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
+}
+
+const report = {
+ runtime: {
+ node: process.version,
+ platform: process.platform,
+ arch: process.arch,
+ },
+ sources: {
+ baselineBlob: blob(baselinePath),
+ candidateBlob: blob(candidatePath),
+ },
+ method: {
+ rounds,
+ warmupsPerVersion: 1,
+ order: 'alternating before/after',
+ gcBeforeEachSample: Boolean(global.gc),
+ timingIncludes:
+ 'actual applyTransferSearch only; fixtures/assertions outside timing',
+ operationCounts: 'separate untimed production calls',
+ providerRequests: 0,
+ },
+ scenarios: [],
+};
+for (const [count, order, limit] of [
+ [5000, 'shuffled', 100],
+ [100000, 'shuffled', 100],
+ [100000, 'ascending', 500],
+ [100000, 'descending', 500],
+]) {
+ const rows = fixture(count, order);
+ const query = { actorId: 'GACTOR_A', limit };
+ const expected = before.applyTransferSearch(rows, query, { now });
+ assert.deepEqual(after.applyTransferSearch(rows, query, { now }), expected);
+ const samples = [];
+ for (let round = 0; round < rounds; round += 1) {
+ const calls = round % 2 === 0 ? ['before', 'after'] : ['after', 'before'];
+ const pair = {};
+ for (const name of calls) {
+ const fn =
+ name === 'before'
+ ? before.applyTransferSearch
+ : after.applyTransferSearch;
+ const sample = measure(fn, rows, query);
+ assert.deepEqual(sample.result, expected);
+ assert.equal(sample.result.totalMatched, count);
+ sample.result.items.forEach((row, i) =>
+ assert.equal(row, expected.items[i]),
+ );
+ pair[name] = sample.ms;
+ }
+ samples.push(pair);
+ }
+ const baselineMedianMs = median(samples.map((pair) => pair.before));
+ const candidateMedianMs = median(samples.map((pair) => pair.after));
+ report.scenarios.push({
+ count,
+ order,
+ limit,
+ samples,
+ baselineMedianMs,
+ candidateMedianMs,
+ speedup: baselineMedianMs / candidateMedianMs,
+ baselineOperations: countOperations(
+ before.applyTransferSearch,
+ rows,
+ query,
+ ),
+ candidateOperations: countOperations(
+ after.applyTransferSearch,
+ rows,
+ query,
+ ),
+ identicalResults: true,
+ });
+}
+console.log(JSON.stringify(report, null, 2));
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index 336af1b..452bf9f 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -1,4 +1,3 @@
-
import { createContext, useContext, useEffect, useState } from 'react';
import {
connectWallet,
diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js
index dea1e61..4819116 100644
--- a/src/hooks/useTransfers.js
+++ b/src/hooks/useTransfers.js
@@ -1,28 +1,114 @@
-
-import { useCallback, useEffect, useState } from 'react';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
import { listTransfers, createTransfer } from '../services/api.js';
import { ContractViolationError } from '../services/contracts/schema.js';
import { getUserErrorMessage, normalizeError } from '../services/errors.js';
+import { DEMO_PUBLIC_KEY } from '../services/wallet.js';
+import {
+ DEFAULT_RESULT_CAP,
+ applyTransferSearch,
+ isVisibleToActor,
+ normalizeTransferQuery,
+ transferQueryScopeKey,
+} from '../utils/transferSearch.js';
+
+function isAbortError(err) {
+ return (
+ err?.name === 'AbortError' ||
+ err?.code === 'ERR_CANCELED' ||
+ err?.code === 'ECONNABORTED'
+ );
+}
/**
* Hook for loading and creating transfers.
+ *
+ * List requests always carry an actor scope, honour AbortSignal cancellation,
+ * and ignore stale responses so a slow reply for an old filter cannot
+ * overwrite the current result set.
+ *
+ * @param {{
+ * actorId?: string,
+ * filters?: {search?: string, status?: string, range?: string},
+ * limit?: number,
+ * }} [options]
* @returns {{transfers: Array, loading: boolean, error: string|null,
* retryable: boolean, reload: Function|undefined, addTransfer: Function}}
*/
-export function useTransfers() {
+export function useTransfers(options = {}) {
+ const actorId = options.actorId || DEMO_PUBLIC_KEY;
+ const filters = options.filters || {};
+ const limit = options.limit ?? DEFAULT_RESULT_CAP;
+
const [transfers, setTransfers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryable, setRetryable] = useState(false);
+ const requestGen = useRef(0);
+ const abortRef = useRef(null);
+ const activeScopeRef = useRef(null);
+
+ const scopeKey = transferQueryScopeKey({
+ actorId,
+ search: filters.search,
+ status: filters.status,
+ range: filters.range,
+ limit,
+ });
+
+ useLayoutEffect(() => {
+ // A new committed lifecycle stays distinct even after A -> B -> A.
+ activeScopeRef.current = { key: scopeKey };
+ return () => {
+ activeScopeRef.current = null;
+ requestGen.current += 1;
+ abortRef.current?.abort();
+ };
+ }, [scopeKey]);
+
const reload = useCallback(async () => {
+ const expectedScope = activeScopeRef.current;
+ // A retained callback for an old query must not cancel the active one.
+ if (!expectedScope || expectedScope.key !== scopeKey) return;
+ const gen = ++requestGen.current;
+
+ if (abortRef.current) {
+ abortRef.current.abort();
+ }
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const isCurrent = () =>
+ activeScopeRef.current === expectedScope &&
+ gen === requestGen.current &&
+ !controller.signal.aborted;
+
setLoading(true);
setError(null);
setRetryable(false);
try {
- const data = await listTransfers();
+ const data = await listTransfers({
+ actorId,
+ search: filters.search,
+ status: filters.status,
+ range: filters.range,
+ limit,
+ signal: controller.signal,
+ });
+ // Drop stale replies: a newer filter/request already superseded this one.
+ if (!isCurrent()) {
+ return;
+ }
setTransfers(data);
} catch (err) {
+ if (isAbortError(err) || !isCurrent()) {
+ return;
+ }
if (err instanceof ContractViolationError) {
// A schema change, not a flaky request. Retrying will not help, and
// showing an empty list would imply the transfers no longer exist.
@@ -37,19 +123,57 @@ export function useTransfers() {
setRetryable(normalized.retryable);
}
} finally {
- setLoading(false);
+ if (isCurrent()) {
+ setLoading(false);
+ }
}
- }, []);
+ }, [actorId, filters.search, filters.status, filters.range, limit, scopeKey]);
useEffect(() => {
reload();
+ return () => {
+ if (abortRef.current) {
+ abortRef.current.abort();
+ }
+ };
}, [reload]);
- const addTransfer = useCallback(async (payload) => {
- const created = await createTransfer(payload);
- setTransfers((prev) => [created, ...prev]);
- return created;
- }, []);
+ const addTransfer = useCallback(
+ async (payload) => {
+ const expectedScope = activeScopeRef.current;
+ const isCurrent = () =>
+ expectedScope &&
+ expectedScope.key === scopeKey &&
+ activeScopeRef.current === expectedScope;
+ const created = await createTransfer({
+ ...payload,
+ actorId: payload?.actorId || actorId,
+ });
+ // Creation still succeeds for its caller when its old view has retired.
+ if (!isCurrent()) return created;
+ setTransfers((prev) => {
+ if (!isCurrent()) return prev;
+ const query = normalizeTransferQuery({
+ actorId,
+ search: filters.search,
+ status: filters.status,
+ range: filters.range,
+ limit,
+ });
+ const visibility = { legacyActorId: DEMO_PUBLIC_KEY };
+ // Another actor's colliding ID must not replace this actor's row.
+ if (!isVisibleToActor(created, query.actorId, visibility)) return prev;
+ // Match the list API's actor, date, status, sort and cap semantics.
+ return applyTransferSearch(
+ [created, ...prev.filter((row) => row.id !== created.id)],
+ query,
+ visibility,
+ ).items;
+ });
+ return created;
+ },
+ [actorId, filters.search, filters.status, filters.range, limit, scopeKey],
+ );
// Existing consumers use reload for both pull-to-refresh and the error-state
// retry action. Withhold it only while a non-retryable error is displayed.
diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx
index c1e03f0..3fdb4e4 100644
--- a/src/pages/SendMoney.jsx
+++ b/src/pages/SendMoney.jsx
@@ -9,7 +9,11 @@ import Modal from '../components/Modal.jsx';
import { buildQuote } from '../services/quote.js';
import { ContractViolationError } from '../services/contracts/schema.js';
import { getUserErrorMessage, normalizeError } from '../services/errors.js';
-import { formatAmount, formatCurrencyInput, parseCurrencyInput } from '../utils/format.js';
+import {
+ formatAmount,
+ formatCurrencyInput,
+ parseCurrencyInput,
+} from '../utils/format.js';
import {
isPositiveAmount,
validateRecipient,
@@ -36,7 +40,7 @@ import './SendMoney.css';
export default function SendMoney() {
const navigate = useNavigate();
const { wallet, isConnected, connect } = useWallet();
- const { addTransfer } = useTransfers();
+ const { addTransfer } = useTransfers({ actorId: wallet?.publicKey });
const { locale } = useApp();
const isOnline = useOnlineStatus();
@@ -167,7 +171,9 @@ export default function SendMoney() {
}
const finalQuote = buildQuote(parsedAmount.value, from, to);
if (!finalQuote) {
- applyErrors({ amount: 'Enter an amount greater than zero.' });
+ setSubmitError(
+ 'We could not price this transfer. Check the amount and currencies.',
+ );
return;
}
@@ -187,18 +193,23 @@ export default function SendMoney() {
submissionLock.current = true;
setSubmitting(true);
try {
- if (!isConnected) {
- await connect();
+ const account = isConnected ? wallet : await connect();
+ if (!account?.publicKey) {
+ throw new Error('Connect a wallet before sending.');
}
// Rebuild at confirmation time so the committed amounts match the note:
// rates are indicative and update at confirmation.
- const parsedAmount = parseCurrencyInput(amount, { currency: from, locale });
+ const parsedAmount = parseCurrencyInput(amount, {
+ currency: from,
+ locale,
+ });
if (!parsedAmount.ok) {
setSubmitError(parsedAmount.error);
return;
}
- const finalQuote = pendingQuote ?? buildQuote(parsedAmount.value, from, to);
+ const finalQuote =
+ pendingQuote ?? buildQuote(parsedAmount.value, from, to);
if (!finalQuote) {
setSubmitError(
'We could not price this transfer. Check the amount and the selected currencies.',
@@ -210,6 +221,7 @@ export default function SendMoney() {
// can reproduce exactly what was quoted rather than re-deriving it from
// a rate that may since have moved.
const created = await addTransfer({
+ actorId: account.publicKey,
recipient,
from,
to,
@@ -235,18 +247,19 @@ export default function SendMoney() {
);
} else {
const normalized = normalizeError(err, { source: 'api' });
- // A transfer can be interrupted mid-signature by a connection drop.
- // The honest message here is "unknown", not "failed": the backend may
- // have accepted the transfer even though the response never arrived.
- // The transfers page reconciles real status on reconnect.
- // Read the current connectivity directly (not from the render closure)
- // so that a mid-flight disconnect produces the correct message.
- const connectedNow = typeof navigator !== 'undefined' && navigator.onLine;
- setSubmitError(
- connectedNow
- ? getUserErrorMessage(normalized)
- : 'Connection lost while sending. Reconnect to check your transfer status.',
- );
+ // A transfer can be interrupted mid-signature by a connection drop.
+ // The honest message here is "unknown", not "failed": the backend may
+ // have accepted the transfer even though the response never arrived.
+ // The transfers page reconciles real status on reconnect.
+ // Read the current connectivity directly (not from the render closure)
+ // so that a mid-flight disconnect produces the correct message.
+ const connectedNow =
+ typeof navigator !== 'undefined' && navigator.onLine;
+ setSubmitError(
+ connectedNow
+ ? getUserErrorMessage(normalized)
+ : 'Connection lost while sending. Reconnect to check your transfer status.',
+ );
}
} finally {
submissionLock.current = false;
@@ -292,8 +305,8 @@ export default function SendMoney() {
role="status"
aria-live="polite"
>
- ✓ Back online. Your form was not submitted while you were
- offline — review it and send when ready.
+ ✓ Back online. Your form was not submitted while you were offline
+ — review it and send when ready.
)}
diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx
index def8104..7f5ecbd 100644
--- a/src/pages/Transfers.jsx
+++ b/src/pages/Transfers.jsx
@@ -1,11 +1,20 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Link, useSearchParams } from 'react-router-dom';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Link,
+ useLocation,
+ useNavigationType,
+ useSearchParams,
+} from 'react-router-dom';
import Chart from '../components/Chart.jsx';
import { formatMoney, parseDecimal } from '../utils/money.js';
-import {
- TRANSFER_STATUSES,
- normalizeStatus,
-} from '../services/contracts/transfer.js';
+import { TRANSFER_STATUSES } from '../services/contracts/transfer.js';
import TransferRow from '../components/TransferRow.jsx';
import { TRANSFER_STATUS_LABELS } from '../components/StatusBadge.jsx';
import Skeleton from '../components/Skeleton.jsx';
@@ -18,7 +27,12 @@ import SelectionToolbar from '../components/SelectionToolbar.jsx';
import { useTransfers } from '../hooks/useTransfers.js';
import { useOnlineStatus } from '../hooks/useOnlineStatus.js';
import { useApp } from '../context/AppContext.jsx';
-import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js';
+import { DATE_RANGE_PRESETS } from '../utils/dateRange.js';
+import { DEMO_PUBLIC_KEY } from '../services/wallet.js';
+import {
+ DEFAULT_RESULT_CAP,
+ SEARCH_DEBOUNCE_MS,
+} from '../utils/transferSearch.js';
import './Transfers.css';
// Derived from the contract so a new lifecycle state cannot be filterable in
@@ -35,13 +49,17 @@ const PAGE_SIZE = 5;
/**
* Transfers page: lists all transfers with their status.
- * Filter state is synced to the URL query string.
+ * Filter state is synced to the URL query string. Free-text search is
+ * debounced before it drives the actor-scoped list query so keystrokes do
+ * not fan out requests; obsolete in-flight queries are aborted by the hook.
*/
export default function Transfers() {
- const { transfers, loading, error, reload } = useTransfers();
- const { locale } = useApp();
+ const { locale, wallet } = useApp();
+ const actorId = wallet?.publicKey || DEMO_PUBLIC_KEY;
const isOnline = useOnlineStatus();
const [searchParams, setSearchParams] = useSearchParams();
+ const location = useLocation();
+ const navigationType = useNavigationType();
// Becomes true while the browser is offline, then flips back to false the
// moment connectivity returns so we can reconcile transfers against the
@@ -49,9 +67,68 @@ export default function Transfers() {
const [wasOffline, setWasOffline] = useState(false);
const [syncingAfterReconnect, setSyncingAfterReconnect] = useState(false);
- const search = searchParams.get('search') || '';
const status = searchParams.get('status') || '';
const range = searchParams.get('range') || '';
+ const urlSearch = searchParams.get('search') || '';
+
+ // Only a user edit may schedule a URL write. Navigation is authoritative.
+ const [searchDraft, setSearchDraft] = useState(urlSearch);
+ const pendingSearch = useRef(null);
+
+ useLayoutEffect(() => {
+ const pending = pendingSearch.current;
+ // POP also retires a draft when history changes only status/range.
+ // Local filter controls use PUSH and may preserve an unfinished edit.
+ if (
+ navigationType === 'POP' ||
+ !pending ||
+ pending.baseSearch !== urlSearch
+ ) {
+ pendingSearch.current = null;
+ setSearchDraft(urlSearch);
+ }
+ }, [urlSearch, location.key, location.search, navigationType]);
+
+ useEffect(() => {
+ const pending = pendingSearch.current;
+ if (
+ !pending ||
+ pending.value !== searchDraft ||
+ pending.baseSearch !== urlSearch
+ ) {
+ return;
+ }
+ const timer = setTimeout(() => {
+ if (pendingSearch.current !== pending) return;
+ pendingSearch.current = null;
+ setSearchParams(
+ (prev) => {
+ if ((prev.get('search') || '') !== pending.baseSearch) return prev;
+ const next = new URLSearchParams(prev);
+ if (pending.value) next.set('search', pending.value);
+ else next.delete('search');
+ return next;
+ },
+ { replace: true },
+ );
+ }, SEARCH_DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [searchDraft, urlSearch, setSearchParams]);
+
+ const queryFilters = useMemo(
+ () => ({
+ search: urlSearch,
+ status,
+ range,
+ }),
+ [urlSearch, status, range],
+ );
+
+ const { transfers, loading, error, reload } = useTransfers({
+ actorId,
+ filters: queryFilters,
+ limit: DEFAULT_RESULT_CAP,
+ });
// Selection state
const [selectedIds, setSelectedIds] = useState(() => new Set());
@@ -60,39 +137,29 @@ export default function Transfers() {
// Pagination state
const [page, setPage] = useState(1);
- // Reset page and selection when filters change
+ // Reset page and selection when the actor or committed filters change.
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
setSelectAllAcross(false);
- }, [search, status, range]);
+ }, [actorId, urlSearch, status, range]);
- // Normalise the query-string status so a legacy or provider spelling in a
- // shared/bookmarked URL (?status=settled) still selects the right rows.
- const canonicalStatus = normalizeStatus(status);
// Track connectivity so that a reconnect triggers an automatic reload.
// The reload reconciles the true status of transfers that may have been
// created or settled while the connection was down — without resubmitting
// anything.
useEffect(() => {
- if (isOnline && wasOffline && !loading) {
+ if (isOnline && wasOffline && !loading && reload) {
setSyncingAfterReconnect(true);
- reload().finally(() => {
+ Promise.resolve(reload()).finally(() => {
setSyncingAfterReconnect(false);
});
}
setWasOffline(!isOnline);
}, [isOnline, wasOffline, loading, reload]);
- const filteredTransfers = useMemo(() => {
- return transfers.filter((t) => {
- if (status && normalizeStatus(t.status) !== canonicalStatus) return false;
- if (search && !t.recipient.toLowerCase().includes(search.toLowerCase()))
- return false;
- if (!isWithinDateRange(t.createdAt, range)) return false;
- return true;
- });
- }, [transfers, search, status, canonicalStatus, range]);
+ // API already applies actor scope, filters, stable sort, and the result cap.
+ const filteredTransfers = transfers;
// Paginated data
const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1;
@@ -115,13 +182,11 @@ export default function Transfers() {
const handleSearchChange = useCallback(
(e) => {
const value = e.target.value;
- setSearchParams((prev) => {
- if (value) prev.set('search', value);
- else prev.delete('search');
- return prev;
- });
+ pendingSearch.current =
+ value === urlSearch ? null : { value, baseSearch: urlSearch };
+ setSearchDraft(value);
},
- [setSearchParams],
+ [urlSearch],
);
const handleStatusChange = useCallback(
@@ -148,7 +213,7 @@ export default function Transfers() {
[setSearchParams],
);
- const hasActiveFilters = Boolean(search || status || range);
+ const hasActiveFilters = Boolean(urlSearch || status || range);
// Selection handlers
const handleToggleSelect = useCallback((id) => {
@@ -188,6 +253,12 @@ export default function Transfers() {
setSelectAllAcross(false);
}, []);
+ const handleClearFilters = useCallback(() => {
+ pendingSearch.current = null;
+ setSearchDraft('');
+ setSearchParams({});
+ }, [setSearchParams]);
+
const renderContent = () => {
if (loading) {
return (
@@ -215,7 +286,7 @@ export default function Transfers() {
}
action={
hasActiveFilters ? (
-
+
) : (
@@ -288,7 +359,7 @@ export default function Transfers() {
type="search"
className="transfers-filters-search"
placeholder="Search by recipient…"
- value={search}
+ value={searchDraft}
onChange={handleSearchChange}
aria-label="Search transfers by recipient"
/>
diff --git a/src/services/api.js b/src/services/api.js
index d8d2797..1ec5dcd 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -11,6 +11,14 @@ import {
parseTransferList,
transferContract,
} from './contracts/transfer.js';
+import { DEMO_PUBLIC_KEY } from './wallet.js';
+import {
+ DEFAULT_RESULT_CAP,
+ applyTransferSearch,
+ isVisibleToActor,
+ normalizeTransferQuery,
+ requireActorId,
+} from '../utils/transferSearch.js';
const STORAGE_KEY = 'remitflow.transfers';
@@ -25,6 +33,7 @@ const SEED_TRANSFERS = [
receiveAmount: 294620,
status: 'completed',
createdAt: '2026-05-28T10:15:00Z',
+ actorId: DEMO_PUBLIC_KEY,
},
{
id: 'tx_1002',
@@ -35,6 +44,7 @@ const SEED_TRANSFERS = [
receiveAmount: 9920,
status: 'pending',
createdAt: '2026-06-02T08:42:00Z',
+ actorId: DEMO_PUBLIC_KEY,
},
];
@@ -67,20 +77,68 @@ function reportRejected(rejected) {
}
}
+function abortedError() {
+ if (typeof DOMException === 'function') {
+ return new DOMException(
+ 'The transfer list request was aborted.',
+ 'AbortError',
+ );
+ }
+ const err = new Error('The transfer list request was aborted.');
+ err.name = 'AbortError';
+ return err;
+}
+
/**
- * List all transfers, newest first.
+ * List transfers for one actor, newest first, with optional filters.
+ *
+ * Every call must carry `actorId` so results cannot cross wallet scope.
+ * Pass `signal` to cancel an obsolete in-flight query when filters change.
+ * Results are stably sorted and capped so large histories stay responsive.
*
* One malformed record is dropped and logged so the rest of the list still
* renders. A response where *every* record fails is a schema change, not bad
* data, and is raised so the UI can say so instead of showing "no transfers".
*
- * @returns {Promise} contract-normalised transfers
+ * @param {{
+ * actorId?: string,
+ * search?: string,
+ * status?: string,
+ * range?: string,
+ * limit?: number,
+ * signal?: AbortSignal,
+ * }} [options]
+ * @returns {Promise