From 7a980e7f18c3ee3ae1a1efa58c0ea3875abccefe Mon Sep 17 00:00:00 2001 From: yusuftomilola Date: Sat, 22 Aug 2026 10:21:32 +0100 Subject: [PATCH] feat(payments): idempotency guard against duplicate payment submission Adds a two-layer idempotency/dedup guard so a single user-intended payment action (deposit, withdraw, early-exit) can't execute more than once across UI double-clicks, network retries, a page reload mid-flight, or two tabs racing the same wallet: - SDK (sdks/typescript): TransactionPipeline.invoke() dedups concurrent calls sharing an idempotencyKey by returning the same in-flight promise instead of building/submitting twice. deposit/withdraw/earlyExit derive a default key from account+tier+asset+amount automatically, with an override available for callers (e.g. the app) that want their own per-intent key. - App (app/src/lib/paymentIntent.ts, new): a persisted per-intent state machine (idle -> building -> awaiting_signature -> submitted -> confirmed | failed) plus a coarser operation lock (kind+address+tier, no nonce) in localStorage. The lock is what stops two browser tabs racing the same deposit even though each tab independently generates its own nonce- suffixed intent key; the per-intent record is what lets a reload mid-flight rehydrate instead of allowing a fresh resubmission. A failed or rejected signing attempt releases the lock so retry isn't blocked forever; a genuinely new deliberate action gets a fresh key. - Wires the guard into deposit/page.tsx's confirm step and gives EarlyExitModal's previously-dead "Exit Early" button a real, idempotency-guarded submission path. - Adds a minimal vitest setup to the app (mirroring the SDK's existing one) plus a CI test step, since the app had no test runner at all before this issue's acceptance criteria required automated double-click/reload coverage. Closes #140 --- .github/workflows/ci.yml | 4 + app/package.json | 6 +- app/pnpm-lock.yaml | 997 ++++++++++++++++++ app/src/app/deposit/page.tsx | 100 +- app/src/components/EarlyExitModal.tsx | 97 +- app/src/lib/paymentIntent.test.ts | 173 +++ app/src/lib/paymentIntent.ts | 319 ++++++ .../src/__tests__/idempotency.test.ts | 200 ++++ sdks/typescript/src/index.ts | 54 +- sdks/typescript/src/transactions.ts | 41 + 10 files changed, 1974 insertions(+), 17 deletions(-) create mode 100644 app/src/lib/paymentIntent.test.ts create mode 100644 app/src/lib/paymentIntent.ts create mode 100644 sdks/typescript/src/__tests__/idempotency.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7e0475..e08addf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,10 @@ jobs: working-directory: app run: pnpm typecheck + - name: Test + working-directory: app + run: pnpm test + sdk: name: TypeScript SDK runs-on: ubuntu-latest diff --git a/app/package.json b/app/package.json index 63c0e9e..af8dd35 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run" }, "dependencies": { "next": "14.2.3", @@ -23,6 +24,7 @@ "@types/react-dom": "^18.3.0", "eslint": "8.57.0", "eslint-config-next": "14.2.3", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "vitest": "^1.6.1" } } diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index ea88821..e3eb426 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: typescript: specifier: ^5.4.5 version: 5.9.3 + vitest: + specifier: ^1.6.1 + version: 1.6.1(@types/node@20.19.39) packages: @@ -58,6 +61,144 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -93,6 +234,19 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -179,12 +333,140 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -221,6 +503,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} @@ -382,11 +667,30 @@ packages: cpu: [x64] os: [win32] + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -407,6 +711,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -454,6 +762,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -490,6 +801,10 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -509,10 +824,17 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -530,6 +852,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -616,6 +941,10 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -627,6 +956,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -691,6 +1024,11 @@ packages: resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -799,6 +1137,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -806,6 +1147,10 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -865,6 +1210,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -879,6 +1229,9 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -887,6 +1240,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -961,6 +1318,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1079,6 +1440,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -1120,6 +1485,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -1155,6 +1523,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1169,13 +1541,22 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1184,6 +1565,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -1202,6 +1587,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1210,6 +1598,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1241,6 +1634,10 @@ packages: resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1276,6 +1673,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1288,6 +1689,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -1308,6 +1713,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1319,6 +1728,15 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1330,6 +1748,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1338,10 +1759,18 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1419,6 +1848,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1482,6 +1916,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1503,6 +1940,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1554,10 +1997,17 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + styled-jsx@5.1.1: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -1585,10 +2035,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1609,6 +2070,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -1634,6 +2099,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -1650,6 +2118,67 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1671,6 +2200,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -1690,6 +2224,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + snapshots: '@babel/runtime@7.29.7': {} @@ -1710,6 +2248,75 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)': dependencies: eslint: 8.57.0 @@ -1754,6 +2361,15 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1811,10 +2427,87 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.16.1': {} + '@sinclair/typebox@0.27.12': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5': @@ -1851,6 +2544,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.9': {} + '@types/json5@0.0.29': {} '@types/node@20.19.39': @@ -1980,10 +2675,43 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + acorn@8.17.0: {} ajv@6.15.0: @@ -2001,6 +2729,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@2.0.1: {} @@ -2076,6 +2806,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@1.1.0: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2107,6 +2839,8 @@ snapshots: dependencies: streamsearch: 1.1.0 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2128,11 +2862,25 @@ snapshots: caniuse-lite@1.0.30001792: {} + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + client-only@0.0.1: {} clsx@2.1.1: {} @@ -2145,6 +2893,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2221,6 +2971,10 @@ snapshots: decimal.js-light@2.5.1: {} + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2235,6 +2989,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + diff-sequences@29.6.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -2374,6 +3130,32 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + escape-string-regexp@4.0.0: {} eslint-config-next@14.2.3(eslint@8.57.0)(typescript@5.9.3): @@ -2569,10 +3351,26 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} eventemitter3@4.0.7: {} + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -2629,6 +3427,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -2647,6 +3448,8 @@ snapshots: generator-function@2.0.1: {} + get-func-name@2.0.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2665,6 +3468,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@8.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -2746,6 +3551,8 @@ snapshots: dependencies: function-bind: 1.1.2 + human-signals@5.0.0: {} + ignore@5.3.2: {} import-fresh@3.3.1: @@ -2864,6 +3671,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@3.0.0: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -2911,6 +3720,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -2947,6 +3758,11 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2959,10 +3775,20 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + lru-cache@10.4.3: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2970,6 +3796,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mimic-fn@4.0.0: {} + minimatch@3.1.5: dependencies: brace-expansion: 1.1.15 @@ -2986,10 +3814,19 @@ snapshots: minipass@7.1.3: {} + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + ms@2.1.3: {} nanoid@3.3.12: {} + nanoid@3.3.18: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -3026,6 +3863,10 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -3072,6 +3913,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3091,6 +3936,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -3105,6 +3954,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -3114,12 +3965,24 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.4: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -3128,8 +3991,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3227,6 +4102,38 @@ snapshots: dependencies: glob: 7.2.3 + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3314,6 +4221,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slash@3.0.0: {} @@ -3327,6 +4236,10 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -3407,8 +4320,14 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@3.0.0: {} + strip-json-comments@3.1.1: {} + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + styled-jsx@5.1.1(react@18.3.1): dependencies: client-only: 0.0.1 @@ -3424,11 +4343,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3450,6 +4375,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.1.0: {} + type-fest@0.20.2: {} typed-array-buffer@1.0.3: @@ -3487,6 +4414,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.4: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -3544,6 +4473,67 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@1.6.1(@types/node@20.19.39): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.39) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.39): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.62.5 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.39): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.39) + vite-node: 1.6.1(@types/node@20.19.39) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -3589,6 +4579,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: @@ -3606,3 +4601,5 @@ snapshots: wrappy@1.0.2: {} yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} diff --git a/app/src/app/deposit/page.tsx b/app/src/app/deposit/page.tsx index 878f1a9..fa2d101 100644 --- a/app/src/app/deposit/page.tsx +++ b/app/src/app/deposit/page.tsx @@ -3,6 +3,17 @@ import { useState, useEffect, Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; +import { loadSession } from '@/lib/wallet/session'; +import { + createIntent, + beginSubmission, + markAwaitingSignature, + markSubmitted, + markConfirmed, + markFailed, + findActiveIntent, + type PaymentIntent, +} from '@/lib/paymentIntent'; const TIERS = [ { id: 'flex', label: 'Flex', lock: 'None', lockMonths: 0, multiplier: 1.0, multiplierLabel: '1.00x', exitFee: '0%', minDeposit: 1, apy: 4.2 }, @@ -31,11 +42,40 @@ function DepositFlow() { const [amount, setAmount] = useState(''); const [txStatus, setTxStatus] = useState(null); const [txError, setTxError] = useState(''); + const [intent, setIntent] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Not gated behind a "must connect wallet" requirement (out of scope + // here) — falls back to a placeholder so the idempotency key is still + // well-formed if a user somehow reaches this page unconnected. + const address = loadSession()?.account.publicKey ?? 'anonymous'; useEffect(() => { if (paramTier) setTierId(paramTier); }, [paramTier]); + // Rehydrate on mount: a previous submission that reached `submitted` (or + // later) before a reload must be resumed, not silently allowed to be + // resubmitted (issue #140). + useEffect(() => { + const active = findActiveIntent('deposit', address, tierId); + if (!active) return; + setIntent(active); + setAmount(active.amount); + setStep(4); + setTxStatus( + active.status === 'confirmed' + ? 'confirmed' + : active.status === 'failed' + ? 'failed' + : 'pending', + ); + if (active.error) setTxError(active.error); + // Intentionally mount-only: this is a one-time rehydration check, not a + // live subscription to tier/address changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const tier = TIERS.find((t) => t.id === tierId)!; const amountNum = parseFloat(amount) || 0; const belowMin = amountNum < tier.minDeposit; @@ -45,14 +85,59 @@ function DepositFlow() { const shares = (amountNum * tier.multiplier).toFixed(0); const estimatedFee = '0.00001 XLM'; - function handleConfirm() { + // Generated once per arrival at the confirm step (issue #140) — a fresh + // nonce every time, so re-entering step 3 with a changed amount/tier + // never silently reuses a stale key, while double-clicking Confirm on + // the SAME step-3 visit reuses the SAME intent. + function handleEnterConfirmStep() { + setIntent(createIntent('deposit', address, tierId, 'USDC', amount)); + setStep(3); + } + + async function handleConfirm() { + if (!intent || submitting) return; + + // The actual, race-safe guard: reads/writes storage synchronously, so + // even two handleConfirm invocations fired back-to-back before any + // re-render (a real double-click) can't both pass this check. + const started = beginSubmission(intent); + if (!started) return; + + setSubmitting(true); + setIntent(started); setStep(4); setTxStatus('pending'); - // sdk.deposit({ tier: tier.label, amount }) would go here - setTimeout(() => setTxStatus('confirmed'), 2000); + + const awaiting = markAwaitingSignature(started); + setIntent(awaiting); + + try { + // sdk.deposit({ tier: tier.label, amount, idempotencyKey: awaiting.key }) would go here + const txHash = await new Promise((resolve) => + setTimeout(() => resolve(`mock-tx-${awaiting.key}`), 2000), + ); + const submitted = markSubmitted(awaiting, txHash); + setIntent(submitted); + const confirmed = markConfirmed(submitted); + setIntent(confirmed); + setTxStatus('confirmed'); + } catch (error) { + const failed = markFailed( + awaiting, + error instanceof Error ? error.message : 'Transaction failed', + ); + setIntent(failed); + setTxStatus('failed'); + setTxError(failed.error ?? 'An error occurred. Please try again.'); + } finally { + setSubmitting(false); + } } function handleRetry() { + // Reuses the SAME (now-failed) intent — a retry continues the same + // user-intended payment, it isn't a new deliberate deposit, so it + // should not get a new nonce. setTxStatus(null); setTxError(''); setStep(3); @@ -129,7 +214,7 @@ function DepositFlow() { style={belowMin || !amountNum ? s.btnDisabled : s.btnPrimary} type="button" disabled={belowMin || !amountNum} - onClick={() => setStep(3)} + onClick={handleEnterConfirmStep} > Continue @@ -152,7 +237,12 @@ function DepositFlow() {
-
diff --git a/app/src/components/EarlyExitModal.tsx b/app/src/components/EarlyExitModal.tsx index 9597e91..4d99d57 100644 --- a/app/src/components/EarlyExitModal.tsx +++ b/app/src/components/EarlyExitModal.tsx @@ -1,6 +1,17 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { loadSession } from "@/lib/wallet/session"; +import { + createIntent, + beginSubmission, + markAwaitingSignature, + markSubmitted, + markConfirmed, + markFailed, + findActiveIntent, + type PaymentIntent, +} from "@/lib/paymentIntent"; function fixed7(n: number) { return Math.round(n * 1e7) / 1e7; @@ -9,6 +20,7 @@ function fixed7(n: number) { export default function EarlyExitModal({ open, onClose, + tier, principal, accruedYield, exitFeeRate, @@ -16,11 +28,81 @@ export default function EarlyExitModal({ }: { open: boolean; onClose: () => void; + /** Which vault tier this position is being exited from — part of the idempotency key (issue #140). */ + tier: string; principal: number; accruedYield: number; exitFeeRate: number; // e.g., 0.0125 remainingDays: number; }) { + const [intent, setIntent] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [status, setStatus] = useState<'idle' | 'pending' | 'confirmed' | 'failed'>('idle'); + const [error, setError] = useState(''); + + const address = loadSession()?.account.publicKey ?? 'anonymous'; + + // Generated once per time the modal opens (issue #140) — reopening with + // different numbers (e.g. accrued yield ticked up) gets a fresh nonce, + // rather than silently reusing a stale key. On open, also rehydrate any + // still-in-flight intent from before a reload instead of starting fresh. + useEffect(() => { + if (!open) return; + const active = findActiveIntent('earlyExit', address, tier); + if (active) { + setIntent(active); + setStatus( + active.status === 'confirmed' + ? 'confirmed' + : active.status === 'failed' + ? 'failed' + : 'pending', + ); + if (active.error) setError(active.error); + return; + } + setIntent(createIntent('earlyExit', address, tier, 'USDC', String(principal))); + setStatus('idle'); + setError(''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + async function handleExitEarly() { + if (!intent || submitting) return; + + const started = beginSubmission(intent); + if (!started) return; + + setSubmitting(true); + setIntent(started); + setStatus('pending'); + + const awaiting = markAwaitingSignature(started); + setIntent(awaiting); + + try { + // sdk.earlyExit({ tier, idempotencyKey: awaiting.key }) would go here + const txHash = await new Promise((resolve) => + setTimeout(() => resolve(`mock-tx-${awaiting.key}`), 2000), + ); + const submitted = markSubmitted(awaiting, txHash); + setIntent(submitted); + const confirmed = markConfirmed(submitted); + setIntent(confirmed); + setStatus('confirmed'); + } catch (err) { + const failed = markFailed( + awaiting, + err instanceof Error ? err.message : 'Transaction failed', + ); + setIntent(failed); + setStatus('failed'); + setError(failed.error ?? 'An error occurred. Please try again.'); + } finally { + setSubmitting(false); + } + } + const calc = useMemo(() => { const p = fixed7(principal); const y = fixed7(accruedYield); @@ -76,6 +158,9 @@ export default function EarlyExitModal({ ~{calc.opportunity.toFixed(2)} USDC + {status === 'pending' &&

Submitting transaction…

} + {status === 'confirmed' &&

Exit confirmed.

} + {status === 'failed' &&

{error || 'An error occurred. Please try again.'}

}
- +
diff --git a/app/src/lib/paymentIntent.test.ts b/app/src/lib/paymentIntent.test.ts new file mode 100644 index 0000000..5b1df51 --- /dev/null +++ b/app/src/lib/paymentIntent.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createIntent, + beginSubmission, + markAwaitingSignature, + markSubmitted, + markConfirmed, + markFailed, + findActiveIntent, + loadIntent, + isTerminal, + type StorageLike, +} from './paymentIntent'; + +function makeMemoryStorage(): StorageLike { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + }; +} + +describe('paymentIntent (issue #140)', () => { + let storage: StorageLike; + + beforeEach(() => { + storage = makeMemoryStorage(); + }); + + it('generates distinct keys for two intents created independently, even with identical params', () => { + const a = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const b = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + + expect(a.key).not.toBe(b.key); + }); + + it('rapid double-invocation for the same intent: the second beginSubmission is refused', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + + const first = beginSubmission(intent, storage); + expect(first).not.toBeNull(); + expect(first!.status).toBe('building'); + + // Simulate a double-click: the caller still holds the ORIGINAL idle + // intent object (React state hasn't re-rendered yet) and re-invokes. + const second = beginSubmission(intent, storage); + expect(second).toBeNull(); + }); + + it('a second click using the already-updated (building) intent is also refused', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const building = beginSubmission(intent, storage)!; + + const again = beginSubmission(building, storage); + expect(again).toBeNull(); + }); + + it('blocks a second tab racing the same logical operation with a DIFFERENT intent key', () => { + const tabA = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const tabB = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + expect(tabA.key).not.toBe(tabB.key); + + const startedA = beginSubmission(tabA, storage); + expect(startedA).not.toBeNull(); + + // Tab B's own intent is still `idle`, but the operation lock is held by + // tab A's (different) intent — must be refused. + const startedB = beginSubmission(tabB, storage); + expect(startedB).toBeNull(); + }); + + it('does not conflate two genuinely separate deliberate deposits (sequential, not concurrent)', () => { + const first = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const startedFirst = beginSubmission(first, storage)!; + markConfirmed(startedFirst, storage); + + const second = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const startedSecond = beginSubmission(second, storage); + + expect(startedSecond).not.toBeNull(); + expect(startedSecond!.status).toBe('building'); + }); + + it('releases the lock on failure so a resubmission is not blocked forever', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const building = beginSubmission(intent, storage)!; + const awaiting = markAwaitingSignature(building, storage); + const failed = markFailed(awaiting, 'User rejected the signing request', storage); + + expect(failed.status).toBe('failed'); + expect(isTerminal(failed.status)).toBe(true); + + // Retry: the SAME intent (still failed/idle-eligible) can re-enter building. + const retried = beginSubmission(failed, storage); + expect(retried).not.toBeNull(); + expect(retried!.status).toBe('building'); + + // A completely fresh intent for the same operation is also unblocked. + const fresh = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + // ...but the retried intent above already re-acquired the lock, so this + // fresh one is correctly refused until THAT one resolves. + expect(beginSubmission(fresh, storage)).toBeNull(); + }); + + it('releases the lock on success so a later fresh deposit is not blocked', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const building = beginSubmission(intent, storage)!; + const submitted = markSubmitted(building, 'tx-hash-1', storage); + expect(submitted.status).toBe('submitted'); + expect(submitted.txHash).toBe('tx-hash-1'); + + markConfirmed(submitted, storage); + + const next = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + expect(beginSubmission(next, storage)).not.toBeNull(); + }); + + it('findActiveIntent rehydrates a submitted-but-not-yet-terminal intent (reload mid-flight)', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const building = beginSubmission(intent, storage)!; + const awaiting = markAwaitingSignature(building, storage); + markSubmitted(awaiting, 'tx-hash-2', storage); + + // Simulate a page reload: nothing but storage survives. + const rehydrated = findActiveIntent('deposit', 'GADDR', 'Flex', storage); + expect(rehydrated).not.toBeNull(); + expect(rehydrated!.key).toBe(intent.key); + expect(rehydrated!.status).toBe('submitted'); + expect(rehydrated!.txHash).toBe('tx-hash-2'); + }); + + it('findActiveIntent returns null once the intent has reached a terminal state', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const building = beginSubmission(intent, storage)!; + markConfirmed(building, storage); + + expect(findActiveIntent('deposit', 'GADDR', 'Flex', storage)).toBeNull(); + }); + + it('findActiveIntent returns null when nothing has ever been started', () => { + expect(findActiveIntent('deposit', 'GADDR', 'Flex', storage)).toBeNull(); + }); + + it('different tiers for the same address do not share a lock', () => { + const flex = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const l3 = createIntent('deposit', 'GADDR', 'L3', 'USDC', '50', storage); + + expect(beginSubmission(flex, storage)).not.toBeNull(); + expect(beginSubmission(l3, storage)).not.toBeNull(); + }); + + it('different kinds (deposit vs earlyExit) for the same address/tier do not share a lock', () => { + const deposit = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const exit = createIntent('earlyExit', 'GADDR', 'Flex', 'USDC', '10', storage); + + expect(beginSubmission(deposit, storage)).not.toBeNull(); + expect(beginSubmission(exit, storage)).not.toBeNull(); + }); + + it('persists intent state through loadIntent by key', () => { + const intent = createIntent('withdraw', 'GADDR', 'L6', 'USDC', '100', storage); + beginSubmission(intent, storage); + + const reloaded = loadIntent(intent.key, storage); + expect(reloaded).not.toBeNull(); + expect(reloaded!.status).toBe('building'); + }); +}); diff --git a/app/src/lib/paymentIntent.ts b/app/src/lib/paymentIntent.ts new file mode 100644 index 0000000..e050725 --- /dev/null +++ b/app/src/lib/paymentIntent.ts @@ -0,0 +1,319 @@ +// Application-level idempotency guard for user-initiated payment actions +// (deposit / withdraw / early-exit) — issue #140. Shared by +// app/src/app/deposit/page.tsx and app/src/components/EarlyExitModal.tsx. +// +// Two records are persisted per logical operation: +// - an "intent" (keyed by kind+address+tier+asset+amount+nonce) tracking +// this specific attempt's state machine: idle -> building -> +// awaiting_signature -> submitted -> confirmed | failed. +// - an "operation lock" (keyed by kind+address+tier, NO nonce) pointing at +// whichever intent currently holds the right to submit. This is what +// stops two browser tabs racing the same deposit even though each tab +// generates its own nonce-suffixed intent key independently. +// +// Deliberately backed by localStorage rather than sessionStorage (unlike +// wallet/session.ts's session, which is intentionally per-tab): a reload +// AND a second tab both need to see the same lock, and localStorage is the +// only same-origin storage both share. + +export type IntentKind = 'deposit' | 'withdraw' | 'earlyExit'; + +export type IntentStatus = + | 'idle' + | 'building' + | 'awaiting_signature' + | 'submitted' + | 'confirmed' + | 'failed'; + +export interface PaymentIntent { + key: string; + kind: IntentKind; + address: string; + tier: string; + asset: string; + amount: string; + status: IntentStatus; + txHash: string | null; + error: string | null; + createdAt: number; + updatedAt: number; +} + +export interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +interface Lock { + intentKey: string; +} + +const INTENT_PREFIX = 'yl:intent:'; +const LOCK_PREFIX = 'yl:intent-lock:'; + +// In-memory fallback so this module works during SSR and in tests without +// a real browser localStorage. This never persists across an actual page +// reload — but that gap only matters in a real browser tab, where +// localStorage exists and is used instead. +function makeMemoryStorage(): StorageLike { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + }; +} + +const memoryFallback = makeMemoryStorage(); + +function defaultStorage(): StorageLike { + if (typeof window === 'undefined') return memoryFallback; + try { + return window.localStorage; + } catch { + return memoryFallback; + } +} + +function intentStorageKey(key: string): string { + return `${INTENT_PREFIX}${key}`; +} + +function lockStorageKey(kind: IntentKind, address: string, tier: string): string { + return `${LOCK_PREFIX}${kind}:${address}:${tier}`; +} + +function generateNonce(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Every field except the nonce is deterministic for a given logical + * operation. The nonce is what distinguishes two deliberate repeat actions + * (same params, different intent, e.g. two separate deposits of the same + * amount minutes apart) from an accidental resubmission of the SAME intent + * (double-clicking reuses the already-generated key, not a new one). + */ +function buildIntentKey( + kind: IntentKind, + address: string, + tier: string, + asset: string, + amount: string, +): string { + return [kind, address, tier, asset, amount, generateNonce()].join(':'); +} + +export function isTerminal(status: IntentStatus): boolean { + return status === 'confirmed' || status === 'failed'; +} + +export function createIntent( + kind: IntentKind, + address: string, + tier: string, + asset: string, + amount: string, + storage: StorageLike = defaultStorage(), +): PaymentIntent { + const now = Date.now(); + const intent: PaymentIntent = { + key: buildIntentKey(kind, address, tier, asset, amount), + kind, + address, + tier, + asset, + amount, + status: 'idle', + txHash: null, + error: null, + createdAt: now, + updatedAt: now, + }; + saveIntent(intent, storage); + return intent; +} + +export function loadIntent( + key: string, + storage: StorageLike = defaultStorage(), +): PaymentIntent | null { + const raw = storage.getItem(intentStorageKey(key)); + if (!raw) return null; + try { + return JSON.parse(raw) as PaymentIntent; + } catch { + return null; + } +} + +export function saveIntent( + intent: PaymentIntent, + storage: StorageLike = defaultStorage(), +): void { + storage.setItem(intentStorageKey(intent.key), JSON.stringify(intent)); +} + +export function clearIntent( + key: string, + storage: StorageLike = defaultStorage(), +): void { + storage.removeItem(intentStorageKey(key)); +} + +function loadLock( + kind: IntentKind, + address: string, + tier: string, + storage: StorageLike, +): Lock | null { + const raw = storage.getItem(lockStorageKey(kind, address, tier)); + if (!raw) return null; + try { + return JSON.parse(raw) as Lock; + } catch { + return null; + } +} + +function saveLock( + kind: IntentKind, + address: string, + tier: string, + intentKey: string, + storage: StorageLike, +): void { + storage.setItem(lockStorageKey(kind, address, tier), JSON.stringify({ intentKey })); +} + +function releaseLock( + kind: IntentKind, + address: string, + tier: string, + storage: StorageLike, +): void { + storage.removeItem(lockStorageKey(kind, address, tier)); +} + +/** + * Finds a persisted, not-yet-terminal intent for this logical operation — + * used on page load / component mount to rehydrate instead of allowing a + * fresh submission while a previous one is still `submitted` (or further + * along) after a reload mid-flight. + */ +export function findActiveIntent( + kind: IntentKind, + address: string, + tier: string, + storage: StorageLike = defaultStorage(), +): PaymentIntent | null { + const lock = loadLock(kind, address, tier, storage); + if (!lock) return null; + const intent = loadIntent(lock.intentKey, storage); + if (!intent || isTerminal(intent.status)) return null; + return intent; +} + +/** + * The re-entry guard. Refuses (returns null) when: + * - this exact intent has already left idle/failed — a double-click on + * the same confirm button reuses the same already-generated intent, so + * its second call sees status !== idle/failed and is refused; or + * - a DIFFERENT intent for the same logical operation (kind/address/tier) + * currently holds the lock and hasn't reached a terminal state — this + * is what stops two browser tabs racing the same deposit even though + * each tab independently generated its own intent key. + * + * On success, returns the intent transitioned to `building` — the caller + * must persist further transitions via markAwaitingSignature/markSubmitted/ + * markConfirmed/markFailed as the real submission progresses. + */ +export function beginSubmission( + intent: PaymentIntent, + storage: StorageLike = defaultStorage(), +): PaymentIntent | null { + // Always re-check the PERSISTED status by key, never the possibly-stale + // `intent.status` the caller happens to be holding — a double-click's + // second handler invocation typically still closes over the exact same + // (now-stale) React state object from before the first click's update, + // so trusting `intent.status` here would defeat the guard entirely. + const current = loadIntent(intent.key, storage) ?? intent; + if (current.status !== 'idle' && current.status !== 'failed') { + return null; + } + + const lock = loadLock(intent.kind, intent.address, intent.tier, storage); + if (lock && lock.intentKey !== intent.key) { + const holder = loadIntent(lock.intentKey, storage); + if (holder && !isTerminal(holder.status)) { + return null; + } + } + + const updated: PaymentIntent = { + ...current, + status: 'building', + error: null, + updatedAt: Date.now(), + }; + saveIntent(updated, storage); + saveLock(intent.kind, intent.address, intent.tier, intent.key, storage); + return updated; +} + +export function markAwaitingSignature( + intent: PaymentIntent, + storage: StorageLike = defaultStorage(), +): PaymentIntent { + return transition(intent, { status: 'awaiting_signature' }, storage); +} + +export function markSubmitted( + intent: PaymentIntent, + txHash: string, + storage: StorageLike = defaultStorage(), +): PaymentIntent { + return transition(intent, { status: 'submitted', txHash }, storage); +} + +export function markConfirmed( + intent: PaymentIntent, + storage: StorageLike = defaultStorage(), +): PaymentIntent { + const updated = transition(intent, { status: 'confirmed' }, storage); + releaseLock(intent.kind, intent.address, intent.tier, storage); + return updated; +} + +/** + * Also releases the operation lock — a rejected/cancelled/failed + * submission must never leave a stuck lock blocking all future + * submissions for this operation (issue #140 edge case: wallet rejects + * signing after `awaiting_signature`). + */ +export function markFailed( + intent: PaymentIntent, + error: string, + storage: StorageLike = defaultStorage(), +): PaymentIntent { + const updated = transition(intent, { status: 'failed', error }, storage); + releaseLock(intent.kind, intent.address, intent.tier, storage); + return updated; +} + +function transition( + intent: PaymentIntent, + patch: Partial, + storage: StorageLike, +): PaymentIntent { + const current = loadIntent(intent.key, storage) ?? intent; + const updated: PaymentIntent = { ...current, ...patch, updatedAt: Date.now() }; + saveIntent(updated, storage); + return updated; +} diff --git a/sdks/typescript/src/__tests__/idempotency.test.ts b/sdks/typescript/src/__tests__/idempotency.test.ts new file mode 100644 index 0000000..7768e93 --- /dev/null +++ b/sdks/typescript/src/__tests__/idempotency.test.ts @@ -0,0 +1,200 @@ +import { randomBytes } from 'crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Address, Keypair, nativeToScVal, xdr } from '@stellar/stellar-sdk'; +import { YieldLadder } from '../index'; +import type { YieldLadderOptions } from '../types'; + +const mockGetAccount = vi.fn(); +const mockSimulateTransaction = vi.fn(); +const mockSendTransaction = vi.fn(); +const mockAssembleTransaction = vi.fn(); + +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual( + '@stellar/stellar-sdk', + ); + return { + ...actual, + SorobanRpc: { + ...actual.SorobanRpc, + Server: vi.fn().mockImplementation(() => ({ + getAccount: (...args: unknown[]) => mockGetAccount(...args), + simulateTransaction: (...args: unknown[]) => + mockSimulateTransaction(...args), + sendTransaction: (...args: unknown[]) => mockSendTransaction(...args), + })), + assembleTransaction: (...args: unknown[]) => + mockAssembleTransaction(...args), + }, + }; +}); + +const routerContractId = Address.contract(randomBytes(32)).toString(); +const assetContractId = Address.contract(randomBytes(32)).toString(); +const userKeypair = Keypair.random(); + +function makeOptions(overrides: Partial = {}): YieldLadderOptions { + return { + network: 'testnet', + publicKey: userKeypair.publicKey(), + signer: { signTransaction: vi.fn(async (xdrString: string) => xdrString) }, + vaultRouterContractId: routerContractId, + assetContractId, + ...overrides, + }; +} + +function fakeAccount(publicKey: string) { + return { + accountId: () => publicKey, + sequenceNumber: () => '1', + incrementSequenceNumber: () => undefined, + }; +} + +function successSim(retval: xdr.ScVal) { + return { + id: '1', + latestLedger: 100, + events: [], + _parsed: true, + transactionData: {}, + minResourceFee: '100', + cost: { cpuInsns: '0', memBytes: '0' }, + result: { auth: [], retval }, + }; +} + +/** Resolves sendTransaction only once `resolve()` is called — lets a test + * hold a "submission" open long enough to fire a second, racing call. */ +function positionScVal(principal: bigint, shares: bigint, lockUntil: number) { + return xdr.ScVal.scvMap( + [ + ['principal', nativeToScVal(principal, { type: 'i128' })], + ['shares', nativeToScVal(shares, { type: 'i128' })], + ['lock_until', xdr.ScVal.scvU32(lockUntil)], + ].map( + ([key, val]) => + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol(key as string), + val: val as xdr.ScVal, + }), + ), + ); +} + +function deferredSend() { + let resolve!: (value: { status: string; hash: string }) => void; + const promise = new Promise<{ status: string; hash: string }>((r) => { + resolve = r; + }); + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + mockSendTransaction.mockReturnValueOnce(promise); + return { resolve }; +} + +describe('idempotency guard (issue #140)', () => { + let sdk: YieldLadder; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetAccount.mockImplementation(async (pk: string) => fakeAccount(pk)); + mockSimulateTransaction.mockResolvedValue(successSim(xdr.ScVal.scvVoid())); + sdk = new YieldLadder(makeOptions()); + }); + + it('rapid double-invocation of deposit for the same intent results in exactly one submitted transaction', async () => { + const deferred = deferredSend(); + + const first = sdk.deposit({ tier: 'Flex', amount: '10' }); + const second = sdk.deposit({ tier: 'Flex', amount: '10' }); + + deferred.resolve({ status: 'PENDING', hash: 'only-hash' }); + const [hash1, hash2] = await Promise.all([first, second]); + + expect(hash1).toBe('only-hash'); + expect(hash2).toBe('only-hash'); + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + }); + + it('rapid double-invocation of withdraw for the same intent results in exactly one submitted transaction', async () => { + mockSimulateTransaction.mockResolvedValue( + successSim(positionScVal(5_000_000_000n, 5_000_000_000n, 0)), + ); + const deferred = deferredSend(); + + const first = sdk.withdraw({ tier: 'L3' }); + const second = sdk.withdraw({ tier: 'L3' }); + + deferred.resolve({ status: 'PENDING', hash: 'withdraw-only-hash' }); + const [hash1, hash2] = await Promise.all([first, second]); + + expect(hash1).toBe('withdraw-only-hash'); + expect(hash2).toBe('withdraw-only-hash'); + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + }); + + it('does not dedup two calls with an explicitly different idempotencyKey', async () => { + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + mockSendTransaction + .mockResolvedValueOnce({ status: 'PENDING', hash: 'hash-a' }) + .mockResolvedValueOnce({ status: 'PENDING', hash: 'hash-b' }); + + const hashA = await sdk.deposit({ tier: 'Flex', amount: '10', idempotencyKey: 'intent-a' }); + const hashB = await sdk.deposit({ tier: 'Flex', amount: '10', idempotencyKey: 'intent-b' }); + + expect(hashA).toBe('hash-a'); + expect(hashB).toBe('hash-b'); + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + }); + + it('allows a later call with the same default key once the first has settled', async () => { + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + mockSendTransaction + .mockResolvedValueOnce({ status: 'PENDING', hash: 'first-hash' }) + .mockResolvedValueOnce({ status: 'PENDING', hash: 'second-hash' }); + + const first = await sdk.deposit({ tier: 'Flex', amount: '10' }); + const second = await sdk.deposit({ tier: 'Flex', amount: '10' }); + + expect(first).toBe('first-hash'); + expect(second).toBe('second-hash'); + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + }); + + it('releases the in-flight key on failure, so an immediate retry with the same key is not blocked forever', async () => { + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + mockSendTransaction + .mockResolvedValueOnce({ status: 'ERROR', hash: 'bad-hash', errorResult: { result: () => ({ switch: () => ({ name: 'txFailed' }) }) } }) + .mockResolvedValueOnce({ status: 'PENDING', hash: 'retry-hash' }); + + await expect(sdk.deposit({ tier: 'Flex', amount: '10' })).rejects.toThrow(); + const retried = await sdk.deposit({ tier: 'Flex', amount: '10' }); + + expect(retried).toBe('retry-hash'); + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + }); + + it('deduplicates two concurrent calls with the same explicit idempotencyKey even across different tiers/amounts', async () => { + const deferred = deferredSend(); + + const first = sdk.deposit({ tier: 'Flex', amount: '10', idempotencyKey: 'shared-key' }); + const second = sdk.deposit({ tier: 'L3', amount: '50', idempotencyKey: 'shared-key' }); + + deferred.resolve({ status: 'PENDING', hash: 'shared-hash' }); + const [hash1, hash2] = await Promise.all([first, second]); + + expect(hash1).toBe('shared-hash'); + expect(hash2).toBe('shared-hash'); + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 4f5a066..9b64842 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -46,8 +46,21 @@ export class YieldLadder { }); } - /** Deposits `amount` (decimal USDC string) into `tier`. Returns the submitted transaction hash. */ - async deposit(params: { tier: Tier; amount: string }): Promise { + /** + * Deposits `amount` (decimal USDC string) into `tier`. Returns the + * submitted transaction hash. + * + * `idempotencyKey` guards against a double-click/retry submitting twice + * (issue #140) — defaults to a key derived from the account/tier/asset/ + * amount, so two calls with identical params dedup automatically. Pass + * your own key (e.g. a per-intent nonce) if you need two calls with + * otherwise-identical params to NOT be conflated. + */ + async deposit(params: { + tier: Tier; + amount: string; + idempotencyKey?: string; + }): Promise { const stroops = this.toStroops(params.amount); if (stroops < TIER_MIN_DEPOSIT[params.tier]) { throw new BelowMinDepositError(); @@ -62,11 +75,17 @@ export class YieldLadder { addressToScVal(this.options.assetContractId), amountToScVal(stroops), ], + idempotencyKey: + params.idempotencyKey ?? + this.defaultIdempotencyKey('deposit', params.tier, params.amount), }); } - /** Withdraws the caller's full matured position from `tier`. Returns the submitted transaction hash. */ - async withdraw(params: { tier: Tier }): Promise { + /** + * Withdraws the caller's full matured position from `tier`. Returns the + * submitted transaction hash. See `deposit`'s doc for `idempotencyKey`. + */ + async withdraw(params: { tier: Tier; idempotencyKey?: string }): Promise { const position = await this.queryTierPosition( this.options.publicKey, params.tier, @@ -81,11 +100,17 @@ export class YieldLadder { addressToScVal(this.options.assetContractId), amountToScVal(BigInt(position.principal)), ], + idempotencyKey: + params.idempotencyKey ?? + this.defaultIdempotencyKey('withdraw', params.tier, position.principal), }); } - /** Exits the caller's full position from `tier` before maturity (exit fee applies). */ - async earlyExit(params: { tier: Tier }): Promise { + /** + * Exits the caller's full position from `tier` before maturity (exit fee + * applies). See `deposit`'s doc for `idempotencyKey`. + */ + async earlyExit(params: { tier: Tier; idempotencyKey?: string }): Promise { const position = await this.queryTierPosition( this.options.publicKey, params.tier, @@ -100,9 +125,26 @@ export class YieldLadder { addressToScVal(this.options.assetContractId), amountToScVal(BigInt(position.principal)), ], + idempotencyKey: + params.idempotencyKey ?? + this.defaultIdempotencyKey('early_exit', params.tier, position.principal), }); } + private defaultIdempotencyKey( + method: string, + tier: Tier, + amount: string, + ): string { + return [ + method, + this.options.publicKey, + tier, + this.options.assetContractId, + amount, + ].join(':'); + } + async position(address: string): Promise { const positions = await Promise.all( TIERS.map(tier => this.queryTierPosition(address, tier)), diff --git a/sdks/typescript/src/transactions.ts b/sdks/typescript/src/transactions.ts index a399092..9f0d63b 100644 --- a/sdks/typescript/src/transactions.ts +++ b/sdks/typescript/src/transactions.ts @@ -50,6 +50,16 @@ export interface InvokeContractParams { contractId: string; method: string; args: xdr.ScVal[]; + /** + * Dedup key (issue #140): concurrent invoke() calls sharing the same + * idempotencyKey return the SAME in-flight promise instead of each + * building/simulating/submitting their own transaction — guards a + * double-click or a retry-after-dropped-response from racing two + * separate submissions of the same intent. The key is released the + * moment the call settles (success or failure), so it never blocks a + * later, genuinely separate call reusing the same key. + */ + idempotencyKey?: string; } /** Encodes a Tier the same way Soroban encodes any #[contracttype] enum variant. */ @@ -73,6 +83,7 @@ export function amountToScVal(amount: bigint): xdr.ScVal { export class TransactionPipeline { private readonly server: SorobanRpc.Server; private readonly networkPassphrase: string; + private readonly inFlight = new Map>(); constructor(private readonly config: TransactionPipelineConfig) { this.server = new SorobanRpc.Server(config.rpcUrl); @@ -99,8 +110,38 @@ export class TransactionPipeline { * write call. Returns the submitted transaction's hash — callers who need * confirmation should watch the vault's state (a later issue's concern), * not assume submission alone means success. + * + * When `idempotencyKey` is set, a call already in flight for that key is + * returned as-is instead of starting a second one (issue #140). */ async invoke(params: InvokeContractParams): Promise { + const { idempotencyKey } = params; + if (idempotencyKey) { + const existing = this.inFlight.get(idempotencyKey); + if (existing) { + return existing; + } + } + + const promise = this.doInvoke(params); + + if (idempotencyKey) { + this.inFlight.set(idempotencyKey, promise); + // Release once settled — a distinct chained promise, so this never + // affects the original `promise` returned to the caller above. + promise + .finally(() => { + if (this.inFlight.get(idempotencyKey) === promise) { + this.inFlight.delete(idempotencyKey); + } + }) + .catch(() => undefined); + } + + return promise; + } + + private async doInvoke(params: InvokeContractParams): Promise { const rawTx = await this.buildTransaction(params); // Always simulate fresh for this specific call — never reuse a fee or