From 69c03120c4c3e3644bd1ef6b6c57cd78ffaad844 Mon Sep 17 00:00:00 2001 From: Dirtybird99 <249585555+Dirtybird99@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:13:03 -0500 Subject: [PATCH 1/6] =?UTF-8?q?history:=20RAM=20row=20cache=20=E2=80=94=20?= =?UTF-8?q?instant=20paint,=20background=20refresh,=20login=20pre-warm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet's Show_Transfers takes the account's exclusive lock, which Save_Wallet holds while marshalling and encrypting the entire history every sync cycle — so the history fetch intermittently stalls for seconds on mobile. Stop making the user wait on it: cache the formatted rows (per wallet address, per view) in RAM, paint them synchronously on revisit, and swap in a fresh full fetch underneath only when something actually changed. Warm the Normal view a few seconds after login so even the first visit paints instantly. Cache is dropped on wallet close/switch (privacy) and an address mismatch can never serve another wallet's rows. Also lands the previously uncommitted history groundwork this builds on: the extracted row builders/streamed reveal (history_format.go), the async load() in layoutHistory, gate tests, and sandbox/ ignore. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +- functions.go | 3 + go.sum | 418 +++++++++++++++++++++++++++++++++++++++++ history_cache.go | 80 ++++++++ history_format.go | 235 +++++++++++++++++++++++ history_format_test.go | 376 ++++++++++++++++++++++++++++++++++++ layouts.go | 357 ++++++++++++----------------------- 7 files changed, 1235 insertions(+), 237 deletions(-) create mode 100644 go.sum create mode 100644 history_cache.go create mode 100644 history_format.go create mode 100644 history_format_test.go diff --git a/.gitignore b/.gitignore index 2582989..1758efa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ *.db datashards* mainnet* -testnet* \ No newline at end of file +testnet* +sandbox/ \ No newline at end of file diff --git a/functions.go b/functions.go index e587233..8173f6b 100644 --- a/functions.go +++ b/functions.go @@ -728,6 +728,7 @@ func closeWallet() { session.LastBalance = 0 engram.Disk = nil tx = Transfers{} + histCache.clear() if gnomon.Index != nil { logger.Printf("[Gnomon] Shutting down indexers...\n") @@ -993,6 +994,8 @@ func login() { shard := fmt.Sprintf("%x", sha1.Sum([]byte(address))) session.ID = shard session.LimitMessages = true + + go warmHistoryCache() } // Remove all overlays diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ac39db --- /dev/null +++ b/go.sum @@ -0,0 +1,418 @@ +cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +fyne.io/fyne/v2 v2.6.2 h1:RPgwmXWn+EuP/TKwO7w5p73ILVC26qHD9j3CZUZNwgM= +fyne.io/fyne/v2 v2.6.2/go.mod h1:9IJ8uWgzfcMossFoUkLiOrUIEtaDvF4nML114WiCtXU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +fyne.io/x/fyne v0.0.0-20250418202416-58a230ad1acb h1:2BazNmb/kwgqRdvE9L+NgW8sfoWGn3iy1Ox8R4+CSmc= +fyne.io/x/fyne v0.0.0-20250418202416-58a230ad1acb/go.mod h1:u3LF1EkElytjOT8OHxft16trctGndF9qpsoH6YIDOUU= +github.com/Andrew-M-C/go.jsonvalue v1.4.1 h1:MGtqaKy5Z7Xi/hnWaeqWDMwno79uNRt0XoCGHa1pm74= +github.com/Andrew-M-C/go.jsonvalue v1.4.1/go.mod h1:EsYbZ97LlOhGUs+7qTwZI9KaJrPe6nK8sEZKEqr70Ww= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/VictoriaMetrics/metrics v1.23.1 h1:/j8DzeJBxSpL2qSIdqnRFLvQQhbJyJbbEi22yMm7oL0= +github.com/VictoriaMetrics/metrics v1.23.1/go.mod h1:rAr/llLpEnAdTehiNlUxKgnjcOuROSzpw0GvjpEbvFc= +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/beevik/ntp v1.4.3 h1:PlbTvE5NNy4QHmA4Mg57n7mcFTmr1W1j3gcK7L1lqho= +github.com/beevik/ntp v1.4.3/go.mod h1:Unr8Zg+2dRn7d8bHFuehIMSvvUYssHMxW3Q5Nx4RW5Q= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= +github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/cenkalti/hub v1.0.2 h1:Nqv9TNaA9boeO2wQFW8o87BY3zKthtnzXmWGmJqhAV8= +github.com/cenkalti/hub v1.0.2/go.mod h1:8LAFAZcCasb83vfxatMUnZHRoQcffho2ELpHb+kaTJU= +github.com/cenkalti/rpc2 v1.0.4 h1:MJWmm7mbt8r/ZkQS+qr/e2KMMrhMLPr/62CYZIHybdI= +github.com/cenkalti/rpc2 v1.0.4/go.mod h1:2yfU5b86vOr16+iY1jN3MvT6Kxc9Nf8j5iZWwUf7iaw= +github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/civilware/Gnomon v0.0.0-20240403103529-8b2fdb2b3106 h1:9NSEj0KUC/Xlaw7uk/CIswEevheDNojrG6JoMPXv3G8= +github.com/civilware/Gnomon v0.0.0-20240403103529-8b2fdb2b3106/go.mod h1:B0/D3D/FVqqugHZ0fO0da2AW+5MiO82uGLOZcmS0CFk= +github.com/civilware/epoch v0.0.0-20241002060739-1ed2fc6f74cb h1:ZM5hGZMiUvheyVjzJYgvY/A34NeDZ2WO8x7oMQA6Oyc= +github.com/civilware/epoch v0.0.0-20241002060739-1ed2fc6f74cb/go.mod h1:aATobu9h/6BK9C+1e/DRBi6+dUNrHFqmykhhrxQvbTQ= +github.com/civilware/tela v0.0.0-20250806221602-aa892d2ff8d4 h1:Coa5YQoaFcOd4fBEtUhbj3Kth3TX+9s5okjfulEis58= +github.com/civilware/tela v0.0.0-20250806221602-aa892d2ff8d4/go.mod h1:J+maI4zPRe7zpSpTyHDopUg3eQRqCKffvBIRnOhipB8= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f h1:WBZRG4aNOuI15bLRrCgN8fCq8E5Xuty6jGbmSNEvSsU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creachadair/jrpc2 v0.35.4 h1:5ELLV7CMKLfALzkKNsQ//ngZLWDbEmAXgTgkL3JXAcU= +github.com/creachadair/jrpc2 v0.35.4/go.mod h1:a53Cer/NMD1y8P9UB2XbuOLRELKRLDf8u7bRi4v1qsE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deroproject/derohe v0.0.0-20250813215012-9b6a8b82c839 h1:RWh3dbb/Z29Non+otA9su7AfYpHPpadHzY6v5JNforg= +github.com/deroproject/derohe v0.0.0-20250813215012-9b6a8b82c839/go.mod h1:EWHh1VkXRnCHvyGML98kXhngDFYebmOhk/9kZ1ATJ1c= +github.com/deroproject/graviton v0.0.0-20220130070622-2c248a53b2e1 h1:nsiNx83HYmRmYpYO37pUzSTmB7p9PFtGBl4FyD+a0jg= +github.com/deroproject/graviton v0.0.0-20220130070622-2c248a53b2e1/go.mod h1:a4u6QJtGGIADg1JwujD77UtaAyhIxg14+I0C7xjyQcc= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.3.5 h1:sWtmgNxYM9P2sP+xEItMozsR3w0cqZFlqnNN1bdl41Y= +github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4 h1:rEvIZUSZ3fx39WIi3JkQqQBitGwpELBIYWeBVh6wn+E= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= +github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= +github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= +github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= +github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/jackmordaunt/icns/v2 v2.2.6 h1:M7kg6pWRmB+SyCvM058cV2BlAz3MedOHy4e3j2i7FQg= +github.com/jackmordaunt/icns/v2 v2.2.6/go.mod h1:DqlVnR5iafSphrId7aSD06r3jg0KRC9V6lEBBp504ZQ= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/josephspurrier/goversioninfo v1.4.0 h1:Puhl12NSHUSALHSuzYwPYQkqa2E1+7SrtAPJorKK0C8= +github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/klauspost/compress v1.15.13 h1:NFn1Wr8cfnenSJSA46lLq4wHCcBzKTSjnBIexDMMOV0= +github.com/klauspost/compress v1.15.13/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.14/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/reedsolomon v1.10.0/go.mod h1:qHMIzMkuZUWqIh8mS/GruPdo3u0qwX2jk/LH440ON7Y= +github.com/klauspost/reedsolomon v1.12.5 h1:4cJuyH926If33BeDgiZpI5OU0pE+wUHZvMSyNGqN73Y= +github.com/klauspost/reedsolomon v1.12.5/go.mod h1:LkXRjLYGM8K/iQfujYnaPeDmhZLqkrGUyG9p7zs5L68= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lesismal/llib v1.2.2 h1:ZoVgP9J58Ju3Yue5jtj8ybWl+BKqoVmdRaN1mNwG5Gc= +github.com/lesismal/llib v1.2.2/go.mod h1:70tFXXe7P1FZ02AU9l8LgSOK7d7sRrpnkUr3rd3gKSg= +github.com/lesismal/nbio v1.6.7 h1:EeiH0Vn0v5NG7masYNWugibPdNZZYYBPa0pGj4GOrbg= +github.com/lesismal/nbio v1.6.7/go.mod h1:mBn1rSIZ+cmOILhvP+/1Mb/JimgA+1LQudlHJUb/aNA= +github.com/lucor/goinfo v0.9.0 h1:EdsMzmY5TZujA4xb9xMLIdlp2+zvF7miNYkVXvqqgOQ= +github.com/lucor/goinfo v0.9.0/go.mod h1:L6m6tN5Rlova5Z83h1ZaKsMP1iiaoZ9vGTNzu5QKOD4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 h1:YocNLcTBdEdvY3iDK6jfWXvEaM5OCKkjxPKoJRdB3Gg= +github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= +github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.20.0 h1:8W0cWlwFkflGPLltQvLRB7ZVD5HuP6ng320w2IS245Q= +github.com/onsi/gomega v1.20.0/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= +github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= +github.com/templexxx/cpu v0.0.9 h1:cGGLK8twbc1J1S/fHnZW7BylXYaFP+0fR2s+nzsFDiU= +github.com/templexxx/cpu v0.0.9/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= +github.com/templexxx/xorsimd v0.4.1 h1:iUZcywbOYDRAZUasAs2eSCUW8eobuZDy0I9FJiORkVg= +github.com/templexxx/xorsimd v0.4.1/go.mod h1:W+ffZz8jJMH2SXwuKu9WhygqBMbFnp14G2fqEr8qaNo= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/twpayne/go-geom v1.0.0 h1:ARrRnN4+rBX3LZFZQy9NFeXXlgQqVL4OOvAcnLdgy2g= +github.com/twpayne/go-geom v1.0.0/go.mod h1:RWsl+e3XSahOul/KH2BHCfF0QxSL4RMnMlFw/TNmET0= +github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I= +github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg= +github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= +github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= +github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY= +github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= +github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xtaci/kcp-go/v5 v5.6.2 h1:pSXMa5MOsb+EIZKe4sDBqlTExu2A/2Z+DFhoX2qtt2A= +github.com/xtaci/kcp-go/v5 v5.6.2/go.mod h1:LsinWoru+lWWJHb+EM9HeuqYxV6bb9rNcK12v67jYzQ= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE= +github.com/ybbus/jsonrpc v2.1.2+incompatible h1:V4mkE9qhbDQ92/MLMIhlhMSbz8jNXdagC3xBR5NDwaQ= +github.com/ybbus/jsonrpc v2.1.2+incompatible/go.mod h1:XJrh1eMSzdIYFbM08flv0wp5G35eRniyeGut1z+LSiE= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= +go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.etcd.io/gofail v0.1.0 h1:XItAMIhOojXFQMgrxjnd2EIIHun/d5qL0Pf7FzVTkFg= +go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513122933-cd7d49e622d5/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4 h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools/go/vcs v0.1.0-deprecated h1:cOIJqWBl99H1dH5LWizPa+0ImeeJq3t3cJjaeOWUAL4= +golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc= +mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/history_cache.go b/history_cache.go new file mode 100644 index 0000000..4c4b750 --- /dev/null +++ b/history_cache.go @@ -0,0 +1,80 @@ +package main + +import ( + "sync" + "time" + + "github.com/deroproject/derohe/cryptography/crypto" +) + +// historyCache holds the formatted history rows (newest first) for the wallet +// that is currently open, keyed per view ("Normal"/"Coinbase"/"Messages"). It +// is a paint accelerator only: every history visit still runs a full fetch and +// replaces the cached rows, so chain reorgs self-heal on the next refresh. +// RAM only; dropped on wallet close. Row slices are treated as immutable by +// all callers — never mutate a slice handed to put or returned by get. +type historyCache struct { + sync.Mutex + addr string // wallet address the cached views belong to + views map[string][]string // view name -> rows +} + +var histCache historyCache + +// get returns the cached rows for addr's view. A different address is always +// a miss, so one wallet can never paint another wallet's history. +func (c *historyCache) get(addr, view string) ([]string, bool) { + c.Lock() + defer c.Unlock() + if addr == "" || addr != c.addr { + return nil, false + } + rows, ok := c.views[view] + return rows, ok +} + +// put stores rows for addr's view. A new address evicts every view of the +// previous wallet. nil rows are a valid entry: an empty history still counts +// as a hit, so empty wallets paint instantly too. +func (c *historyCache) put(addr, view string, rows []string) { + if addr == "" { + return + } + c.Lock() + defer c.Unlock() + if addr != c.addr || c.views == nil { + c.addr = addr + c.views = make(map[string][]string) + } + c.views[view] = rows +} + +// clear drops all cached rows (privacy + memory on wallet close). +func (c *historyCache) clear() { + c.Lock() + defer c.Unlock() + c.addr = "" + c.views = nil +} + +// warmHistoryCache pre-builds the Normal-view rows shortly after login so the +// first History visit paints from cache. It runs one full Show_Transfers scan +// (which takes the wallet's exclusive lock), deferred a few seconds to stay +// clear of the login-time sync/save burst. Only the default view is warmed; +// Coinbase/Messages fill on their first visit. The "Normal" literal must match +// the label layoutHistory passes to load (see menu.OnChanged in layouts.go). +func warmHistoryCache() { + time.Sleep(3 * time.Second) + w := engram.Disk + if w == nil { + return + } + addr := w.GetAddress().String() + var zeroscid crypto.Hash + entries := w.Show_Transfers(zeroscid, false, true, true, 0, w.Get_Height(), "", "", 0, 0) + rows := streamHistoryRows(entries, historyRowNormal, 0, nil) + if engram.Disk != w { // wallet closed or switched while we scanned + return + } + histCache.put(addr, "Normal", rows) +} diff --git a/history_format.go b/history_format.go new file mode 100644 index 0000000..a1fa14a --- /dev/null +++ b/history_format.go @@ -0,0 +1,235 @@ +package main + +import ( + "image/color" + "strconv" + "strings" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/widget" + + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" +) + +// History rows are encoded as ";;;"-delimited strings consumed by the list's +// UpdateItem callback (see newHistoryList). The functions below are pure +// (entry in, row out) so they can be unit-tested and benchmarked without a +// wallet, a daemon, or a running Fyne app. They are extracted verbatim from the +// three inline closures that used to live in layoutHistory(). + +// appendDate appends t as YYYY-MM-DD, byte-identical to +// t.AppendFormat(dst, "2006-01-02"). For the normal 4-digit-year range it uses +// direct digit math; outside it (year <1000 or >9999) it defers to AppendFormat, +// so the output is identical for any date. Gated by TestAppendDateMatchesFormat. +func appendDate(dst []byte, t time.Time) []byte { + y, m, d := t.Date() + if y < 1000 || y > 9999 { + return t.AppendFormat(dst, "2006-01-02") + } + return append(dst, + byte('0'+y/1000%10), byte('0'+y/100%10), byte('0'+y/10%10), byte('0'+y%10), + '-', byte('0'+int(m)/10), byte('0'+int(m)%10), + '-', byte('0'+d/10), byte('0'+d%10), + ) +} + +// historyStamp formats t as YYYY-MM-DD, byte-identical to t.Format("2006-01-02"). +func historyStamp(t time.Time) string { + var b [10]byte + return string(appendDate(b[:0], t)) +} + +// fastMoneyMax bounds the integer fast path of fastFormatMoney. Below it, exact +// integer arithmetic reproduces globals.FormatMoney byte-for-byte: FormatMoney's +// big.Float rounding error stays far under half a unit in the 5th decimal until +// ~9.2e18, so 1e17 leaves a huge margin. Above it we defer to FormatMoney, so the +// result is identical for every uint64. DERO's whole supply is ~2.1e12 atomic +// units, so the fast path covers all real amounts with orders of magnitude spare. +const fastMoneyMax = 100_000_000_000_000_000 // 1e17 atomic units + +// appendMoney appends globals.FormatMoney(amount) to dst without the per-call +// big.Float allocation, deferring to FormatMoney for extreme values (fastMoneyMax). +func appendMoney(dst []byte, amount uint64) []byte { + if amount > fastMoneyMax { + return append(dst, globals.FormatMoney(amount)...) + } + whole := amount / 100000 + frac := amount % 100000 + dst = strconv.AppendUint(dst, whole, 10) + return append(dst, + '.', + byte('0'+frac/10000%10), + byte('0'+frac/1000%10), + byte('0'+frac/100%10), + byte('0'+frac/10%10), + byte('0'+frac%10), + ) +} + +// fastFormatMoney is an allocation-light equivalent of globals.FormatMoney(amount) +// (atomic units -> ".<5 decimals>"). Equivalence to FormatMoney across the +// full uint64 range is enforced by the gate test TestFastFormatMoneyMatchesGlobals. +func fastFormatMoney(amount uint64) string { + var b [24]byte + return string(appendMoney(b[:0], amount)) +} + +// historyRowNormal formats a non-coinbase transfer. Returns (row, true) to show +// it, ("", false) to skip. It reads none of the fields ProcessPayload populates. +func historyRowNormal(e rpc.Entry) (string, bool) { + if e.Coinbase { + return "", false + } + // Build the whole ";;;"-delimited row in one stack buffer: a single allocation + // for the returned string instead of separate amount/height/stamp strings plus + // a final concat. Output is byte-identical (pinned by TestHistoryRowNormal). + var sb [128]byte + buf := sb[:0] + if e.Incoming { + buf = append(buf, "Received;;;"...) + buf = appendMoney(buf, e.Amount) + } else { + buf = append(buf, "Sent;;;("...) + buf = appendMoney(buf, e.Amount) + buf = append(buf, ')') + } + buf = append(buf, ";;;"...) + buf = strconv.AppendUint(buf, e.Height, 10) + buf = append(buf, ";;;"...) + buf = appendDate(buf, e.Time) + buf = append(buf, ";;;"...) + buf = append(buf, e.TXID...) + return string(buf), true +} + +// historyRowCoinbase formats a coinbase (network reward) transfer. +func historyRowCoinbase(e rpc.Entry) (string, bool) { + if !e.Coinbase { + return "", false + } + stamp := historyStamp(e.Time) + height := strconv.FormatUint(e.Height, 10) + amount := fastFormatMoney(e.Amount) + return "Network" + ";;;" + amount + ";;;" + height + ";;;" + stamp + ";;;" + e.TXID, true +} + +// clipHistoryField shortens a display field to 10 chars + ".." when it overflows, +// leaving shorter values untouched. Shared by the message row's username and comment. +func clipHistoryField(s string) string { + if len(s) > 10 { + return s[0:10] + ".." + } + return s +} + +// historyRowMessage formats an encrypted-message transfer (dst port 1337). It is +// the only view that reads Payload_RPC, so it is the only one that needs the +// decoded payload. The build loop currently decodes every entry up front +// (streamHistoryRows), so this function assumes Payload_RPC is already populated. +func historyRowMessage(e rpc.Entry) (string, bool) { + // Only the message view reads the decoded payload, so the decode lives here + // (guarded) instead of running unconditionally for every transfer in the + // build loop. Entries that arrive already-decoded (empty Payload) are reused. + if len(e.Payload) > 0 { + e.ProcessPayload() + } + if !e.Payload_RPC.HasValue(rpc.RPC_COMMENT, rpc.DataString) { + return "", false + } + stamp := historyStamp(e.Time) + direction := "Received" + if !e.Incoming { + direction = "Sent " + } + contact := "" + username := "" + if e.Payload_RPC.HasValue(rpc.RPC_NEEDS_REPLYBACK_ADDRESS, rpc.DataString) { + contact = e.Payload_RPC.Value(rpc.RPC_NEEDS_REPLYBACK_ADDRESS, rpc.DataString).(string) + username = clipHistoryField(contact) + } + comment := clipHistoryField(e.Payload_RPC.Value(rpc.RPC_COMMENT, rpc.DataString).(string)) + return direction + ";;;" + username + ";;;" + comment + ";;;" + stamp + ";;;" + e.TXID + ";;;" + contact, true +} + +// revealRowCap stops progressive snapshots once the list already holds far more +// than a screenful. Beyond it the caller's final Set delivers the remaining rows, +// so a long history avoids copying ever-larger snapshots it would never paint. +const revealRowCap = 2048 + +// streamHistoryRows builds the display rows from a transfer set, newest first, +// applying `row` as both filter and formatter. When batch > 0 it calls `reveal` +// with a snapshot of the rows built so far on a geometric cadence (up to +// revealRowCap rows), so the caller can paint partial results progressively. It +// returns the full final slice. No Fyne or UI-thread assumptions live here. +func streamHistoryRows(entries []rpc.Entry, row func(rpc.Entry) (string, bool), batch int, reveal func([]string)) []string { + rows := make([]string, 0, len(entries)) + next := batch + for i := len(entries) - 1; i >= 0; i-- { // newest first + r, ok := row(entries[i]) + if !ok { + continue + } + rows = append(rows, r) + + // Reveal partial results on a geometric cadence (batch, 2*batch, 4*batch, ...): + // the first screenful paints early; past revealRowCap we stop snapshotting so a + // long history pays neither the O(n^2) fixed-chunk copy nor huge late snapshots. + if batch > 0 && reveal != nil && len(rows) >= next && len(rows) <= revealRowCap { + snap := append([]string(nil), rows...) + reveal(snap) + next *= 2 + } + } + return rows +} + +// newHistoryList builds the bound list widget used by the history screen. The +// caller owns the backing slice (`data`); listData.Set writes through it so the +// screen's OnSelected can read data[id]. Extracted from layoutHistory() so the +// render/model path is constructable in a headless test. +func newHistoryList(data *[]string) (*widget.List, binding.StringList) { + listData := binding.BindStringList(data) + + rect := canvas.NewRectangle(color.Transparent) + rect.SetMinSize(fyne.NewSize(ui.Width*0.3, 35)) + rectMid := canvas.NewRectangle(color.Transparent) + rectMid.SetMinSize(fyne.NewSize(ui.Width*0.35, 35)) + + listBox := widget.NewListWithData(listData, + func() fyne.CanvasObject { + return container.NewHBox( + container.NewStack( + rect, + widget.NewLabel(""), + ), + container.NewStack( + rectMid, + widget.NewLabel(""), + ), + container.NewStack( + rect, + widget.NewLabel(""), + ), + ) + }, + func(di binding.DataItem, co fyne.CanvasObject) { + dat := di.(binding.String) + str, err := dat.Get() + if err != nil { + return + } + + split := strings.Split(str, ";;;") + + co.(*fyne.Container).Objects[0].(*fyne.Container).Objects[1].(*widget.Label).SetText(split[0]) + co.(*fyne.Container).Objects[1].(*fyne.Container).Objects[1].(*widget.Label).SetText(split[1]) + co.(*fyne.Container).Objects[2].(*fyne.Container).Objects[1].(*widget.Label).SetText(split[3]) + }) + + return listBox, listData +} diff --git a/history_format_test.go b/history_format_test.go new file mode 100644 index 0000000..5f289bd --- /dev/null +++ b/history_format_test.go @@ -0,0 +1,376 @@ +package main + +import ( + "math/rand" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "fyne.io/fyne/v2/test" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" +) + +// Fixed instant so the "2006-01-02" stamp is deterministic. +var testTime = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + +const testStamp = "2024-01-02" + +func TestHistoryRowNormal(t *testing.T) { + cases := []struct { + name string + e rpc.Entry + want string + wantOK bool + }{ + { + name: "received", + e: rpc.Entry{Coinbase: false, Incoming: true, Amount: 150000, Height: 12345, TXID: "tx_recv", Time: testTime}, + want: "Received;;;" + globals.FormatMoney(150000) + ";;;12345;;;" + testStamp + ";;;tx_recv", + wantOK: true, + }, + { + name: "sent_is_parenthesized", + e: rpc.Entry{Coinbase: false, Incoming: false, Amount: 250000, Height: 7, TXID: "tx_sent", Time: testTime}, + want: "Sent;;;(" + globals.FormatMoney(250000) + ");;;7;;;" + testStamp + ";;;tx_sent", + wantOK: true, + }, + { + name: "coinbase_skipped", + e: rpc.Entry{Coinbase: true, Incoming: true, Amount: 1, Height: 1, TXID: "cb", Time: testTime}, + want: "", + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := historyRowNormal(tc.e) + if ok != tc.wantOK || got != tc.want { + t.Fatalf("historyRowNormal() = %q,%v; want %q,%v", got, ok, tc.want, tc.wantOK) + } + }) + } +} + +func TestHistoryRowCoinbase(t *testing.T) { + cb := rpc.Entry{Coinbase: true, Amount: 600000, Height: 99, TXID: "cbtx", Time: testTime} + got, ok := historyRowCoinbase(cb) + want := "Network;;;" + globals.FormatMoney(600000) + ";;;99;;;" + testStamp + ";;;cbtx" + if !ok || got != want { + t.Fatalf("historyRowCoinbase() = %q,%v; want %q,true", got, ok, want) + } + if _, ok := historyRowCoinbase(rpc.Entry{Coinbase: false, Time: testTime}); ok { + t.Fatalf("non-coinbase entry should be skipped") + } +} + +// msgEntry builds a message entry with Payload_RPC populated directly (decoded), +// which is what historyRowMessage reads. Empty Payload, so it is robust to the +// optimize-loop later moving the ProcessPayload decode into historyRowMessage +// (the len(Payload)>0 guard will skip it and use this preset value). +func msgEntry(incoming bool, comment, replyback, txid string) rpc.Entry { + args := rpc.Arguments{ + {Name: rpc.RPC_COMMENT, DataType: rpc.DataString, Value: comment}, + } + if replyback != "" { + args = append(args, rpc.Argument{Name: rpc.RPC_NEEDS_REPLYBACK_ADDRESS, DataType: rpc.DataString, Value: replyback}) + } + return rpc.Entry{Incoming: incoming, TXID: txid, Time: testTime, Payload_RPC: args} +} + +func TestHistoryRowMessage(t *testing.T) { + // Received, short comment + short replyback (no truncation). + got, ok := historyRowMessage(msgEntry(true, "hi", "deroAddr", "m1")) + want := "Received;;;deroAddr;;;hi;;;" + testStamp + ";;;m1;;;deroAddr" + if !ok || got != want { + t.Fatalf("message(received) = %q,%v; want %q,true", got, ok, want) + } + + // Sent ("Sent " with 4 trailing spaces); long comment + long replyback + // both truncate username/comment to first 10 chars + "..", but contact (field 6) + // keeps the full address. + long := "0123456789ABCDEF" + trunc := long[0:10] + ".." + got2, ok2 := historyRowMessage(msgEntry(false, long, long, "m2")) + want2 := "Sent ;;;" + trunc + ";;;" + trunc + ";;;" + testStamp + ";;;m2;;;" + long + if !ok2 || got2 != want2 { + t.Fatalf("message(sent,long) = %q,%v; want %q,true", got2, ok2, want2) + } + + // No comment -> skipped. + if _, ok := historyRowMessage(rpc.Entry{Time: testTime}); ok { + t.Fatalf("message without comment should be skipped") + } +} + +// TestHistoryRowMessageDecodesPayload exercises the production path: entries from +// Get_Payments_DestinationPort arrive with a raw CBOR Payload and nil Payload_RPC, +// and historyRowMessage must decode it in-place (the optimize-loop hoist). The +// other message tests preset Payload_RPC with empty Payload, so they never hit +// this branch. +func TestHistoryRowMessageDecodesPayload(t *testing.T) { + payload, err := rpc.Arguments{ + {Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: uint64(1337)}, + {Name: rpc.RPC_SOURCE_PORT, DataType: rpc.DataUint64, Value: uint64(0)}, + {Name: rpc.RPC_NEEDS_REPLYBACK_ADDRESS, DataType: rpc.DataString, Value: "deroSender"}, + {Name: rpc.RPC_COMMENT, DataType: rpc.DataString, Value: "gm"}, + }.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary: %v", err) + } + + e := rpc.Entry{Incoming: true, TXID: "mp", Time: testTime, Payload: payload} + got, ok := historyRowMessage(e) + want := "Received;;;deroSender;;;gm;;;" + testStamp + ";;;mp;;;deroSender" + if !ok || got != want { + t.Fatalf("historyRowMessage(raw payload) = %q,%v; want %q,true", got, ok, want) + } +} + +func TestStreamHistoryRows(t *testing.T) { + // Chain order (oldest first). historyRowNormal drops the coinbase. + entries := []rpc.Entry{ + {Coinbase: false, Incoming: true, Amount: 100000, Height: 1, TXID: "a", Time: testTime}, + {Coinbase: true, Incoming: true, Amount: 200000, Height: 2, TXID: "cb", Time: testTime}, + {Coinbase: false, Incoming: false, Amount: 300000, Height: 3, TXID: "b", Time: testTime}, + } + + base := streamHistoryRows(entries, historyRowNormal, 0, nil) + if len(base) != 2 { + t.Fatalf("want 2 rows (coinbase filtered), got %d: %v", len(base), base) + } + // Newest first: height 3 then height 1. + if !strings.Contains(base[0], ";;;3;;;") || !strings.Contains(base[1], ";;;1;;;") { + t.Fatalf("expected newest-first (height 3 then 1), got %v", base) + } + + // The final slice must be invariant to batch size — this is what licenses + // the optimize-loop changing the reveal cadence. + for _, batch := range []int{1, 2, 50, 1000} { + out := streamHistoryRows(entries, historyRowNormal, batch, func([]string) {}) + if len(out) != len(base) { + t.Fatalf("batch %d changed length: %d vs %d", batch, len(out), len(base)) + } + for i := range out { + if out[i] != base[i] { + t.Fatalf("batch %d changed row %d: %q vs %q", batch, i, out[i], base[i]) + } + } + } +} + +func TestHistoryListSmoke(t *testing.T) { + test.NewTempApp(t) + + var data []string + listBox, listData := newHistoryList(&data) + + rows := []string{ + "Received;;;1.00000;;;5;;;2024-01-02;;;x", + "Sent;;;(2.00000);;;6;;;2024-01-02;;;y", + } + if err := listData.Set(rows); err != nil { + t.Fatalf("listData.Set: %v", err) + } + if listBox.Length() != len(rows) { + t.Fatalf("listBox.Length() = %d; want %d", listBox.Length(), len(rows)) + } +} + +// makeEntries builds n synthetic transfers, most carrying a real CBOR payload so +// that ProcessPayload does production-representative decode work (otherwise the +// benchmark would be blind to the dominant build-path cost). +func makeEntries(n int) []rpc.Entry { + payload, err := rpc.Arguments{ + {Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: uint64(0)}, + {Name: rpc.RPC_SOURCE_PORT, DataType: rpc.DataUint64, Value: uint64(0)}, + {Name: rpc.RPC_COMMENT, DataType: rpc.DataString, Value: "payment for services rendered"}, + }.MarshalBinary() + if err != nil { + panic(err) + } + out := make([]rpc.Entry, n) + for i := 0; i < n; i++ { + out[i] = rpc.Entry{ + Height: uint64(i + 1), + Incoming: i%2 == 0, + Amount: uint64((i + 1) * 137), + TXID: "tx" + strconv.Itoa(i), + Time: testTime, + Payload: payload, + } + } + return out +} + +func BenchmarkStreamHistoryRows(b *testing.B) { + entries := makeEntries(20000) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = streamHistoryRows(entries, historyRowNormal, 50, func([]string) {}) + } +} + +func BenchmarkHistoryListPopulate(b *testing.B) { + test.NewApp() + rows := streamHistoryRows(makeEntries(20000), historyRowNormal, 0, nil) + var data []string + _, listData := newHistoryList(&data) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = listData.Set(rows) + } +} + +// TestFastFormatMoneyMatchesGlobals is the correctness gate for the fastFormatMoney +// optimization: it must produce byte-identical output to globals.FormatMoney for +// every uint64. globals.FormatMoney remains the ground truth; this proves the fast +// integer path (and the big.Float fallback above fastMoneyMax) never diverges. +func TestFastFormatMoneyMatchesGlobals(t *testing.T) { + check := func(a uint64) { + t.Helper() + if got, want := fastFormatMoney(a), globals.FormatMoney(a); got != want { + t.Fatalf("fastFormatMoney(%d) = %q; globals.FormatMoney = %q", a, got, want) + } + } + + // Exhaustive low range: every fractional pattern, carry, and leading-zero case. + for a := uint64(0); a <= 1_000_000; a++ { + check(a) + } + + // Many magnitudes of the whole part with edge fractions, up to past total supply + // and to the fast-path boundary. + fracs := []uint64{0, 1, 9, 10, 99, 100, 9999, 10000, 50000, 99998, 99999} + for whole := uint64(1); whole <= 2_000_000_000_000; whole = whole*7 + 3 { + for _, f := range fracs { + check(whole*100000 + f) + } + } + + // The fast/fallback boundary and the big.Float-only region above it. + for _, base := range []uint64{ + fastMoneyMax - 8, fastMoneyMax, fastMoneyMax + 8, + 9_000_000_000_000_000_000, 9_223_372_036_854_775_807, + 18_000_000_000_000_000_000, ^uint64(0), + } { + for d := uint64(0); d < 9 && base+d >= base; d++ { + check(base + d) + } + } + + // Deterministic random across the full uint64 range. + r := rand.New(rand.NewSource(20260630)) + for i := 0; i < 1_000_000; i++ { + check(r.Uint64()) + } +} + +// TestAppendDateMatchesFormat is the correctness gate for appendDate: it must +// match t.Format("2006-01-02") byte-for-byte for any date (the fast 4-digit-year +// path and the AppendFormat fallback for edge years alike). +func TestAppendDateMatchesFormat(t *testing.T) { + check := func(tm time.Time) { + t.Helper() + if got, want := string(appendDate(nil, tm)), tm.Format("2006-01-02"); got != want { + t.Fatalf("appendDate(%v) = %q; Format = %q", tm, got, want) + } + } + + // Structured sweep incl. leap years, month/day boundaries, and edge year ranges + // that exercise the AppendFormat fallback (<1000, >9999). + for _, y := range []int{1, 99, 999, 1000, 1969, 1970, 2000, 2024, 2025, 9999, 10000, 12024} { + for mo := 1; mo <= 12; mo++ { + for _, d := range []int{1, 9, 10, 28, 29, 30, 31} { + check(time.Date(y, time.Month(mo), d, 13, 14, 15, 0, time.UTC)) + } + } + } + + // Deterministic random instants across ~300 years. + r := rand.New(rand.NewSource(42)) + base := time.Date(1971, 1, 1, 0, 0, 0, 0, time.UTC).Unix() + for i := 0; i < 200_000; i++ { + check(time.Unix(base+r.Int63n(300*365*24*3600), 0).UTC()) + } +} + +// TestHistoryCache pins the cache contract layoutHistory relies on: per-view +// storage, address isolation (one wallet can never paint another's rows), +// whole-wallet eviction on address switch, empty history as a valid hit, and +// clear() dropping everything on wallet close. +func TestHistoryCache(t *testing.T) { + var c historyCache + + if _, ok := c.get("walletA", "Normal"); ok { + t.Fatal("empty cache must miss") + } + + rowsA := []string{"r1", "r2"} + c.put("walletA", "Normal", rowsA) + if got, ok := c.get("walletA", "Normal"); !ok || !slices.Equal(got, rowsA) { + t.Fatalf("get after put = %v,%v; want %v,true", got, ok, rowsA) + } + + if _, ok := c.get("walletA", "Coinbase"); ok { + t.Fatal("views must be cached independently") + } + c.put("walletA", "Coinbase", []string{"c1"}) + if _, ok := c.get("walletA", "Normal"); !ok { + t.Fatal("putting a sibling view must not evict") + } + + if got, ok := c.get("walletB", "Normal"); ok || got != nil { + t.Fatal("another wallet's address must miss") + } + + c.put("walletB", "Normal", []string{"x"}) + if _, ok := c.get("walletA", "Normal"); ok { + t.Fatal("address switch must evict the previous wallet") + } + if _, ok := c.get("walletA", "Coinbase"); ok { + t.Fatal("address switch must evict ALL of the previous wallet's views") + } + + c.put("walletB", "Messages", nil) + if got, ok := c.get("walletB", "Messages"); !ok || len(got) != 0 { + t.Fatal("empty rows must cache as a valid hit") + } + + c.put("", "Normal", []string{"x"}) + if _, ok := c.get("", "Normal"); ok { + t.Fatal("empty address must never store or hit") + } + + c.clear() + if _, ok := c.get("walletB", "Normal"); ok { + t.Fatal("clear must evict everything") + } +} + +// TestHistoryCacheConcurrent exercises put/get/clear from many goroutines; it +// exists to fail under `go test -race` if the internal locking ever regresses. +func TestHistoryCacheConcurrent(t *testing.T) { + var c historyCache + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + addr := "wallet" + strconv.Itoa(n%2) + for j := 0; j < 500; j++ { + c.put(addr, "Normal", []string{"r"}) + c.get(addr, "Normal") + if j%100 == 0 { + c.clear() + } + } + }(i) + } + wg.Wait() +} diff --git a/layouts.go b/layouts.go index 24f6b38..5a27af4 100644 --- a/layouts.go +++ b/layouts.go @@ -27,6 +27,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "sort" "strconv" "strings" @@ -3201,6 +3202,7 @@ func layoutRestore() fyne.CanvasObject { session.Path = "" session.Name = "" tx = Transfers{} + histCache.clear() btnCreate.Hide() form.Hide() @@ -9406,11 +9408,7 @@ func layoutAlert(t int) fyne.CanvasObject { func layoutHistory() fyne.CanvasObject { var data []string - var entries []rpc.Entry var zeroscid crypto.Hash - var listData binding.StringList - var listBox *widget.List - var txid string view := "" @@ -9433,46 +9431,10 @@ func layoutHistory() fyne.CanvasObject { heading.Alignment = fyne.TextAlignCenter heading.TextStyle = fyne.TextStyle{Bold: true} - rect := canvas.NewRectangle(color.Transparent) - rect.SetMinSize(fyne.NewSize(ui.Width*0.3, 35)) - - rectMid := canvas.NewRectangle(color.Transparent) - rectMid.SetMinSize(fyne.NewSize(ui.Width*0.35, 35)) - results := canvas.NewText("", colors.Green) results.TextSize = 13 - listData = binding.BindStringList(&data) - listBox = widget.NewListWithData(listData, - func() fyne.CanvasObject { - return container.NewHBox( - container.NewStack( - rect, - widget.NewLabel(""), - ), - container.NewStack( - rectMid, - widget.NewLabel(""), - ), - container.NewStack( - rect, - widget.NewLabel(""), - ), - ) - }, - func(di binding.DataItem, co fyne.CanvasObject) { - dat := di.(binding.String) - str, err := dat.Get() - if err != nil { - return - } - - split := strings.Split(str, ";;;") - - co.(*fyne.Container).Objects[0].(*fyne.Container).Objects[1].(*widget.Label).SetText(split[0]) - co.(*fyne.Container).Objects[1].(*fyne.Container).Objects[1].(*widget.Label).SetText(split[1]) - co.(*fyne.Container).Objects[2].(*fyne.Container).Objects[1].(*widget.Label).SetText(split[3]) - }) + listBox, listData := newHistoryList(&data) menu := widget.NewSelect([]string{"Normal", "Coinbase", "Messages"}, nil) menu.PlaceHolder = "(Select Transaction Type)" @@ -9517,219 +9479,140 @@ func layoutHistory() fyne.CanvasObject { label.TextSize = 15 label.TextStyle = fyne.TextStyle{Bold: true} - menu.OnChanged = func(s string) { - switch s { - case "Normal": - listBox.UnselectAll() - results.Text = " Scanning..." - results.Refresh() - count := 0 - data = nil - listData.Set(nil) - entries = engram.Disk.Show_Transfers(zeroscid, false, true, true, 0, engram.Disk.Get_Height(), "", "", 0, 0) - - if entries != nil { - go func() { - for e := range entries { - var height string - var direction string - var stamp string - - entries[e].ProcessPayload() - - if !entries[e].Coinbase { - timefmt := entries[e].Time - //stamp = string(timefmt.Format(time.RFC822)) - stamp = timefmt.Format("2006-01-02") - height = strconv.FormatUint(entries[e].Height, 10) - amount := "" - txid = entries[e].TXID - - if !entries[e].Incoming { - direction = "Sent" - amount = "(" + globals.FormatMoney(entries[e].Amount) + ")" - } else { - direction = "Received" - amount = globals.FormatMoney(entries[e].Amount) - } - - count += 1 - data = append(data, direction+";;;"+amount+";;;"+height+";;;"+stamp+";;;"+txid) - } - } - - results.Text = fmt.Sprintf(" Results: %d", count) - - listData.Set(data) - - listBox.OnSelected = func(id widget.ListItemID) { - //var zeroscid crypto.Hash - split := strings.Split(data[id], ";;;") - var zeroscid crypto.Hash - _, result := engram.Disk.Get_Payments_TXID(zeroscid, split[4]) - - if result.TXID == "" { - label.Text = "---" - } else { - label.Text = result.TXID - } - label.Refresh() + // load runs the (potentially heavy) transfer scan OFF the UI goroutine and + // reveals rows newest-first in batches, so the first screenful paints almost + // immediately instead of blocking on the full history. Revisits skip even + // that: rows cached by the last build (histCache) paint synchronously and + // the fresh scan swaps in underneath only if anything changed — the scan + // can stall for seconds behind the wallet's save lock, so the user must + // never wait on it twice. Per Fyne v2.6, the non-binding widget calls + // (Text/Refresh/scroll) must run inside fyne.Do; listData.Set is kept on + // the UI thread too so it never races OnSelected reading data[id]. + load := func(label string, fetch func() []rpc.Entry, build func(rpc.Entry) (string, bool), onSel func(widget.ListItemID)) { + listBox.UnselectAll() + listBox.OnSelected = func(id widget.ListItemID) { + // The background refresh can swap the rows between the tap and + // this callback; never index past the current data. + if id < 0 || id >= len(data) { + listBox.UnselectAll() + return + } + onSel(id) + } - overlay := session.Window.Canvas().Overlays() - overlay.Add( - container.NewStack( - &iframe{}, - canvas.NewRectangle(colors.DarkMatter), - ), - ) - overlay.Add(layoutHistoryDetail(split[4])) - listBox.UnselectAll() - } + if engram.Disk == nil { + return + } + addr := engram.Disk.GetAddress().String() - fyne.Do(func() { - results.Refresh() - listBox.Refresh() - listBox.ScrollToBottom() - }) - }() - } else { - results.Text = fmt.Sprintf(" Results: %d", count) - results.Refresh() - } - case "Coinbase": - listBox.UnselectAll() + cached, hit := histCache.get(addr, label) + if hit { + listData.Set(cached) + results.Text = fmt.Sprintf(" Results: %d (updating)", len(cached)) + results.Refresh() + listBox.Refresh() + listBox.ScrollToTop() + } else { results.Text = " Scanning..." results.Refresh() - count := 0 data = nil listData.Set(nil) - entries = engram.Disk.Show_Transfers(zeroscid, true, true, true, 0, engram.Disk.Get_Height(), "", "", 0, 0) - - if entries != nil { - go func() { - for e := range entries { - var height string - var direction string - var stamp string - - entries[e].ProcessPayload() - - if entries[e].Coinbase { - direction = "Network" - timefmt := entries[e].Time - stamp = timefmt.Format("2006-01-02") - height = strconv.FormatUint(entries[e].Height, 10) - amount := globals.FormatMoney(entries[e].Amount) - txid = entries[e].TXID - - count += 1 - data = append(data, direction+";;;"+amount+";;;"+height+";;;"+stamp+";;;"+txid) - } - } + } - results.Text = fmt.Sprintf(" Results: %d", count) + go func() { + if engram.Disk == nil { + return + } - listData.Set(data) - - listBox.OnSelected = func(id widget.ListItemID) { - listBox.UnselectAll() - } + entries := fetch() + logger.Printf("[History] %s: entries=%d height=%d\n", label, len(entries), engram.Disk.Get_Height()) + var final []string + if hit { + // Rows are already on screen: build silently, swap once below. + final = streamHistoryRows(entries, build, 0, nil) + } else { + final = streamHistoryRows(entries, build, 50, func(snap []string) { fyne.Do(func() { - results.Refresh() - listBox.Refresh() - listBox.ScrollToBottom() + listData.Set(snap) }) - }() - } else { - results.Text = fmt.Sprintf(" Results: %d", count) - - fyne.Do(func() { - results.Refresh() }) } - case "Messages": - listBox.UnselectAll() - results.Text = " Scanning..." - results.Refresh() - count := 0 - data = nil - listData.Set(nil) - entries = engram.Disk.Get_Payments_DestinationPort(zeroscid, uint64(1337), 0) - - if entries != nil { - go func() { - for e := range entries { - var stamp string - var direction string - var comment string - - entries[e].ProcessPayload() - - timefmt := entries[e].Time - //stamp = string(timefmt.Format(time.RFC822)) - stamp = timefmt.Format("2006-01-02") - - temp := entries[e].Incoming - if !temp { - direction = "Sent " - } else { - direction = "Received" - } - if entries[e].Payload_RPC.HasValue(rpc.RPC_COMMENT, rpc.DataString) { - contact := "" - username := "" - if entries[e].Payload_RPC.HasValue(rpc.RPC_NEEDS_REPLYBACK_ADDRESS, rpc.DataString) { - contact = entries[e].Payload_RPC.Value(rpc.RPC_NEEDS_REPLYBACK_ADDRESS, rpc.DataString).(string) - if len(contact) > 10 { - username = contact[0:10] + ".." - } else { - username = contact - } - } - - comment = entries[e].Payload_RPC.Value(rpc.RPC_COMMENT, rpc.DataString).(string) - if len(comment) > 10 { - comment = comment[0:10] + ".." - } - - txid = entries[e].TXID - count += 1 - data = append(data, direction+";;;"+username+";;;"+comment+";;;"+stamp+";;;"+txid+";;;"+contact) - } - } - - results.Text = fmt.Sprintf(" Results: %d", count) + histCache.put(addr, label, final) - listData.Set(data) + fyne.Do(func() { + // After a cached paint, an identical rebuild is the common case; + // skipping the swap avoids flicker and keeps the scroll position. + if !hit || !slices.Equal(final, cached) { + listData.Set(final) + listBox.Refresh() + } + results.Text = fmt.Sprintf(" Results: %d", len(final)) + results.Refresh() + if !hit { + listBox.ScrollToTop() + } + }) + }() + } - listBox.OnSelected = func(id widget.ListItemID) { - split := strings.Split(data[id], ";;;") - overlay := session.Window.Canvas().Overlays() - overlay.Add( - container.NewStack( - &iframe{}, - canvas.NewRectangle(colors.DarkMatter), - ), - ) - overlay.Add(layoutHistoryDetail(split[4])) - listBox.UnselectAll() - listBox.Refresh() + menu.OnChanged = func(s string) { + switch s { + case "Normal": + load("Normal", + func() []rpc.Entry { + return engram.Disk.Show_Transfers(zeroscid, false, true, true, 0, engram.Disk.Get_Height(), "", "", 0, 0) + }, + historyRowNormal, + func(id widget.ListItemID) { + split := strings.Split(data[id], ";;;") + var zeroscid crypto.Hash + _, result := engram.Disk.Get_Payments_TXID(zeroscid, split[4]) + if result.TXID == "" { + label.Text = "---" + } else { + label.Text = result.TXID } + label.Refresh() - fyne.Do(func() { - results.Refresh() - listBox.Refresh() - listBox.ScrollToBottom() - }) - }() - } else { - results.Text = fmt.Sprintf(" Results: %d", count) - - fyne.Do(func() { - results.Refresh() + overlay := session.Window.Canvas().Overlays() + overlay.Add( + container.NewStack( + &iframe{}, + canvas.NewRectangle(colors.DarkMatter), + ), + ) + overlay.Add(layoutHistoryDetail(split[4])) + listBox.UnselectAll() + }) + case "Coinbase": + load("Coinbase", + func() []rpc.Entry { + return engram.Disk.Show_Transfers(zeroscid, true, true, true, 0, engram.Disk.Get_Height(), "", "", 0, 0) + }, + historyRowCoinbase, + func(id widget.ListItemID) { + listBox.UnselectAll() + }) + case "Messages": + load("Messages", + func() []rpc.Entry { + return engram.Disk.Get_Payments_DestinationPort(zeroscid, uint64(1337), 0) + }, + historyRowMessage, + func(id widget.ListItemID) { + split := strings.Split(data[id], ";;;") + overlay := session.Window.Canvas().Overlays() + overlay.Add( + container.NewStack( + &iframe{}, + canvas.NewRectangle(colors.DarkMatter), + ), + ) + overlay.Add(layoutHistoryDetail(split[4])) + listBox.UnselectAll() + listBox.Refresh() }) - } default: } @@ -9804,6 +9687,8 @@ func layoutHistory() fyne.CanvasObject { ), ) + menu.SetSelected("Normal") + return NewVScroll(layout) } From 2b709eb0de40092b5d1ca01942364db474149dbc Mon Sep 17 00:00:00 2001 From: Dirtybird99 <249585555+Dirtybird99@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:15:09 -0500 Subject: [PATCH 2/6] history: stop scanning the wallet on every row tap A row tap ran two full wallet scans under the exclusive account lock: one feeding a label that was never added to any container (dead since the detail overlay replaced the inline panel), and one inside layoutHistoryDetail to re-find an entry the list already had. Delete the dead scan and pass the entry into the detail view via a TXID-indexed copy of the rows on screen; the by-TXID fallback scan remains only for taps that land before the first fetch completes. Tap-to-detail no longer touches the wallet mutex, so it can no longer stall behind a wallet save. Co-Authored-By: Claude Fable 5 --- layouts.go | 63 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/layouts.go b/layouts.go index 5a27af4..acd7b1a 100644 --- a/layouts.go +++ b/layouts.go @@ -9410,21 +9410,18 @@ func layoutHistory() fyne.CanvasObject { var data []string var zeroscid crypto.Hash - view := "" + // TXID -> entry for the rows currently shown, published on the UI thread + // by load()'s final fyne.Do. Lets a row tap open the detail view without + // re-scanning the wallet under its exclusive lock. + var byTXID map[string]rpc.Entry header := canvas.NewText(" Transaction History", colors.Green) header.TextSize = 22 header.TextStyle = fyne.TextStyle{Bold: true} - details_header := canvas.NewText(" Transaction Detail", colors.Green) - details_header.TextSize = 22 - details_header.TextStyle = fyne.TextStyle{Bold: true} - frame := &iframe{} rectWidth := canvas.NewRectangle(color.Transparent) rectWidth.SetMinSize(fyne.NewSize(ui.MaxWidth, 10)) - rectWidth90 := canvas.NewRectangle(color.Transparent) - rectWidth90.SetMinSize(fyne.NewSize(ui.Width, 10)) heading := canvas.NewText("H I S T O R Y", colors.Gray) heading.TextSize = 16 @@ -9475,10 +9472,6 @@ func layoutHistory() fyne.CanvasObject { removeOverlays() } - label := canvas.NewText(view, colors.Account) - label.TextSize = 15 - label.TextStyle = fyne.TextStyle{Bold: true} - // load runs the (potentially heavy) transfer scan OFF the UI goroutine and // reveals rows newest-first in batches, so the first screenful paints almost // immediately instead of blocking on the full history. Revisits skip even @@ -9504,6 +9497,7 @@ func layoutHistory() fyne.CanvasObject { return } addr := engram.Disk.GetAddress().String() + byTXID = nil cached, hit := histCache.get(addr, label) if hit { @@ -9540,7 +9534,13 @@ func layoutHistory() fyne.CanvasObject { } histCache.put(addr, label, final) + m := make(map[string]rpc.Entry, len(entries)) + for _, e := range entries { + m[e.TXID] = e + } + fyne.Do(func() { + byTXID = m // After a cached paint, an identical rebuild is the common case; // skipping the swap avoids flicker and keeps the scroll position. if !hit || !slices.Equal(final, cached) { @@ -9566,14 +9566,6 @@ func layoutHistory() fyne.CanvasObject { historyRowNormal, func(id widget.ListItemID) { split := strings.Split(data[id], ";;;") - var zeroscid crypto.Hash - _, result := engram.Disk.Get_Payments_TXID(zeroscid, split[4]) - if result.TXID == "" { - label.Text = "---" - } else { - label.Text = result.TXID - } - label.Refresh() overlay := session.Window.Canvas().Overlays() overlay.Add( @@ -9582,7 +9574,11 @@ func layoutHistory() fyne.CanvasObject { canvas.NewRectangle(colors.DarkMatter), ), ) - overlay.Add(layoutHistoryDetail(split[4])) + var entry *rpc.Entry + if e, ok := byTXID[split[4]]; ok { + entry = &e + } + overlay.Add(layoutHistoryDetail(split[4], entry)) listBox.UnselectAll() }) case "Coinbase": @@ -9609,7 +9605,11 @@ func layoutHistory() fyne.CanvasObject { canvas.NewRectangle(colors.DarkMatter), ), ) - overlay.Add(layoutHistoryDetail(split[4])) + var entry *rpc.Entry + if e, ok := byTXID[split[4]]; ok { + entry = &e + } + overlay.Add(layoutHistoryDetail(split[4], entry)) listBox.UnselectAll() listBox.Refresh() }) @@ -9692,7 +9692,12 @@ func layoutHistory() fyne.CanvasObject { return NewVScroll(layout) } -func layoutHistoryDetail(txid string) fyne.CanvasObject { +// layoutHistoryDetail renders the detail overlay for one transfer. The caller +// passes the entry when it already holds it (the history list keeps a +// TXID-indexed copy of the rows it shows); the nil fallback re-scans the +// wallet by TXID and is only reachable while the list's first fetch is still +// in flight. +func layoutHistoryDetail(txid string, entry *rpc.Entry) fyne.CanvasObject { wSpacer := widget.NewLabel(" ") rectWidth := canvas.NewRectangle(color.Transparent) @@ -9832,8 +9837,18 @@ func layoutHistoryDetail(txid string) fyne.CanvasObject { layout.NewSpacer(), ) - var zeroscid crypto.Hash - _, details := engram.Disk.Get_Payments_TXID(zeroscid, txid) + var details rpc.Entry + if entry != nil { + details = *entry + // Stored entries can carry an undecoded payload; decode the local + // copy before display (mirrors historyRowMessage). + if len(details.Payload) > 0 { + details.ProcessPayload() + } + } else { + var zeroscid crypto.Hash + _, details = engram.Disk.Get_Payments_TXID(zeroscid, txid) + } stamp := string(details.Time.Format(time.RFC822)) height := strconv.FormatUint(details.Height, 10) From 17487860d03075d2c84241599028bc620a273b8a Mon Sep 17 00:00:00 2001 From: Dirtybird99 <249585555+Dirtybird99@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:15:40 -0500 Subject: [PATCH 3/6] mobile: do not build layouts twice on the back button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The back handler built layoutDashboard()/layoutMain() into session.LastDomain, immediately overwrote it with the current window content, then built the same layout again for SetContent. The first build was pure discarded work — and each dashboard build starts its own listeners, so it also double-registered those. Drop the discarded assignment (BUG_NOTES #13). Co-Authored-By: Claude Fable 5 --- main.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/main.go b/main.go index 9841e24..e0b0822 100644 --- a/main.go +++ b/main.go @@ -169,12 +169,10 @@ func main() { session.Window.SetContent(session.LastDomain) } else { if engram.Disk != nil { - session.LastDomain = layoutDashboard() session.LastDomain = session.Window.Content() session.Window.SetContent(layoutTransition()) session.Window.SetContent(layoutDashboard()) } else { - session.LastDomain = layoutMain() session.LastDomain = session.Window.Content() session.Window.SetContent(layoutTransition()) session.Window.SetContent(layoutMain()) From becfe724c9785b6135de805c90af5d60c3fa45a2 Mon Sep 17 00:00:00 2001 From: Dirtybird99 <249585555+Dirtybird99@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:16:44 -0500 Subject: [PATCH 4/6] history: drop the transition layout that never paints on this path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every nav site swaps SetContent(layoutTransition()) and then the real screen synchronously inside the same event handler; Fyne paints only after the handler returns, so the transition frame is never shown. On the History round trip and the mobile back handler that build was pure overhead (GIF re-decode + animation start per navigation) — and the back handler never removed its overlay, leaving the GIF animating unseen afterwards. Remove it from these paths only; layoutTransition itself stays (shared with showLoadingOverlay). Co-Authored-By: Claude Fable 5 --- layouts.go | 6 ++++-- main.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/layouts.go b/layouts.go index acd7b1a..32b5d2a 100644 --- a/layouts.go +++ b/layouts.go @@ -552,7 +552,9 @@ func layoutDashboard() fyne.CanvasObject { linkHistory := widget.NewHyperlinkWithStyle("View History", nil, fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) linkHistory.OnTapped = func() { session.LastDomain = session.Window.Content() - session.Window.SetContent(layoutTransition()) + // No layoutTransition here: both SetContent calls run in this event + // handler, so the transition frame never paints — it was only a GIF + // re-decode on the tap path. session.Window.SetContent(layoutHistory()) removeOverlays() } @@ -9467,7 +9469,7 @@ func layoutHistory() fyne.CanvasObject { linkBack := widget.NewHyperlinkWithStyle("Back to Dashboard", nil, fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) linkBack.OnTapped = func() { session.LastDomain = session.Window.Content() - session.Window.SetContent(layoutTransition()) + // No layoutTransition: it never paints (see linkHistory in layoutDashboard). session.Window.SetContent(layoutDashboard()) removeOverlays() } diff --git a/main.go b/main.go index e0b0822..0214ffb 100644 --- a/main.go +++ b/main.go @@ -164,17 +164,17 @@ func main() { // Intercept mobile back button event session.Window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { if ev.Name == mobile.KeyBack { + // No layoutTransition on the back path: both SetContent calls run + // inside this key handler, so the transition frame never paints — + // it only re-decoded the loading GIF and left it animating unseen. if session.LastDomain != nil { - session.Window.SetContent(layoutTransition()) session.Window.SetContent(session.LastDomain) } else { if engram.Disk != nil { session.LastDomain = session.Window.Content() - session.Window.SetContent(layoutTransition()) session.Window.SetContent(layoutDashboard()) } else { session.LastDomain = session.Window.Content() - session.Window.SetContent(layoutTransition()) session.Window.SetContent(layoutMain()) } } From 9637707d6f98743186803231ea9bc56f6b04478f Mon Sep 17 00:00:00 2001 From: Dirtybird99 <249585555+Dirtybird99@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:20:37 -0500 Subject: [PATCH 5/6] history: ignore stale in-flight loads after a view switch If the user switches views while a fetch is still stalled behind a wallet save, the older goroutine could finish later and repaint the previous view's rows and count under the new view's heading (a latent flaw in the original async load too). A per-screen generation counter, bumped on every load and checked inside each fyne.Do, makes superseded fetches drop their UI updates; their result is still cached, since the cache is keyed per view. Co-Authored-By: Claude Fable 5 --- layouts.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/layouts.go b/layouts.go index 32b5d2a..b014384 100644 --- a/layouts.go +++ b/layouts.go @@ -9417,6 +9417,12 @@ func layoutHistory() fyne.CanvasObject { // re-scanning the wallet under its exclusive lock. var byTXID map[string]rpc.Entry + // loadGen invalidates in-flight loads when the user switches views, so a + // slow older fetch (stalled behind a wallet save) can never repaint the + // screen after a newer view took over. Only touched on the UI thread + // (load itself and fyne.Do callbacks). + var loadGen int + header := canvas.NewText(" Transaction History", colors.Green) header.TextSize = 22 header.TextStyle = fyne.TextStyle{Bold: true} @@ -9500,6 +9506,8 @@ func layoutHistory() fyne.CanvasObject { } addr := engram.Disk.GetAddress().String() byTXID = nil + loadGen++ + gen := loadGen cached, hit := histCache.get(addr, label) if hit { @@ -9530,10 +9538,15 @@ func layoutHistory() fyne.CanvasObject { } else { final = streamHistoryRows(entries, build, 50, func(snap []string) { fyne.Do(func() { + if gen != loadGen { + return + } listData.Set(snap) }) }) } + // Cache even a superseded fetch: it is still the freshest full + // build of this view and the cache is keyed per view. histCache.put(addr, label, final) m := make(map[string]rpc.Entry, len(entries)) @@ -9542,6 +9555,9 @@ func layoutHistory() fyne.CanvasObject { } fyne.Do(func() { + if gen != loadGen { + return + } byTXID = m // After a cached paint, an identical rebuild is the common case; // skipping the swap avoids flicker and keeps the scroll position. From 4e47dd697a044a0180d886f5c5340855b188d8c9 Mon Sep 17 00:00:00 2001 From: Dirtybird99 <249585555+Dirtybird99@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:17:20 -0500 Subject: [PATCH 6/6] =?UTF-8?q?history:=20fix=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20snapshot=20the=20wallet=20across=20the=20async=20lo?= =?UTF-8?q?ad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the branch surfaced three defects: - Crash: load()'s goroutine re-dereferenced the global engram.Disk after fetch() returned. Signing out while a fetch sat behind the wallet's save lock nils the global, and the goroutine then panicked — an unrecovered panic in a bare goroutine kills the app. The wallet is now snapshotted once on the UI thread and threaded through fetch, so the goroutine never reads the global (same pattern as warmHistoryCache). - Privacy: the unconditional histCache.put could repopulate the cache seconds after closeWallet() cleared it. The put is now guarded by a wallet-identity re-check. - Parity: multi-payload transactions repeat a TXID; the byTXID map kept the last entry while the old tap-time scan returned the first match. The map now keeps the first, matching the old detail view. Co-Authored-By: Claude Fable 5 --- layouts.go | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/layouts.go b/layouts.go index b014384..c6bb263 100644 --- a/layouts.go +++ b/layouts.go @@ -9489,7 +9489,7 @@ func layoutHistory() fyne.CanvasObject { // never wait on it twice. Per Fyne v2.6, the non-binding widget calls // (Text/Refresh/scroll) must run inside fyne.Do; listData.Set is kept on // the UI thread too so it never races OnSelected reading data[id]. - load := func(label string, fetch func() []rpc.Entry, build func(rpc.Entry) (string, bool), onSel func(widget.ListItemID)) { + load := func(label string, fetch func(w *walletapi.Wallet_Disk) []rpc.Entry, build func(rpc.Entry) (string, bool), onSel func(widget.ListItemID)) { listBox.UnselectAll() listBox.OnSelected = func(id widget.ListItemID) { // The background refresh can swap the rows between the tap and @@ -9501,10 +9501,15 @@ func layoutHistory() fyne.CanvasObject { onSel(id) } - if engram.Disk == nil { + // Snapshot the wallet for the whole load: the goroutine below may + // outlive a sign-out (closeWallet sets engram.Disk to nil while the + // fetch waits on the wallet's save lock), so it must never touch the + // global again — a nil deref in a bare goroutine would kill the app. + w := engram.Disk + if w == nil { return } - addr := engram.Disk.GetAddress().String() + addr := w.GetAddress().String() byTXID = nil loadGen++ gen := loadGen @@ -9524,12 +9529,8 @@ func layoutHistory() fyne.CanvasObject { } go func() { - if engram.Disk == nil { - return - } - - entries := fetch() - logger.Printf("[History] %s: entries=%d height=%d\n", label, len(entries), engram.Disk.Get_Height()) + entries := fetch(w) + logger.Printf("[History] %s: entries=%d height=%d\n", label, len(entries), w.Get_Height()) var final []string if hit { @@ -9545,13 +9546,21 @@ func layoutHistory() fyne.CanvasObject { }) }) } + if engram.Disk != w { // wallet closed or switched mid-fetch: + return // do not re-populate the cache closeWallet just cleared + } // Cache even a superseded fetch: it is still the freshest full // build of this view and the cache is keyed per view. histCache.put(addr, label, final) m := make(map[string]rpc.Entry, len(entries)) for _, e := range entries { - m[e.TXID] = e + // Keep the FIRST entry per TXID: multi-payload transactions + // repeat a TXID, and the old tap-time Get_Payments_TXID scan + // returned the first match in slice order. + if _, ok := m[e.TXID]; !ok { + m[e.TXID] = e + } } fyne.Do(func() { @@ -9578,8 +9587,8 @@ func layoutHistory() fyne.CanvasObject { switch s { case "Normal": load("Normal", - func() []rpc.Entry { - return engram.Disk.Show_Transfers(zeroscid, false, true, true, 0, engram.Disk.Get_Height(), "", "", 0, 0) + func(w *walletapi.Wallet_Disk) []rpc.Entry { + return w.Show_Transfers(zeroscid, false, true, true, 0, w.Get_Height(), "", "", 0, 0) }, historyRowNormal, func(id widget.ListItemID) { @@ -9601,8 +9610,8 @@ func layoutHistory() fyne.CanvasObject { }) case "Coinbase": load("Coinbase", - func() []rpc.Entry { - return engram.Disk.Show_Transfers(zeroscid, true, true, true, 0, engram.Disk.Get_Height(), "", "", 0, 0) + func(w *walletapi.Wallet_Disk) []rpc.Entry { + return w.Show_Transfers(zeroscid, true, true, true, 0, w.Get_Height(), "", "", 0, 0) }, historyRowCoinbase, func(id widget.ListItemID) { @@ -9610,8 +9619,8 @@ func layoutHistory() fyne.CanvasObject { }) case "Messages": load("Messages", - func() []rpc.Entry { - return engram.Disk.Get_Payments_DestinationPort(zeroscid, uint64(1337), 0) + func(w *walletapi.Wallet_Disk) []rpc.Entry { + return w.Get_Payments_DestinationPort(zeroscid, uint64(1337), 0) }, historyRowMessage, func(id widget.ListItemID) {