From 8585b38ca4d428112b03fa96902e17aca6cc2d9c Mon Sep 17 00:00:00 2001 From: adelekevictor12 <124449439+adelekevictor12@users.noreply.github.com> Date: Wed, 27 May 2026 14:48:17 +0000 Subject: [PATCH 1/3] add bounded retry-with-backoff to Soroban read calls --- docs/backend-performance-guidelines.md | 21 + package-lock.json | 1356 ++++++++++----------- src/lib/backend/contracts.ts | 1118 +++++++++++++++-- tests/api/contracts.service.retry.test.ts | 397 ++++++ 4 files changed, 2128 insertions(+), 764 deletions(-) create mode 100644 tests/api/contracts.service.retry.test.ts diff --git a/docs/backend-performance-guidelines.md b/docs/backend-performance-guidelines.md index 7d6f2503..316cbc1a 100644 --- a/docs/backend-performance-guidelines.md +++ b/docs/backend-performance-guidelines.md @@ -112,3 +112,24 @@ Exceptions to these guidelines must be approved by the Architecture Review Board * Impact analysis on system resources and user experience. * Proposed mitigation (e.g., aggressive caching, async processing). * Timeline for compliance or permanent waiver justification. + + +## 9. Soroban Read Retry Policy + +Read calls to Soroban RPC (`src/lib/backend/services/contracts.ts`) are wrapped in a bounded retry-with-exponential-backoff layer. Its purpose is to absorb short-lived RPC hiccups — rate limiting, gateway errors, timeouts — without surfacing them to callers, while staying strictly bounded so a struggling RPC endpoint can never stall a request. + +### What is retried + +Only **read-mode** contract calls, and only when they fail with a *transient* error. Transience is decided by `isRetryableContractError`, which reuses the classification produced by `normalizeContractError` so there is a single source of truth. Retried failures are HTTP 429, HTTP 503/504, timeout/deadline errors, and generic gateway-class failures (e.g. a dropped socket) — safe to retry because reads are idempotent. + +### What is never retried + +- **Write transactions — ever.** Re-submitting a signed transaction risks double execution. See "Write-retry guard" below. +- HTTP 404 (not found) — a deterministic result, not a transient failure. +- HTTP 400 / validation errors — retrying cannot change the outcome. +- Configuration errors (missing contract address or source account, surfaced as a 500 `BLOCKCHAIN_UNAVAILABLE`) — not transient. +- The optimistic `get_user_commitments` probe in `getUserCommitmentsFromChain`. Its failure is *expected* on contracts that don't implement that method and is handled by an id-based fallback; retrying an expected failure would only add latency. Transient errors are still covered, because the fallback `get_user_commitment_ids` read and the per-id `getCommitmentFromChain` reads each go through the retrying path. + +### Backoff algorithm + +Delays grow exponentially and use **equal jitter** so that many concurrent callers (e.g. parallel per-commitment reads) do not retry in lock-step: \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c67d8485..58f2a9bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,9 +42,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, @@ -61,14 +61,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -77,9 +77,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -87,9 +87,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -97,13 +97,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -113,9 +113,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -123,14 +123,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -150,7 +150,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -162,7 +161,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -173,7 +171,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -181,9 +178,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -198,9 +195,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -215,9 +212,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -232,9 +229,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -249,9 +246,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -266,9 +263,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -283,9 +280,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -300,9 +297,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -317,9 +314,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -334,9 +331,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -351,9 +348,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -368,9 +365,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -385,9 +382,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -402,9 +399,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -419,9 +416,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -436,9 +433,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -453,9 +450,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -470,9 +467,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -487,9 +484,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -504,9 +501,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -521,9 +518,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -538,9 +535,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -555,9 +552,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -572,9 +569,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -589,9 +586,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -606,9 +603,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -816,16 +813,21 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@next/env": { @@ -1037,9 +1039,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -1065,9 +1067,9 @@ "license": "MIT" }, "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1091,9 +1093,9 @@ } }, "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1101,9 +1103,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1118,9 +1120,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1135,9 +1137,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1152,9 +1154,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1169,9 +1171,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -1186,9 +1188,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -1203,9 +1205,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -1220,9 +1222,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -1237,9 +1239,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1254,9 +1256,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1271,9 +1273,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1288,9 +1290,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1305,9 +1307,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1323,29 +1325,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1360,9 +1343,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -1377,9 +1360,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1470,47 +1453,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -1524,9 +1507,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -1540,9 +1523,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -1556,9 +1539,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -1572,9 +1555,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -1588,9 +1571,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -1604,9 +1587,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -1620,9 +1603,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -1636,9 +1619,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -1652,9 +1635,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1669,10 +1652,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -1680,68 +1663,10 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -1755,9 +1680,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -1771,16 +1696,16 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", - "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "postcss": "^8.5.6", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" } }, "node_modules/@testing-library/dom": { @@ -1860,10 +1785,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, "dependencies": { @@ -1960,9 +1884,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1974,9 +1898,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1991,9 +1915,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "version": "18.3.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2035,17 +1959,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2058,7 +1982,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2074,16 +1998,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2099,14 +2023,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2121,14 +2045,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2139,9 +2063,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -2156,15 +2080,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2181,9 +2105,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -2195,16 +2119,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2233,9 +2157,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2262,16 +2186,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2286,13 +2210,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2317,16 +2241,16 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", "cpu": [ "arm" ], @@ -2338,9 +2262,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", "cpu": [ "arm64" ], @@ -2352,9 +2276,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ "arm64" ], @@ -2366,9 +2290,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "cpu": [ "x64" ], @@ -2380,9 +2304,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", "cpu": [ "x64" ], @@ -2394,9 +2318,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "cpu": [ "arm" ], @@ -2408,9 +2332,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", "cpu": [ "arm" ], @@ -2422,9 +2346,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "cpu": [ "arm64" ], @@ -2436,9 +2360,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "cpu": [ "arm64" ], @@ -2449,10 +2373,38 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "cpu": [ "ppc64" ], @@ -2464,9 +2416,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "cpu": [ "riscv64" ], @@ -2478,9 +2430,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", "cpu": [ "riscv64" ], @@ -2492,9 +2444,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", "cpu": [ "s390x" ], @@ -2506,9 +2458,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "cpu": [ "x64" ], @@ -2520,9 +2472,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "cpu": [ "x64" ], @@ -2533,10 +2485,24 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", "cpu": [ "wasm32" ], @@ -2544,16 +2510,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", "cpu": [ "arm64" ], @@ -2565,9 +2533,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", "cpu": [ "ia32" ], @@ -2579,9 +2547,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], @@ -2593,14 +2561,14 @@ ] }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2614,8 +2582,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2624,16 +2592,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2642,13 +2610,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2669,9 +2637,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -2682,13 +2650,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -2696,14 +2664,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2712,9 +2680,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -2722,13 +2690,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", - "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", + "integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.7", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -2740,17 +2708,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.5" + "vitest": "4.1.7" } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2781,6 +2749,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3019,9 +2999,9 @@ "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3069,9 +3049,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", - "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3079,13 +3059,14 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -3126,9 +3107,9 @@ } }, "node_modules/bare-module-resolve": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", - "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.2.tgz", + "integrity": "sha512-j+hiD5k99qec4KjJvYsI67q5AOBifmy9JG3oeMVxTmvrhn2sIdp8StrUvZu4YNgwTpO+NhniQG16N1ETDe1k5w==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -3189,9 +3170,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3292,9 +3273,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "funding": [ { "type": "opencollective", @@ -3624,7 +3605,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3764,9 +3744,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -3912,9 +3892,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3970,9 +3950,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz", - "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", "license": "MIT", "workspaces": [ "docs", @@ -3980,9 +3960,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3993,32 +3973,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escape-string-regexp": { @@ -4583,9 +4563,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "dev": true, "license": "MIT" }, @@ -4723,13 +4703,13 @@ } }, "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -4927,9 +4907,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -5125,6 +5105,19 @@ "dev": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5338,13 +5331,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -5744,9 +5737,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -6186,13 +6179,13 @@ } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", + "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } @@ -6287,18 +6280,18 @@ } }, "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, "node_modules/mrmime": { @@ -6315,13 +6308,12 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -6821,9 +6813,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -6840,7 +6832,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6999,16 +6991,16 @@ } }, "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT", "peer": true }, "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -7151,14 +7143,14 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", @@ -7245,14 +7237,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7261,21 +7253,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/run-parallel": { @@ -7387,9 +7379,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -7959,9 +7951,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", - "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", "funding": { "type": "github", @@ -7969,9 +7961,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "license": "MIT" }, "node_modules/tapable": { @@ -8008,9 +8000,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", "dev": true, "license": "MIT", "engines": { @@ -8107,14 +8099,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -8276,38 +8267,41 @@ "license": "MIT" }, "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.3.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/uri-js": { @@ -8358,16 +8352,16 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -8384,7 +8378,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8436,19 +8430,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8476,12 +8470,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8629,13 +8623,13 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -8785,9 +8779,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -8820,9 +8814,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/src/lib/backend/contracts.ts b/src/lib/backend/contracts.ts index 61645b28..c6938a80 100644 --- a/src/lib/backend/contracts.ts +++ b/src/lib/backend/contracts.ts @@ -1,125 +1,1077 @@ -import { ConflictError, ValidationError } from "./errors"; -import { BackendConfig } from "./config"; -import { ChainCommitmentModel } from "./dto"; +import { + Account, + Address, + BASE_FEE, + Contract, + Keypair, + SorobanRpc, + TransactionBuilder, + nativeToScVal, + scValToNative, +} from "@stellar/stellar-sdk"; +import { + BackendError, + BackendErrorCode, + normalizeBackendError, +} from "@/lib/backend/errors"; +import { getBackendConfig } from "@/lib/backend/config"; +import { logInfo } from "@/lib/backend/logger"; +import { cache } from "@/lib/backend/cache/factory"; +import { CacheKey, CacheTTL } from "@/lib/backend/cache/index"; +import { getCountersAdapter } from "@/lib/backend/counters/provider"; -/** - * Input for creating a commitment on-chain. - */ -export interface CreateCommitmentInput { +export type ChainCommitmentStatus = + | "ACTIVE" + | "SETTLED" + | "VIOLATED" + | "EARLY_EXIT" + | "UNKNOWN"; + +export interface CreateCommitmentOnChainParams { ownerAddress: string; + asset: string; amount: string; - assetCode: string; - assetIssuer: string; durationDays: number; - maxLossPercent: number; - commitmentType: string; + maxLossBps: number; + metadata?: Record; } -/** - * Input for early exit of a commitment. - */ -export interface EarlyExitInput { - currentStatus?: string; +export interface ChainCommitment { + id: string; + ownerAddress: string; + asset: string; + amount: string; + status: ChainCommitmentStatus; + complianceScore: number; + currentValue: string; + feeEarned: string; + violationCount: number; + createdAt?: string; + expiresAt?: string; } export interface CreateCommitmentOnChainResult { - commitment: ChainCommitmentModel; commitmentId: string; - nftTokenId: string; + commitment: ChainCommitment; + txHash?: string; +} + +export interface RecordAttestationOnChainParams { + commitmentId: string; + attestorAddress: string; + complianceScore: number; + violation: boolean; + feeEarned?: string; + timestamp?: string; + details?: Record; +} + +export interface RecordAttestationOnChainResult { + attestationId: string; + commitmentId: string; + complianceScore: number; + violation: boolean; + feeEarned: string; + recordedAt: string; txHash?: string; - reference?: string; } -export interface EarlyExitOnChainResult { - penaltyAmount: string; - returnedAmount: string; +export interface SettleCommitmentOnChainParams { + commitmentId: string; + callerAddress?: string; +} + +export interface SettleCommitmentOnChainResult { + settlementAmount: string; txHash?: string; reference?: string; + finalStatus: string; } -function buildMockReference(action: string): string { - return `TODO_CHAIN_CALL_${action.toUpperCase()}`; +type ContractCallMode = "read" | "write"; +interface ContractInvocationResult { + value: unknown; + txHash?: string; } -export async function createCommitmentOnChain( - config: BackendConfig, - input: CreateCommitmentInput, -): Promise { - const commitmentId = `cm_${Date.now()}`; - const nftTokenId = `nft_${Date.now()}`; - - const commitment: ChainCommitmentModel = { - id: commitmentId, - ownerAddress: input.ownerAddress, - amount: input.amount, - assetCode: input.assetCode, - assetIssuer: input.assetIssuer, - durationDays: input.durationDays, - maxLossPercent: input.maxLossPercent, - commitmentType: input.commitmentType, - status: "active", - nftTokenId, +const ANALYTICS_SCALE = 100; + +function getRpcUrl(): string { + return getBackendConfig().sorobanRpcUrl; +} + +function getNetworkPassphrase(): string { + return getBackendConfig().networkPassphrase; +} + +function getContractId(kind: "commitmentCore" | "attestationEngine"): string { + const config = getBackendConfig(); + if (kind === "commitmentCore") { + return config.contractAddresses.commitmentCore; + } + return config.contractAddresses.attestationEngine; +} + +function getSourceKeypair(): Keypair | null { + const secret = process.env.SOROBAN_SERVER_SECRET_KEY; + if (!secret) { + return null; + } + return Keypair.fromSecret(secret); +} + +function getSourcePublicKey(): string | null { + const keypair = getSourceKeypair(); + if (keypair) { + return keypair.publicKey(); + } + + return process.env.SOROBAN_SOURCE_ACCOUNT || null; +} + +function getSorobanServer(): SorobanRpc.Server { + const url = getRpcUrl(); + return new SorobanRpc.Server(url, { allowHttp: url.startsWith("http://") }); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +function asString(value: unknown, fallback = ""): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "bigint") { + return String(value); + } + return fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +} + +function normalizeStatus(value: unknown): ChainCommitmentStatus { + const raw = asString(value, "UNKNOWN").toUpperCase(); + if ( + raw === "ACTIVE" || + raw === "SETTLED" || + raw === "VIOLATED" || + raw === "EARLY_EXIT" + ) { + return raw; + } + return "UNKNOWN"; +} + +/** + * Normalizes blockchain-related errors into stable BackendError types. + * Maps RPC failures, simulation errors, and timeouts to appropriate status codes. + * Ensures that sensitive raw RPC details are not leaked to the client. + */ +function normalizeContractError( + error: unknown, + defaults: { + code: BackendErrorCode; + message: string; + status: number; + details?: Record; + }, +): BackendError { + // If it's already a well-formed BackendError, we enrich it with defaults + if (error instanceof BackendError) { + const isRetryable = [429, 503, 504].includes(error.status); + return new BackendError({ + code: error.code, + message: error.message, + status: error.status, + details: { + ...asRecord(error.details), + ...asRecord(defaults.details), + retryable: isRetryable || asRecord(error.details).retryable === true, + }, + }); + } + + const errMessage = error instanceof Error ? error.message : String(error); + const errStr = errMessage.toLowerCase(); + + let status = defaults.status; + let code = defaults.code; + let message = defaults.message; + let retryable = false; + + // Pattern match for specific failure types from Soroban RPC or SDK + if ( + errStr.includes("timeout") || + errStr.includes("deadline") || + errStr.includes("timed out") + ) { + status = 504; + code = "GATEWAY_TIMEOUT"; + message = + "The blockchain operation timed out. It may still be processed later."; + retryable = true; + } else if ( + errStr.includes("429") || + errStr.includes("rate limit") || + errStr.includes("too many requests") + ) { + status = 429; + code = "TOO_MANY_REQUESTS"; + message = + "Rate limit exceeded for blockchain calls. Please try again later."; + retryable = true; + } else if (errStr.includes("not found") || errStr.includes("404")) { + status = 404; + code = "NOT_FOUND"; + message = "The requested resource was not found on the blockchain."; + } else if ( + errStr.includes("insufficient") || + errStr.includes("invalid") || + errStr.includes("malformed") + ) { + status = 400; + code = "VALIDATION_ERROR"; + message = + "The transaction was rejected due to invalid parameters or state."; + } else if (status >= 500) { + retryable = true; + } + + return new BackendError({ + code, + message, + status, + details: { + ...asRecord(defaults.details), + retryable, + }, + }); +} + +// --------------------------------------------------------------------------- +// Retry-with-backoff for read-mode Soroban calls +// +// Transient RPC failures (429 / 503 / 504 / timeouts) on *read* calls are safe +// to retry because reads are idempotent — re-running one cannot change on-chain +// state. Write transactions are NEVER retried here: re-submitting a signed +// transaction risks double execution. The retry path is therefore exposed only +// through `invokeReadContractMethod`, whose call mode is hard-coded to "read". +// +// Both the attempt count and the cumulative backoff are bounded so that a flaky +// endpoint cannot stall a request indefinitely. +// --------------------------------------------------------------------------- + +/** Async sleep helper. Injectable so unit tests run without real timers. */ +export type SleepFn = (ms: number) => Promise; + +const defaultSleep: SleepFn = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Tunables for {@link retryWithBackoff}. */ +export interface RetryOptions { + /** Total attempts, including the first. Coerced to at least 1. */ + maxAttempts: number; + /** Delay used to seed the first backoff, in milliseconds. */ + baseDelayMs: number; + /** Hard ceiling for any single backoff delay, in milliseconds. */ + maxDelayMs: number; + /** Hard ceiling for the sum of all backoff delays in a single call. */ + maxTotalBackoffMs: number; + /** Growth factor applied to the delay ceiling after each failed attempt. */ + backoffMultiplier: number; + /** Returns true when an error is a transient failure worth retrying. */ + isRetryable: (error: unknown) => boolean; + /** Random source in [0, 1) used for jitter. Injectable for tests. */ + random?: () => number; + /** Sleep implementation. Injectable for tests. */ + sleep?: SleepFn; + /** Observability hook fired immediately before each backoff sleep. */ + onRetry?: (info: { + attempt: number; + delayMs: number; + error: unknown; + }) => void; +} + +/** + * Runs `operation` and retries it with bounded exponential backoff for as long + * as it keeps failing with a *retryable* error. + * + * Guarantees: + * - Bounded work: at most `maxAttempts` invocations and at most + * `maxTotalBackoffMs` of cumulative sleeping, so a failing dependency can + * never stall the caller indefinitely. + * - Non-retryable errors are re-thrown on first occurrence, untouched. + * - The error from the final attempt is re-thrown unchanged, so the caller's + * existing error handling (normalization, failure metrics) is unaffected. + * - No side effects of its own: it does not log or emit metrics. The caller + * decides what happens once retries are exhausted. + * + * Backoff uses "equal jitter" (half fixed, half random) so that many concurrent + * callers — e.g. parallel per-commitment reads — do not retry in lock-step and + * stampede the RPC endpoint. + */ +export async function retryWithBackoff( + operation: (attempt: number) => Promise, + options: RetryOptions, +): Promise { + const random = options.random ?? Math.random; + const sleep = options.sleep ?? defaultSleep; + const maxAttempts = Math.max(1, Math.floor(options.maxAttempts)); + + let totalBackoffMs = 0; + + for (let attempt = 1; ; attempt += 1) { + try { + // The operation receives the 1-based attempt number so lower layers can + // enforce per-attempt invariants (see assertRetrySafe). + return await operation(attempt); + } catch (error) { + const isLastAttempt = attempt >= maxAttempts; + if (isLastAttempt || !options.isRetryable(error)) { + throw error; + } + + // Exponential growth, capped per attempt, then jittered. + const ceiling = Math.min( + options.baseDelayMs * options.backoffMultiplier ** (attempt - 1), + options.maxDelayMs, + ); + const delayMs = ceiling / 2 + random() * (ceiling / 2); + + // Honour the cumulative backoff budget: rather than stalling, stop and + // surface the error if the next sleep would exceed it. + if (totalBackoffMs + delayMs > options.maxTotalBackoffMs) { + throw error; + } + totalBackoffMs += delayMs; + + options.onRetry?.({ attempt, delayMs, error }); + await sleep(delayMs); + } + } +} + +/** + * Bounded retry policy applied to read-mode Soroban calls only. Worst-case + * added latency is roughly `maxTotalBackoffMs` on top of the time spent in the + * failed attempts themselves. These values are intentionally conservative: + * they absorb brief RPC hiccups, not sustained outages. + */ +const READ_RETRY_CONFIG = { + maxAttempts: 3, + baseDelayMs: 200, + maxDelayMs: 2_000, + maxTotalBackoffMs: 4_000, + backoffMultiplier: 2, +} as const; + +/** + * Decides whether a failed Soroban call is a *transient* failure worth + * retrying. It reuses the retryable classification produced by + * {@link normalizeContractError} (429 / 503 / 504 / timeouts and generic + * gateway errors), so there is a single source of truth. Deterministic + * failures — 404 (not found) and 400 (validation) — are never retried. + */ +export function isRetryableContractError(error: unknown): boolean { + const normalized = normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Soroban read call failed.", + status: 502, + details: {}, + }); + return asRecord(normalized.details).retryable === true; +} + +/** + * Guard against retrying write transactions. + * + * Read calls are idempotent and may safely run multiple times. A write + * transaction must be submitted exactly once: retrying it (attempt > 1) risks + * a double submission. This invariant is enforced at the lowest level — inside + * {@link invokeContractMethod} — so it holds regardless of how a call is wired + * up. If the invariant is ever violated by a future change, this throws a + * non-retryable error *before* any transaction is submitted, converting a + * silent double-spend into a loud, safe failure. + * + * Exported so the guard can be unit tested directly. + */ +export function assertRetrySafe(mode: ContractCallMode, attempt: number): void { + if (attempt > 1 && mode !== "read") { + throw new BackendError({ + code: "BLOCKCHAIN_CALL_FAILED", + message: "Internal error: write transactions must never be retried.", + status: 500, + details: { mode, attempt }, + }); + } +} + +function parseChainCommitment(value: unknown): ChainCommitment { + const raw = asRecord(value); + const id = asString(raw.id ?? raw.commitmentId); + + if (!id) { + throw new BackendError({ + code: "BLOCKCHAIN_CALL_FAILED", + message: "Soroban returned a commitment without an id.", + status: 502, + details: { raw }, + }); + } + + return { + id, + ownerAddress: asString(raw.ownerAddress ?? raw.owner_address), + asset: asString(raw.asset), + amount: asString(raw.amount, "0"), + status: normalizeStatus(raw.status), + complianceScore: asNumber(raw.complianceScore ?? raw.compliance_score), + currentValue: asString( + raw.currentValue ?? raw.current_value ?? raw.amount, + "0", + ), + feeEarned: asString(raw.feeEarned ?? raw.fees_earned, "0"), + violationCount: asNumber(raw.violationCount ?? raw.violation_count), + createdAt: asString(raw.createdAt ?? raw.created_at) || undefined, + expiresAt: asString(raw.expiresAt ?? raw.expires_at) || undefined, }; +} - if (!config.chainWritesEnabled) { +function parseCreateCommitmentResult( + value: unknown, + txHash?: string, +): CreateCommitmentOnChainResult { + if (typeof value === "string") { return { - commitment, - commitmentId, - nftTokenId, - reference: buildMockReference("create_commitment"), + commitmentId: value, + commitment: { + id: value, + ownerAddress: "", + asset: "", + amount: "0", + status: "UNKNOWN", + complianceScore: 0, + currentValue: "0", + feeEarned: "0", + violationCount: 0, + }, + txHash, }; } - if ( - !config.contractAddresses.commitmentCore || - !config.contractAddresses.commitmentNFT - ) { - throw new ValidationError( - "Missing COMMITMENT_CORE_CONTRACT or COMMITMENT_NFT_CONTRACT for on-chain create.", - ); + const raw = asRecord(value); + const parsedCommitment = parseChainCommitment(raw.commitment ?? raw); + + return { + commitmentId: parsedCommitment.id, + commitment: parsedCommitment, + txHash: asString(raw.txHash) || txHash, + }; +} + +function parseAttestationResult( + value: unknown, + txHash?: string, +): RecordAttestationOnChainResult { + const raw = asRecord(value); + const attestationId = asString(raw.attestationId ?? raw.id); + const commitmentId = asString(raw.commitmentId ?? raw.commitment_id); + + if (!attestationId || !commitmentId) { + throw new BackendError({ + code: "BLOCKCHAIN_CALL_FAILED", + message: "Soroban returned an invalid attestation payload.", + status: 502, + details: { raw }, + }); } - // TODO: Replace with real Soroban transaction submission once backend signing flow is available. return { - commitment, + attestationId, commitmentId, - nftTokenId, - reference: buildMockReference("create_commitment"), + complianceScore: asNumber(raw.complianceScore ?? raw.compliance_score), + violation: Boolean(raw.violation), + feeEarned: asString(raw.feeEarned ?? raw.fees_earned, "0"), + recordedAt: + asString(raw.recordedAt ?? raw.recorded_at) || new Date().toISOString(), + txHash: asString(raw.txHash) || txHash, }; } -export async function earlyExitCommitmentOnChain( - config: BackendConfig, - commitmentId: string, - input: EarlyExitInput, -): Promise { - if (!commitmentId.trim()) { - throw new ValidationError("Commitment id is required."); - } - if (input.currentStatus !== undefined && input.currentStatus !== "active") { - throw new ConflictError( - "Commitment cannot be early-exited from its current state.", - ); +function parseCommitmentList(value: unknown): ChainCommitment[] { + if (!Array.isArray(value)) { + return []; + } + + return value.map((item) => parseChainCommitment(item)); +} + +async function waitForTransactionResult( + server: SorobanRpc.Server, + hash: string, + timeoutMs = 15_000, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const tx = await server.getTransaction(hash); + if (tx.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return tx.returnValue ? scValToNative(tx.returnValue) : null; + } + if (tx.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw normalizeContractError(new Error("Transaction execution failed"), { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Soroban transaction failed.", + status: 502, + details: { hash, txStatus: tx.status }, + }); + } + + await new Promise((resolve) => { + setTimeout(resolve, 600); + }); } - if (!config.chainWritesEnabled) { + throw normalizeContractError(new Error("RPC Timeout"), { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Timed out waiting for Soroban transaction result.", + status: 504, + details: { hash }, + }); +} + +async function invokeContractMethod( + contractId: string, + methodName: string, + params: unknown[], + mode: ContractCallMode, + attempt = 1, +): Promise { + // Guard: a write transaction must be submitted exactly once. Retrying one + // (attempt > 1) risks a double submission, so it is rejected before any + // network work is performed. Direct (non-retried) calls always pass + // attempt = 1 and are unaffected. + assertRetrySafe(mode, attempt); + + if (!contractId) { + throw new BackendError({ + code: "BLOCKCHAIN_UNAVAILABLE", + message: "Missing Soroban contract configuration.", + status: 500, + details: { methodName }, + }); + } + + const sourcePublicKey = getSourcePublicKey(); + if (!sourcePublicKey) { + throw new BackendError({ + code: "BLOCKCHAIN_UNAVAILABLE", + message: "Missing SOROBAN source account configuration.", + status: 500, + details: { methodName }, + }); + } + + const server = getSorobanServer(); + const contract = new Contract(contractId); + const account = + mode === "write" + ? await server.getAccount(sourcePublicKey) + : new Account(sourcePublicKey, "0"); + const operation = contract.call( + methodName, + ...params.map((value) => nativeToScVal(value)), + ); + + const tx = new TransactionBuilder(account, { + fee: String(BASE_FEE), + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simulation = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw normalizeContractError(new Error(simulation.error), { + code: "BLOCKCHAIN_CALL_FAILED", + message: `Soroban simulation failed for ${methodName}.`, + status: 502, + details: { methodName }, + }); + } + + if (mode === "read") { return { - penaltyAmount: "0", - returnedAmount: "0", - reference: buildMockReference("early_exit"), + value: simulation.result ? scValToNative(simulation.result.retval) : null, }; } - if (!config.contractAddresses.commitmentCore) { - throw new ValidationError( - "Missing COMMITMENT_CORE_CONTRACT for on-chain early exit.", + const sourceKeypair = getSourceKeypair(); + if (!sourceKeypair) { + throw new BackendError({ + code: "BLOCKCHAIN_UNAVAILABLE", + message: "Missing SOROBAN_SERVER_SECRET_KEY for write contract calls.", + status: 500, + details: { methodName }, + }); + } + + const preparedTx = await server.prepareTransaction(tx); + preparedTx.sign(sourceKeypair); + const sendResult = await server.sendTransaction(preparedTx); + const txHash = sendResult.hash; + + const onChainValue = await waitForTransactionResult(server, txHash); + return { value: onChainValue, txHash }; +} + +/** + * Read-only counterpart of {@link invokeContractMethod} that adds bounded + * retry-with-exponential-backoff for transient RPC failures. + * + * Use this for ALL read-mode contract calls. The call mode is hard-coded to + * "read", so a write transaction can never be submitted — let alone + * re-submitted — through this path. Write calls must keep calling + * `invokeContractMethod(..., "write")` directly so each runs exactly once. + * + * Only failures classified retryable by {@link isRetryableContractError} are + * retried; attempts and total backoff are capped by {@link READ_RETRY_CONFIG}. + * The final error is propagated unchanged, so a caller's `incrementChainFailures` + * runs exactly once, only after retries are exhausted. + */ +async function invokeReadContractMethod( + contractId: string, + methodName: string, + params: unknown[], +): Promise { + return retryWithBackoff( + (attempt) => + invokeContractMethod(contractId, methodName, params, "read", attempt), + { + ...READ_RETRY_CONFIG, + isRetryable: isRetryableContractError, + onRetry: ({ attempt, delayMs, error }) => { + logInfo(undefined, "[soroban] retrying read after transient failure", { + methodName, + attempt, + delayMs: Math.round(delayMs), + error: error instanceof Error ? error.message : String(error), + }); + }, + }, + ); +} + +function validateOwnerAddress(ownerAddress: string): void { + if (!ownerAddress || ownerAddress.trim().length < 5) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Invalid owner address.", + status: 400, + details: { ownerAddress }, + }); + } +} + +export async function createCommitmentOnChain( + params: CreateCommitmentOnChainParams, +): Promise { + try { + validateOwnerAddress(params.ownerAddress); + const invocation = await invokeContractMethod( + getContractId("commitmentCore"), + "create_commitment", + [ + new Address(params.ownerAddress).toScVal(), + nativeToScVal(params.asset), + nativeToScVal(params.amount), + nativeToScVal(params.durationDays), + nativeToScVal(params.maxLossBps), + nativeToScVal(params.metadata ?? {}), + ], + "write", ); + + // Increment successful actions counter on successful commitment creation + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics + + void cache.delete(CacheKey.userCommitments(params.ownerAddress)); + + return parseCreateCommitmentResult(invocation.value, invocation.txHash); + } catch (error) { + // Increment chain failures counter on blockchain operation failures + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementChainFailures(); // Fire and forget for metrics + + throw normalizeBackendError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to create commitment on chain.", + status: 502, + details: { method: "create_commitment" }, + }); } +} - // TODO: Replace with real Soroban transaction submission once backend signing flow is available. - return { - penaltyAmount: "0", - returnedAmount: "0", - reference: buildMockReference("early_exit"), - }; +export async function getCommitmentFromChain( + commitmentId: string, +): Promise { + try { + if (!commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id.", + status: 400, + }); + } + + const cacheKey = CacheKey.commitment(commitmentId); + const cached = await cache.get(cacheKey); + if (cached !== null) { + logInfo(undefined, "[cache] hit commitment", { commitmentId }); + return cached; + } + logInfo(undefined, "[cache] miss commitment", { commitmentId }); + + // Read call: wrapped with bounded retry-and-backoff for transient failures. + const invocation = await invokeReadContractMethod( + getContractId("commitmentCore"), + "get_commitment", + [commitmentId], + ); + + // Increment successful actions counter on successful chain read + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics + + const commitment = parseChainCommitment(invocation.value); + await cache.set(cacheKey, commitment, CacheTTL.COMMITMENT_DETAIL); + return commitment; + } catch (error) { + // Increment chain failures counter on blockchain operation failures. + // Reached only after read retries (if any) have been exhausted. + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementChainFailures(); // Fire and forget for metrics + + throw normalizeBackendError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to fetch commitment from chain.", + status: 502, + details: { method: "get_commitment", commitmentId }, + }); + } +} + +export async function getUserCommitmentsFromChain( + ownerAddress: string, +): Promise { + try { + validateOwnerAddress(ownerAddress); + + const cacheKey = CacheKey.userCommitments(ownerAddress); + const cached = await cache.get(cacheKey); + if (cached !== null) { + logInfo(undefined, "[cache] hit user-commitments", { ownerAddress }); + return cached; + } + logInfo(undefined, "[cache] miss user-commitments", { ownerAddress }); + + const contractId = getContractId("commitmentCore"); + + try { + // Optimistic probe. `get_user_commitments` may not exist on every + // deployed contract, and its failure is expected and handled by the + // id-based fallback below — so it is deliberately NOT retried. Retrying + // an expected failure would only add latency. Genuine transient errors + // are still covered: the fallback `get_user_commitment_ids` read and the + // per-id `getCommitmentFromChain` reads each go through + // `invokeReadContractMethod` and so are retried. + const directResult = await invokeContractMethod( + contractId, + "get_user_commitments", + [ownerAddress], + "read", + ); + const commitments = parseCommitmentList(directResult.value); + if (commitments.length > 0) { + await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); + // Increment successful actions counter on successful chain read + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementSuccessfulActions(); + return commitments; + } + } catch (error) { + if (!(error instanceof BackendError)) { + throw error; + } + } + + // Read call: wrapped with bounded retry-and-backoff for transient failures. + const idsResult = await invokeReadContractMethod( + contractId, + "get_user_commitment_ids", + [ownerAddress], + ); + const commitmentIds = Array.isArray(idsResult.value) + ? idsResult.value.map((id) => asString(id)).filter(Boolean) + : []; + const commitments = await Promise.all( + commitmentIds.map((commitmentId) => getCommitmentFromChain(commitmentId)), + ); + + await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); + // Increment successful actions counter on successful chain read + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementSuccessfulActions(); + return commitments; + } catch (error) { + // Increment chain failures counter on blockchain operation failures. + // Reached only after read retries (if any) have been exhausted. + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementChainFailures(); + + throw normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to fetch user commitments from chain.", + status: 502, + details: { method: "get_user_commitments", ownerAddress }, + }); + } +} + +export async function recordAttestationOnChain( + params: RecordAttestationOnChainParams, +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id for attestation.", + status: 400, + }); + } + + // Snapshot ownerAddress from cache before writing so we can invalidate the + // user-commitments list even though attestation params don't carry it. + const cachedCommitment = await cache.get( + CacheKey.commitment(params.commitmentId), + ); + + const invocation = await invokeContractMethod( + getContractId("attestationEngine"), + "record_attestation", + [ + nativeToScVal(params.commitmentId), + new Address(params.attestorAddress).toScVal(), + nativeToScVal(params.complianceScore / ANALYTICS_SCALE), + nativeToScVal(params.violation), + nativeToScVal(params.feeEarned ?? "0"), + nativeToScVal(params.timestamp ?? new Date().toISOString()), + nativeToScVal(params.details ?? {}), + ], + "write", + ); + + // Increment successful actions counter on successful attestation recording + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics + + void cache.delete(CacheKey.commitment(params.commitmentId)); + if (cachedCommitment?.ownerAddress) { + void cache.delete( + CacheKey.userCommitments(cachedCommitment.ownerAddress), + ); + } + + return parseAttestationResult(invocation.value, invocation.txHash); + } catch (error) { + // Increment chain failures counter on blockchain operation failures + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementChainFailures(); // Fire and forget for metrics + + throw normalizeBackendError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to record attestation on chain.", + status: 502, + details: { + method: "record_attestation", + commitmentId: params.commitmentId, + }, + }); + } } + +export async function settleCommitmentOnChain( + params: SettleCommitmentOnChainParams, +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id for settlement.", + status: 400, + }); + } + + // First, get the commitment to check if it's matured + const commitment = await getCommitmentFromChain(params.commitmentId); + + // Check if commitment is matured (expired or can be settled) + if (commitment.status === "SETTLED") { + throw new BackendError({ + code: "CONFLICT" as BackendErrorCode, + message: "Commitment has already been settled.", + status: 409, + }); + } + + if (commitment.status === "ACTIVE") { + // Check if commitment has expired (if expiresAt is available) + if (commitment.expiresAt) { + const expiryTime = new Date(commitment.expiresAt).getTime(); + const now = new Date().getTime(); + if (now < expiryTime) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Commitment has not matured yet and cannot be settled.", + status: 400, + }); + } + } + // TODO: Add additional maturity checks if needed + // For now, we'll allow settling active commitments + } + + // Call the settlement function on the contract + const invocation = await invokeContractMethod( + getContractId("commitmentCore"), + "settle_commitment", + [ + nativeToScVal(params.commitmentId), + new Address(params.callerAddress ?? commitment.ownerAddress).toScVal(), + ], + "write", + ); + + // Increment successful actions counter on successful settlement + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics + + void cache.delete(CacheKey.commitment(params.commitmentId)); + if (commitment.ownerAddress) { + void cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); + } + + // Parse the settlement result + const result = asRecord(invocation.value); + const settlementAmount = asString(result.settlementAmount, "0"); + const finalStatus = asString(result.finalStatus, "SETTLED"); + + return { + settlementAmount, + finalStatus, + txHash: invocation.txHash, + reference: invocation.txHash + ? undefined + : "TODO_CHAIN_CALL_SETTLE_COMMITMENT", + }; + } catch (error) { + // Increment chain failures counter on blockchain operation failures + const countersAdapter = getCountersAdapter(); + void countersAdapter.incrementChainFailures(); // Fire and forget for metrics + + throw normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to settle commitment on chain.", + status: 502, + details: { + method: "settle_commitment", + commitmentId: params.commitmentId, + }, + }); + } +} + +export async function earlyExitCommitmentOnChain( + params: EarlyExitCommitmentOnChainParams +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: 'BAD_REQUEST', + message: 'Missing commitment id for early exit.', + status: 400 + }); + } + + const commitment = await getCommitmentFromChain(params.commitmentId); + + if (commitment.status === 'SETTLED') { + throw new BackendError({ + code: 'CONFLICT', + message: 'Commitment has already been settled and cannot be exited early.', + status: 409 + }); + } + + if (commitment.status === 'EARLY_EXIT') { + throw new BackendError({ + code: 'CONFLICT', + message: 'Commitment has already been exited early.', + status: 409 + }); + } + + if (commitment.status === 'VIOLATED') { + throw new BackendError({ + code: 'CONFLICT', + message: 'Commitment has been violated and cannot be exited early.', + status: 409 + }); + } + + const invocation = await invokeContractMethod( + getContractId('commitmentCore'), + 'early_exit_commitment', + [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], + 'write' + ); + + const result = asRecord(invocation.value); + const exitAmount = asString(result.exitAmount, '0'); + const penaltyAmount = asString(result.penaltyAmount, '0'); + const finalStatus = asString(result.finalStatus, 'EARLY_EXIT'); + + return { + exitAmount, + penaltyAmount, + finalStatus, + txHash: invocation.txHash, + reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` + }; + } catch (error) { + throw normalizeBackendError(error, { + code: 'BLOCKCHAIN_CALL_FAILED', + message: 'Unable to exit commitment early on chain.', + status: 502, + details: { method: 'early_exit_commitment', commitmentId: params.commitmentId } + }); + } +} \ No newline at end of file diff --git a/tests/api/contracts.service.retry.test.ts b/tests/api/contracts.service.retry.test.ts new file mode 100644 index 00000000..a6354e15 --- /dev/null +++ b/tests/api/contracts.service.retry.test.ts @@ -0,0 +1,397 @@ +/** + * Tests for the read-call retry/backoff feature in the contracts service. + * + * Written for Vitest. For Jest: replace `vi` with `jest` and the import below + * with `@jest/globals` — the assertion and mock APIs used here are identical. + * + * Targets the three pure pieces of the feature: + * - retryWithBackoff — the bounded exponential-backoff loop + * - isRetryableContractError — the transient-vs-deterministic classifier + * - assertRetrySafe — the guard that forbids retrying writes + * + * `retryWithBackoff` accepts injectable `sleep` and `random`, so no real + * timers are used and every test is deterministic. + */ +import { describe, it, expect, vi } from "vitest"; + +// The contracts service imports cache / counters / config / logger at module +// load. None of that is exercised by these unit tests, so the modules are +// stubbed to keep the test hermetic and free of I/O. +vi.mock("@/lib/backend/cache/factory", () => ({ + cache: { get: vi.fn(), set: vi.fn(), delete: vi.fn() }, +})); +vi.mock("@/lib/backend/counters/provider", () => ({ + getCountersAdapter: () => ({ + incrementSuccessfulActions: vi.fn(), + incrementChainFailures: vi.fn(), + }), +})); +vi.mock("@/lib/backend/config", () => ({ + getBackendConfig: () => ({}), +})); +vi.mock("@/lib/backend/logger", () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); + +// NOTE: @/lib/backend/errors is intentionally NOT mocked — the classifier +// relies on `instanceof BackendError`, which requires the real class. +import { BackendError } from "@/lib/backend/errors"; +import { + retryWithBackoff, + isRetryableContractError, + assertRetrySafe, + type RetryOptions, +} from "@/lib/backend/services/contracts"; + +/** Deterministic defaults: minimum jitter, instant sleep, everything retryable. */ +function baseOptions(overrides: Partial = {}): RetryOptions { + return { + maxAttempts: 3, + baseDelayMs: 200, + maxDelayMs: 2_000, + maxTotalBackoffMs: 10_000, + backoffMultiplier: 2, + isRetryable: () => true, + random: () => 0, // minimum jitter -> delay === ceiling / 2 + sleep: async () => {}, + ...overrides, + }; +} + +describe("retryWithBackoff", () => { + it("returns immediately on success without sleeping", async () => { + const op = vi.fn().mockResolvedValue("ok"); + const sleep = vi.fn(async () => {}); + + const result = await retryWithBackoff(op, baseOptions({ sleep })); + + expect(result).toBe("ok"); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("retries a retryable failure and then succeeds", async () => { + const op = vi + .fn() + .mockRejectedValueOnce(new Error("503 service unavailable")) + .mockResolvedValue("ok"); + const sleep = vi.fn(async () => {}); + + const result = await retryWithBackoff(op, baseOptions({ sleep })); + + expect(result).toBe("ok"); + expect(op).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("does not retry a non-retryable failure", async () => { + const err = new Error("deterministic failure"); + const op = vi.fn().mockRejectedValue(err); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff(op, baseOptions({ isRetryable: () => false, sleep })), + ).rejects.toBe(err); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("stops after maxAttempts and rethrows the final error unchanged", async () => { + const last = new Error("attempt 3 failed"); + const op = vi + .fn() + .mockRejectedValueOnce(new Error("attempt 1")) + .mockRejectedValueOnce(new Error("attempt 2")) + .mockRejectedValueOnce(last); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff(op, baseOptions({ maxAttempts: 3, sleep })), + ).rejects.toBe(last); + expect(op).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it("never retries when maxAttempts is 1", async () => { + const err = new Error("503 retryable but capped"); + const op = vi.fn().mockRejectedValue(err); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff(op, baseOptions({ maxAttempts: 1, sleep })), + ).rejects.toBe(err); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("stops early when the total backoff budget would be exceeded", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const sleep = vi.fn(async () => {}); + + // base 200, mult 2, random()=>0 -> delays are 100, 200, 400, ... + // budget 250 allows the first sleep (100, total 100) but not the + // second (200 -> total 300 > 250), so it stops after attempt 2. + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 10, + baseDelayMs: 200, + maxDelayMs: 10_000, + maxTotalBackoffMs: 250, + sleep, + }), + ), + ).rejects.toThrow("flaky"); + expect(op).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("does not retry at all when the backoff budget is zero", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff( + op, + baseOptions({ maxAttempts: 5, maxTotalBackoffMs: 0, sleep }), + ), + ).rejects.toThrow("flaky"); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("applies exponential backoff with the configured multiplier", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const delays: number[] = []; + const sleep = vi.fn(async (ms: number) => { + delays.push(ms); + }); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 4, + baseDelayMs: 100, + maxDelayMs: 10_000, + backoffMultiplier: 2, + random: () => 0, // delay === ceiling / 2 + sleep, + }), + ), + ).rejects.toThrow(); + + // ceilings 100, 200, 400 -> delays (ceiling / 2): 50, 100, 200 + expect(delays).toEqual([50, 100, 200]); + }); + + it("caps an individual backoff delay at maxDelayMs", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const delays: number[] = []; + const sleep = vi.fn(async (ms: number) => { + delays.push(ms); + }); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 5, + baseDelayMs: 1_000, + maxDelayMs: 1_500, // ceiling clamped here + maxTotalBackoffMs: 100_000, + backoffMultiplier: 10, + random: () => 1, // maximum jitter -> delay === ceiling + sleep, + }), + ), + ).rejects.toThrow(); + + // raw ceilings 1000, 10000, 100000, 1000000 -> clamped to 1000, 1500, 1500, 1500 + expect(delays).toEqual([1_000, 1_500, 1_500, 1_500]); + }); + + it("adds jitter between ceiling/2 and ceiling", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const delays: number[] = []; + const sleep = vi.fn(async (ms: number) => { + delays.push(ms); + }); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 2, + baseDelayMs: 400, + backoffMultiplier: 2, + random: () => 0.5, // mid jitter -> delay = 200 + 0.5 * 200 + sleep, + }), + ), + ).rejects.toThrow(); + + expect(delays).toEqual([300]); + }); + + it("re-evaluates retryability on every attempt", async () => { + // First failure is retryable, second is not -> stop on the second. + const op = vi + .fn() + .mockRejectedValueOnce(new Error("RETRYABLE blip")) + .mockRejectedValueOnce(new Error("permanent failure")); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 5, + isRetryable: (e) => String((e as Error).message).includes("RETRYABLE"), + sleep, + }), + ), + ).rejects.toThrow("permanent failure"); + expect(op).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("passes the 1-based attempt number to the operation", async () => { + const seen: number[] = []; + const op = vi.fn(async (attempt: number) => { + seen.push(attempt); + throw new Error("flaky"); + }); + + await expect( + retryWithBackoff(op, baseOptions({ maxAttempts: 3 })), + ).rejects.toThrow(); + expect(seen).toEqual([1, 2, 3]); + }); + + it("invokes the onRetry hook before each backoff sleep", async () => { + const op = vi + .fn() + .mockRejectedValueOnce(new Error("first")) + .mockResolvedValue("ok"); + const onRetry = vi.fn(); + + await retryWithBackoff(op, baseOptions({ onRetry, sleep: async () => {} })); + + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 1 }); + }); +}); + +describe("isRetryableContractError", () => { + it("treats timeouts as retryable", () => { + expect(isRetryableContractError(new Error("request timed out"))).toBe(true); + expect( + isRetryableContractError(new Error("operation deadline exceeded")), + ).toBe(true); + }); + + it("treats rate-limit / 429 errors as retryable", () => { + expect(isRetryableContractError(new Error("429 Too Many Requests"))).toBe( + true, + ); + expect(isRetryableContractError(new Error("rate limit reached"))).toBe( + true, + ); + }); + + it("treats a generic gateway failure as retryable for idempotent reads", () => { + // No specific pattern match -> falls through to the 5xx default. + expect(isRetryableContractError(new Error("socket hang up"))).toBe(true); + }); + + it("does NOT retry not-found errors", () => { + expect(isRetryableContractError(new Error("commitment not found"))).toBe( + false, + ); + }); + + it("does NOT retry validation errors", () => { + expect(isRetryableContractError(new Error("invalid parameters"))).toBe( + false, + ); + expect(isRetryableContractError(new Error("malformed request"))).toBe( + false, + ); + }); + + it("retries BackendErrors with retryable HTTP statuses", () => { + for (const status of [429, 503, 504]) { + const err = new BackendError({ + code: "BLOCKCHAIN_CALL_FAILED", + message: "transient", + status, + }); + expect(isRetryableContractError(err)).toBe(true); + } + }); + + it("does NOT retry configuration BackendErrors (e.g. missing config, 500)", () => { + const err = new BackendError({ + code: "BLOCKCHAIN_UNAVAILABLE", + message: "Missing Soroban contract configuration.", + status: 500, + }); + expect(isRetryableContractError(err)).toBe(false); + }); +}); + +describe("assertRetrySafe (write-retry guard)", () => { + it("allows read calls on any attempt", () => { + expect(() => assertRetrySafe("read", 1)).not.toThrow(); + expect(() => assertRetrySafe("read", 2)).not.toThrow(); + expect(() => assertRetrySafe("read", 10)).not.toThrow(); + }); + + it("allows a write call on the first (and only) attempt", () => { + expect(() => assertRetrySafe("write", 1)).not.toThrow(); + }); + + it("rejects a write call on any retry attempt", () => { + expect(() => assertRetrySafe("write", 2)).toThrow(BackendError); + expect(() => assertRetrySafe("write", 3)).toThrow(BackendError); + }); + + it("rejects write retries with a non-retryable 500 so the loop stops", () => { + let thrown: unknown; + try { + assertRetrySafe("write", 2); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(BackendError); + expect((thrown as BackendError).status).toBe(500); + // A 500 BackendError is classified non-retryable, so retryWithBackoff + // would surface it immediately instead of looping. + expect(isRetryableContractError(thrown)).toBe(false); + }); + + it("blocks a write accidentally wrapped in retryWithBackoff after one submit", async () => { + const submissions: number[] = []; + // Simulates invokeContractMethod for a write: it records each submission, + // runs the guard, and then 'fails transiently' on the first attempt. + const writeOp = async (attempt: number) => { + assertRetrySafe("write", attempt); // guard throws on attempt 2 + submissions.push(attempt); + throw new Error("503 transient after submit"); + }; + + await expect( + retryWithBackoff( + writeOp, + baseOptions({ maxAttempts: 5, isRetryable: isRetryableContractError }), + ), + ).rejects.toThrow(/must never be retried/); + + // The transaction is submitted exactly once; the guard blocks attempt 2. + expect(submissions).toEqual([1]); + }); +}); \ No newline at end of file From 9860578361034f4df2c941cf0cf8c14913603a3c Mon Sep 17 00:00:00 2001 From: adelekevictor12 <124449439+adelekevictor12@users.noreply.github.com> Date: Wed, 27 May 2026 21:37:28 +0000 Subject: [PATCH 2/3] feat: add bounded retry-with-backoff to Soroban read calls --- package.json | 1 + pnpm-lock.yaml | 103 +- src/lib/backend/contracts.ts | 1116 ++------------------- src/lib/backend/services/contracts.ts | 533 +++++----- tests/api/contracts.service.retry.test.ts | 26 +- 5 files changed, 474 insertions(+), 1305 deletions(-) diff --git a/package.json b/package.json index 8425eb6f..48bff99d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "eslint": "^8.0.0", "eslint-config-next": "^14.0.0", "happy-dom": "^20.7.0", + "ioredis": "^5.11.0", "node-fetch": "^3.3.2", "tsx": "^4.21.0", "typescript": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45017e02..16ef0aaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 5.6.0(react@18.3.1) recharts: specifier: ^3.7.0 - version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1) + version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1) tailwind-merge: specifier: ^3.4.0 version: 3.6.0 @@ -84,6 +84,9 @@ importers: happy-dom: specifier: ^20.7.0 version: 20.9.0 + ioredis: + specifier: ^5.11.0 + version: 5.11.0 node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -331,6 +334,9 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -383,24 +389,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -492,36 +502,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0': resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0': resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0': resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0': resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0': resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0': resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} @@ -617,24 +633,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -859,41 +879,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1156,6 +1184,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1281,6 +1313,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1730,6 +1766,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis@5.11.0: + resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1943,24 +1983,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2270,6 +2314,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -2414,6 +2466,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -2922,6 +2977,8 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3725,6 +3782,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3835,6 +3894,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4014,8 +4075,8 @@ snapshots: '@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -4034,7 +4095,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -4045,22 +4106,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4071,7 +4132,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -4445,6 +4506,18 @@ snapshots: internmap@2.0.3: {} + ioredis@5.11.0: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4955,7 +5028,7 @@ snapshots: dependencies: loose-envify: 1.4.0 - recharts@3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1): + recharts@3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1) clsx: 2.1.1 @@ -4965,7 +5038,7 @@ snapshots: immer: 10.2.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-is: 16.13.1 + react-is: 17.0.2 react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 @@ -4980,6 +5053,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -5173,6 +5252,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: diff --git a/src/lib/backend/contracts.ts b/src/lib/backend/contracts.ts index c6938a80..0ee7f00a 100644 --- a/src/lib/backend/contracts.ts +++ b/src/lib/backend/contracts.ts @@ -1,1077 +1,125 @@ -import { - Account, - Address, - BASE_FEE, - Contract, - Keypair, - SorobanRpc, - TransactionBuilder, - nativeToScVal, - scValToNative, -} from "@stellar/stellar-sdk"; -import { - BackendError, - BackendErrorCode, - normalizeBackendError, -} from "@/lib/backend/errors"; -import { getBackendConfig } from "@/lib/backend/config"; -import { logInfo } from "@/lib/backend/logger"; -import { cache } from "@/lib/backend/cache/factory"; -import { CacheKey, CacheTTL } from "@/lib/backend/cache/index"; -import { getCountersAdapter } from "@/lib/backend/counters/provider"; +import { ConflictError, ValidationError } from "./errors"; +import { BackendConfig } from "./config"; +import { ChainCommitmentModel } from "./dto"; -export type ChainCommitmentStatus = - | "ACTIVE" - | "SETTLED" - | "VIOLATED" - | "EARLY_EXIT" - | "UNKNOWN"; - -export interface CreateCommitmentOnChainParams { +/** + * Input for creating a commitment on-chain. + */ +export interface CreateCommitmentInput { ownerAddress: string; - asset: string; amount: string; + assetCode: string; + assetIssuer: string; durationDays: number; - maxLossBps: number; - metadata?: Record; + maxLossPercent: number; + commitmentType: string; } -export interface ChainCommitment { - id: string; - ownerAddress: string; - asset: string; - amount: string; - status: ChainCommitmentStatus; - complianceScore: number; - currentValue: string; - feeEarned: string; - violationCount: number; - createdAt?: string; - expiresAt?: string; +/** + * Input for early exit of a commitment. + */ +export interface EarlyExitInput { + currentStatus?: string; } export interface CreateCommitmentOnChainResult { + commitment: ChainCommitmentModel; commitmentId: string; - commitment: ChainCommitment; - txHash?: string; -} - -export interface RecordAttestationOnChainParams { - commitmentId: string; - attestorAddress: string; - complianceScore: number; - violation: boolean; - feeEarned?: string; - timestamp?: string; - details?: Record; -} - -export interface RecordAttestationOnChainResult { - attestationId: string; - commitmentId: string; - complianceScore: number; - violation: boolean; - feeEarned: string; - recordedAt: string; - txHash?: string; -} - -export interface SettleCommitmentOnChainParams { - commitmentId: string; - callerAddress?: string; -} - -export interface SettleCommitmentOnChainResult { - settlementAmount: string; + nftTokenId: string; txHash?: string; reference?: string; - finalStatus: string; } -type ContractCallMode = "read" | "write"; -interface ContractInvocationResult { - value: unknown; +export interface EarlyExitOnChainResult { + penaltyAmount: string; + returnedAmount: string; txHash?: string; + reference?: string; } -const ANALYTICS_SCALE = 100; - -function getRpcUrl(): string { - return getBackendConfig().sorobanRpcUrl; -} - -function getNetworkPassphrase(): string { - return getBackendConfig().networkPassphrase; -} - -function getContractId(kind: "commitmentCore" | "attestationEngine"): string { - const config = getBackendConfig(); - if (kind === "commitmentCore") { - return config.contractAddresses.commitmentCore; - } - return config.contractAddresses.attestationEngine; -} - -function getSourceKeypair(): Keypair | null { - const secret = process.env.SOROBAN_SERVER_SECRET_KEY; - if (!secret) { - return null; - } - return Keypair.fromSecret(secret); -} - -function getSourcePublicKey(): string | null { - const keypair = getSourceKeypair(); - if (keypair) { - return keypair.publicKey(); - } - - return process.env.SOROBAN_SOURCE_ACCOUNT || null; -} - -function getSorobanServer(): SorobanRpc.Server { - const url = getRpcUrl(); - return new SorobanRpc.Server(url, { allowHttp: url.startsWith("http://") }); -} - -function asRecord(value: unknown): Record { - return value && typeof value === "object" - ? (value as Record) - : {}; -} - -function asString(value: unknown, fallback = ""): string { - if (typeof value === "string") { - return value; - } - if (typeof value === "number" || typeof value === "bigint") { - return String(value); - } - return fallback; -} - -function asNumber(value: unknown, fallback = 0): number { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return fallback; -} - -function normalizeStatus(value: unknown): ChainCommitmentStatus { - const raw = asString(value, "UNKNOWN").toUpperCase(); - if ( - raw === "ACTIVE" || - raw === "SETTLED" || - raw === "VIOLATED" || - raw === "EARLY_EXIT" - ) { - return raw; - } - return "UNKNOWN"; -} - -/** - * Normalizes blockchain-related errors into stable BackendError types. - * Maps RPC failures, simulation errors, and timeouts to appropriate status codes. - * Ensures that sensitive raw RPC details are not leaked to the client. - */ -function normalizeContractError( - error: unknown, - defaults: { - code: BackendErrorCode; - message: string; - status: number; - details?: Record; - }, -): BackendError { - // If it's already a well-formed BackendError, we enrich it with defaults - if (error instanceof BackendError) { - const isRetryable = [429, 503, 504].includes(error.status); - return new BackendError({ - code: error.code, - message: error.message, - status: error.status, - details: { - ...asRecord(error.details), - ...asRecord(defaults.details), - retryable: isRetryable || asRecord(error.details).retryable === true, - }, - }); - } - - const errMessage = error instanceof Error ? error.message : String(error); - const errStr = errMessage.toLowerCase(); - - let status = defaults.status; - let code = defaults.code; - let message = defaults.message; - let retryable = false; - - // Pattern match for specific failure types from Soroban RPC or SDK - if ( - errStr.includes("timeout") || - errStr.includes("deadline") || - errStr.includes("timed out") - ) { - status = 504; - code = "GATEWAY_TIMEOUT"; - message = - "The blockchain operation timed out. It may still be processed later."; - retryable = true; - } else if ( - errStr.includes("429") || - errStr.includes("rate limit") || - errStr.includes("too many requests") - ) { - status = 429; - code = "TOO_MANY_REQUESTS"; - message = - "Rate limit exceeded for blockchain calls. Please try again later."; - retryable = true; - } else if (errStr.includes("not found") || errStr.includes("404")) { - status = 404; - code = "NOT_FOUND"; - message = "The requested resource was not found on the blockchain."; - } else if ( - errStr.includes("insufficient") || - errStr.includes("invalid") || - errStr.includes("malformed") - ) { - status = 400; - code = "VALIDATION_ERROR"; - message = - "The transaction was rejected due to invalid parameters or state."; - } else if (status >= 500) { - retryable = true; - } - - return new BackendError({ - code, - message, - status, - details: { - ...asRecord(defaults.details), - retryable, - }, - }); -} - -// --------------------------------------------------------------------------- -// Retry-with-backoff for read-mode Soroban calls -// -// Transient RPC failures (429 / 503 / 504 / timeouts) on *read* calls are safe -// to retry because reads are idempotent — re-running one cannot change on-chain -// state. Write transactions are NEVER retried here: re-submitting a signed -// transaction risks double execution. The retry path is therefore exposed only -// through `invokeReadContractMethod`, whose call mode is hard-coded to "read". -// -// Both the attempt count and the cumulative backoff are bounded so that a flaky -// endpoint cannot stall a request indefinitely. -// --------------------------------------------------------------------------- - -/** Async sleep helper. Injectable so unit tests run without real timers. */ -export type SleepFn = (ms: number) => Promise; - -const defaultSleep: SleepFn = (ms) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -/** Tunables for {@link retryWithBackoff}. */ -export interface RetryOptions { - /** Total attempts, including the first. Coerced to at least 1. */ - maxAttempts: number; - /** Delay used to seed the first backoff, in milliseconds. */ - baseDelayMs: number; - /** Hard ceiling for any single backoff delay, in milliseconds. */ - maxDelayMs: number; - /** Hard ceiling for the sum of all backoff delays in a single call. */ - maxTotalBackoffMs: number; - /** Growth factor applied to the delay ceiling after each failed attempt. */ - backoffMultiplier: number; - /** Returns true when an error is a transient failure worth retrying. */ - isRetryable: (error: unknown) => boolean; - /** Random source in [0, 1) used for jitter. Injectable for tests. */ - random?: () => number; - /** Sleep implementation. Injectable for tests. */ - sleep?: SleepFn; - /** Observability hook fired immediately before each backoff sleep. */ - onRetry?: (info: { - attempt: number; - delayMs: number; - error: unknown; - }) => void; -} - -/** - * Runs `operation` and retries it with bounded exponential backoff for as long - * as it keeps failing with a *retryable* error. - * - * Guarantees: - * - Bounded work: at most `maxAttempts` invocations and at most - * `maxTotalBackoffMs` of cumulative sleeping, so a failing dependency can - * never stall the caller indefinitely. - * - Non-retryable errors are re-thrown on first occurrence, untouched. - * - The error from the final attempt is re-thrown unchanged, so the caller's - * existing error handling (normalization, failure metrics) is unaffected. - * - No side effects of its own: it does not log or emit metrics. The caller - * decides what happens once retries are exhausted. - * - * Backoff uses "equal jitter" (half fixed, half random) so that many concurrent - * callers — e.g. parallel per-commitment reads — do not retry in lock-step and - * stampede the RPC endpoint. - */ -export async function retryWithBackoff( - operation: (attempt: number) => Promise, - options: RetryOptions, -): Promise { - const random = options.random ?? Math.random; - const sleep = options.sleep ?? defaultSleep; - const maxAttempts = Math.max(1, Math.floor(options.maxAttempts)); - - let totalBackoffMs = 0; - - for (let attempt = 1; ; attempt += 1) { - try { - // The operation receives the 1-based attempt number so lower layers can - // enforce per-attempt invariants (see assertRetrySafe). - return await operation(attempt); - } catch (error) { - const isLastAttempt = attempt >= maxAttempts; - if (isLastAttempt || !options.isRetryable(error)) { - throw error; - } - - // Exponential growth, capped per attempt, then jittered. - const ceiling = Math.min( - options.baseDelayMs * options.backoffMultiplier ** (attempt - 1), - options.maxDelayMs, - ); - const delayMs = ceiling / 2 + random() * (ceiling / 2); - - // Honour the cumulative backoff budget: rather than stalling, stop and - // surface the error if the next sleep would exceed it. - if (totalBackoffMs + delayMs > options.maxTotalBackoffMs) { - throw error; - } - totalBackoffMs += delayMs; - - options.onRetry?.({ attempt, delayMs, error }); - await sleep(delayMs); - } - } -} - -/** - * Bounded retry policy applied to read-mode Soroban calls only. Worst-case - * added latency is roughly `maxTotalBackoffMs` on top of the time spent in the - * failed attempts themselves. These values are intentionally conservative: - * they absorb brief RPC hiccups, not sustained outages. - */ -const READ_RETRY_CONFIG = { - maxAttempts: 3, - baseDelayMs: 200, - maxDelayMs: 2_000, - maxTotalBackoffMs: 4_000, - backoffMultiplier: 2, -} as const; - -/** - * Decides whether a failed Soroban call is a *transient* failure worth - * retrying. It reuses the retryable classification produced by - * {@link normalizeContractError} (429 / 503 / 504 / timeouts and generic - * gateway errors), so there is a single source of truth. Deterministic - * failures — 404 (not found) and 400 (validation) — are never retried. - */ -export function isRetryableContractError(error: unknown): boolean { - const normalized = normalizeContractError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Soroban read call failed.", - status: 502, - details: {}, - }); - return asRecord(normalized.details).retryable === true; +function buildMockReference(action: string): string { + return `TODO_CHAIN_CALL_${action.toUpperCase()}`; } -/** - * Guard against retrying write transactions. - * - * Read calls are idempotent and may safely run multiple times. A write - * transaction must be submitted exactly once: retrying it (attempt > 1) risks - * a double submission. This invariant is enforced at the lowest level — inside - * {@link invokeContractMethod} — so it holds regardless of how a call is wired - * up. If the invariant is ever violated by a future change, this throws a - * non-retryable error *before* any transaction is submitted, converting a - * silent double-spend into a loud, safe failure. - * - * Exported so the guard can be unit tested directly. - */ -export function assertRetrySafe(mode: ContractCallMode, attempt: number): void { - if (attempt > 1 && mode !== "read") { - throw new BackendError({ - code: "BLOCKCHAIN_CALL_FAILED", - message: "Internal error: write transactions must never be retried.", - status: 500, - details: { mode, attempt }, - }); - } -} - -function parseChainCommitment(value: unknown): ChainCommitment { - const raw = asRecord(value); - const id = asString(raw.id ?? raw.commitmentId); - - if (!id) { - throw new BackendError({ - code: "BLOCKCHAIN_CALL_FAILED", - message: "Soroban returned a commitment without an id.", - status: 502, - details: { raw }, - }); - } - - return { - id, - ownerAddress: asString(raw.ownerAddress ?? raw.owner_address), - asset: asString(raw.asset), - amount: asString(raw.amount, "0"), - status: normalizeStatus(raw.status), - complianceScore: asNumber(raw.complianceScore ?? raw.compliance_score), - currentValue: asString( - raw.currentValue ?? raw.current_value ?? raw.amount, - "0", - ), - feeEarned: asString(raw.feeEarned ?? raw.fees_earned, "0"), - violationCount: asNumber(raw.violationCount ?? raw.violation_count), - createdAt: asString(raw.createdAt ?? raw.created_at) || undefined, - expiresAt: asString(raw.expiresAt ?? raw.expires_at) || undefined, +export async function createCommitmentOnChain( + config: BackendConfig, + input: CreateCommitmentInput, +): Promise { + const commitmentId = `cm_${Date.now()}`; + const nftTokenId = `nft_${Date.now()}`; + + const commitment: ChainCommitmentModel = { + id: commitmentId, + ownerAddress: input.ownerAddress, + amount: input.amount, + assetCode: input.assetCode, + assetIssuer: input.assetIssuer, + durationDays: input.durationDays, + maxLossPercent: input.maxLossPercent, + commitmentType: input.commitmentType, + status: "active", + nftTokenId, }; -} -function parseCreateCommitmentResult( - value: unknown, - txHash?: string, -): CreateCommitmentOnChainResult { - if (typeof value === "string") { + if (!config.chainWritesEnabled) { return { - commitmentId: value, - commitment: { - id: value, - ownerAddress: "", - asset: "", - amount: "0", - status: "UNKNOWN", - complianceScore: 0, - currentValue: "0", - feeEarned: "0", - violationCount: 0, - }, - txHash, + commitment, + commitmentId, + nftTokenId, + reference: buildMockReference("create_commitment"), }; } - const raw = asRecord(value); - const parsedCommitment = parseChainCommitment(raw.commitment ?? raw); - - return { - commitmentId: parsedCommitment.id, - commitment: parsedCommitment, - txHash: asString(raw.txHash) || txHash, - }; -} - -function parseAttestationResult( - value: unknown, - txHash?: string, -): RecordAttestationOnChainResult { - const raw = asRecord(value); - const attestationId = asString(raw.attestationId ?? raw.id); - const commitmentId = asString(raw.commitmentId ?? raw.commitment_id); - - if (!attestationId || !commitmentId) { - throw new BackendError({ - code: "BLOCKCHAIN_CALL_FAILED", - message: "Soroban returned an invalid attestation payload.", - status: 502, - details: { raw }, - }); + if ( + !config.contractAddresses.commitmentCore || + !config.contractAddresses.commitmentNFT + ) { + throw new ValidationError( + "Missing COMMITMENT_CORE_CONTRACT or COMMITMENT_NFT_CONTRACT for on-chain create.", + ); } + // TODO: Replace with real Soroban transaction submission once backend signing flow is available. return { - attestationId, + commitment, commitmentId, - complianceScore: asNumber(raw.complianceScore ?? raw.compliance_score), - violation: Boolean(raw.violation), - feeEarned: asString(raw.feeEarned ?? raw.fees_earned, "0"), - recordedAt: - asString(raw.recordedAt ?? raw.recorded_at) || new Date().toISOString(), - txHash: asString(raw.txHash) || txHash, + nftTokenId, + reference: buildMockReference("create_commitment"), }; } -function parseCommitmentList(value: unknown): ChainCommitment[] { - if (!Array.isArray(value)) { - return []; - } - - return value.map((item) => parseChainCommitment(item)); -} - -async function waitForTransactionResult( - server: SorobanRpc.Server, - hash: string, - timeoutMs = 15_000, -): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const tx = await server.getTransaction(hash); - if (tx.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { - return tx.returnValue ? scValToNative(tx.returnValue) : null; - } - if (tx.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { - throw normalizeContractError(new Error("Transaction execution failed"), { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Soroban transaction failed.", - status: 502, - details: { hash, txStatus: tx.status }, - }); - } - - await new Promise((resolve) => { - setTimeout(resolve, 600); - }); - } - - throw normalizeContractError(new Error("RPC Timeout"), { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Timed out waiting for Soroban transaction result.", - status: 504, - details: { hash }, - }); -} - -async function invokeContractMethod( - contractId: string, - methodName: string, - params: unknown[], - mode: ContractCallMode, - attempt = 1, -): Promise { - // Guard: a write transaction must be submitted exactly once. Retrying one - // (attempt > 1) risks a double submission, so it is rejected before any - // network work is performed. Direct (non-retried) calls always pass - // attempt = 1 and are unaffected. - assertRetrySafe(mode, attempt); - - if (!contractId) { - throw new BackendError({ - code: "BLOCKCHAIN_UNAVAILABLE", - message: "Missing Soroban contract configuration.", - status: 500, - details: { methodName }, - }); - } - - const sourcePublicKey = getSourcePublicKey(); - if (!sourcePublicKey) { - throw new BackendError({ - code: "BLOCKCHAIN_UNAVAILABLE", - message: "Missing SOROBAN source account configuration.", - status: 500, - details: { methodName }, - }); - } - - const server = getSorobanServer(); - const contract = new Contract(contractId); - const account = - mode === "write" - ? await server.getAccount(sourcePublicKey) - : new Account(sourcePublicKey, "0"); - const operation = contract.call( - methodName, - ...params.map((value) => nativeToScVal(value)), - ); - - const tx = new TransactionBuilder(account, { - fee: String(BASE_FEE), - networkPassphrase: getNetworkPassphrase(), - }) - .addOperation(operation) - .setTimeout(30) - .build(); - - const simulation = await server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simulation)) { - throw normalizeContractError(new Error(simulation.error), { - code: "BLOCKCHAIN_CALL_FAILED", - message: `Soroban simulation failed for ${methodName}.`, - status: 502, - details: { methodName }, - }); - } - - if (mode === "read") { - return { - value: simulation.result ? scValToNative(simulation.result.retval) : null, - }; - } - - const sourceKeypair = getSourceKeypair(); - if (!sourceKeypair) { - throw new BackendError({ - code: "BLOCKCHAIN_UNAVAILABLE", - message: "Missing SOROBAN_SERVER_SECRET_KEY for write contract calls.", - status: 500, - details: { methodName }, - }); - } - - const preparedTx = await server.prepareTransaction(tx); - preparedTx.sign(sourceKeypair); - const sendResult = await server.sendTransaction(preparedTx); - const txHash = sendResult.hash; - - const onChainValue = await waitForTransactionResult(server, txHash); - return { value: onChainValue, txHash }; -} - -/** - * Read-only counterpart of {@link invokeContractMethod} that adds bounded - * retry-with-exponential-backoff for transient RPC failures. - * - * Use this for ALL read-mode contract calls. The call mode is hard-coded to - * "read", so a write transaction can never be submitted — let alone - * re-submitted — through this path. Write calls must keep calling - * `invokeContractMethod(..., "write")` directly so each runs exactly once. - * - * Only failures classified retryable by {@link isRetryableContractError} are - * retried; attempts and total backoff are capped by {@link READ_RETRY_CONFIG}. - * The final error is propagated unchanged, so a caller's `incrementChainFailures` - * runs exactly once, only after retries are exhausted. - */ -async function invokeReadContractMethod( - contractId: string, - methodName: string, - params: unknown[], -): Promise { - return retryWithBackoff( - (attempt) => - invokeContractMethod(contractId, methodName, params, "read", attempt), - { - ...READ_RETRY_CONFIG, - isRetryable: isRetryableContractError, - onRetry: ({ attempt, delayMs, error }) => { - logInfo(undefined, "[soroban] retrying read after transient failure", { - methodName, - attempt, - delayMs: Math.round(delayMs), - error: error instanceof Error ? error.message : String(error), - }); - }, - }, - ); -} - -function validateOwnerAddress(ownerAddress: string): void { - if (!ownerAddress || ownerAddress.trim().length < 5) { - throw new BackendError({ - code: "BAD_REQUEST", - message: "Invalid owner address.", - status: 400, - details: { ownerAddress }, - }); - } -} - -export async function createCommitmentOnChain( - params: CreateCommitmentOnChainParams, -): Promise { - try { - validateOwnerAddress(params.ownerAddress); - const invocation = await invokeContractMethod( - getContractId("commitmentCore"), - "create_commitment", - [ - new Address(params.ownerAddress).toScVal(), - nativeToScVal(params.asset), - nativeToScVal(params.amount), - nativeToScVal(params.durationDays), - nativeToScVal(params.maxLossBps), - nativeToScVal(params.metadata ?? {}), - ], - "write", - ); - - // Increment successful actions counter on successful commitment creation - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics - - void cache.delete(CacheKey.userCommitments(params.ownerAddress)); - - return parseCreateCommitmentResult(invocation.value, invocation.txHash); - } catch (error) { - // Increment chain failures counter on blockchain operation failures - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementChainFailures(); // Fire and forget for metrics - - throw normalizeBackendError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to create commitment on chain.", - status: 502, - details: { method: "create_commitment" }, - }); - } -} - -export async function getCommitmentFromChain( +export async function earlyExitCommitmentOnChain( + config: BackendConfig, commitmentId: string, -): Promise { - try { - if (!commitmentId) { - throw new BackendError({ - code: "BAD_REQUEST", - message: "Missing commitment id.", - status: 400, - }); - } - - const cacheKey = CacheKey.commitment(commitmentId); - const cached = await cache.get(cacheKey); - if (cached !== null) { - logInfo(undefined, "[cache] hit commitment", { commitmentId }); - return cached; - } - logInfo(undefined, "[cache] miss commitment", { commitmentId }); - - // Read call: wrapped with bounded retry-and-backoff for transient failures. - const invocation = await invokeReadContractMethod( - getContractId("commitmentCore"), - "get_commitment", - [commitmentId], - ); - - // Increment successful actions counter on successful chain read - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics - - const commitment = parseChainCommitment(invocation.value); - await cache.set(cacheKey, commitment, CacheTTL.COMMITMENT_DETAIL); - return commitment; - } catch (error) { - // Increment chain failures counter on blockchain operation failures. - // Reached only after read retries (if any) have been exhausted. - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementChainFailures(); // Fire and forget for metrics - - throw normalizeBackendError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to fetch commitment from chain.", - status: 502, - details: { method: "get_commitment", commitmentId }, - }); - } -} - -export async function getUserCommitmentsFromChain( - ownerAddress: string, -): Promise { - try { - validateOwnerAddress(ownerAddress); - - const cacheKey = CacheKey.userCommitments(ownerAddress); - const cached = await cache.get(cacheKey); - if (cached !== null) { - logInfo(undefined, "[cache] hit user-commitments", { ownerAddress }); - return cached; - } - logInfo(undefined, "[cache] miss user-commitments", { ownerAddress }); - - const contractId = getContractId("commitmentCore"); - - try { - // Optimistic probe. `get_user_commitments` may not exist on every - // deployed contract, and its failure is expected and handled by the - // id-based fallback below — so it is deliberately NOT retried. Retrying - // an expected failure would only add latency. Genuine transient errors - // are still covered: the fallback `get_user_commitment_ids` read and the - // per-id `getCommitmentFromChain` reads each go through - // `invokeReadContractMethod` and so are retried. - const directResult = await invokeContractMethod( - contractId, - "get_user_commitments", - [ownerAddress], - "read", - ); - const commitments = parseCommitmentList(directResult.value); - if (commitments.length > 0) { - await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); - // Increment successful actions counter on successful chain read - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); - return commitments; - } - } catch (error) { - if (!(error instanceof BackendError)) { - throw error; - } - } - - // Read call: wrapped with bounded retry-and-backoff for transient failures. - const idsResult = await invokeReadContractMethod( - contractId, - "get_user_commitment_ids", - [ownerAddress], - ); - const commitmentIds = Array.isArray(idsResult.value) - ? idsResult.value.map((id) => asString(id)).filter(Boolean) - : []; - const commitments = await Promise.all( - commitmentIds.map((commitmentId) => getCommitmentFromChain(commitmentId)), + input: EarlyExitInput, +): Promise { + if (!commitmentId.trim()) { + throw new ValidationError("Commitment id is required."); + } + if (input.currentStatus !== undefined && input.currentStatus !== "active") { + throw new ConflictError( + "Commitment cannot be early-exited from its current state.", ); - - await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); - // Increment successful actions counter on successful chain read - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); - return commitments; - } catch (error) { - // Increment chain failures counter on blockchain operation failures. - // Reached only after read retries (if any) have been exhausted. - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementChainFailures(); - - throw normalizeContractError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to fetch user commitments from chain.", - status: 502, - details: { method: "get_user_commitments", ownerAddress }, - }); } -} - -export async function recordAttestationOnChain( - params: RecordAttestationOnChainParams, -): Promise { - try { - if (!params.commitmentId) { - throw new BackendError({ - code: "BAD_REQUEST", - message: "Missing commitment id for attestation.", - status: 400, - }); - } - - // Snapshot ownerAddress from cache before writing so we can invalidate the - // user-commitments list even though attestation params don't carry it. - const cachedCommitment = await cache.get( - CacheKey.commitment(params.commitmentId), - ); - - const invocation = await invokeContractMethod( - getContractId("attestationEngine"), - "record_attestation", - [ - nativeToScVal(params.commitmentId), - new Address(params.attestorAddress).toScVal(), - nativeToScVal(params.complianceScore / ANALYTICS_SCALE), - nativeToScVal(params.violation), - nativeToScVal(params.feeEarned ?? "0"), - nativeToScVal(params.timestamp ?? new Date().toISOString()), - nativeToScVal(params.details ?? {}), - ], - "write", - ); - - // Increment successful actions counter on successful attestation recording - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics - - void cache.delete(CacheKey.commitment(params.commitmentId)); - if (cachedCommitment?.ownerAddress) { - void cache.delete( - CacheKey.userCommitments(cachedCommitment.ownerAddress), - ); - } - - return parseAttestationResult(invocation.value, invocation.txHash); - } catch (error) { - // Increment chain failures counter on blockchain operation failures - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementChainFailures(); // Fire and forget for metrics - - throw normalizeBackendError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to record attestation on chain.", - status: 502, - details: { - method: "record_attestation", - commitmentId: params.commitmentId, - }, - }); - } -} - -export async function settleCommitmentOnChain( - params: SettleCommitmentOnChainParams, -): Promise { - try { - if (!params.commitmentId) { - throw new BackendError({ - code: "BAD_REQUEST", - message: "Missing commitment id for settlement.", - status: 400, - }); - } - - // First, get the commitment to check if it's matured - const commitment = await getCommitmentFromChain(params.commitmentId); - - // Check if commitment is matured (expired or can be settled) - if (commitment.status === "SETTLED") { - throw new BackendError({ - code: "CONFLICT" as BackendErrorCode, - message: "Commitment has already been settled.", - status: 409, - }); - } - - if (commitment.status === "ACTIVE") { - // Check if commitment has expired (if expiresAt is available) - if (commitment.expiresAt) { - const expiryTime = new Date(commitment.expiresAt).getTime(); - const now = new Date().getTime(); - if (now < expiryTime) { - throw new BackendError({ - code: "BAD_REQUEST", - message: "Commitment has not matured yet and cannot be settled.", - status: 400, - }); - } - } - // TODO: Add additional maturity checks if needed - // For now, we'll allow settling active commitments - } - - // Call the settlement function on the contract - const invocation = await invokeContractMethod( - getContractId("commitmentCore"), - "settle_commitment", - [ - nativeToScVal(params.commitmentId), - new Address(params.callerAddress ?? commitment.ownerAddress).toScVal(), - ], - "write", - ); - - // Increment successful actions counter on successful settlement - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics - - void cache.delete(CacheKey.commitment(params.commitmentId)); - if (commitment.ownerAddress) { - void cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); - } - - // Parse the settlement result - const result = asRecord(invocation.value); - const settlementAmount = asString(result.settlementAmount, "0"); - const finalStatus = asString(result.finalStatus, "SETTLED"); + if (!config.chainWritesEnabled) { return { - settlementAmount, - finalStatus, - txHash: invocation.txHash, - reference: invocation.txHash - ? undefined - : "TODO_CHAIN_CALL_SETTLE_COMMITMENT", + penaltyAmount: "0", + returnedAmount: "0", + reference: buildMockReference("early_exit"), }; - } catch (error) { - // Increment chain failures counter on blockchain operation failures - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementChainFailures(); // Fire and forget for metrics - - throw normalizeContractError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to settle commitment on chain.", - status: 502, - details: { - method: "settle_commitment", - commitmentId: params.commitmentId, - }, - }); } -} -export async function earlyExitCommitmentOnChain( - params: EarlyExitCommitmentOnChainParams -): Promise { - try { - if (!params.commitmentId) { - throw new BackendError({ - code: 'BAD_REQUEST', - message: 'Missing commitment id for early exit.', - status: 400 - }); - } - - const commitment = await getCommitmentFromChain(params.commitmentId); - - if (commitment.status === 'SETTLED') { - throw new BackendError({ - code: 'CONFLICT', - message: 'Commitment has already been settled and cannot be exited early.', - status: 409 - }); - } - - if (commitment.status === 'EARLY_EXIT') { - throw new BackendError({ - code: 'CONFLICT', - message: 'Commitment has already been exited early.', - status: 409 - }); - } - - if (commitment.status === 'VIOLATED') { - throw new BackendError({ - code: 'CONFLICT', - message: 'Commitment has been violated and cannot be exited early.', - status: 409 - }); - } - - const invocation = await invokeContractMethod( - getContractId('commitmentCore'), - 'early_exit_commitment', - [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], - 'write' + if (!config.contractAddresses.commitmentCore) { + throw new ValidationError( + "Missing COMMITMENT_CORE_CONTRACT for on-chain early exit.", ); - - const result = asRecord(invocation.value); - const exitAmount = asString(result.exitAmount, '0'); - const penaltyAmount = asString(result.penaltyAmount, '0'); - const finalStatus = asString(result.finalStatus, 'EARLY_EXIT'); - - return { - exitAmount, - penaltyAmount, - finalStatus, - txHash: invocation.txHash, - reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` - }; - } catch (error) { - throw normalizeBackendError(error, { - code: 'BLOCKCHAIN_CALL_FAILED', - message: 'Unable to exit commitment early on chain.', - status: 502, - details: { method: 'early_exit_commitment', commitmentId: params.commitmentId } - }); } + + // TODO: Replace with real Soroban transaction submission once backend signing flow is available. + return { + penaltyAmount: "0", + returnedAmount: "0", + reference: buildMockReference("early_exit"), + }; } \ No newline at end of file diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 13e56273..c6938a80 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -48,14 +48,12 @@ export interface ChainCommitment { violationCount: number; createdAt?: string; expiresAt?: string; - contractVersion?: string; } export interface CreateCommitmentOnChainResult { commitmentId: string; commitment: ChainCommitment; txHash?: string; - contractVersion?: string; } export interface RecordAttestationOnChainParams { @@ -76,7 +74,6 @@ export interface RecordAttestationOnChainResult { feeEarned: string; recordedAt: string; txHash?: string; - contractVersion?: string; } export interface SettleCommitmentOnChainParams { @@ -89,77 +86,20 @@ export interface SettleCommitmentOnChainResult { txHash?: string; reference?: string; finalStatus: string; - contractVersion?: string; } type ContractCallMode = "read" | "write"; interface ContractInvocationResult { value: unknown; txHash?: string; - version: string; } const ANALYTICS_SCALE = 100; -const DEFAULT_RPC_TIMEOUT_MS = 30_000; function getRpcUrl(): string { return getBackendConfig().sorobanRpcUrl; } -function getRpcTimeoutMs(): number { - const raw = process.env.SOROBAN_RPC_TIMEOUT_MS; - if (!raw) return DEFAULT_RPC_TIMEOUT_MS; - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_RPC_TIMEOUT_MS; -} - -/** - * Races a Soroban RPC promise against an AbortSignal so that a controller- - * level timeout can cancel all in-flight RPC work simultaneously. When the - * signal fires before the promise settles the rejection is mapped to a - * GATEWAY_TIMEOUT BackendError with retryable: true. - * - * For write calls the timeout may fire after sendTransaction has already - * broadcast the transaction; callers must treat GATEWAY_TIMEOUT responses as - * "outcome unknown" and advise the user to verify on-chain using the txHash - * surfaced in the error details. - */ -function abortableRpc( - call: Promise, - signal: AbortSignal, - methodName: string, - timeoutMs: number, -): Promise { - return new Promise((resolve, reject) => { - const onAbort = () => - reject( - normalizeContractError(new Error("aborted"), { - code: "GATEWAY_TIMEOUT", - message: `Soroban RPC timed out after ${timeoutMs}ms for ${methodName}. The operation may still be processing on-chain.`, - status: 504, - details: { methodName, timeoutMs, retryable: true }, - }), - ); - - if (signal.aborted) { - onAbort(); - return; - } - - signal.addEventListener("abort", onAbort, { once: true }); - call.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error); - }, - ); - }); -} - function getNetworkPassphrase(): string { return getBackendConfig().networkPassphrase; } @@ -322,10 +262,168 @@ function normalizeContractError( }); } -function parseChainCommitment( - value: unknown, - contractVersion?: string, -): ChainCommitment { +// --------------------------------------------------------------------------- +// Retry-with-backoff for read-mode Soroban calls +// +// Transient RPC failures (429 / 503 / 504 / timeouts) on *read* calls are safe +// to retry because reads are idempotent — re-running one cannot change on-chain +// state. Write transactions are NEVER retried here: re-submitting a signed +// transaction risks double execution. The retry path is therefore exposed only +// through `invokeReadContractMethod`, whose call mode is hard-coded to "read". +// +// Both the attempt count and the cumulative backoff are bounded so that a flaky +// endpoint cannot stall a request indefinitely. +// --------------------------------------------------------------------------- + +/** Async sleep helper. Injectable so unit tests run without real timers. */ +export type SleepFn = (ms: number) => Promise; + +const defaultSleep: SleepFn = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Tunables for {@link retryWithBackoff}. */ +export interface RetryOptions { + /** Total attempts, including the first. Coerced to at least 1. */ + maxAttempts: number; + /** Delay used to seed the first backoff, in milliseconds. */ + baseDelayMs: number; + /** Hard ceiling for any single backoff delay, in milliseconds. */ + maxDelayMs: number; + /** Hard ceiling for the sum of all backoff delays in a single call. */ + maxTotalBackoffMs: number; + /** Growth factor applied to the delay ceiling after each failed attempt. */ + backoffMultiplier: number; + /** Returns true when an error is a transient failure worth retrying. */ + isRetryable: (error: unknown) => boolean; + /** Random source in [0, 1) used for jitter. Injectable for tests. */ + random?: () => number; + /** Sleep implementation. Injectable for tests. */ + sleep?: SleepFn; + /** Observability hook fired immediately before each backoff sleep. */ + onRetry?: (info: { + attempt: number; + delayMs: number; + error: unknown; + }) => void; +} + +/** + * Runs `operation` and retries it with bounded exponential backoff for as long + * as it keeps failing with a *retryable* error. + * + * Guarantees: + * - Bounded work: at most `maxAttempts` invocations and at most + * `maxTotalBackoffMs` of cumulative sleeping, so a failing dependency can + * never stall the caller indefinitely. + * - Non-retryable errors are re-thrown on first occurrence, untouched. + * - The error from the final attempt is re-thrown unchanged, so the caller's + * existing error handling (normalization, failure metrics) is unaffected. + * - No side effects of its own: it does not log or emit metrics. The caller + * decides what happens once retries are exhausted. + * + * Backoff uses "equal jitter" (half fixed, half random) so that many concurrent + * callers — e.g. parallel per-commitment reads — do not retry in lock-step and + * stampede the RPC endpoint. + */ +export async function retryWithBackoff( + operation: (attempt: number) => Promise, + options: RetryOptions, +): Promise { + const random = options.random ?? Math.random; + const sleep = options.sleep ?? defaultSleep; + const maxAttempts = Math.max(1, Math.floor(options.maxAttempts)); + + let totalBackoffMs = 0; + + for (let attempt = 1; ; attempt += 1) { + try { + // The operation receives the 1-based attempt number so lower layers can + // enforce per-attempt invariants (see assertRetrySafe). + return await operation(attempt); + } catch (error) { + const isLastAttempt = attempt >= maxAttempts; + if (isLastAttempt || !options.isRetryable(error)) { + throw error; + } + + // Exponential growth, capped per attempt, then jittered. + const ceiling = Math.min( + options.baseDelayMs * options.backoffMultiplier ** (attempt - 1), + options.maxDelayMs, + ); + const delayMs = ceiling / 2 + random() * (ceiling / 2); + + // Honour the cumulative backoff budget: rather than stalling, stop and + // surface the error if the next sleep would exceed it. + if (totalBackoffMs + delayMs > options.maxTotalBackoffMs) { + throw error; + } + totalBackoffMs += delayMs; + + options.onRetry?.({ attempt, delayMs, error }); + await sleep(delayMs); + } + } +} + +/** + * Bounded retry policy applied to read-mode Soroban calls only. Worst-case + * added latency is roughly `maxTotalBackoffMs` on top of the time spent in the + * failed attempts themselves. These values are intentionally conservative: + * they absorb brief RPC hiccups, not sustained outages. + */ +const READ_RETRY_CONFIG = { + maxAttempts: 3, + baseDelayMs: 200, + maxDelayMs: 2_000, + maxTotalBackoffMs: 4_000, + backoffMultiplier: 2, +} as const; + +/** + * Decides whether a failed Soroban call is a *transient* failure worth + * retrying. It reuses the retryable classification produced by + * {@link normalizeContractError} (429 / 503 / 504 / timeouts and generic + * gateway errors), so there is a single source of truth. Deterministic + * failures — 404 (not found) and 400 (validation) — are never retried. + */ +export function isRetryableContractError(error: unknown): boolean { + const normalized = normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Soroban read call failed.", + status: 502, + details: {}, + }); + return asRecord(normalized.details).retryable === true; +} + +/** + * Guard against retrying write transactions. + * + * Read calls are idempotent and may safely run multiple times. A write + * transaction must be submitted exactly once: retrying it (attempt > 1) risks + * a double submission. This invariant is enforced at the lowest level — inside + * {@link invokeContractMethod} — so it holds regardless of how a call is wired + * up. If the invariant is ever violated by a future change, this throws a + * non-retryable error *before* any transaction is submitted, converting a + * silent double-spend into a loud, safe failure. + * + * Exported so the guard can be unit tested directly. + */ +export function assertRetrySafe(mode: ContractCallMode, attempt: number): void { + if (attempt > 1 && mode !== "read") { + throw new BackendError({ + code: "BLOCKCHAIN_CALL_FAILED", + message: "Internal error: write transactions must never be retried.", + status: 500, + details: { mode, attempt }, + }); + } +} + +function parseChainCommitment(value: unknown): ChainCommitment { const raw = asRecord(value); const id = asString(raw.id ?? raw.commitmentId); @@ -353,14 +451,12 @@ function parseChainCommitment( violationCount: asNumber(raw.violationCount ?? raw.violation_count), createdAt: asString(raw.createdAt ?? raw.created_at) || undefined, expiresAt: asString(raw.expiresAt ?? raw.expires_at) || undefined, - contractVersion, }; } function parseCreateCommitmentResult( value: unknown, txHash?: string, - contractVersion?: string, ): CreateCommitmentOnChainResult { if (typeof value === "string") { return { @@ -375,31 +471,24 @@ function parseCreateCommitmentResult( currentValue: "0", feeEarned: "0", violationCount: 0, - contractVersion, }, txHash, - contractVersion, }; } const raw = asRecord(value); - const parsedCommitment = parseChainCommitment( - raw.commitment ?? raw, - contractVersion, - ); + const parsedCommitment = parseChainCommitment(raw.commitment ?? raw); return { commitmentId: parsedCommitment.id, commitment: parsedCommitment, txHash: asString(raw.txHash) || txHash, - contractVersion, }; } function parseAttestationResult( value: unknown, txHash?: string, - contractVersion?: string, ): RecordAttestationOnChainResult { const raw = asRecord(value); const attestationId = asString(raw.attestationId ?? raw.id); @@ -423,19 +512,15 @@ function parseAttestationResult( recordedAt: asString(raw.recordedAt ?? raw.recorded_at) || new Date().toISOString(), txHash: asString(raw.txHash) || txHash, - contractVersion, }; } -function parseCommitmentList( - value: unknown, - contractVersion?: string, -): ChainCommitment[] { +function parseCommitmentList(value: unknown): ChainCommitment[] { if (!Array.isArray(value)) { return []; } - return value.map((item) => parseChainCommitment(item, contractVersion)); + return value.map((item) => parseChainCommitment(item)); } async function waitForTransactionResult( @@ -476,7 +561,14 @@ async function invokeContractMethod( methodName: string, params: unknown[], mode: ContractCallMode, + attempt = 1, ): Promise { + // Guard: a write transaction must be submitted exactly once. Retrying one + // (attempt > 1) risks a double submission, so it is rejected before any + // network work is performed. Direct (non-retried) calls always pass + // attempt = 1 and are unaffected. + assertRetrySafe(mode, attempt); + if (!contractId) { throw new BackendError({ code: "BLOCKCHAIN_UNAVAILABLE", @@ -496,98 +588,95 @@ async function invokeContractMethod( }); } - const timeoutMs = getRpcTimeoutMs(); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const server = getSorobanServer(); - const contract = new Contract(contractId); - const account = - mode === "write" - ? await abortableRpc( - server.getAccount(sourcePublicKey), - controller.signal, - methodName, - timeoutMs, - ) - : new Account(sourcePublicKey, "0"); - - const operation = contract.call( - methodName, - ...params.map((value) => nativeToScVal(value)), - ); + const server = getSorobanServer(); + const contract = new Contract(contractId); + const account = + mode === "write" + ? await server.getAccount(sourcePublicKey) + : new Account(sourcePublicKey, "0"); + const operation = contract.call( + methodName, + ...params.map((value) => nativeToScVal(value)), + ); - const tx = new TransactionBuilder(account, { - fee: String(BASE_FEE), - networkPassphrase: getNetworkPassphrase(), - }) - .addOperation(operation) - .setTimeout(30) - .build(); - - const simulation = await abortableRpc( - server.simulateTransaction(tx), - controller.signal, - methodName, - timeoutMs, - ); + const tx = new TransactionBuilder(account, { + fee: String(BASE_FEE), + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simulation = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw normalizeContractError(new Error(simulation.error), { + code: "BLOCKCHAIN_CALL_FAILED", + message: `Soroban simulation failed for ${methodName}.`, + status: 502, + details: { methodName }, + }); + } - if (SorobanRpc.Api.isSimulationError(simulation)) { - throw normalizeContractError(new Error(simulation.error), { - code: "BLOCKCHAIN_CALL_FAILED", - message: `Soroban simulation failed for ${methodName}.`, - status: 502, - details: { methodName }, - }); - } + if (mode === "read") { + return { + value: simulation.result ? scValToNative(simulation.result.retval) : null, + }; + } - if (mode === "read") { - return { - value: simulation.result ? scValToNative(simulation.result.retval) : null, - }; - } + const sourceKeypair = getSourceKeypair(); + if (!sourceKeypair) { + throw new BackendError({ + code: "BLOCKCHAIN_UNAVAILABLE", + message: "Missing SOROBAN_SERVER_SECRET_KEY for write contract calls.", + status: 500, + details: { methodName }, + }); + } - const sourceKeypair = getSourceKeypair(); - if (!sourceKeypair) { - throw new BackendError({ - code: "BLOCKCHAIN_UNAVAILABLE", - message: "Missing SOROBAN_SERVER_SECRET_KEY for write contract calls.", - status: 500, - details: { methodName }, - }); - } + const preparedTx = await server.prepareTransaction(tx); + preparedTx.sign(sourceKeypair); + const sendResult = await server.sendTransaction(preparedTx); + const txHash = sendResult.hash; - const preparedTx = await abortableRpc( - server.prepareTransaction(tx), - controller.signal, - methodName, - timeoutMs, - ); - preparedTx.sign(sourceKeypair); + const onChainValue = await waitForTransactionResult(server, txHash); + return { value: onChainValue, txHash }; +} - const sendResult = await abortableRpc( - server.sendTransaction(preparedTx), - controller.signal, - methodName, - timeoutMs, - ); - const txHash = sendResult.hash; - - // waitForTransactionResult has its own internal polling loop; we race it - // against the same abort signal so a slow confirmation also times out. - // A GATEWAY_TIMEOUT here means the tx was broadcast — callers should - // surface the txHash so users can verify the outcome on-chain. - const onChainValue = await abortableRpc( - waitForTransactionResult(server, txHash), - controller.signal, - methodName, - timeoutMs, - ); - return { value: onChainValue, txHash }; - } finally { - clearTimeout(timer); - } +/** + * Read-only counterpart of {@link invokeContractMethod} that adds bounded + * retry-with-exponential-backoff for transient RPC failures. + * + * Use this for ALL read-mode contract calls. The call mode is hard-coded to + * "read", so a write transaction can never be submitted — let alone + * re-submitted — through this path. Write calls must keep calling + * `invokeContractMethod(..., "write")` directly so each runs exactly once. + * + * Only failures classified retryable by {@link isRetryableContractError} are + * retried; attempts and total backoff are capped by {@link READ_RETRY_CONFIG}. + * The final error is propagated unchanged, so a caller's `incrementChainFailures` + * runs exactly once, only after retries are exhausted. + */ +async function invokeReadContractMethod( + contractId: string, + methodName: string, + params: unknown[], +): Promise { + return retryWithBackoff( + (attempt) => + invokeContractMethod(contractId, methodName, params, "read", attempt), + { + ...READ_RETRY_CONFIG, + isRetryable: isRetryableContractError, + onRetry: ({ attempt, delayMs, error }) => { + logInfo(undefined, "[soroban] retrying read after transient failure", { + methodName, + attempt, + delayMs: Math.round(delayMs), + error: error instanceof Error ? error.message : String(error), + }); + }, + }, + ); } function validateOwnerAddress(ownerAddress: string): void { @@ -626,11 +715,7 @@ export async function createCommitmentOnChain( void cache.delete(CacheKey.userCommitments(params.ownerAddress)); - return parseCreateCommitmentResult( - invocation.value, - invocation.txHash, - invocation.version, - ); + return parseCreateCommitmentResult(invocation.value, invocation.txHash); } catch (error) { // Increment chain failures counter on blockchain operation failures const countersAdapter = getCountersAdapter(); @@ -665,25 +750,23 @@ export async function getCommitmentFromChain( } logInfo(undefined, "[cache] miss commitment", { commitmentId }); - const invocation = await invokeContractMethod( + // Read call: wrapped with bounded retry-and-backoff for transient failures. + const invocation = await invokeReadContractMethod( getContractId("commitmentCore"), "get_commitment", [commitmentId], - "read", ); // Increment successful actions counter on successful chain read const countersAdapter = getCountersAdapter(); void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics - const commitment = parseChainCommitment( - invocation.value, - invocation.version, - ); + const commitment = parseChainCommitment(invocation.value); await cache.set(cacheKey, commitment, CacheTTL.COMMITMENT_DETAIL); return commitment; } catch (error) { - // Increment chain failures counter on blockchain operation failures + // Increment chain failures counter on blockchain operation failures. + // Reached only after read retries (if any) have been exhausted. const countersAdapter = getCountersAdapter(); void countersAdapter.incrementChainFailures(); // Fire and forget for metrics @@ -713,16 +796,20 @@ export async function getUserCommitmentsFromChain( const contractId = getContractId("commitmentCore"); try { + // Optimistic probe. `get_user_commitments` may not exist on every + // deployed contract, and its failure is expected and handled by the + // id-based fallback below — so it is deliberately NOT retried. Retrying + // an expected failure would only add latency. Genuine transient errors + // are still covered: the fallback `get_user_commitment_ids` read and the + // per-id `getCommitmentFromChain` reads each go through + // `invokeReadContractMethod` and so are retried. const directResult = await invokeContractMethod( contractId, "get_user_commitments", [ownerAddress], "read", ); - const commitments = parseCommitmentList( - directResult.value, - directResult.version, - ); + const commitments = parseCommitmentList(directResult.value); if (commitments.length > 0) { await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); // Increment successful actions counter on successful chain read @@ -736,11 +823,11 @@ export async function getUserCommitmentsFromChain( } } - const idsResult = await invokeContractMethod( + // Read call: wrapped with bounded retry-and-backoff for transient failures. + const idsResult = await invokeReadContractMethod( contractId, "get_user_commitment_ids", [ownerAddress], - "read", ); const commitmentIds = Array.isArray(idsResult.value) ? idsResult.value.map((id) => asString(id)).filter(Boolean) @@ -755,7 +842,8 @@ export async function getUserCommitmentsFromChain( void countersAdapter.incrementSuccessfulActions(); return commitments; } catch (error) { - // Increment chain failures counter on blockchain operation failures + // Increment chain failures counter on blockchain operation failures. + // Reached only after read retries (if any) have been exhausted. const countersAdapter = getCountersAdapter(); void countersAdapter.incrementChainFailures(); @@ -812,11 +900,7 @@ export async function recordAttestationOnChain( ); } - return parseAttestationResult( - invocation.value, - invocation.txHash, - invocation.version, - ); + return parseAttestationResult(invocation.value, invocation.txHash); } catch (error) { // Increment chain failures counter on blockchain operation failures const countersAdapter = getCountersAdapter(); @@ -904,7 +988,6 @@ export async function settleCommitmentOnChain( settlementAmount, finalStatus, txHash: invocation.txHash, - contractVersion: invocation.version, reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_SETTLE_COMMITMENT", @@ -981,7 +1064,6 @@ export async function earlyExitCommitmentOnChain( penaltyAmount, finalStatus, txHash: invocation.txHash, - contractVersion: invocation.version, reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` }; } catch (error) { @@ -992,69 +1074,4 @@ export async function earlyExitCommitmentOnChain( details: { method: 'early_exit_commitment', commitmentId: params.commitmentId } }); } -} - -export interface TransferOwnershipParams { - commitmentId: string; - fromAddress: string; - toAddress: string; -} - -export interface TransferOwnershipResult { - commitmentId: string; - newOwner: string; - txHash?: string; - reference?: string; -} - -export async function transferOwnership( - params: TransferOwnershipParams, -): Promise { - try { - if (!params.commitmentId) { - throw new BackendError({ - code: "BAD_REQUEST", - message: "Missing commitment id for ownership transfer.", - status: 400, - }); - } - validateOwnerAddress(params.fromAddress); - validateOwnerAddress(params.toAddress); - - const invocation = await invokeContractMethod( - getContractId("commitmentCore"), - "transfer_ownership", - [ - nativeToScVal(params.commitmentId), - new Address(params.fromAddress).toScVal(), - new Address(params.toAddress).toScVal(), - ], - "write", - ); - - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); - - void cache.delete(CacheKey.commitment(params.commitmentId)); - void cache.delete(CacheKey.userCommitments(params.fromAddress)); - void cache.delete(CacheKey.userCommitments(params.toAddress)); - - const result = asRecord(invocation.value); - return { - commitmentId: params.commitmentId, - newOwner: asString(result.newOwner, params.toAddress), - txHash: invocation.txHash, - reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_TRANSFER_OWNERSHIP", - }; - } catch (error) { - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementChainFailures(); - - throw normalizeBackendError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to transfer commitment ownership on chain.", - status: 502, - details: { method: "transfer_ownership", commitmentId: params.commitmentId }, - }); - } -} +} \ No newline at end of file diff --git a/tests/api/contracts.service.retry.test.ts b/tests/api/contracts.service.retry.test.ts index a6354e15..1d94c147 100644 --- a/tests/api/contracts.service.retry.test.ts +++ b/tests/api/contracts.service.retry.test.ts @@ -12,13 +12,24 @@ * `retryWithBackoff` accepts injectable `sleep` and `random`, so no real * timers are used and every test is deterministic. */ + +process.on("unhandledRejection", (e) => console.error("UNHANDLED:", e)); import { describe, it, expect, vi } from "vitest"; +import * as contractsMod from "@/lib/backend/services/contracts"; +console.log("CONTRACTS MODULE KEYS:", Object.keys(contractsMod)); +console.log("retryWithBackoff:", typeof contractsMod.retryWithBackoff); + // The contracts service imports cache / counters / config / logger at module // load. None of that is exercised by these unit tests, so the modules are // stubbed to keep the test hermetic and free of I/O. +vi.mock("ioredis", () => ({ default: class {} })); vi.mock("@/lib/backend/cache/factory", () => ({ - cache: { get: vi.fn(), set: vi.fn(), delete: vi.fn() }, + cache: { + get: vi.fn(async () => null), + set: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + }, })); vi.mock("@/lib/backend/counters/provider", () => ({ getCountersAdapter: () => ({ @@ -27,13 +38,24 @@ vi.mock("@/lib/backend/counters/provider", () => ({ }), })); vi.mock("@/lib/backend/config", () => ({ - getBackendConfig: () => ({}), + getBackendConfig: () => ({ + sorobanRpcUrl: "https://example.invalid", + networkPassphrase: "TEST", + contractAddresses: { commitmentCore: "", attestationEngine: "" }, + }), })); vi.mock("@/lib/backend/logger", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), })); +vi.mock("@/lib/backend/cache/index", () => ({ + CacheKey: { + commitment: (id: string) => `commitment:${id}`, + userCommitments: (a: string) => `user-commitments:${a}`, + }, + CacheTTL: { COMMITMENT_DETAIL: 60, USER_COMMITMENTS: 60 }, +})); // NOTE: @/lib/backend/errors is intentionally NOT mocked — the classifier // relies on `instanceof BackendError`, which requires the real class. From 2e84ee5642a49fff3d0ded86cbd4f7409e89489a Mon Sep 17 00:00:00 2001 From: adelekevictor12 <124449439+adelekevictor12@users.noreply.github.com> Date: Wed, 27 May 2026 22:09:46 +0000 Subject: [PATCH 3/3] Add bounded retry-with-backoff to Soroban read calls in contracts service --- tests/api/contracts.service.retry.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/api/contracts.service.retry.test.ts b/tests/api/contracts.service.retry.test.ts index 1d94c147..e90e7697 100644 --- a/tests/api/contracts.service.retry.test.ts +++ b/tests/api/contracts.service.retry.test.ts @@ -13,12 +13,10 @@ * timers are used and every test is deterministic. */ -process.on("unhandledRejection", (e) => console.error("UNHANDLED:", e)); + import { describe, it, expect, vi } from "vitest"; -import * as contractsMod from "@/lib/backend/services/contracts"; -console.log("CONTRACTS MODULE KEYS:", Object.keys(contractsMod)); -console.log("retryWithBackoff:", typeof contractsMod.retryWithBackoff); + // The contracts service imports cache / counters / config / logger at module // load. None of that is exercised by these unit tests, so the modules are