diff --git a/.github/workflows/crank.yml b/.github/workflows/crank.yml new file mode 100644 index 0000000..352734c --- /dev/null +++ b/.github/workflows/crank.yml @@ -0,0 +1,57 @@ +name: Crank + +# Path-filtered: only fires when the crank service or its SDK dep change. +# Branch-protection should NOT require this lane until the canary launch — +# it's advisory while the service is being shaken out. +on: + push: + branches: [main] + paths: + - "services/crank/**" + - "sdk/**" + - ".github/workflows/crank.yml" + pull_request: + branches: [main] + paths: + - "services/crank/**" + - "sdk/**" + - ".github/workflows/crank.yml" + +concurrency: + group: crank-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + crank: + name: crank · typecheck + test (advisory) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 20 + + - name: Enable Corepack (resolves pnpm via packageManager) + run: | + corepack enable + corepack prepare pnpm@9.12.0 --activate + pnpm --version + + - name: pnpm install + run: pnpm install --no-frozen-lockfile + + - name: TypeScript typecheck + run: pnpm --filter @roundfi/crank typecheck + + - name: Vitest + # Four pure-function spec files: classifyError, healthServer, + # settleDefaults (eligibility + INFRA classification), and + # pollingLoop (lease + RPC + per-pool isolation). The actual + # settle_default CPI is integration-level — bankrun/litesvm + # lanes cover that. + run: pnpm --filter @roundfi/crank test diff --git a/FREEZE.md b/FREEZE.md index ed78521..fc68bb9 100644 --- a/FREEZE.md +++ b/FREEZE.md @@ -67,6 +67,7 @@ The `.github/workflows/freeze-enforcement.yml` workflow automates checks #1 and | 2026-05-26 | Founder | PR #412 (`[FREEZE-EXCEPTION]`) | ECO-002/003 (item 5 — additive economics surface): `StressLabConfig.installmentUsdc` opt-in independent installment (presets byte-identical) + `FrameMetrics.overCollection`; ECO-003 "16.7% breakpoint" re-derived (artifact of retracted premises; gone under on-chain $600). Founder-approved. | | 2026-05-26 | Founder | PR #413 (`[FREEZE-EXCEPTION]`) | Wallet receive QR (item 7 — bounded front-end visibility): `/carteira` Receive modal QR code. No protocol behavior change. Founder-approved. | | 2026-05-30 | Founder | PR #402 (`[FREEZE-EXCEPTION]`) | Canary grace gate (`pre-ceremony-beta` §6.3 Opção B): opt-in cargo feature `devnet-canary` lowers `GRACE_PERIOD_SECS` 604_800→86_400 (exactly the SEV-002 floor) for the 48h-cycle Canary phase. Feature is NOT default; SEV-002 floor guard runs in both modes (`86_400 >= 86_400`). No mainnet behavior change. Founder-approved. | +| 2026-06-01 | Founder | PR #441 (`[FREEZE-EXCEPTION]`) | Audit findings remediation (item 2): new `services/crank/` daemon closes the 6 gaps from the internal canary-readiness audit (settle_default firing, continuous polling, /health 503 contract, RPC liveness probe, typed `pool.all()` decoder, INFRA/LOGIC error classifier). Postgres lease mirrors the indexer's `reconciler_lease` pattern. No on-chain change, no schema change, no consumer wired yet — daemon doesn't run until Railway is pointed at it. Founder-approved. | ## Self-imposed escape valve diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f92ad2..688e668 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,37 @@ importers: specifier: ^5.6.2 version: 5.9.3 + services/crank: + dependencies: + '@coral-xyz/anchor': + specifier: ^0.30.1 + version: 0.30.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@prisma/client': + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) + '@roundfi/sdk': + specifier: workspace:* + version: link:../../sdk + '@solana/web3.js': + specifier: ^1.95.3 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bs58: + specifier: ^6.0.0 + version: 6.0.0 + devDependencies: + '@types/node': + specifier: ^22.7.5 + version: 22.19.19 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@22.19.19)(terser@5.46.2) + services/indexer: dependencies: '@prisma/client': @@ -306,102 +337,204 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -414,6 +547,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -426,6 +565,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -438,24 +583,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -847,6 +1016,131 @@ packages: '@types/react': optional: true + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -1452,6 +1746,12 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1467,6 +1767,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} @@ -1493,6 +1796,35 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@wallet-standard/app@1.1.0': resolution: {integrity: sha512-3CijvrO9utx598kjr45hTbbeeykQrQfKmSnxeWOgU25TOEpvcipD/bYDQWIqUv1Oc6KK4YStokSMu/FBNecGUQ==} engines: {node: '>=16'} @@ -1603,6 +1935,10 @@ packages: assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1698,6 +2034,10 @@ packages: resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -1717,6 +2057,10 @@ packages: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1728,6 +2072,10 @@ packages: check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1845,6 +2193,10 @@ packages: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + delay@5.0.0: resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} engines: {node: '>=10'} @@ -1913,12 +2265,20 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} es6-promisify@5.0.0: resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1935,6 +2295,9 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1949,6 +2312,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -2346,12 +2713,18 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -2619,9 +2992,16 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2846,6 +3226,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rpc-websockets@9.3.8: resolution: {integrity: sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==} @@ -2918,6 +3303,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -2982,6 +3370,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -2997,6 +3388,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-chain@2.2.5: resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} @@ -3082,10 +3476,28 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3148,6 +3560,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -3184,6 +3599,67 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -3207,6 +3683,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + workerpool@6.5.1: resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} @@ -3441,81 +3922,150 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -3869,6 +4419,81 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + '@sinclair/typebox@0.27.10': {} '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.8(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.29.0)(@types/react@19.2.15)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@5.9.3)': @@ -4678,7 +5303,11 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.19.19 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -4694,6 +5323,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + '@types/node@25.9.1': dependencies: undici-types: 7.24.6 @@ -4710,11 +5343,11 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.19.19 '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.19.19 '@types/yargs-parser@21.0.3': {} @@ -4722,6 +5355,46 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19)(terser@5.46.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19)(terser@5.46.2) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@wallet-standard/app@1.1.0': dependencies: '@wallet-standard/base': 1.1.0 @@ -4814,6 +5487,8 @@ snapshots: assertion-error@1.1.0: {} + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} autoprefixer@10.5.0(postcss@8.5.15): @@ -4912,6 +5587,8 @@ snapshots: node-gyp-build: 4.8.4 optional: true + cac@6.7.14: {} + camelcase-css@2.0.1: {} camelcase@5.3.1: {} @@ -4930,6 +5607,14 @@ snapshots: pathval: 1.1.1 type-detect: 4.1.0 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4941,6 +5626,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -5061,6 +5748,8 @@ snapshots: dependencies: type-detect: 4.1.0 + deep-eql@5.0.2: {} + delay@5.0.0: {} depd@2.0.0: {} @@ -5105,12 +5794,40 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es6-promise@4.2.8: {} es6-promisify@5.0.0: dependencies: es6-promise: 4.2.8 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -5146,6 +5863,10 @@ snapshots: escape-string-regexp@4.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -5154,6 +5875,8 @@ snapshots: eventemitter3@5.0.4: {} + expect-type@1.3.0: {} + exponential-backoff@3.1.3: {} eyes@0.1.8: {} @@ -5551,6 +6274,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -5559,6 +6284,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-error@1.3.6: {} makeerror@1.0.12: @@ -5923,8 +6652,12 @@ snapshots: path-parse@1.0.7: {} + pathe@1.1.2: {} + pathval@1.1.1: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6158,6 +6891,37 @@ snapshots: rfdc@1.4.1: {} + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + rpc-websockets@9.3.8: dependencies: '@swc/helpers': 0.5.21 @@ -6270,6 +7034,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -6328,6 +7094,8 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -6338,6 +7106,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + stream-chain@2.2.5: {} stream-json@1.9.1: @@ -6442,11 +7212,21 @@ snapshots: throat@5.0.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -6494,6 +7274,8 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + undici-types@7.24.6: {} undici-types@8.3.0: {} @@ -6519,6 +7301,69 @@ snapshots: uuid@8.3.2: {} + vite-node@2.1.9(@types/node@22.19.19)(terser@5.46.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19)(terser@5.46.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19)(terser@5.46.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + terser: 5.46.2 + + vitest@2.1.9(@types/node@22.19.19)(terser@5.46.2): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)(terser@5.46.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19)(terser@5.46.2) + vite-node: 2.1.9(@types/node@22.19.19)(terser@5.46.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vlq@1.0.1: {} walker@1.0.8: @@ -6540,6 +7385,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + workerpool@6.5.1: {} wrap-ansi@6.2.0: diff --git a/services/crank/README.md b/services/crank/README.md new file mode 100644 index 0000000..40eee5c --- /dev/null +++ b/services/crank/README.md @@ -0,0 +1,121 @@ +# @roundfi/crank + +Daemon that keeps RoundFi pools moving forward on-chain. Long-running +companion to the indexer; never user-facing. + +## Why this service exists + +The Canary launch needs **48h cycles + 24h grace** — well beyond any +human-driven run loop. `services/orchestrator` is the demo runner +(single-shot `runCycle`), not a daemon. The on-chain program will not +advance a pool past a defaulted member without `settle_default` being +explicitly called; without continuous cranking, every other member's +score gets held hostage to one missing tx. + +Closes the 6 gaps from the canary readiness audit (May 2026): + +| Gap | Symptom if unfixed | Where | +| --- | ------------------------------------------------------ | --------------------- | +| 1 | Defaulted members stall the pool indefinitely | `settleDefaults.ts` | +| 2 | Cycles only advance when a human runs a script | `pollingLoop.ts` | +| 3 | No way for UptimeRobot / Railway to see degradation | `healthServer.ts` | +| 4 | Flaky RPC → silent "no pools" → /health stays ok | `rpcHealth.ts` | +| 5 | Hardcoded `memcmp` offset desyncs after struct edits | `fetchActivePools.ts` | +| 6 | INFRA blip indistinguishable from on-chain LOGIC error | `classifyError.ts` | + +## Env vars + +| Name | Required | Default | Notes | +| ------------------------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `SOLANA_RPC_URL` | yes | — | Devnet/mainnet RPC. Single endpoint; multi-RPC quorum is the indexer's job, not the crank's. | +| `ROUNDFI_CORE_PROGRAM_ID` | yes | — | Pinned per cluster — devnet ≠ mainnet. | +| `ROUNDFI_REPUTATION_PROGRAM_ID` | yes | — | Pinned per cluster. | +| `CRANK_KEYPAIR` | yes | — | Base58 secret key (devnet/local) OR JSON byte array (Solana CLI export). Pays gas on `settle_default`. **Must hold SOL.** | +| `POLL_INTERVAL_MS` | no | `60000` | 60s catches grace-deadline crossings within a minute without melting RPC quota. | +| `HEALTH_PORT` | no | `3000` | UptimeRobot hits `/health` on this port. | +| `CRANK_LEASE_ENABLED` | no | `false` | Set `true` when running ≥2 replicas (Railway autoscale, blue-green). Single-instance dev leaves it off. | +| `DATABASE_URL` | conditional | — | Required iff `CRANK_LEASE_ENABLED=true`. Reuses the indexer's Postgres (Prisma `reconciler_lease` table; ROW id `crank-main`). | + +## Local run + +```sh +pnpm --filter @roundfi/crank install +pnpm --filter @roundfi/crank dev # tsx watch +``` + +For a one-shot tick (no loop): + +```sh +SOLANA_RPC_URL=https://api.devnet.solana.com \ +ROUNDFI_CORE_PROGRAM_ID= \ +ROUNDFI_REPUTATION_PROGRAM_ID= \ +CRANK_KEYPAIR= \ +pnpm --filter @roundfi/crank start +``` + +## /health contract + +UptimeRobot keys off HTTP status (not JSON body), so the mapping is +load-bearing: + +| Body status | HTTP | When | +| ----------- | ---- | ---------------------------------------------------- | +| `starting` | 200 | First 5 min of process life (Railway redeploy grace) | +| `ok` | 200 | Last successful tick was < 5 min ago | +| `degraded` | 503 | Past boot grace AND no successful tick in 5 min | + +A "successful tick" is: lease held (or no-op lease) → RPC reachable → +`fetchActivePools` returned → per-pool catch finished. A LOGIC failure +in one pool does NOT downgrade the tick; the pool's own log line is the +escalation path. + +## INFRA_FAILURE vs PAYMENT_MISSED + +The on-chain `settle_default` instruction has no `reason` arg (would +require a core PR + new audit). The crank emits the classification in +the structured log so the indexer + admin score-contestation UI can +flip the verdict off-chain: + +- `INFRA_FAILURE`: the crank's RPC was down at or before this member's + grace deadline. The missed contribution isn't necessarily theirs to + own — eligible for off-chain score reversal. +- `PAYMENT_MISSED`: RPC was healthy across the deadline. Member simply + didn't pay. + +## Multi-replica (Postgres lease) + +Mirrors the indexer's reconciler lease pattern (Wave 9.2, PR #431): +single Postgres row `reconciler_lease.id = 'crank-main'`, TTL 90s +(1.5× the default poll interval). The lease holder advances its own +`acquiredAt`; followers' `tryAcquire` matches the WHERE-clause cutoff +and skips the tick with an `event_type: tick.no_lease` log. + +Leaving `CRANK_LEASE_ENABLED=false` avoids the Postgres coupling in +dev / single-replica deployments. + +## Deployment notes + +- **Railway**: 1 worker by default. If you scale to ≥2, set + `CRANK_LEASE_ENABLED=true` + `DATABASE_URL` and the lease will gate + the active worker. +- **UptimeRobot**: HTTP keyword monitor on `/health`, alert on `5xx`. + Don't key off the JSON body — the 503/200 split is the contract. +- **Cranker keypair funding**: ~0.01 SOL per `settle_default` is the + rough upper bound observed on devnet (one CPI to reputation, one + burn, ATA writes). Top up before each cycle's grace window opens. + +## Tests + +```sh +pnpm --filter @roundfi/crank test +``` + +Covers the four pure surfaces: + +- `classifyError.spec.ts` — INFRA/LOGIC/UNKNOWN bucket boundaries. +- `healthServer.spec.ts` — starting/ok/degraded transitions, 503 mapping. +- `settleDefaults.spec.ts` — eligibility + INFRA_FAILURE classification. +- `pollingLoop.spec.ts` — lease gating, RPC skip, per-pool isolation. + +The actual `settle_default` CPI is integration-level — covered in the +bankrun / litesvm lanes, not here. diff --git a/services/crank/package.json b/services/crank/package.json new file mode 100644 index 0000000..552ea04 --- /dev/null +++ b/services/crank/package.json @@ -0,0 +1,28 @@ +{ + "name": "@roundfi/crank", + "version": "0.1.0", + "private": true, + "description": "RoundFi crank — daemon that advances pool lifecycle on-chain. Polls active pools, fires settle_default when a member misses the grace deadline, and emits structured events for the indexer/admin to surface (incl. INFRA_FAILURE vs PAYMENT_MISSED classification for off-chain score contestation). Required for Canary: ciclos de 48h + grace de 24h precisam de cranking automático contínuo.", + "license": "Apache-2.0", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json", + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "test": "vitest run" + }, + "dependencies": { + "@roundfi/sdk": "workspace:*", + "@coral-xyz/anchor": "^0.30.1", + "@solana/web3.js": "^1.95.3", + "@prisma/client": "^5.22.0", + "bs58": "^6.0.0" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "tsx": "^4.19.2", + "typescript": "^5.6.3", + "vitest": "^2.1.4" + } +} diff --git a/services/crank/src/classifyError.ts b/services/crank/src/classifyError.ts new file mode 100644 index 0000000..4ea8a0a --- /dev/null +++ b/services/crank/src/classifyError.ts @@ -0,0 +1,78 @@ +/** + * Classify an error from an on-chain action into operational buckets, + * so the crank knows whether to retry silently (INFRA) or escalate + * (LOGIC). Without this split every error gets the same treatment and + * an on-chain constraint violation looks identical to an RPC blip. + * + * The classification is deliberately string-pattern based on the error + * message — this is the lowest-friction way that works across the + * Anchor / web3.js / node:fetch error surface (each lib throws a + * different Error subclass). When a new pattern surfaces in prod logs, + * add it here with a comment naming the prod incident. + */ + +export type ErrorKind = "INFRA" | "LOGIC" | "UNKNOWN"; + +/** + * Returns: + * - INFRA: network / RPC / blockhash / timeout — retry on next tick + * - LOGIC: on-chain custom error / Anchor constraint — needs eng + * - UNKNOWN: catch-all; surfaces in alert so we can extend the rules + */ +export function classifyError(err: unknown): ErrorKind { + const msg = errorMessage(err).toLowerCase(); + + // ── INFRA: retry-safe network/blockchain ephemera ────────────────── + if ( + msg.includes("timeout") || + msg.includes("etimedout") || + msg.includes("econnreset") || + msg.includes("econnrefused") || + msg.includes("enotfound") || + msg.includes("fetch failed") || + msg.includes("network request failed") || + msg.includes("socket hang up") || + msg.includes("blockhash not found") || + msg.includes("transaction was not confirmed") || + msg.includes("node is behind") || + msg.includes("rate limit") || + msg.includes("429") || + msg.includes("503") + ) { + return "INFRA"; + } + + // ── LOGIC: on-chain rejection — retry does NOT help ─────────────── + if ( + msg.includes("custom program error") || + msg.includes("anchorerror") || + msg.includes("error code:") || + msg.includes("constraint") || + msg.includes("simulation failed") || + msg.includes("instruction error") || + // Common roundfi-core / reputation guards (already audited surface) + msg.includes("graceperiodnotelapsed") || + msg.includes("membernotbehind") || + msg.includes("wrongcycle") || + msg.includes("poolnotactive") || + msg.includes("alreadycontributed") || + msg.includes("cooldownactive") + ) { + return "LOGIC"; + } + + return "UNKNOWN"; +} + +/** Defensive: error objects can be anything thrown — incl. null / undefined. */ +function errorMessage(err: unknown): string { + if (err == null) return ""; + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + try { + // JSON.stringify can return undefined (e.g. for symbols); fall back. + return JSON.stringify(err) ?? String(err); + } catch { + return String(err); + } +} diff --git a/services/crank/src/crankState.ts b/services/crank/src/crankState.ts new file mode 100644 index 0000000..a8f31e7 --- /dev/null +++ b/services/crank/src/crankState.ts @@ -0,0 +1,64 @@ +/** + * Shared in-process state for the crank — visible to the polling loop, + * the health endpoint, and the default classifier. + * + * Kept in a tiny module (not globals on `globalThis`) so: + * - tests can import and reset between cases, + * - the health server, the loop, and settleDefaults all read/write + * through the same surface (no risk of two copies drifting), + * - intent is explicit at call sites (`crankState.markCycleSuccess()` + * reads better than a bare `lastSuccessfulRun = new Date()`). + * + * State semantics: + * bootAt: set once at process start; used by /health to + * emit `starting` (not `degraded`) during the + * first 5 min so Railway redeploys don't trip + * UptimeRobot every time. + * lastSuccessfulRun: advanced ONLY when a full polling tick + * completed without a fatal error AND the RPC + * health check passed. Stays null until then. + * rpcDownSince: set when the RPC health check first fails; + * cleared the next tick the RPC is reachable. + * Drives the INFRA_FAILURE vs PAYMENT_MISSED + * classification in settleDefaults — if the + * crank was unreachable across a member's grace + * deadline, the default is not the member's + * fault and the score contestation can use this + * to flip the verdict off-chain. + */ + +export interface CrankState { + bootAt: Date; + lastSuccessfulRun: Date | null; + rpcDownSince: Date | null; +} + +function makeInitialState(): CrankState { + return { + bootAt: new Date(), + lastSuccessfulRun: null, + rpcDownSince: null, + }; +} + +let state: CrankState = makeInitialState(); + +export const crankState = { + /** Read-only snapshot. Don't mutate; use the setters below. */ + get snapshot(): Readonly { + return state; + }, + markCycleSuccess(): void { + state.lastSuccessfulRun = new Date(); + }, + markRpcDown(): void { + if (!state.rpcDownSince) state.rpcDownSince = new Date(); + }, + markRpcUp(): void { + state.rpcDownSince = null; + }, + /** TEST-ONLY: reset between cases. Not exported via index.ts. */ + __resetForTest(): void { + state = makeInitialState(); + }, +}; diff --git a/services/crank/src/fetchActivePools.ts b/services/crank/src/fetchActivePools.ts new file mode 100644 index 0000000..3f385fb --- /dev/null +++ b/services/crank/src/fetchActivePools.ts @@ -0,0 +1,68 @@ +/** + * Fetch the set of active pools the crank should consider this tick — + * Gap 5 of the canary audit. + * + * Mirrors `services/orchestrator/src/indexer.ts:listAllPools` (PR #N/A, + * pre-canary): Anchor's typed `pool.all()` decoder (via + * `client.programs.core.account.pool.all()`) is the right tool here — + * it goes through `getProgramAccounts` under the hood but uses the + * 8-byte discriminator filter from the IDL, so it's robust to field + * reordering inside the Pool struct. A hardcoded `memcmp` with a + * field-offset would silently desync after any struct edit. + * + * We deliberately do NOT import from `@roundfi/orchestrator` (would pull + * in the demo runCycle harness + its prisma deps the crank doesn't need). + * + * We still warn loud when the call returns zero pools, because the + * audit's worst-case scenario (offsets wrong, silent zero) can still + * manifest in different ways — e.g. wrong program id from a stale env + * var, RPC pointed at the wrong cluster. A 0-pool result is rarely + * benign in production; surface it instead of silently sleeping. + */ + +import type { PublicKey } from "@solana/web3.js"; +import type { PoolView, RoundFiClient } from "@roundfi/sdk"; +import { fetchPool } from "@roundfi/sdk"; + +import { logger } from "./logger.js"; + +export async function fetchActivePools(client: RoundFiClient): Promise { + const pools = await listAllPools(client); + const active = pools.filter((p) => p.status === "Active"); + + if (active.length === 0) { + logger.warn( + { + event_type: "pools.empty", + totalPools: pools.length, + statuses: pools.map((p) => p.status), + }, + "No active pools — verify program id + RPC cluster point at the right deployment", + ); + } + + return active; +} + +/** + * Enumerate every Pool account owned by the core program. Anchor's + * typed account decoder handles discriminator + IDL deserialization; + * we re-normalize each via the SDK's `fetchPool` so the returned + * `PoolView` shape matches the rest of the crank's expectations. + * + * `client.programs.core.account.pool.all` is `any`-shaped in the + * generated client surface — the cast mirrors what indexer.ts does and + * keeps us insulated from anchor-ts's surface drift. + */ +async function listAllPools(client: RoundFiClient): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const accounts = (await (client.programs.core.account as any).pool.all()) as Array<{ + publicKey: PublicKey; + }>; + const pools: PoolView[] = []; + for (const entry of accounts) { + const p = await fetchPool(client, entry.publicKey); + if (p) pools.push(p); + } + return pools; +} diff --git a/services/crank/src/healthServer.ts b/services/crank/src/healthServer.ts new file mode 100644 index 0000000..44dc96f --- /dev/null +++ b/services/crank/src/healthServer.ts @@ -0,0 +1,114 @@ +/** + * /health endpoint — Gap 3 of the canary audit. + * + * Used by UptimeRobot (or any HTTP poller) to detect crank degradation. + * Returns one of three states: + * + * starting (HTTP 200): process is in its first 5 minutes — common + * on Railway redeploys, so we don't trip the + * alert on every restart. + * ok (HTTP 200): lastSuccessfulRun is recent (< 5 min ago). + * degraded (HTTP 503): past the boot grace AND no successful tick + * in the last 5 min. Caller should alert. + * + * 503 vs 200 is the right shape for UptimeRobot: it treats 5xx as + * down by default. A 200 with `{status: "degraded"}` would require a + * keyword check. + * + * Why node:http instead of fastify/express: one route, no schema, no + * middleware. Adding a dep here would dwarf the implementation. + */ + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +import { crankState } from "./crankState.js"; +import { logger } from "./logger.js"; + +/** First 5 min of process life report `starting` (Railway redeploy grace). */ +const BOOT_GRACE_MS = 5 * 60 * 1000; +/** A tick older than this means the crank is degraded. */ +const STALE_TICK_MS = 5 * 60 * 1000; + +export type HealthStatus = "starting" | "ok" | "degraded"; + +export interface HealthBody { + status: HealthStatus; + bootAt: string; + lastRun: string | null; + rpcDownSince: string | null; + /** Seconds since the last successful tick — null when there's none yet. */ + secondsSinceLastRun: number | null; +} + +export function computeHealth(now: Date = new Date()): { status: HealthStatus; body: HealthBody } { + const snap = crankState.snapshot; + const sinceBoot = now.getTime() - snap.bootAt.getTime(); + const sinceLastRun = snap.lastSuccessfulRun + ? now.getTime() - snap.lastSuccessfulRun.getTime() + : null; + + let status: HealthStatus; + if (sinceBoot < BOOT_GRACE_MS) { + status = "starting"; + } else if (sinceLastRun !== null && sinceLastRun < STALE_TICK_MS) { + status = "ok"; + } else { + status = "degraded"; + } + + return { + status, + body: { + status, + bootAt: snap.bootAt.toISOString(), + lastRun: snap.lastSuccessfulRun?.toISOString() ?? null, + rpcDownSince: snap.rpcDownSince?.toISOString() ?? null, + secondsSinceLastRun: sinceLastRun !== null ? Math.floor(sinceLastRun / 1000) : null, + }, + }; +} + +function handleRequest(req: IncomingMessage, res: ServerResponse): void { + // Only one route — anything else is 404. + if (req.url !== "/health" && req.url !== "/") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not_found" })); + return; + } + const { status, body } = computeHealth(); + const code = status === "degraded" ? 503 : 200; + res.writeHead(code, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +export interface HealthServerHandle { + server: Server; + close: () => Promise; +} + +/** + * Start the health server. Binds to 0.0.0.0 by default so Railway + * (and any container orchestrator) can reach it. + */ +export function startHealthServer( + opts: { + port?: number; + host?: string; + } = {}, +): Promise { + const port = opts.port ?? Number(process.env.HEALTH_PORT ?? 3000); + const host = opts.host ?? "0.0.0.0"; + + return new Promise((resolve, reject) => { + const server = createServer(handleRequest); + server.once("error", reject); + server.listen(port, host, () => { + logger.info({ event_type: "health.listen", port, host }, "Health server listening"); + resolve({ + server, + close: () => + new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), + }); + }); + }); +} diff --git a/services/crank/src/index.ts b/services/crank/src/index.ts new file mode 100644 index 0000000..925cc76 --- /dev/null +++ b/services/crank/src/index.ts @@ -0,0 +1,171 @@ +/** + * RoundFi crank — entrypoint. + * + * Boots the health server, optional Postgres lease, and the polling + * loop. Wires SIGTERM/SIGINT to a graceful shutdown so Railway + * redeploys don't leave a half-finished settle in the air. + * + * Env vars (see README): + * SOLANA_RPC_URL required — devnet/mainnet RPC + * ROUNDFI_CORE_PROGRAM_ID required + * ROUNDFI_REPUTATION_PROGRAM_ID required + * CRANK_KEYPAIR required — base58 of the cranker keypair + * (pays gas on settle_default; must hold SOL) + * ROUNDFI_IDL_DIR default `./target/idl` — directory with + * roundfi_core.json + roundfi_reputation.json + * + roundfi_yield_mock.json (anchor build output) + * POLL_INTERVAL_MS default 60000 + * HEALTH_PORT default 3000 + * CRANK_LEASE_ENABLED "true" to enable Postgres lease (multi-replica) + * DATABASE_URL required if CRANK_LEASE_ENABLED=true + * + * Exit codes: + * 0 — graceful shutdown (SIGTERM/SIGINT) + * 1 — startup config error (missing env, bad keypair, RPC unreachable) + * + * The crank ITSELF never exits on a runtime error — pollingLoop's + * try/catch keeps it running. Exit=1 is reserved for config issues + * that make startup impossible. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { AnchorProvider, Wallet, type Idl } from "@coral-xyz/anchor"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import bs58 from "bs58"; + +import { createClient } from "@roundfi/sdk"; + +import { crankState } from "./crankState.js"; +import { startHealthServer } from "./healthServer.js"; +import { createPostgresLease, noopLease, type LeaseClient } from "./lease.js"; +import { logger } from "./logger.js"; +import { startPollingLoop } from "./pollingLoop.js"; + +function readRequiredEnv(name: string): string { + const v = process.env[name]; + if (!v || v.trim() === "") { + logger.error({ event_type: "startup.missing_env", name }, `Missing env var: ${name}`); + process.exit(1); + } + return v.trim(); +} + +function loadKeypair(): Keypair { + const raw = readRequiredEnv("CRANK_KEYPAIR"); + try { + // Accept JSON array (Solana CLI export) — base58 (devnet/local) also fine. + if (raw.startsWith("[")) { + const arr = JSON.parse(raw) as number[]; + return Keypair.fromSecretKey(Uint8Array.from(arr)); + } + return Keypair.fromSecretKey(bs58.decode(raw)); + } catch (err) { + logger.error( + { + event_type: "startup.bad_keypair", + error: err instanceof Error ? err.message : String(err), + }, + "CRANK_KEYPAIR is not a valid base58 secret key or JSON array", + ); + process.exit(1); + } +} + +function loadIdl(idlDir: string, name: string): Idl { + const path = resolve(idlDir, `${name}.json`); + if (!existsSync(path)) { + logger.error( + { event_type: "startup.missing_idl", path }, + `IDL not found: ${path}. Run 'anchor build' or set ROUNDFI_IDL_DIR.`, + ); + process.exit(1); + } + return JSON.parse(readFileSync(path, "utf-8")) as Idl; +} + +async function main(): Promise { + const rpcUrl = readRequiredEnv("SOLANA_RPC_URL"); + const corePk = new PublicKey(readRequiredEnv("ROUNDFI_CORE_PROGRAM_ID")); + const reputationPk = new PublicKey(readRequiredEnv("ROUNDFI_REPUTATION_PROGRAM_ID")); + const cranker = loadKeypair(); + const idlDir = process.env.ROUNDFI_IDL_DIR ?? resolve(process.cwd(), "target", "idl"); + + logger.info( + { + event_type: "startup", + rpc: rpcUrl, + core: corePk.toBase58(), + reputation: reputationPk.toBase58(), + cranker: cranker.publicKey.toBase58(), + idlDir, + bootAt: crankState.snapshot.bootAt.toISOString(), + }, + "Crank booting", + ); + + const connection = new Connection(rpcUrl, "confirmed"); + const wallet = new Wallet(cranker); + const provider = new AnchorProvider(connection, wallet, { + commitment: "confirmed", + preflightCommitment: "confirmed", + }); + + const idls = { + core: loadIdl(idlDir, "roundfi_core"), + reputation: loadIdl(idlDir, "roundfi_reputation"), + yieldAdapter: loadIdl(idlDir, "roundfi_yield_mock"), + }; + + const client = createClient({ + provider, + idls, + // expectedIds catches the "wrong cluster" failure mode the audit's + // Gap 5 warned about — IDL ↔ env mismatch throws at boot. + expectedIds: { core: corePk, reputation: reputationPk }, + }); + + // Health server comes up first so Railway / UptimeRobot can see + // `starting` immediately even if the polling loop hasn't ticked yet. + const health = await startHealthServer(); + + // Lease is opt-in via env (avoids forcing Postgres on dev / single-replica). + const leaseEnabled = process.env.CRANK_LEASE_ENABLED === "true"; + const lease: LeaseClient = leaseEnabled ? await createPostgresLease() : noopLease; + if (leaseEnabled) { + logger.info({ event_type: "lease.enabled" }, "Postgres lease enabled (multi-replica)"); + } + + const loop = startPollingLoop({ + connection, + client, + lease, + intervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000), + }); + + // Graceful shutdown. + const shutdown = async (signal: string): Promise => { + logger.info({ event_type: "shutdown.received", signal }, `Received ${signal}, draining`); + loop.stop(); + await loop.done; + await lease.release(); + await health.close(); + logger.info({ event_type: "shutdown.complete" }, "Crank shut down cleanly"); + process.exit(0); + }; + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); +} + +main().catch((err) => { + logger.error( + { + event_type: "startup.fatal", + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }, + "Fatal error during startup — exiting", + ); + process.exit(1); +}); diff --git a/services/crank/src/lease.ts b/services/crank/src/lease.ts new file mode 100644 index 0000000..5f0ce03 --- /dev/null +++ b/services/crank/src/lease.ts @@ -0,0 +1,123 @@ +/** + * Multi-instance lease — espelhando o padrão do + * `services/indexer/src/reconciler.ts` (PR #431, Wave 9.2). + * + * Sob Railway o operador pode rodar 2+ réplicas (autoscale, blue-green + * redeploy). Sem lease, os cranks competiriam pelo mesmo settle e + * gastariam gas duplicado (pior: poderiam racear num replay já enviado + * mas ainda não confirmado). O lease garante que UM crank-por-vez + * processa cada tick: o vencedor renova `acquiredAt = now`, perdedores + * pulam a tick e tentam de novo no próximo intervalo. + * + * Postgres é o coordenador (não Redis, não advisory locks): a tabela + * `reconciler_lease` já existe no schema.prisma do indexer + * (`@prisma/client` consome o mesmo DATABASE_URL). Reaproveitamos o + * mesmo padrão de WHERE de update (`acquiredAt < cutoff`) — exatamente + * um caller casa o WHERE e ganha o lease. + * + * IMPORTANTE: como o crank é opcional-multi-instância, o lease só é + * exigido quando `CRANK_LEASE_ENABLED=true`. Em dev / single-instance + * Railway o env var fica off e o loop roda sem checar nada. Isso evita + * acoplamento forçado ao Postgres em ambientes simples. + */ + +import { logger } from "./logger.js"; + +const LEASE_ID = "crank-main"; +const LEASE_TTL_SECS = 90; // 1.5x do POLL_INTERVAL_MS padrão (60s) + +function holderId(): string { + const host = process.env.HOSTNAME ?? "unknown-host"; + return `crank/${host}:${process.pid}`; +} + +export interface LeaseClient { + /** Try to acquire / renew the lease. Returns true if THIS process holds it now. */ + tryAcquire(): Promise; + /** Optional: release the lease on graceful shutdown (best-effort). */ + release(): Promise; +} + +/** + * Lease backed by the Prisma `reconciler_lease` row used by the indexer + * — same WHERE/SET pattern, just a different row id (`crank-main`). + * + * Wired lazily so the crank doesn't pull in @prisma/client at import + * time — useful for the lease-disabled path (dev / single replica). + */ +export async function createPostgresLease(): Promise { + // Late import: avoids prisma generate friction in environments that + // never enable the lease. + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient(); + const holder = holderId(); + + return { + async tryAcquire(): Promise { + const now = new Date(); + const cutoff = new Date(now.getTime() - LEASE_TTL_SECS * 1000); + + // First, try to renew (we already hold it) — only matches if WE + // are the current holder regardless of TTL. + const renew = await prisma.reconcilerLease.updateMany({ + where: { id: LEASE_ID, holder }, + data: { acquiredAt: now }, + }); + if (renew.count === 1) return true; + + // Otherwise try to grab a stale lease. + const grab = await prisma.reconcilerLease.updateMany({ + where: { id: LEASE_ID, acquiredAt: { lt: cutoff } }, + data: { acquiredAt: now, holder }, + }); + if (grab.count === 1) { + logger.info({ event_type: "lease.acquired", holder }, "Crank lease acquired"); + return true; + } + + // Bootstrap: row doesn't exist yet. Insert with our holder; if + // another instance raced us, the unique constraint on id rejects + // and we yield this tick. + try { + await prisma.reconcilerLease.create({ + data: { id: LEASE_ID, acquiredAt: now, holder }, + }); + logger.info({ event_type: "lease.bootstrapped", holder }, "Crank lease bootstrapped"); + return true; + } catch { + return false; // someone else inserted it first this tick + } + }, + + async release(): Promise { + try { + await prisma.reconcilerLease.updateMany({ + where: { id: LEASE_ID, holder }, + // Set acquiredAt far in the past so next caller's cutoff check + // sees a stale lease immediately. Avoids deleting the row + // (keeps audit trail of last holder). + data: { acquiredAt: new Date(0) }, + }); + logger.info({ event_type: "lease.released", holder }, "Crank lease released (shutdown)"); + } catch (err) { + // Best-effort: don't crash shutdown if the DB is down. + logger.warn( + { + event_type: "lease.release_failed", + holder, + error: err instanceof Error ? err.message : String(err), + }, + "Lease release failed during shutdown", + ); + } finally { + await prisma.$disconnect().catch(() => {}); + } + }, + }; +} + +/** No-op lease for single-instance deployments. Always wins. */ +export const noopLease: LeaseClient = { + tryAcquire: async () => true, + release: async () => {}, +}; diff --git a/services/crank/src/logger.ts b/services/crank/src/logger.ts new file mode 100644 index 0000000..25e4c8e --- /dev/null +++ b/services/crank/src/logger.ts @@ -0,0 +1,43 @@ +/** + * Minimal structured logger — one JSON object per line, no transitive + * dep on pino/winston. Keeps the crank tree small and Railway-friendly + * (their UI pretty-prints stdout JSON natively). + * + * Conventions: + * - event_type is the lookup key for the indexer's projector and the + * admin's filter (e.g. settle.success, settle.skip.precondition). + * - level is { info | warn | error } only — no debug noise in prod. + * - ts is always ISO8601 UTC, generated server-side here so log lines + * don't depend on the host TZ. + */ + +export type Level = "info" | "warn" | "error"; + +export interface LogPayload { + [key: string]: unknown; + event_type: string; +} + +function emit(level: Level, payload: LogPayload, msg?: string): void { + const line = { + ts: new Date().toISOString(), + level, + service: "crank", + ...payload, + ...(msg ? { msg } : {}), + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify(line)); +} + +export const logger = { + info(payload: LogPayload, msg?: string): void { + emit("info", payload, msg); + }, + warn(payload: LogPayload, msg?: string): void { + emit("warn", payload, msg); + }, + error(payload: LogPayload, msg?: string): void { + emit("error", payload, msg); + }, +}; diff --git a/services/crank/src/pollingLoop.ts b/services/crank/src/pollingLoop.ts new file mode 100644 index 0000000..a28db61 --- /dev/null +++ b/services/crank/src/pollingLoop.ts @@ -0,0 +1,156 @@ +/** + * Gap 2 of the canary audit — continuous polling loop. + * + * The orchestrator's `runCycle` was a single-shot demo runner; + * production cycles of 48h + 24h grace cannot be advanced by a human + * running a script. This loop polls every POLL_INTERVAL_MS (default + * 60s — frequent enough to catch grace-deadline crossings within a + * minute, infrequent enough to keep RPC quota low) and: + * + * 1. checks RPC health — skip the tick if it's down (Gap 4), do NOT + * mark cycle success (so /health degrades after BOOT_GRACE); + * 2. fetches active pools (Gap 5); + * 3. for each pool, checks-and-settles defaults (Gap 1); + * 4. marks the tick successful. + * + * Error handling is per-pool: an error processing pool A does NOT stop + * pool B from being processed in the same tick. Errors are NOT + * rethrown — the loop keeps running. The lastSuccessfulRun is still + * advanced on a partial-success tick (some pools settled, some failed) + * because the loop *itself* worked; the failed pools' errors are + * surfaced in their own log lines for ops to triage. + */ + +import type { Connection } from "@solana/web3.js"; +import type { RoundFiClient } from "@roundfi/sdk"; + +import { classifyError } from "./classifyError.js"; +import { crankState } from "./crankState.js"; +import { fetchActivePools } from "./fetchActivePools.js"; +import { type LeaseClient, noopLease } from "./lease.js"; +import { logger } from "./logger.js"; +import { checkRpcHealth } from "./rpcHealth.js"; +import { checkAndSettleDefaults } from "./settleDefaults.js"; + +export interface PollingLoopOptions { + connection: Connection; + client: RoundFiClient; + /** ms between ticks. Defaults to POLL_INTERVAL_MS env or 60_000. */ + intervalMs?: number; + /** Multi-instance lease. Defaults to noopLease (single-replica). */ + lease?: LeaseClient; +} + +export interface PollingLoopHandle { + /** Resolves when the loop has stopped (after `stop()` was called). */ + done: Promise; + stop: () => void; +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export function startPollingLoop(opts: PollingLoopOptions): PollingLoopHandle { + const intervalMs = opts.intervalMs ?? Number(process.env.POLL_INTERVAL_MS ?? 60_000); + let stopped = false; + + const done = (async () => { + logger.info( + { event_type: "loop.start", intervalMs }, + `Polling loop started (interval ${intervalMs}ms)`, + ); + while (!stopped) { + await runOneTick(opts); + if (stopped) break; + await sleep(intervalMs); + } + logger.info({ event_type: "loop.stopped" }, "Polling loop stopped"); + })(); + + return { + done, + stop: () => { + stopped = true; + }, + }; +} + +/** Exported for tests + the one-shot CLI mode. */ +export async function runOneTick(opts: PollingLoopOptions): Promise { + const tickStart = Date.now(); + const lease = opts.lease ?? noopLease; + try { + // Lease guard FIRST — if another instance holds it, this tick is a + // no-op. We deliberately don't markCycleSuccess: the holder is the + // one doing real work and advancing its own lastSuccessfulRun; + // followers' /health would otherwise show "ok" via lease-piggyback. + const haveLease = await lease.tryAcquire(); + if (!haveLease) { + logger.info( + { event_type: "tick.no_lease" }, + "Lease held by another instance — skipping tick", + ); + return; + } + + const rpcOk = await checkRpcHealth(opts.connection); + if (!rpcOk) { + // Do NOT markCycleSuccess — that would mask the outage and the + // /health endpoint would keep returning ok. Returning here lets + // /health naturally degrade after STALE_TICK_MS. + return; + } + + const pools = await fetchActivePools(opts.client); + const summary = { + pools: pools.length, + settled: 0, + failed: 0, + skipped: 0, + }; + + for (const pool of pools) { + try { + const results = await checkAndSettleDefaults(opts.client, pool); + for (const r of results) { + if (r.status === "settled") summary.settled++; + else if (r.status === "failed") summary.failed++; + else summary.skipped++; + } + } catch (err) { + // Per-pool catch: keep going with the next pool. + const errorKind = classifyError(err); + const msg = err instanceof Error ? err.message : String(err); + logger.error( + { + event_type: "tick.pool_failed", + pool: pool.address.toBase58(), + errorKind, + error: msg, + }, + `Pool processing failed (${errorKind}) — continuing`, + ); + } + } + + crankState.markCycleSuccess(); + logger.info( + { event_type: "tick.complete", ...summary, ms: Date.now() - tickStart }, + "Tick complete", + ); + } catch (err) { + // Top-level catch: never rethrow, never stop the loop. If we got + // here, the error wasn't per-pool — likely from fetchActivePools + // or rpcHealth misbehaving. Still surface and continue. + const errorKind = classifyError(err); + const msg = err instanceof Error ? err.message : String(err); + logger.error( + { + event_type: "tick.failed", + errorKind, + error: msg, + ms: Date.now() - tickStart, + }, + `Tick failed (${errorKind}) — loop continues`, + ); + } +} diff --git a/services/crank/src/rpcHealth.ts b/services/crank/src/rpcHealth.ts new file mode 100644 index 0000000..c635f90 --- /dev/null +++ b/services/crank/src/rpcHealth.ts @@ -0,0 +1,55 @@ +/** + * RPC health check — Gap 4 of the canary audit. + * + * Why a dedicated pre-cycle probe instead of just relying on the cycle + * itself to fail: a flaky RPC + `getProgramAccounts` returning [] (no + * pools) is indistinguishable from "no active pools" without an + * up-front liveness check. The crank would then *appear* to succeed + * (mark lastSuccessfulRun), the /health endpoint stays "ok", and the + * UptimeRobot never alerts — silent stall, exactly the failure mode the + * audit's Gap 4 describes. + * + * `getVersion()` is the canonical cheap liveness call on a Solana RPC + * (constant-time, no slot lookups). We treat any throw or network error + * as down; the caller is expected to skip the tick and NOT advance + * `lastSuccessfulRun`. The state module tracks `rpcDownSince` so the + * default classifier can later distinguish PAYMENT_MISSED (member + * skipped while infra was healthy) from INFRA_FAILURE (member's grace + * deadline elapsed while the crank's RPC was unreachable — not the + * member's fault, eligible for off-chain score contestation). + */ + +import type { Connection } from "@solana/web3.js"; + +import { crankState } from "./crankState.js"; +import { logger } from "./logger.js"; + +export async function checkRpcHealth(conn: Connection): Promise { + try { + // `getVersion` is the standard cheap liveness call. We deliberately + // don't `await` anything heavier — the goal is binary up/down, not + // a slot delta measurement. + await conn.getVersion(); + if (crankState.snapshot.rpcDownSince) { + logger.info( + { + event_type: "rpc.recovered", + downSince: crankState.snapshot.rpcDownSince.toISOString(), + }, + "RPC reachable again after outage", + ); + crankState.markRpcUp(); + } + return true; + } catch (err) { + crankState.markRpcDown(); + logger.error( + { + event_type: "rpc.unreachable", + error: err instanceof Error ? err.message : String(err), + }, + "RPC liveness check failed — skipping cycle", + ); + return false; + } +} diff --git a/services/crank/src/settleDefaults.ts b/services/crank/src/settleDefaults.ts new file mode 100644 index 0000000..87e7fda --- /dev/null +++ b/services/crank/src/settleDefaults.ts @@ -0,0 +1,158 @@ +/** + * Gap 1 of the canary audit — actually fire `settle_default` when a + * member misses the grace deadline. + * + * Why this is critical: the on-chain program will not advance the cycle + * past a defaulted member without `settle_default` (or `skip_defaulted_ + * payout`) being explicitly called. Without the crank firing this, the + * pool sits stalled and every other member's score is held hostage to + * the missing tx. The `services/orchestrator/src/runCycle.ts` is + * explicit that it does NOT do this — the crank is the only path. + * + * Off-chain `reason` (PAYMENT_MISSED vs INFRA_FAILURE): + * The on-chain `settleDefault` instruction does NOT take a reason + * argument — adding one would require a roundfi-core PR + new audit, + * out of crank scope. Instead we emit it in the structured event log + * here so the indexer / admin score-contestation UI can flip the + * verdict off-chain (the score lives in roundfi-reputation; penalty is + * effectively soft from this layer). Classification rule: + * + * INFRA_FAILURE if rpcDownSince ≤ graceDeadline: + * the crank's RPC was unreachable across the member's deadline, + * so this member's missed tx is not necessarily their fault — + * eligible for off-chain score reversal. + * PAYMENT_MISSED otherwise: + * the crank was healthy and the member simply didn't pay. + * + * Preconditions the on-chain handler enforces (we mirror them here so + * we skip un-eligible calls instead of paying gas for guaranteed + * reverts): + * - args.cycle == pool.current_cycle (handler:155-161) + * - member.contributions_paid < pool.current_cycle (MemberNotBehind) + * - clock.unix_timestamp >= pool.next_cycle_at + GRACE_PERIOD_SECS + * - !member.defaulted + */ + +import type { MemberView, PoolView, RoundFiClient } from "@roundfi/sdk"; +import { listPoolMembers, settleDefault } from "@roundfi/sdk"; + +import { classifyError } from "./classifyError.js"; +import { crankState } from "./crankState.js"; +import { logger } from "./logger.js"; + +/** GRACE_PERIOD_SECS = 7d (mainnet, SEV-002 floor; programs/roundfi-core/src/constants.rs:62). */ +const GRACE_PERIOD_SECS = 7 * 24 * 60 * 60; + +export type DefaultReason = "PAYMENT_MISSED" | "INFRA_FAILURE"; + +export interface DefaultSettleResult { + pool: string; + member: string; + slotIndex: number; + cycle: number; + reason: DefaultReason; + status: "settled" | "skipped" | "failed"; + errorKind?: "INFRA" | "LOGIC" | "UNKNOWN"; +} + +/** + * For each pool, sweep its members and fire settle_default for any + * who missed cycle N's contribute past the grace deadline. Returns one + * entry per attempted settlement (settled / skipped / failed) for the + * caller to roll up into a single tick summary. + */ +export async function checkAndSettleDefaults( + client: RoundFiClient, + pool: PoolView, + nowEpochSecs: number = Math.floor(Date.now() / 1000), +): Promise { + const results: DefaultSettleResult[] = []; + + // Cycle alignment: handler requires args.cycle == pool.current_cycle. + // The "missed" cycle is the previous one (current_cycle was advanced + // by the most recent claim_payout / skip_defaulted_payout). If the + // pool just transitioned and current_cycle is 0, nothing to settle. + if (pool.currentCycle === 0) return results; + + const graceDeadlineSecs = Number(pool.nextCycleAt) + GRACE_PERIOD_SECS; + if (nowEpochSecs < graceDeadlineSecs) { + // Still inside the grace window for this cycle — nothing to do. + return results; + } + + const members = await listPoolMembers(client, pool.address); + + for (const member of members) { + const eligible = isEligibleForSettle(member, pool); + if (!eligible.ok) { + // Skip silently for the routine reasons (already paid, already + // defaulted, member not behind). Don't spam the log. + continue; + } + + const reason = classifyDefaultReason(graceDeadlineSecs); + const ctx = { + pool: pool.address.toBase58(), + member: member.address.toBase58(), + slotIndex: member.slotIndex, + cycle: pool.currentCycle, + reason, + }; + + try { + logger.info({ event_type: "settle.start", ...ctx }, "Firing settle_default"); + await settleDefault(client, { + pool: pool.address, + usdcMint: pool.usdcMint, + defaultedMemberWallet: member.wallet, + slotIndex: member.slotIndex, + cycle: pool.currentCycle, + }); + logger.info({ event_type: "settle.success", ...ctx }, "settle_default confirmed"); + results.push({ ...ctx, status: "settled" }); + } catch (err) { + const errorKind = classifyError(err); + const msg = err instanceof Error ? err.message : String(err); + // LOGIC errors mean the on-chain state diverged from what we + // believed — escalate. INFRA / UNKNOWN: log + carry on, next + // tick will retry. + const level = errorKind === "LOGIC" ? "error" : "warn"; + logger[level]( + { event_type: "settle.failed", ...ctx, errorKind, error: msg }, + `settle_default failed (${errorKind})`, + ); + results.push({ ...ctx, status: "failed", errorKind }); + } + } + + return results; +} + +/** Compact result of the per-member precondition check. */ +interface EligibilityCheck { + ok: boolean; + reason?: string; +} + +export function isEligibleForSettle(member: MemberView, pool: PoolView): EligibilityCheck { + if (member.defaulted) return { ok: false, reason: "already_defaulted" }; + if (member.paidOut) return { ok: false, reason: "already_paid_out" }; + // MemberNotBehind: handler:163 requires contributions_paid < current_cycle. + if (member.contributionsPaid >= pool.currentCycle) { + return { ok: false, reason: "not_behind" }; + } + return { ok: true }; +} + +export function classifyDefaultReason(graceDeadlineSecs: number): DefaultReason { + // If the crank's RPC was down at the time the deadline elapsed, + // surface that — the member's failure to contribute may be due to + // our infra, not theirs. The check is conservative: if `rpcDownSince` + // is set at all and predates the deadline, classify as INFRA. If the + // RPC recovered before the deadline, classify as PAYMENT_MISSED. + const rpcDownSince = crankState.snapshot.rpcDownSince; + if (rpcDownSince && Math.floor(rpcDownSince.getTime() / 1000) <= graceDeadlineSecs) { + return "INFRA_FAILURE"; + } + return "PAYMENT_MISSED"; +} diff --git a/services/crank/test/classifyError.spec.ts b/services/crank/test/classifyError.spec.ts new file mode 100644 index 0000000..8d8e482 --- /dev/null +++ b/services/crank/test/classifyError.spec.ts @@ -0,0 +1,86 @@ +/** + * Pins the INFRA / LOGIC / UNKNOWN bucket boundaries of classifyError. + * + * Drift here changes whether settle.failed shows up as a quiet WARN (INFRA, + * "next tick will retry") or an escalation ERROR (LOGIC, "on-chain state + * diverged — eng needed"). Both directions are bugs: + * + * - LOGIC misclassified as INFRA → real bug retried silently every minute + * - INFRA misclassified as LOGIC → on-call paged for a 429 + * + * Each case below ties to one of the strings the crank actually sees in + * prod-shaped errors (Anchor, web3.js, node:fetch). Add a case here when a + * new prod-incident message appears. + */ + +import { describe, it, expect } from "vitest"; + +import { classifyError } from "../src/classifyError.js"; + +describe("classifyError — INFRA bucket", () => { + it.each([ + "Transaction was not confirmed in 30.00 seconds", + "fetch failed", + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "ENOTFOUND", + "socket hang up", + "Blockhash not found", + "Node is behind by 200 slots", + "429 Too Many Requests", + "503 Service Unavailable", + "rate limit exceeded", + "network request failed", + ])("classifies %s as INFRA", (msg) => { + expect(classifyError(new Error(msg))).toBe("INFRA"); + }); +}); + +describe("classifyError — LOGIC bucket", () => { + it.each([ + "custom program error: 0x1771", + "AnchorError occurred. Error Code: GracePeriodNotElapsed", + "Error Code: MemberNotBehind. Error Number: 6005", + "constraint violated", + "Transaction simulation failed: ...", + "instruction error", + "WrongCycle", + "PoolNotActive", + "AlreadyContributed", + "CooldownActive", + ])("classifies %s as LOGIC", (msg) => { + expect(classifyError(new Error(msg))).toBe("LOGIC"); + }); +}); + +describe("classifyError — UNKNOWN bucket", () => { + it("falls through to UNKNOWN on a novel message", () => { + expect(classifyError(new Error("the printer is on fire"))).toBe("UNKNOWN"); + }); + + it("handles non-Error throws (string)", () => { + // web3.js historically throws plain strings from a few code paths; + // the classifier must not blow up on them. + expect(classifyError("ETIMEDOUT")).toBe("INFRA"); + }); + + it("handles non-Error throws (object)", () => { + expect(classifyError({ code: "ECONNRESET" })).toBe("INFRA"); + }); + + it("handles null without throwing", () => { + expect(classifyError(null)).toBe("UNKNOWN"); + }); + + it("handles undefined without throwing", () => { + expect(classifyError(undefined)).toBe("UNKNOWN"); + }); +}); + +describe("classifyError — case insensitivity", () => { + it("matches regardless of message case", () => { + expect(classifyError(new Error("CUSTOM PROGRAM ERROR"))).toBe("LOGIC"); + expect(classifyError(new Error("Blockhash Not Found"))).toBe("INFRA"); + }); +}); diff --git a/services/crank/test/healthServer.spec.ts b/services/crank/test/healthServer.spec.ts new file mode 100644 index 0000000..27dc0ff --- /dev/null +++ b/services/crank/test/healthServer.spec.ts @@ -0,0 +1,102 @@ +/** + * Pins the `starting` / `ok` / `degraded` transitions of computeHealth. + * + * The HTTP code mapping (200 vs 503) is what UptimeRobot keys on — drift + * from `degraded → 503` to `degraded → 200` would silently disarm the + * alert, which is exactly the failure mode Gap 3 was designed to detect. + * + * Uses a frozen `now` to test the BOOT_GRACE_MS / STALE_TICK_MS edges + * deterministically (no setTimeout / no sleep). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { crankState } from "../src/crankState.js"; +import { computeHealth } from "../src/healthServer.js"; + +const BOOT_GRACE_MS = 5 * 60 * 1000; +const STALE_TICK_MS = 5 * 60 * 1000; + +beforeEach(() => { + crankState.__resetForTest(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("computeHealth — starting bucket", () => { + it("returns starting while within 5min of boot, even with no ticks", () => { + const bootAt = crankState.snapshot.bootAt; + const now = new Date(bootAt.getTime() + 60_000); // +1 min + const { status, body } = computeHealth(now); + expect(status).toBe("starting"); + expect(body.lastRun).toBeNull(); + }); + + it("starting persists even one ms before the boot grace ends", () => { + const bootAt = crankState.snapshot.bootAt; + const now = new Date(bootAt.getTime() + BOOT_GRACE_MS - 1); + expect(computeHealth(now).status).toBe("starting"); + }); +}); + +describe("computeHealth — ok bucket", () => { + it("returns ok when past boot grace AND last tick is recent", () => { + const bootAt = crankState.snapshot.bootAt; + const now = new Date(bootAt.getTime() + BOOT_GRACE_MS + 60_000); + // Mark a successful tick 30s before `now`. vi.setSystemTime patches + // the Date constructor so markCycleSuccess()'s `new Date()` records + // the staged time. + const tickAt = new Date(now.getTime() - 30_000); + vi.useFakeTimers(); + vi.setSystemTime(tickAt); + crankState.markCycleSuccess(); + vi.useRealTimers(); + const { status, body } = computeHealth(now); + expect(status).toBe("ok"); + expect(body.secondsSinceLastRun).toBe(30); + }); +}); + +describe("computeHealth — degraded bucket", () => { + it("returns degraded when past boot grace AND no tick ever", () => { + const bootAt = crankState.snapshot.bootAt; + const now = new Date(bootAt.getTime() + BOOT_GRACE_MS + 60_000); + const { status, body } = computeHealth(now); + expect(status).toBe("degraded"); + expect(body.lastRun).toBeNull(); + }); + + it("returns degraded when last tick is older than STALE_TICK_MS", () => { + const bootAt = crankState.snapshot.bootAt; + const now = new Date(bootAt.getTime() + BOOT_GRACE_MS + STALE_TICK_MS + 60_000); + // Tick that's just past the stale boundary. + const tickAt = new Date(now.getTime() - STALE_TICK_MS - 1); + vi.useFakeTimers(); + vi.setSystemTime(tickAt); + crankState.markCycleSuccess(); + vi.useRealTimers(); + expect(computeHealth(now).status).toBe("degraded"); + }); +}); + +describe("computeHealth — body shape", () => { + it("surfaces rpcDownSince when the RPC is currently flagged down", () => { + crankState.markRpcDown(); + const { body } = computeHealth(); + expect(body.rpcDownSince).not.toBeNull(); + expect(body.rpcDownSince).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO8601 + }); + + it("emits null secondsSinceLastRun when no tick has run", () => { + expect(computeHealth().body.secondsSinceLastRun).toBeNull(); + }); + + it("body.status mirrors the outer status (no drift)", () => { + const bootAt = crankState.snapshot.bootAt; + const now = new Date(bootAt.getTime() + BOOT_GRACE_MS + 60_000); + const r = computeHealth(now); + expect(r.body.status).toBe(r.status); + }); +}); diff --git a/services/crank/test/pollingLoop.spec.ts b/services/crank/test/pollingLoop.spec.ts new file mode 100644 index 0000000..051dd7e --- /dev/null +++ b/services/crank/test/pollingLoop.spec.ts @@ -0,0 +1,153 @@ +/** + * Tests for runOneTick's gating + isolation contract. This is the + * load-bearing piece of the daemon — drift here is the difference + * between a quiet outage and a paged incident. + * + * Pins: + * - lease.tryAcquire returning false: tick is a no-op (NO markCycleSuccess, + * so a follower's /health doesn't piggyback on the holder's work). + * - RPC unreachable: tick returns without markCycleSuccess (lets /health + * degrade naturally after STALE_TICK_MS). + * - Per-pool isolation: an exception from pool A does NOT stop pool B + * from being processed. + * - Top-level catch: an exception from fetchActivePools never propagates + * out of runOneTick. + * + * We stub the SDK by passing a minimal `client` shape and a fake + * `connection` with the one method `checkRpcHealth` uses (`getVersion`). + * `fetchActivePools` reads from the real SDK module, so the test instead + * exercises the tick through a wrapper that injects pools via vi.mock. + */ + +import type { Connection } from "@solana/web3.js"; +import type { RoundFiClient } from "@roundfi/sdk"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { crankState } from "../src/crankState.js"; +import type { LeaseClient } from "../src/lease.js"; + +vi.mock("../src/fetchActivePools.js", () => ({ + fetchActivePools: vi.fn(), +})); + +vi.mock("../src/settleDefaults.js", () => ({ + checkAndSettleDefaults: vi.fn(), +})); + +// Imported AFTER vi.mock so the loop sees the mocked impls. +const { runOneTick } = await import("../src/pollingLoop.js"); +const { fetchActivePools } = await import("../src/fetchActivePools.js"); +const { checkAndSettleDefaults } = await import("../src/settleDefaults.js"); + +const fetchActivePoolsMock = vi.mocked(fetchActivePools); +const checkAndSettleDefaultsMock = vi.mocked(checkAndSettleDefaults); + +function fakeConnection(ok: boolean): Connection { + return { + getVersion: ok + ? vi.fn().mockResolvedValue({ "solana-core": "1.18.26" }) + : vi.fn().mockRejectedValue(new Error("ECONNRESET")), + } as unknown as Connection; +} + +function fakeClient(): RoundFiClient { + // Fields touched in runOneTick path are mocked via the module mocks + // above; the client is just passed through. + return {} as RoundFiClient; +} + +function alwaysLease(): LeaseClient { + return { + tryAcquire: vi.fn().mockResolvedValue(true), + release: vi.fn().mockResolvedValue(undefined), + }; +} + +function neverLease(): LeaseClient { + return { + tryAcquire: vi.fn().mockResolvedValue(false), + release: vi.fn().mockResolvedValue(undefined), + }; +} + +beforeEach(() => { + crankState.__resetForTest(); + fetchActivePoolsMock.mockReset(); + checkAndSettleDefaultsMock.mockReset(); +}); + +describe("runOneTick — lease guard", () => { + it("returns early without calling RPC or settling when lease is held by another instance", async () => { + const conn = fakeConnection(true); + await runOneTick({ + connection: conn, + client: fakeClient(), + lease: neverLease(), + }); + expect(conn.getVersion).not.toHaveBeenCalled(); + expect(fetchActivePoolsMock).not.toHaveBeenCalled(); + expect(crankState.snapshot.lastSuccessfulRun).toBeNull(); + }); +}); + +describe("runOneTick — RPC health gate", () => { + it("does NOT markCycleSuccess when RPC is down", async () => { + await runOneTick({ + connection: fakeConnection(false), + client: fakeClient(), + lease: alwaysLease(), + }); + expect(crankState.snapshot.lastSuccessfulRun).toBeNull(); + expect(crankState.snapshot.rpcDownSince).not.toBeNull(); + expect(fetchActivePoolsMock).not.toHaveBeenCalled(); + }); + + it("markCycleSuccess only fires after RPC ok + pools processed", async () => { + fetchActivePoolsMock.mockResolvedValue([]); + await runOneTick({ + connection: fakeConnection(true), + client: fakeClient(), + lease: alwaysLease(), + }); + expect(crankState.snapshot.lastSuccessfulRun).not.toBeNull(); + }); +}); + +describe("runOneTick — per-pool isolation", () => { + it("one failing pool does not stop the next pool from being processed", async () => { + const poolA = { address: { toBase58: () => "POOL_A" } } as never; + const poolB = { address: { toBase58: () => "POOL_B" } } as never; + fetchActivePoolsMock.mockResolvedValue([poolA, poolB]); + // A throws, B succeeds. + checkAndSettleDefaultsMock + .mockRejectedValueOnce(new Error("AnchorError")) + .mockResolvedValueOnce([]); + + await runOneTick({ + connection: fakeConnection(true), + client: fakeClient(), + lease: alwaysLease(), + }); + + expect(checkAndSettleDefaultsMock).toHaveBeenCalledTimes(2); + // Tick still counts as success — the loop itself worked, the failed + // pool's error was surfaced in its own log line. + expect(crankState.snapshot.lastSuccessfulRun).not.toBeNull(); + }); +}); + +describe("runOneTick — top-level catch", () => { + it("does NOT throw when fetchActivePools rejects", async () => { + fetchActivePoolsMock.mockRejectedValue(new Error("getProgramAccounts timeout")); + await expect( + runOneTick({ + connection: fakeConnection(true), + client: fakeClient(), + lease: alwaysLease(), + }), + ).resolves.toBeUndefined(); + // markCycleSuccess never fires on a top-level failure — /health + // will degrade after STALE_TICK_MS. + expect(crankState.snapshot.lastSuccessfulRun).toBeNull(); + }); +}); diff --git a/services/crank/test/settleDefaults.spec.ts b/services/crank/test/settleDefaults.spec.ts new file mode 100644 index 0000000..2ea956e --- /dev/null +++ b/services/crank/test/settleDefaults.spec.ts @@ -0,0 +1,122 @@ +/** + * Pure-function tests for the settle gating logic: + * + * - isEligibleForSettle: mirrors handler:163 (MemberNotBehind) + the + * "already defaulted" + "already paid out" early returns. Misclassifying + * here means paying gas for a guaranteed revert. + * + * - classifyDefaultReason: PAYMENT_MISSED vs INFRA_FAILURE — this is the + * hook the off-chain score-contestation UI uses. Drift in either + * direction is a real user-facing fairness bug. + * + * The settle CPI itself is integration-level (needs a live RoundFiClient + * + funded keypair) and lives in the bankrun/litesvm lanes, not here. + */ + +import type { MemberView, PoolView } from "@roundfi/sdk"; +import { PublicKey } from "@solana/web3.js"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { crankState } from "../src/crankState.js"; +import { classifyDefaultReason, isEligibleForSettle } from "../src/settleDefaults.js"; + +const SOME_PK = new PublicKey("11111111111111111111111111111111"); + +function makePool(overrides: Partial = {}): PoolView { + return { + address: SOME_PK, + usdcMint: SOME_PK, + currentCycle: 2, + nextCycleAt: BigInt(1_700_000_000), + status: "Active", + ...overrides, + } as PoolView; +} + +function makeMember(overrides: Partial = {}): MemberView { + return { + address: SOME_PK, + wallet: SOME_PK, + slotIndex: 0, + contributionsPaid: 1, // behind by one cycle (current=2) + defaulted: false, + paidOut: false, + ...overrides, + } as MemberView; +} + +describe("isEligibleForSettle", () => { + it("eligible when member is behind, not defaulted, not paid out", () => { + expect(isEligibleForSettle(makeMember(), makePool()).ok).toBe(true); + }); + + it("skips when already defaulted (we don't double-settle)", () => { + const r = isEligibleForSettle(makeMember({ defaulted: true }), makePool()); + expect(r.ok).toBe(false); + expect(r.reason).toBe("already_defaulted"); + }); + + it("skips when already paid out (claim happened first)", () => { + const r = isEligibleForSettle(makeMember({ paidOut: true }), makePool()); + expect(r.ok).toBe(false); + expect(r.reason).toBe("already_paid_out"); + }); + + it("skips when contributions_paid >= current_cycle (handler MemberNotBehind)", () => { + const r = isEligibleForSettle( + makeMember({ contributionsPaid: 2 }), + makePool({ currentCycle: 2 }), + ); + expect(r.ok).toBe(false); + expect(r.reason).toBe("not_behind"); + }); + + it("skips when contributions_paid > current_cycle (sanity)", () => { + const r = isEligibleForSettle( + makeMember({ contributionsPaid: 5 }), + makePool({ currentCycle: 2 }), + ); + expect(r.ok).toBe(false); + }); +}); + +describe("classifyDefaultReason", () => { + beforeEach(() => { + crankState.__resetForTest(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns PAYMENT_MISSED when RPC has never been down", () => { + expect(classifyDefaultReason(1_700_000_000)).toBe("PAYMENT_MISSED"); + }); + + it("returns INFRA_FAILURE when RPC was down at/before the grace deadline", () => { + // RPC went down "yesterday" (well before the deadline). Use + // vi.setSystemTime so markRpcDown's `new Date()` records that. + const downAt = new Date((1_700_000_000 - 86_400) * 1000); + vi.useFakeTimers(); + vi.setSystemTime(downAt); + crankState.markRpcDown(); + vi.useRealTimers(); + expect(classifyDefaultReason(1_700_000_000)).toBe("INFRA_FAILURE"); + }); + + it("returns PAYMENT_MISSED when RPC went down AFTER the grace deadline", () => { + // RPC went down 1 hour after the deadline — member's miss is on them. + const downAt = new Date((1_700_000_000 + 3600) * 1000); + vi.useFakeTimers(); + vi.setSystemTime(downAt); + crankState.markRpcDown(); + vi.useRealTimers(); + expect(classifyDefaultReason(1_700_000_000)).toBe("PAYMENT_MISSED"); + }); + + it("treats markRpcUp clearing rpcDownSince as PAYMENT_MISSED", () => { + crankState.markRpcDown(); + crankState.markRpcUp(); + expect(classifyDefaultReason(1_700_000_000)).toBe("PAYMENT_MISSED"); + }); +}); diff --git a/services/crank/tsconfig.json b/services/crank/tsconfig.json new file mode 100644 index 0000000..446318d --- /dev/null +++ b/services/crank/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "outDir": "./dist" + }, + "include": ["src/**/*", "test/**/*"] +}