diff --git a/components/grid-wallet-prod/.dockerignore b/components/grid-wallet-prod/.dockerignore new file mode 100644 index 000000000..d1fb2e565 --- /dev/null +++ b/components/grid-wallet-prod/.dockerignore @@ -0,0 +1,20 @@ +# Build context hygiene — anything here is either rebuilt in the image, secret, +# or irrelevant to the server bundle. +node_modules +.next +.git +.cursor +.playwright-mcp + +# Secrets never belong in an image layer; pass them at `docker run` with +# --env-file / -e instead. +.env +.env.local +.env*.local + +# Local scratch + verification artefacts. +scripts/.tmp-* +*.png +tsconfig.tsbuildinfo +npm-debug.log* +.DS_Store diff --git a/components/grid-wallet-prod/Dockerfile b/components/grid-wallet-prod/Dockerfile new file mode 100644 index 000000000..b03e7c6ed --- /dev/null +++ b/components/grid-wallet-prod/Dockerfile @@ -0,0 +1,56 @@ +# Grid wallet production demo — Next 14 standalone server. +# +# docker build -t grid-wallet-prod . +# docker run --rm -p 4001:4001 --env-file .env.local grid-wallet-prod +# +# Then http://localhost:4001, and the webhook receiver at /api/webhooks +# (point ngrok or your platform's webhook config at that path). +# +# NOTE ON ENV: NEXT_PUBLIC_* is INLINED AT BUILD TIME, so the sandbox flag is a +# build arg, not a runtime var — a production image must be built WITHOUT it, or +# it will still offer the "simulate funding" affordance. Everything else +# (GRID_CLIENT_ID / GRID_CLIENT_SECRET / GRID_API_BASE_URL / GRID_CUSTOMER_ID / +# GRID_WEBHOOK_PUBKEY) is read per request and belongs at `docker run` time. + +# ── deps ────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS deps +WORKDIR /app +# Playwright is a devDependency used only by the verification scripts; its +# postinstall would otherwise pull ~400MB of browsers into the build. +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +COPY package.json package-lock.json ./ +RUN npm ci + +# ── build ───────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +# Sandbox keys? Pass --build-arg NEXT_PUBLIC_GRID_SANDBOX=true to bake in the +# simulated-funding button. Omit for a production build. +ARG NEXT_PUBLIC_GRID_SANDBOX +ENV NEXT_PUBLIC_GRID_SANDBOX=$NEXT_PUBLIC_GRID_SANDBOX +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# ── run ─────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=4001 \ + HOSTNAME=0.0.0.0 +# Unprivileged: nothing here needs root. +RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 -G nodejs nextjs +# `standalone` traces in only the server files actually reached, and ships its +# own minimal node_modules; static assets and public/ are copied alongside it. +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +USER nextjs +EXPOSE 4001 +# The SSE webhook stream is long-lived; give the container a plain HTTP check +# that doesn't hold a connection open. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4001)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server.js"] diff --git a/components/grid-wallet-prod/README.md b/components/grid-wallet-prod/README.md index bb54a955d..39e9178b9 100644 --- a/components/grid-wallet-prod/README.md +++ b/components/grid-wallet-prod/README.md @@ -18,3 +18,10 @@ Differences from the demo: npm install --ignore-scripts # --ignore-scripts avoids a central-icons license postinstall npm run dev # http://localhost:4001 (grid-wallet-demo keeps 4000) ``` + +Sandbox "Add money" runs the real platform on-ramp (fund the platform's USD account, then +quote + execute platform -> your embedded wallet) — a direct sandbox fund of the wallet itself +only mints book balance with no on-chain USDB. Any fee Grid's on-ramp charges comes straight out +of the real quote (observed anywhere from $0 to ~$0.20 on a $5-$20 add across sandbox runs), so +the balance credit can land at or slightly under the amount you added — that's real fee behavior, +not a bug. diff --git a/components/grid-wallet-prod/next.config.mjs b/components/grid-wallet-prod/next.config.mjs index 7d42db48f..6be58cc9d 100644 --- a/components/grid-wallet-prod/next.config.mjs +++ b/components/grid-wallet-prod/next.config.mjs @@ -6,6 +6,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** @type {import('next').NextConfig} */ const nextConfig = { + // Self-contained server bundle (.next/standalone) — what the Docker runtime + // stage copies, so the image carries no node_modules of its own. + output: 'standalone', transpilePackages: ['@lightsparkdev/origin'], typescript: { // Origin is source-linked without its own node_modules, @@ -33,11 +36,15 @@ const nextConfig = { ]; }, webpack: (config) => { - // Ensure dependencies imported by the local Origin package - // resolve from this project's node_modules + // Ensure dependencies imported by the local Origin package resolve from + // this project's node_modules. The relative 'node_modules' entry comes + // FIRST so webpack's default nested walk-up still wins (e.g. @turnkey/crypto's + // own node_modules/@noble/* pin, which differs from this project's top-level + // @noble/* version and exports different subpaths) — the absolute path is + // only a fallback for imports with no node_modules to walk up from. config.resolve.modules = [ - path.resolve(__dirname, 'node_modules'), 'node_modules', + path.resolve(__dirname, 'node_modules'), ]; // Origin only exports its main barrel — narrow imports for tree-shaken components. diff --git a/components/grid-wallet-prod/package-lock.json b/components/grid-wallet-prod/package-lock.json index ef9d9ac39..d75126ec1 100644 --- a/components/grid-wallet-prod/package-lock.json +++ b/components/grid-wallet-prod/package-lock.json @@ -21,6 +21,8 @@ "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", "@scure/base": "^2.2.0", + "@turnkey/api-key-stamper": "^0.6.5", + "@turnkey/crypto": "^2.8.14", "clsx": "^2.1.1", "geist": "^1.7.2", "motion": "^12.33.0", @@ -38,7 +40,8 @@ "@types/three": "^0.169.0", "playwright": "^1.60.0", "tsx": "^4.22.4", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^2.1.9" } }, "node_modules/@babel/runtime": { @@ -660,6 +663,13 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@lightsparkdev/origin": { "version": "0.13.6", "resolved": "https://registry.npmjs.org/@lightsparkdev/origin/-/origin-0.13.6.tgz", @@ -852,6 +862,18 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", @@ -1175,6 +1197,160 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.12.3.tgz", + "integrity": "sha512-+Mzq+W7cNEKfkNZzyLl6A6ffqc3r21HGZUezgfKxpZrkORfOqgRXnS80Zu0IV6a9Ue9QBJeKD7kN0iWfc3bhRQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.13", + "@peculiar/asn1-csr": "^2.3.13", + "@peculiar/asn1-ecc": "^2.3.14", + "@peculiar/asn1-pkcs9": "^2.3.13", + "@peculiar/asn1-rsa": "^2.3.13", + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "pvtsutils": "^1.3.5", + "reflect-metadata": "^0.2.2", + "tslib": "^2.7.0", + "tsyringe": "^4.8.0" + } + }, "node_modules/@react-spring/animated": { "version": "9.7.5", "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", @@ -1403,1261 +1579,2809 @@ } } }, - "node_modules/@scure/base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", - "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3", - "tslib": "^2.4.0" - } + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "license": "MIT" + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@types/draco3d": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", - "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/offscreencanvas": { - "version": "2019.7.3", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", - "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/react": { - "version": "18.3.29", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", - "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/react-reconciler": { - "version": "0.26.7", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", - "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/react": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/stats.js": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", - "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@turnkey/api-key-stamper": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@turnkey/api-key-stamper/-/api-key-stamper-0.6.8.tgz", + "integrity": "sha512-kBXdNwl7LzqdzfzOtUSZaDf7FOTvD0lK0Ji5CLlPw8CQcXxTjapl9IYdIW7I5oc3r0+UnSrnWZhlNPVzu9k5tw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.3.0", + "@turnkey/crypto": "2.10.1", + "@turnkey/encoding": "0.6.0", + "sha256-uint8array": "^0.10.7" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@turnkey/api-key-stamper/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@turnkey/api-key-stamper/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@turnkey/crypto": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@turnkey/crypto/-/crypto-2.10.1.tgz", + "integrity": "sha512-A9dqEXHIhTNy4t32LodJOTm/I762TjcPAck8gl9E004f73Gz0WCGORnsgiSrSYHAkj4rC4oLUK8rbmRIikGBCQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "1.3.0", + "@noble/curves": "1.9.0", + "@noble/hashes": "1.8.0", + "@peculiar/x509": "1.12.3", + "@turnkey/encoding": "0.6.0", + "@turnkey/sdk-types": "1.2.0", + "borsh": "2.0.0", + "cbor-js": "0.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@turnkey/crypto/node_modules/@noble/curves": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.0.tgz", + "integrity": "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@turnkey/crypto/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@turnkey/encoding": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@turnkey/encoding/-/encoding-0.6.0.tgz", + "integrity": "sha512-IC8qXvy36+iGAeiaVIuJvB35uU2Ld/RAWI/DRTKS+ttBej0GXhOn48Ouu5mlca4jt8ZEuwXmDVv74A8uBQclsA==", + "license": "Apache-2.0", + "dependencies": { + "bs58": "6.0.0", + "bs58check": "4.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@turnkey/sdk-types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@turnkey/sdk-types/-/sdk-types-1.2.0.tgz", + "integrity": "sha512-S37cabMXICiM5ZpeRhYDuPuRrtE3GLlsHjasWCusWS7PUOP7Ru+g7HOHHWhb7A390j+NKvz1CJ1z8kvdfC0nvg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "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" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", + "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/borsh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-2.0.0.tgz", + "integrity": "sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg==", + "license": "Apache-2.0" + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, + "node_modules/bs58check/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cbor-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cbor-js/-/cbor-js-0.1.0.tgz", + "integrity": "sha512-7sQ/TvDZPl7csT1Sif9G0+MA0I0JOVah8+wWlJVQdVEgIbCzlN/ab3x+uvMNsc34TUvO6osQTAmB2ls80JX6tw==", + "license": "MIT" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "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" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/framer-motion": { + "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.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/geist": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.2.tgz", + "integrity": "sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==", + "license": "SIL OPEN FONT LICENSE", + "peerDependencies": { + "next": ">=13.2.0" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/@types/three": { - "version": "0.169.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", - "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "license": "MIT", "dependencies": { - "@tweenjs/tween.js": "~23.1.3", - "@types/stats.js": "*", - "@types/webxr": "*", - "@webgpu/types": "*", - "fflate": "~0.8.2", - "meshoptimizer": "~0.18.1" + "immediate": "~3.0.5" } }, - "node_modules/@types/webxr": { - "version": "0.5.24", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", - "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "license": "MIT" + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, "license": "MIT" }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { - "@use-gesture/core": "10.3.1" - }, + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", "peerDependencies": { - "react": ">= 16.8.0" + "three": ">=0.137" } }, - "node_modules/@webgpu/types": { - "version": "0.1.71", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", - "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", - "license": "BSD-3-Clause" + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "license": "MIT" }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "node_modules/motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", + "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "framer-motion": "^12.40.0", + "tslib": "^2.4.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "react": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "react-dom": { + "optional": true } - ], - "license": "MIT" + } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "node_modules/motion-dom": { + "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": { - "require-from-string": "^2.0.2" + "motion-utils": "^12.39.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "node_modules/motion-utils": { + "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/ms": { + "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.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "@playwright/test": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "sass": { + "optional": true } - ], + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "streamsearch": "^1.1.0" + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">=10.16.0" + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/camera-controls": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", - "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.126.1" + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", - "url": "https://opencollective.com/browserslist" + "url": "https://opencollective.com/postcss/" }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + "url": "https://tidelift.com/funding/github/npm/postcss" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^5.0.0" + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" }, "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": "^10 || ^12 || >=14" } }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" + "loose-envify": "^1.1.0" }, "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" + "node": ">=0.10.0" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "prop-types": "^15.6.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/detect-gpu": { - "version": "5.0.70", - "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", - "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", "license": "MIT", "dependencies": { - "webgl-constants": "^1.1.1" + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, "bin": { - "esbuild": "bin/esbuild" + "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=18" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "node_modules/sass": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", + "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "node_modules/sha256-uint8array": { + "version": "0.10.7", + "resolved": "https://registry.npmjs.org/sha256-uint8array/-/sha256-uint8array-0.10.7.tgz", + "integrity": "sha512-1Q6JQU4tX9NqsDGodej6pkrUVQVNapLZnvkwIhddH/JqzBZF1fSaxSWNY6sziXBE8aEa2twtGkXUrwzGeZCMpQ==", "license": "MIT" }, - "node_modules/framer-motion": { - "version": "12.40.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", - "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "motion-dom": "^12.40.0", - "motion-utils": "^12.39.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "shebang-regex": "^3.0.0" }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=8" } }, - "node_modules/geist": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.2.tgz", - "integrity": "sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==", - "license": "SIL OPEN FONT LICENSE", - "peerDependencies": { - "next": ">=13.2.0" + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/glsl-noise": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", - "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, "license": "MIT" }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/hls.js": { - "version": "1.6.16", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", - "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", - "license": "Apache-2.0" + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, - "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, "license": "MIT" }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "optional": true, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", "engines": { - "node": ">=0.10.0" + "node": ">=10.0.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", "license": "MIT", - "optional": true, "dependencies": { - "is-extglob": "^2.1.1" + "client-only": "0.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" } }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/its-fine": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", - "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", "license": "MIT", - "dependencies": { - "@types/react-reconciler": "^0.28.0" - }, "peerDependencies": { - "react": ">=18.0" + "three": ">= 0.151.0" } }, - "node_modules/its-fine/node_modules/@types/react-reconciler": { - "version": "0.28.9", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", - "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, "peerDependencies": { - "@types/react": "*" + "three": ">=0.128.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, "license": "MIT" }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/maath": { - "version": "0.10.8", - "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", - "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, "license": "MIT", - "peerDependencies": { - "@types/three": ">=0.134.0", - "three": ">=0.134.0" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/meshline": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", - "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, "license": "MIT", - "peerDependencies": { - "three": ">=0.137" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/meshoptimizer": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", - "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", - "license": "MIT" - }, - "node_modules/motion": { - "version": "12.40.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", - "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", + "node_modules/torph": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/torph/-/torph-0.0.9.tgz", + "integrity": "sha512-WrFMtJwqXCfIXbLNuTOwHWff0XVm/Ewctb+71bFis3HykPvCXl1CHXapU0r67pQhAI323fsA1L2pGPeqxXeRRA==", "license": "MIT", - "dependencies": { - "framer-motion": "^12.40.0", - "tslib": "^2.4.0" - }, "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react": ">=18", + "react-dom": ">=18", + "svelte": ">=5", + "vue": ">=3" }, "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, "react": { "optional": true }, "react-dom": { "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true } } }, - "node_modules/motion-dom": { - "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==", + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", "license": "MIT", "dependencies": { - "motion-utils": "^12.39.0" + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" } }, - "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", "license": "MIT" }, - "node_modules/nanoid": { - "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", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "tsx": "dist/cli.mjs" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/next": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", - "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", "license": "MIT", "dependencies": { - "@next/env": "14.2.35", - "@swc/helpers": "0.5.5", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "graceful-fs": "^4.2.11", - "postcss": "8.4.31", - "styled-jsx": "5.1.1" - }, - "bin": { - "next": "dist/bin/next" + "tslib": "^1.9.3" }, "engines": { - "node": ">=18.17.0" + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.33", - "@next/swc-darwin-x64": "14.2.33", - "@next/swc-linux-arm64-gnu": "14.2.33", - "@next/swc-linux-arm64-musl": "14.2.33", - "@next/swc-linux-x64-gnu": "14.2.33", - "@next/swc-linux-x64-musl": "14.2.33", - "@next/swc-win32-arm64-msvc": "14.2.33", - "@next/swc-win32-ia32-msvc": "14.2.33", - "@next/swc-win32-x64-msvc": "14.2.33" + "engines": { + "node": ">=12.7.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "sass": "^1.3.0" + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" }, "peerDependenciesMeta": { - "@opentelemetry/api": { + "@types/react": { "optional": true }, - "@playwright/test": { + "immer": { "optional": true }, - "sass": { + "react": { "optional": true } } }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT", - "optional": true - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=0.10.0" + "node": ">=14.17" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "engines": { - "node": ">=8" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", "license": "MIT", - "optional": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">= 4" } }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright-core": "1.60.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { - "playwright": "cli.js" + "vite": "bin/vite.js" }, "engines": { - "node": ">=18" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" + "fsevents": "~2.3.3" }, - "engines": { - "node": ">=18" - } - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "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 }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "less": { + "optional": true }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true } - ], + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" }, "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/potpack": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", - "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "license": "ISC" - }, - "node_modules/promise-worker-transferable": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", - "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", - "license": "Apache-2.0", - "dependencies": { - "is-promise": "^2.1.0", - "lie": "^3.0.2" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" } }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/react-composer": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", - "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "prop-types": "^15.6.0" - }, - "peerDependencies": { - "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-reconciler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", - "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.21.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^18.0.0" + "node": ">=12" } }, - "node_modules/react-reconciler/node_modules/scheduler": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", - "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" } }, - "node_modules/react-use-measure": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", - "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "react": ">=16.13", - "react-dom": ">=16.13" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "node": ">=12" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.100.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", - "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "chokidar": "^5.0.0", - "immutable": "^5.1.5", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.19.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" + "node": ">=12" } }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/stats-gl": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", - "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/three": "*", - "three": "^0.170.0" - }, - "peerDependencies": { - "@types/three": "*", - "three": "*" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/stats-gl/node_modules/three": { - "version": "0.170.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", - "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", - "license": "MIT" - }, - "node_modules/stats.js": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", - "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", - "license": "MIT" - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.0.0" + "node": ">=12" } }, - "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } + "node": ">=12" } }, - "node_modules/suspend-react": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", - "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "react": ">=17.0" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/three": { - "version": "0.169.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", - "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", - "license": "MIT" - }, - "node_modules/three-mesh-bvh": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", - "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", - "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "three": ">= 0.151.0" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/three-stdlib": { - "version": "2.36.1", - "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", - "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/draco3d": "^1.4.0", - "@types/offscreencanvas": "^2019.6.4", - "@types/webxr": "^0.5.2", - "draco3d": "^1.4.1", - "fflate": "^0.6.9", - "potpack": "^1.0.1" - }, - "peerDependencies": { - "three": ">=0.128.0" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" } }, - "node_modules/three-stdlib/node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", - "license": "MIT" - }, - "node_modules/torph": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/torph/-/torph-0.0.9.tgz", - "integrity": "sha512-WrFMtJwqXCfIXbLNuTOwHWff0XVm/Ewctb+71bFis3HykPvCXl1CHXapU0r67pQhAI323fsA1L2pGPeqxXeRRA==", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18", - "svelte": ">=5", - "vue": ">=3" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true - } + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, - "node_modules/troika-three-text": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", - "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.2", - "troika-three-utils": "^0.52.4", - "troika-worker-utils": "^0.52.0", - "webgl-sdf-generator": "1.1.1" - }, - "peerDependencies": { - "three": ">=0.125.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, - "node_modules/troika-three-utils": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", - "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "three": ">=0.125.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, - "node_modules/troika-worker-utils": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", - "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, "bin": { - "tsx": "dist/cli.mjs" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18.0.0" + "node": ">=12" }, "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/fsevents": { + "@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" + } + }, + "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", @@ -2672,82 +4396,101 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/tunnel-rat": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", - "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "node_modules/vite/node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "zustand": "^4.3.2" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, - "node_modules/tunnel-rat/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, "license": "MIT", "dependencies": { - "use-sync-external-store": "^1.2.2" + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" }, "engines": { - "node": ">=12.7.0" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" }, "peerDependenciesMeta": { - "@types/react": { + "@edge-runtime/vm": { "optional": true }, - "immer": { + "@types/node": { "optional": true }, - "react": { + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { "optional": true } } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/utility-types": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", @@ -2773,6 +4516,23 @@ "engines": { "node": ">= 8" } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/components/grid-wallet-prod/package.json b/components/grid-wallet-prod/package.json index 6ade208e4..ba8f9bf1f 100644 --- a/components/grid-wallet-prod/package.json +++ b/components/grid-wallet-prod/package.json @@ -11,9 +11,11 @@ "export-sf-symbols": "node scripts/export-sf-symbols.mjs --sync-paths", "fetch:flags": "node scripts/fetch-flags.mjs", "gen:bank-fields": "node scripts/generate-bank-fields.mjs", + "ensure:business-customer": "node scripts/ensure-business-customer.mjs", "verify:bank-fields": "node scripts/generate-bank-fields.mjs && git diff --exit-code -- src/data/bankAccountFields.generated.ts", "verify:bank-data": "tsx scripts/verify-bank-data.ts", - "verify:bank": "npm run verify:bank-fields && npm run verify:bank-data" + "verify:bank": "npm run verify:bank-fields && npm run verify:bank-data", + "test": "vitest run" }, "dependencies": { "@central-icons-react/round-filled-radius-0-stroke-2": "^1.1.267", @@ -26,6 +28,8 @@ "@lightsparkdev/origin": "^0.13.4", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", + "@turnkey/api-key-stamper": "^0.6.5", + "@turnkey/crypto": "^2.8.14", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", "@scure/base": "^2.2.0", @@ -46,7 +50,8 @@ "@types/three": "^0.169.0", "playwright": "^1.60.0", "tsx": "^4.22.4", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^2.1.9" }, "comments": { "overrides": "react-three-fiber v8 imports zustand's default export; the next.config resolve.modules flattening forces a single zustand, so pin it to v4 (which still has the default export). drei's Environment/Lightformer don't use zustand directly." diff --git a/components/grid-wallet-prod/scripts/ensure-business-customer.mjs b/components/grid-wallet-prod/scripts/ensure-business-customer.mjs new file mode 100644 index 000000000..745a5a393 --- /dev/null +++ b/components/grid-wallet-prod/scripts/ensure-business-customer.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +/** + * Idempotently provision the demo's sample BUSINESS customer. + * + * node scripts/ensure-business-customer.mjs [--platform-id biz-…] [--dry-run] + * + * Looks the customer up by `platformCustomerId` (GET /customers?platformCustomerId=…), + * creates it only when absent, then upserts GRID_BUSINESS_CUSTOMER_ID into + * .env.local. Re-running is a no-op beyond re-printing the current state. + * + * Note: unlike INDIVIDUAL customers (auto-KYC'd on creation), BUSINESS customers + * start at kybStatus UNVERIFIED and need an out-of-band verification step. The + * embedded-wallet internal accounts only appear AFTER approval, so a freshly + * created business customer has no accounts yet — the script says so rather than + * pretending otherwise. + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const ENV_PATH = resolve(ROOT, '.env.local'); +const ENV_KEY = 'GRID_BUSINESS_CUSTOMER_ID'; +const DEFAULT_PLATFORM_ID = 'biz-grid-wallet-prod'; + +/* ── args ─────────────────────────────────────────────────────────────────── */ + +function parseArgs(argv) { + const args = { platformId: DEFAULT_PLATFORM_ID, dryRun: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--dry-run') args.dryRun = true; + else if (a === '--platform-id') args.platformId = argv[++i]; + else if (a.startsWith('--platform-id=')) args.platformId = a.slice('--platform-id='.length); + else die(`Unknown argument: ${a}`); + } + if (!args.platformId) die('--platform-id needs a value'); + return args; +} + +function die(msg) { + console.error(`error: ${msg}`); + process.exit(1); +} + +/* ── .env.local ───────────────────────────────────────────────────────────── */ + +/** Minimal dotenv read: KEY=value, optional quotes, # comments, no interpolation. */ +function readEnvFile(path) { + if (!existsSync(path)) return {}; + const out = {}; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); + if (!m) continue; + let v = m[2].trim(); + if ( + (v.startsWith('"') && v.endsWith('"') && v.length > 1) || + (v.startsWith("'") && v.endsWith("'") && v.length > 1) + ) { + v = v.slice(1, -1); + } + out[m[1]] = v; + } + return out; +} + +/** Replace KEY's line in place, or append it with a comment block. */ +function upsertEnvKey(path, key, value, comment) { + const original = existsSync(path) ? readFileSync(path, 'utf8') : ''; + const line = `${key}=${value}`; + const re = new RegExp(`^${key}=.*$`, 'm'); + if (re.test(original)) { + const next = original.replace(re, line); + if (next === original) return 'unchanged'; + writeFileSync(path, next); + return 'updated'; + } + const sep = original === '' || original.endsWith('\n') ? '' : '\n'; + const block = `${sep}\n${comment ? `# ${comment}\n` : ''}${line}\n`; + writeFileSync(path, original + block); + return 'added'; +} + +/* ── Grid ─────────────────────────────────────────────────────────────────── */ + +function gridConfig(env) { + const clientId = process.env.GRID_CLIENT_ID || env.GRID_CLIENT_ID; + const clientSecret = process.env.GRID_CLIENT_SECRET || env.GRID_CLIENT_SECRET; + const baseUrl = + process.env.GRID_API_BASE_URL || + env.GRID_API_BASE_URL || + 'https://api.lightspark.com/grid/2025-10-13'; + if (!clientId || !clientSecret) { + die('GRID_CLIENT_ID / GRID_CLIENT_SECRET not found in the environment or .env.local'); + } + const auth = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + return { baseUrl: baseUrl.replace(/\/$/, ''), auth }; +} + +async function grid({ baseUrl, auth }, method, path, body) { + const res = await fetch(baseUrl + path, { + method, + headers: { + Authorization: auth, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let parsed; + try { + parsed = text ? JSON.parse(text) : {}; + } catch { + parsed = { raw: text }; + } + return { status: res.status, body: parsed }; +} + +function fail(where, res) { + const err = res.body?.error; + die( + `${where}: HTTP ${res.status}` + + (err ? ` (${err.code ?? ''} ${err.message ?? ''})`.replace(/\s+/g, ' ').trimEnd() : '') + + `\n${JSON.stringify(res.body, null, 2)}`, + ); +} + +/** Sample business — deliberately boring, matches the OpenAPI businessCustomer example. */ +function businessPayload(platformId) { + return { + customerType: 'BUSINESS', + platformCustomerId: platformId, + region: 'US', + currencies: ['USD', 'USDB'], + email: 'peng+grid-wallet-prod-biz@lightspark.com', + phoneNumber: '+14155559876', + businessInfo: { + legalName: 'Aurora Labs LLC', + doingBusinessAs: 'Aurora', + country: 'US', + registrationNumber: '5523041', + incorporatedOn: '2018-03-14', + entityType: 'LLC', + taxId: '47-1234567', + countriesOfOperation: ['US'], + businessType: 'INFORMATION', + purposeOfAccount: 'CONTRACTOR_PAYOUTS', + sourceOfFunds: 'Funds derived from customer payments for software services', + expectedMonthlyTransactionCount: 'COUNT_100_TO_500', + expectedMonthlyTransactionVolume: 'VOLUME_100K_TO_1M', + expectedRecipientJurisdictions: ['US'], + }, + address: { + line1: '123 Market Street', + line2: 'Suite 400', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + country: 'US', + }, + }; +} + +function kybOf(customer) { + return customer?.kybStatus ?? customer?.kycStatus ?? 'UNKNOWN'; +} + +/* ── main ─────────────────────────────────────────────────────────────────── */ + +const args = parseArgs(process.argv.slice(2)); +const env = readEnvFile(ENV_PATH); +const cfg = gridConfig(env); + +console.log(`base: ${cfg.baseUrl}`); +console.log(`platform id: ${args.platformId}`); + +const lookup = await grid( + cfg, + 'GET', + `/customers?platformCustomerId=${encodeURIComponent(args.platformId)}`, +); +if (lookup.status !== 200) fail('list customers', lookup); + +// The filter is server-side, but match defensively — a broader result set must +// not be mistaken for "found". +const existing = (lookup.body.data ?? []).find( + (c) => c.platformCustomerId === args.platformId && c.customerType === 'BUSINESS', +); + +let customer = existing; +if (existing) { + console.log(`found: ${existing.id} (kyb: ${kybOf(existing)})`); +} else if (args.dryRun) { + console.log('dry-run: no BUSINESS customer with that platform id — would create one'); + console.log(JSON.stringify(businessPayload(args.platformId), null, 2)); + process.exit(0); +} else { + const created = await grid(cfg, 'POST', '/customers', businessPayload(args.platformId)); + if (created.status !== 201) fail('create customer', created); + customer = created.body; + console.log(`created: ${customer.id} (kyb: ${kybOf(customer)})`); +} + +// Internal accounts only exist post-approval; report either way. +const accts = await grid( + cfg, + 'GET', + `/customers/internal-accounts?customerId=${encodeURIComponent(customer.id)}`, +); +if (accts.status === 200) { + const rows = accts.body.data ?? []; + if (rows.length === 0) { + console.log( + `accounts: none yet — BUSINESS customers provision internal accounts after KYB approval (kyb: ${kybOf(customer)})`, + ); + } else { + for (const a of rows) { + console.log(`account: ${a.id} ${a.balance?.currency?.code ?? '?'} ${a.type ?? ''}`.trimEnd()); + } + } +} else { + console.log(`accounts: lookup returned HTTP ${accts.status} (skipped)`); +} + +if (args.dryRun) { + console.log(`dry-run: would set ${ENV_KEY}=${customer.id} in .env.local`); +} else { + const wrote = upsertEnvKey( + ENV_PATH, + ENV_KEY, + customer.id, + `Sample business customer (platformCustomerId: ${args.platformId}) — scripts/ensure-business-customer.mjs`, + ); + console.log(`.env.local: ${ENV_KEY} ${wrote}`); +} diff --git a/components/grid-wallet-prod/src/app/api/grid/[...path]/route.ts b/components/grid-wallet-prod/src/app/api/grid/[...path]/route.ts new file mode 100644 index 000000000..8b8e5e3f9 --- /dev/null +++ b/components/grid-wallet-prod/src/app/api/grid/[...path]/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + isAllowed, + redactHeaders, + substituteCustomerId, +} from '../allowlist'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const BASE = process.env.GRID_API_BASE_URL ?? 'https://api.lightspark.com/grid/2025-10-13'; +const CUSTOMER_ID = process.env.GRID_CUSTOMER_ID ?? ''; + +// Client-supplied headers that must reach Grid verbatim. +const PASS_THROUGH = ['grid-wallet-signature', 'request-id', 'idempotency-key']; +// Grid response headers worth surfacing to the client/panel. +const ECHO_RESPONSE = ['retry-after', 'content-type']; + +function basicAuth(): string { + const id = process.env.GRID_CLIENT_ID ?? ''; + const secret = process.env.GRID_CLIENT_SECRET ?? ''; + return 'Basic ' + Buffer.from(`${id}:${secret}`).toString('base64'); +} + +async function handle(req: NextRequest, method: string): Promise { + const url = new URL(req.url); + const pathname = url.pathname.replace(/^\/api\/grid/, ''); // e.g. "/quotes" + if (!isAllowed(method, pathname)) { + return NextResponse.json( + { + request: { method, path: pathname, headers: {} }, + response: { status: 403, body: { error: { code: 'PROXY_NOT_ALLOWED', message: `${method} ${pathname} is not proxied` } } }, + }, + { status: 403 }, + ); + } + + // Build target URL: substitute {customerId} in path + query. + const query = substituteCustomerId(url.search, CUSTOMER_ID); + const target = BASE + pathname + query; + + // Body: read raw, substitute {customerId}, forward as-is (byte stable for stamps). + const rawBody = method === 'GET' ? undefined : await req.text(); + const body = rawBody ? substituteCustomerId(rawBody, CUSTOMER_ID) : undefined; + + const outHeaders: Record = { Authorization: basicAuth() }; + if (body) outHeaders['Content-Type'] = 'application/json'; + for (const name of PASS_THROUGH) { + const v = req.headers.get(name); + if (v) outHeaders[name] = v; + } + + let gridStatus = 502; + let gridBody: unknown = { error: { code: 'PROXY_UPSTREAM_ERROR', message: 'No response from Grid' } }; + const echoed: Record = {}; + try { + const res = await fetch(target, { method, headers: outHeaders, body }); + gridStatus = res.status; + const text = await res.text(); + try { + gridBody = text ? JSON.parse(text) : {}; + } catch { + gridBody = { raw: text }; + } + for (const name of ECHO_RESPONSE) { + const v = res.headers.get(name); + if (v) echoed[name] = v; + } + } catch (e) { + gridBody = { error: { code: 'PROXY_UPSTREAM_ERROR', message: String(e) } }; + } + + const envelope = { + request: { + method, + path: pathname + query, + headers: redactHeaders(outHeaders), + body: body ? safeJson(body) : undefined, + }, + response: { status: gridStatus, body: gridBody, headers: echoed }, + }; + // Mirror Grid's status as the proxy status so fetch semantics stay truthful. + return NextResponse.json(envelope, { status: gridStatus >= 200 && gridStatus < 600 ? gridStatus : 502 }); +} + +function safeJson(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} + +export async function GET(req: NextRequest) { + return handle(req, 'GET'); +} +export async function POST(req: NextRequest) { + return handle(req, 'POST'); +} diff --git a/components/grid-wallet-prod/src/app/api/grid/allowlist.test.ts b/components/grid-wallet-prod/src/app/api/grid/allowlist.test.ts new file mode 100644 index 000000000..29a47d1e4 --- /dev/null +++ b/components/grid-wallet-prod/src/app/api/grid/allowlist.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { isAllowed, redactHeaders, substituteCustomerId } from './allowlist'; + +describe('proxy allow-list', () => { + it('allows the M1/M2 endpoints', () => { + expect(isAllowed('GET', '/auth/credentials')).toBe(true); + expect(isAllowed('POST', '/auth/credentials')).toBe(true); + expect(isAllowed('POST', '/auth/credentials/AuthMethod:abc/challenge')).toBe(true); + expect(isAllowed('POST', '/auth/credentials/AuthMethod:abc/verify')).toBe(true); + expect(isAllowed('GET', '/customers/internal-accounts')).toBe(true); + expect(isAllowed('GET', '/platform/internal-accounts')).toBe(true); + expect(isAllowed('POST', '/customers/external-accounts')).toBe(true); + expect(isAllowed('GET', '/transactions')).toBe(true); + expect(isAllowed('GET', '/transactions/Transaction:abc')).toBe(true); + expect(isAllowed('POST', '/quotes')).toBe(true); + expect(isAllowed('POST', '/quotes/Quote:abc/execute')).toBe(true); + expect(isAllowed('POST', '/sandbox/internal-accounts/InternalAccount:abc/fund')).toBe(true); + }); + it('rejects everything else (incl. cards + wrong method)', () => { + expect(isAllowed('POST', '/cards')).toBe(false); + expect(isAllowed('DELETE', '/auth/credentials')).toBe(false); + expect(isAllowed('POST', '/transactions/Transaction:abc')).toBe(false); // GET only + expect(isAllowed('GET', '/quotes')).toBe(false); // POST only + expect(isAllowed('POST', '/customers')).toBe(false); + }); + it('redacts Authorization only', () => { + const r = redactHeaders({ Authorization: 'Basic secret', 'Request-Id': 'Request:1' }); + expect(r.Authorization).toBe('Basic ***'); + expect(r['Request-Id']).toBe('Request:1'); + }); + it('substitutes every {customerId} token', () => { + expect(substituteCustomerId('?customerId={customerId}', 'Customer:1')).toBe('?customerId=Customer:1'); + expect(substituteCustomerId('{customerId}/{customerId}', 'C')).toBe('C/C'); + expect(substituteCustomerId('nothing', 'C')).toBe('nothing'); + }); +}); diff --git a/components/grid-wallet-prod/src/app/api/grid/allowlist.ts b/components/grid-wallet-prod/src/app/api/grid/allowlist.ts new file mode 100644 index 000000000..5df3625d5 --- /dev/null +++ b/components/grid-wallet-prod/src/app/api/grid/allowlist.ts @@ -0,0 +1,41 @@ +/** Pure helpers for the Grid proxy — no Next runtime, unit-tested directly. */ + +const ID = '[^/?]+'; // a path segment (e.g. AuthMethod:..., Quote:..., InternalAccount:...) + +export const GRID_ALLOWLIST: { method: string; pattern: RegExp }[] = [ + { method: 'GET', pattern: new RegExp('^/auth/credentials$') }, + { method: 'POST', pattern: new RegExp('^/auth/credentials$') }, + { method: 'POST', pattern: new RegExp(`^/auth/credentials/${ID}/challenge$`) }, + { method: 'POST', pattern: new RegExp(`^/auth/credentials/${ID}/verify$`) }, + { method: 'GET', pattern: new RegExp('^/customers/internal-accounts$') }, + { method: 'GET', pattern: new RegExp('^/platform/internal-accounts$') }, + { method: 'GET', pattern: new RegExp('^/customers/external-accounts$') }, + { method: 'POST', pattern: new RegExp('^/customers/external-accounts$') }, + { method: 'GET', pattern: new RegExp('^/transactions$') }, + { method: 'GET', pattern: new RegExp(`^/transactions/${ID}$`) }, + { method: 'POST', pattern: new RegExp('^/quotes$') }, + { method: 'POST', pattern: new RegExp(`^/quotes/${ID}/execute$`) }, + { method: 'POST', pattern: new RegExp(`^/sandbox/internal-accounts/${ID}/fund$`) }, + { method: 'POST', pattern: new RegExp('^/sandbox/send$') }, +]; + +/** `pathname` is the Grid path WITHOUT query string (e.g. "/quotes"). */ +export function isAllowed(method: string, pathname: string): boolean { + return GRID_ALLOWLIST.some( + (r) => r.method === method.toUpperCase() && r.pattern.test(pathname), + ); +} + +/** Redact the injected Basic auth so it can be echoed to the panel safely. */ +export function redactHeaders(h: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(h)) { + out[k] = k.toLowerCase() === 'authorization' ? 'Basic ***' : v; + } + return out; +} + +/** Replace every {customerId} placeholder token with the real id. */ +export function substituteCustomerId(text: string, customerId: string): string { + return text.split('{customerId}').join(customerId); +} diff --git a/components/grid-wallet-prod/src/app/api/webhooks/route.ts b/components/grid-wallet-prod/src/app/api/webhooks/route.ts new file mode 100644 index 000000000..f912a1a07 --- /dev/null +++ b/components/grid-wallet-prod/src/app/api/webhooks/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyGridSignature } from '@/lib/gridWebhook'; +import { pushEvent } from '@/lib/webhookEvents'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + const raw = await req.text(); // RAW body, exactly as received (used for signature) + const sig = req.headers.get('x-grid-signature') ?? ''; + const pubkey = process.env.GRID_WEBHOOK_PUBKEY ?? ''; + if (!verifyGridSignature(raw, sig, pubkey)) { + return NextResponse.json({ error: { code: 'INVALID_SIGNATURE' } }, { status: 401 }); + } + try { + pushEvent(JSON.parse(raw)); + } catch { + // Signature already passed; a non-JSON body is unexpected but non-fatal. + } + return NextResponse.json({ ok: true }, { status: 200 }); +} diff --git a/components/grid-wallet-prod/src/app/api/webhooks/stream/route.ts b/components/grid-wallet-prod/src/app/api/webhooks/stream/route.ts new file mode 100644 index 000000000..3b3ad4e15 --- /dev/null +++ b/components/grid-wallet-prod/src/app/api/webhooks/stream/route.ts @@ -0,0 +1,65 @@ +import { subscribe, type WebhookEvent } from '@/lib/webhookEvents'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** Comment frames keep proxies (and ngrok) from closing an idle stream. */ +const KEEPALIVE_MS = 15_000; + +/** + * Server-sent events: every webhook that passes signature verification is pushed + * to connected panels as it lands. One-way and text-only, so it survives the + * ngrok tunnel a real Grid webhook arrives through — no polling, no client + * timers, and the panel shows the event at the moment Grid delivers it. + */ +export async function GET(req: Request) { + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | undefined; + let keepalive: ReturnType | undefined; + + const stream = new ReadableStream({ + start(controller) { + const send = (event: WebhookEvent) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + // Client vanished between the push and the enqueue — drop it. + } + }; + // An opening comment flushes headers so EventSource fires `open`. + controller.enqueue(encoder.encode(': connected\n\n')); + unsubscribe = subscribe(send); + keepalive = setInterval(() => { + try { + controller.enqueue(encoder.encode(': keepalive\n\n')); + } catch { + /* closing */ + } + }, KEEPALIVE_MS); + // Tab closed / navigated away. + req.signal.addEventListener('abort', () => { + unsubscribe?.(); + if (keepalive) clearInterval(keepalive); + try { + controller.close(); + } catch { + /* already closed */ + } + }); + }, + cancel() { + unsubscribe?.(); + if (keepalive) clearInterval(keepalive); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + // Nginx-style proxies buffer by default, which would stall the stream. + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/components/grid-wallet-prod/src/app/page.tsx b/components/grid-wallet-prod/src/app/page.tsx index 583a09a3d..405dedd0b 100644 --- a/components/grid-wallet-prod/src/app/page.tsx +++ b/components/grid-wallet-prod/src/app/page.tsx @@ -53,8 +53,18 @@ export default function Page() { cancelOtp={logic.cancelOtp} backOtp={logic.backOtp} emailActive={logic.emailActive} + emailPrefill={logic.emailPrefill} submitEmail={logic.submitEmail} cancelEmail={logic.cancelEmail} + passkeyAdded={logic.passkeyAdded} + onAddPasskey={logic.onAddPasskey} + depositInstructions={logic.depositInstructions} + totalCents={logic.totalCents} + walletToast={logic.walletToast} + storedBanks={logic.storedBanks} + onSelectStoredBank={logic.onSelectStoredBank} + onDepositView={logic.onDepositView} + simulateDeposit={logic.simulateDeposit} phoneActive={logic.phoneActive} submitPhone={logic.submitPhone} cancelPhone={logic.cancelPhone} @@ -83,7 +93,7 @@ export default function Page() { // param) styles the column so the first paint is already correct. style={{ width: apiWidth ?? undefined }} > - + diff --git a/components/grid-wallet-prod/src/apps/SignInFlow.tsx b/components/grid-wallet-prod/src/apps/SignInFlow.tsx index ab1707ef1..1143072fb 100644 --- a/components/grid-wallet-prod/src/apps/SignInFlow.tsx +++ b/components/grid-wallet-prod/src/apps/SignInFlow.tsx @@ -6,8 +6,15 @@ import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import type { AuthMethod } from '@/data/flow'; import type { ExternalAccountInput, ReceivePaymentInfo, TransferDest } from '@/data/apiCalls'; import { easeOutQuick, easeOutSnappy, motionTransition } from '@/lib/easing'; +import type { DepositInstructions } from '@/lib/gridReads'; +import { currencyFor } from '@/data/bankCountries'; import { useMoneySheet, useWalletHome } from '@/apps/shared/wallet'; -import type { WalletEntry, WalletTransferMode } from '@/apps/shared/wallet'; +import type { + WalletEntry, + WalletTransferMode, + WalletListItemData, + SavedBank, +} from '@/apps/shared/wallet'; import type { SkinAuthFlow, SkinAuthScreen, SkinWalletScreen } from './types'; import styles from './SignInFlow.module.scss'; @@ -74,6 +81,26 @@ interface SignInFlowProps { entry?: WalletEntry; /** The active skin's wallet-brain options (from the registry). */ walletOptions?: WalletBrainOptions; + /** Real book balance from the demo logic (e.g. "$225.00") — the wallet home's + * source of truth; see `UseWalletHomeOptions.balance`. */ + balance?: string; + /** The account's real transaction history (GET /transactions), newest first. */ + activity?: WalletListItemData[]; + /** Real deposit details for the customer's fiat account (Add money → Bank). */ + depositInstructions?: DepositInstructions | null; + /** The wallet account's total balance in cents (USDB `totalBalance`). */ + totalCents?: number; + /** One-shot toast raised by an arrival webhook (nonce bumps per delivery). */ + walletToast?: { nonce: number; text: string } | null; + /** Accounts Grid already holds, for the saved-banks list. */ + storedBanks?: SavedBank[]; + /** A stored account was picked — quote against that ExternalAccount id. */ + onSelectStoredBank?: (externalAccountId: string | null) => void; + /** A country's deposit details came into (or left) view — the host offers the + * sandbox funding stand-in in the request panel off the back of this. */ + onDepositView?: (view: { label: string; currency: string; cents?: number } | null) => void; + /** Bumped by the panel's "Simulate funding" button. */ + simulateDeposit?: { nonce: number; cents: number; last4: string } | null; /** Wallet events bubbled up so the demo logs the matching Grid API calls. */ onQuoteCreate?: (mode: WalletTransferMode, cents: number, dest?: TransferDest) => void; onLinkExternalAccount?: (input: ExternalAccountInput, label: string) => void; @@ -81,8 +108,14 @@ interface SignInFlowProps { onCardIssued?: () => void; onTapToPay?: (cents: number, merchant: string) => void; onReceivePayment?: (info: ReceivePaymentInfo) => void; + /** Credential state + the add-a-passkey action (the wallet's security nudge). */ + addPasskey?: { added: boolean; onAdd: () => void }; /** Auth-side overlays (passkey / email sheets) — rendered with the auth screen. */ children?: ReactNode; + /** The same overlays for the WALLET screen: a signed action whose session has + * lapsed re-authenticates in place (an OTP sheet over the wallet), so the + * prompt needs a surface on this side of the flip too. */ + walletOverlays?: ReactNode; } interface WalletHostProps { @@ -92,12 +125,22 @@ interface WalletHostProps { entrance: boolean; entry?: WalletEntry; walletOptions?: WalletBrainOptions; + balance?: string; + activity?: WalletListItemData[]; + depositInstructions?: DepositInstructions | null; + totalCents?: number; + walletToast?: { nonce: number; text: string } | null; + storedBanks?: SavedBank[]; + onSelectStoredBank?: (externalAccountId: string | null) => void; + onDepositView?: (view: { label: string; currency: string; cents?: number } | null) => void; + simulateDeposit?: { nonce: number; cents: number; last4: string } | null; onQuoteCreate?: (mode: WalletTransferMode, cents: number, dest?: TransferDest) => void; onLinkExternalAccount?: (input: ExternalAccountInput, label: string) => void; onTransferExecute?: (mode: WalletTransferMode, cents: number) => void; onCardIssued?: () => void; onTapToPay?: (cents: number, merchant: string) => void; onReceivePayment?: (info: ReceivePaymentInfo) => void; + addPasskey?: { added: boolean; onAdd: () => void }; } /** @@ -114,14 +157,26 @@ function WalletHost({ entrance, entry, walletOptions, + balance, + activity, + depositInstructions, + totalCents, + walletToast, + storedBanks, + onSelectStoredBank, + onDepositView, + simulateDeposit, onQuoteCreate, onLinkExternalAccount, onTransferExecute, onCardIssued, onTapToPay, onReceivePayment, + addPasskey, }: WalletHostProps) { const home = useWalletHome({ + balance, + serverActivity: activity, entrance, entry, transferSuccessScreen: walletOptions?.transferSuccessScreen, @@ -139,10 +194,14 @@ function WalletHost({ const mountSkin = useRef(skinId); const switchedIn = skinId !== mountSkin.current; + const money = useMoneySheet({ open: home.sheetOpen, mode: home.sheetMode, availableCents: home.availableCents, + depositInstructions, + storedBanks, + onSelectStoredBank, confirming: home.sheetConfirming, onDismiss: () => home.setSheetOpen(false), onConfirm: home.confirmTransfer, @@ -164,6 +223,53 @@ function WalletHost({ onReceive: home.handleReceivePayment, }); + // Tell the host when a country's deposit details are on screen: in sandbox it + // offers the funding stand-in as a card in the request list (no real wire is + // coming), and the button there drives `simulateDeposit` below. + const onDetails = money.step === 'fundingDetails' && home.sheetMode === 'add'; + const onCryptoAddress = money.step === 'depositAddress' && home.sheetMode === 'add'; + const detailsCountry = money.pickedCountry; + const cryptoChain = money.depositChain; + const cryptoCents = money.cents; + useEffect(() => { + if (onDetails && detailsCountry) { + onDepositView?.({ + label: detailsCountry.name, + currency: currencyFor(detailsCountry), + }); + return; + } + if (onCryptoAddress && cryptoChain) { + // The payer told us the amount here, so the stand-in can match it exactly. + onDepositView?.({ + label: cryptoChain.name, + currency: money.depositAsset, + cents: cryptoCents, + }); + return; + } + onDepositView?.(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onDetails, detailsCountry, onCryptoAddress, cryptoChain, cryptoCents, onDepositView]); + + // An arrival webhook landed: show it on the phone (the brain owns the toast). + const lastToast = useRef(0); + useEffect(() => { + if (!walletToast || walletToast.nonce === lastToast.current) return; + lastToast.current = walletToast.nonce; + home.showToast(walletToast.text); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletToast]); + + // The panel's "Simulate funding" button: same money path a confirmed add takes. + const lastSimulated = useRef(0); + useEffect(() => { + if (!simulateDeposit || simulateDeposit.nonce === lastSimulated.current) return; + lastSimulated.current = simulateDeposit.nonce; + home.simulateBankDeposit(simulateDeposit.cents, simulateDeposit.last4); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simulateDeposit]); + return ( // Skin switch = same brain, new face: the outgoing view blur-fades out over // the incoming one, exactly where it was (same sheet step, same balance). @@ -176,11 +282,13 @@ function WalletHost({ exit={{ ...SKIN_EXIT, transition: SKIN_FADE }} > @@ -208,13 +316,24 @@ export function SignInFlow({ authFlow, entry, walletOptions, + balance, + activity, + depositInstructions, + totalCents, + walletToast, + storedBanks, + onSelectStoredBank, + onDepositView, + simulateDeposit, onQuoteCreate, onLinkExternalAccount, onTransferExecute, onCardIssued, onTapToPay, onReceivePayment, + addPasskey, children, + walletOverlays, }: SignInFlowProps) { const reduceMotion = useReducedMotion(); // Plain crossfade instead of the blur dissolve: reduced motion, or a skin @@ -322,13 +441,24 @@ export function SignInFlow({ entrance={!reduceMotion} entry={entry} walletOptions={walletOptions} + balance={balance} + activity={activity} + depositInstructions={depositInstructions} + totalCents={totalCents} + walletToast={walletToast} + storedBanks={storedBanks} + onSelectStoredBank={onSelectStoredBank} + onDepositView={onDepositView} + simulateDeposit={simulateDeposit} onQuoteCreate={onQuoteCreate} onLinkExternalAccount={onLinkExternalAccount} onTransferExecute={onTransferExecute} onCardIssued={onCardIssued} onTapToPay={onTapToPay} onReceivePayment={onReceivePayment} + addPasskey={addPasskey} /> + {walletOverlays} )} diff --git a/components/grid-wallet-prod/src/apps/aurora/AuthSheet.tsx b/components/grid-wallet-prod/src/apps/aurora/AuthSheet.tsx index 728273907..eda8fe0f5 100644 --- a/components/grid-wallet-prod/src/apps/aurora/AuthSheet.tsx +++ b/components/grid-wallet-prod/src/apps/aurora/AuthSheet.tsx @@ -43,8 +43,9 @@ import { SquircleFocusHalo } from '@/apps/shared/SquircleFocusHalo'; import styles from './AuthSheet.module.scss'; const CODE_LENGTH = 6; -/** The demo's one-time code — what the notification autofills. */ -const DEMO_CODE = '123456'; +/** The demo's one-time code — what the notification autofills. Must match the + * sandbox magic OTP (any other code fails the live HPKE-encrypted verify). */ +const DEMO_CODE = '000000'; /** Code step settles, then the notification swoops in. */ const NOTIFICATION_DELAY_MS = 1000; /** Autofill cadence: one digit per beat, submit shortly after the last. */ @@ -179,6 +180,9 @@ const METHODS: Record = { interface AuthSheetProps { /** Which entry the first step collects (email address vs phone number). */ method?: AuthSheetMethod; + /** The real address on file (live flow) — overrides the method's placeholder + * prefill, so the step shows where the code is actually going. */ + prefill?: string | null; open: boolean; /** Entry submitted, code prompt not live yet — Continue shows a spinner. */ sending?: boolean; @@ -198,6 +202,7 @@ interface AuthSheetProps { */ export function AuthSheet({ method = 'email', + prefill, open, sending = false, codeActive = false, @@ -253,15 +258,17 @@ export function AuthSheet({ }); }; - // Prefilled per method so Continue is live on open — one tap through the - // demo. The value follows the method when it changes (the sheet stays - // mounted across method switches, so the email prefill must not leak into - // the phone step). - const [value, setValue] = useState(cfg.prefill); - const [prevMethod, setPrevMethod] = useState(method); - if (method !== prevMethod) { - setPrevMethod(method); - setValue(cfg.prefill); + // Prefilled so Continue is live on open — one tap through the demo. The live + // flow supplies the address the credential is actually tied to; otherwise the + // method's own placeholder stands in. The value follows either when it changes + // (the sheet stays mounted across method switches, so the email prefill must + // not leak into the phone step). + const initialValue = prefill || cfg.prefill; + const [value, setValue] = useState(initialValue); + const [prevPrefill, setPrevPrefill] = useState(initialValue); + if (initialValue !== prevPrefill) { + setPrevPrefill(initialValue); + setValue(initialValue); } const valid = cfg.validate(value); // Continue is always active (the amount-entry pattern): invalid input @@ -466,7 +473,11 @@ export function AuthSheet({ {/* Corner-pinned (16px) independent of the tile's header padding. */} = { - bank: { title: 'Bank account', sub: 'Local transfer in 65+ countries', speed: 'Instant' }, + bank: { title: 'Bank account', sub: 'ACH in the US, SEPA across the euro area', speed: 'Instant' }, crypto: { title: 'Crypto wallet', sub: 'Spark, Solana, Base address', speed: 'Instant' }, cashapp: { title: 'Cash App', sub: 'Use your Cash App balance', speed: 'Instant' }, applepay: { title: 'Apple Pay', sub: 'Use Apple Wallet', speed: 'Instant' }, @@ -363,6 +364,15 @@ export function AddMoneySheet({ copyValue, shareFunding, shareFundingAndReceive, + fundingSections, + fundingNote, + addAccountFromDetails, + displayTitle, + depositChain, + pickDepositChain, + depositAddress, + depositAsset, + isDepositDetails, dismiss, } = m; @@ -444,7 +454,7 @@ export function AddMoneySheet({ {selectedBank ? ( ) : ( - + )} @@ -457,6 +467,19 @@ export function AddMoneySheet({ ); + // Add-from-crypto: the money is arriving over a network, so the source card is + // that chain (its brand mark + the asset), not a bank. + const depositChainRow = depositChain ? ( +
+ + + + + {depositChain.name} + {depositAsset} transfer + +
+ ) : null; const balanceRow = (
@@ -546,12 +569,6 @@ export function AddMoneySheet({ // AnimatePresence `custom` prop is re-resolved for exiting children instead. type NavDir = { back: boolean; reduceMotion: boolean }; const navDir: NavDir = { back, reduceMotion: !!reduceMotion }; - // The funding-details step titles itself with the picked country's name (e.g. - // "Mexico"); every other step uses the mode's static step title. - const displayTitle = - step === 'fundingDetails' && pickedCountry - ? `Receive from ${pickedCountry.name}` - : titles[step]; // TRUE push: the incoming screen shares an edge with the outgoing one (full // ±100% travel, simultaneous), and the leaver fades as it exits. The entering // screen arrives at full opacity — it's a push, not a crossfade. @@ -740,14 +757,48 @@ export function AddMoneySheet({ Bank account - Local transfer in 65+ countries + ACH in the US, SEPA across the euro area Instant )} - {DEPOSIT_CHAINS.map((chain, i) => { + {/* Add money: pick a network first — the address comes after + the amount (mode === 'receive' keeps the copyable list). */} + {mode === 'add' && + ADD_DEPOSIT_CHAINS.map((chain, i) => ( + + ))} + {mode !== 'add' && + DEPOSIT_CHAINS.map((chain, i) => { const copied = copiedChainId === chain.id; return (
@@ -814,8 +865,82 @@ export function AddMoneySheet({ )} - {/* Receive — the picked country's inbound funding instructions. */} - {step === 'fundingDetails' && pickedCountry && ( + {/* Add-from-crypto: the address to pay, after the network + amount. + Real Grid-provisioned address for that chain. */} + {step === 'depositAddress' && depositChain && ( + +
+
+
+
+ Send + + + {formatUsdCents(cents)} {depositAsset} + + +
+
+ Network + + {depositChain.name} + +
+
+ Address + + {depositAddress} + + +
+
+
+

+ Send exactly this amount of {depositAsset} on {depositChain.name}. It lands in + your balance once the network confirms. +

+ {IS_SANDBOX && ( +

+ Sandbox: use “Simulate funding” in the API panel to stand in for the transfer. +

+ )} +
+
+ {/* The deposit is out of the app's hands from here; this is + just the way back to the wallet. */} + + Done + +
+
+ )} + + {/* Deposit details: Add money shows the customer's OWN account (real + values from Grid), Receive shows the picked country's inbound + instructions. The brain decides which rows these are. */} + {step === 'fundingDetails' && fundingSections.length > 0 && (
+ {fundingSections.map((section) => ( + + {fundingSections.length > 1 && ( +

{section.label}

+ )}
- {receiveFields(pickedCountry, formBeneficiary).map(([label, value], i, arr) => { - const id = `fd-${label}`; + {section.rows.map(([label, value], i, arr) => { + const id = `fd-${section.label}-${label}`; const copied = copiedChainId === id; return (
-

- Share these details with anyone paying you -

+ {section.note &&

{section.note}

} + + ))} + {!isDepositDetails &&

{fundingNote}

} + {isDepositDetails && IS_SANDBOX && ( +

+ Sandbox: use “Simulate funding” in the API panel to stand in + for the transfer. +

+ )} + {/* …or let us pull instead: the other half of the flow, right + under the break rather than pinned to the sheet bottom. */} + {isDepositDetails && ( + <> +
+ or +
+
+ + Add an account + +
+ + )}
- - - - Share - - + {isDepositDetails ? ( + // Nothing to confirm on a push — the money arrives on the + // bank's clock — so the pinned action is the way home. + + Done + + ) : ( + + + + Share + + + )}
)} @@ -965,8 +1124,8 @@ export function AddMoneySheet({ emptyTitle={isSend ? 'No recipients yet' : 'No bank accounts yet'} emptySub={ isSend - ? 'Send to a bank account in 65+ countries or any crypto wallet' - : 'Add a bank account in 65+ countries to get started' + ? 'Send to a US or euro-area bank account, or any crypto wallet' + : 'Add a US or euro-area bank account to get started' } cta={{ label: isSend ? 'Add recipient' : 'Add bank', @@ -1361,7 +1520,7 @@ export function AddMoneySheet({
- {mode === 'add' ? bankRow : balanceRow} + {mode === 'add' ? (depositChainRow ?? bankRow) : balanceRow}

- + 0 && totalCents !== availableCents + ? formatUsdCents(totalCents) + : undefined + } + /> setSendReceiveOpen(true)} /> - + {/* Security nudge: sits between the actions and the insight cards + while the account still has only its EMAIL_OTP credential. */} + {addPasskey && !addPasskey.added && ( + + + + )} + -

Total balance

+
+

Available balance

+ {/* The headline is what a transfer can actually move; the account's total + sits underneath, and is only worth a line when the two differ (USDB + reports a book total above spendable while funds are still settling). */} + {total &&

{total} total balance

}
); } diff --git a/components/grid-wallet-prod/src/apps/aurora/wallet/PasskeyNudge.module.scss b/components/grid-wallet-prod/src/apps/aurora/wallet/PasskeyNudge.module.scss new file mode 100644 index 000000000..1e37ac6b6 --- /dev/null +++ b/components/grid-wallet-prod/src/apps/aurora/wallet/PasskeyNudge.module.scss @@ -0,0 +1,87 @@ +@use '../../shared/fill-button' as fill; +@use '../../shared/typography/ios-type-mixins' as ios; + +/* Sits in the wallet sheet's stack — same 16px gutters as the insight-card row. */ +.wrap { + padding: 8px 16px; +} + +/* Squircle corners come from useSquircleClip (clip-path), like the insight cards. */ +.card { + @include fill.wallet-elevated-card; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + margin: 0; + padding: 14px 16px; + color: var(--ios-label-primary); + text-align: left; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + -webkit-tap-highlight-color: transparent; + position: relative; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: var(--ios-fill-tertiary); + opacity: 0; + transition: opacity 0.2s ease, background 0.2s ease; + pointer-events: none; + } + + &:hover::after { + opacity: 1; + } + + &:active::after { + opacity: 1; + background: var(--ios-fill-secondary); + } + + &[aria-busy='true'] { + cursor: default; + opacity: 0.6; + } +} + +/* The activity-row graphic treatment: 44px tertiary-fill square, 24px glyph. */ +.tile { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 12px; + corner-shape: var(--corner-shape); + background: var(--ios-fill-tertiary); + color: var(--ios-label-primary); +} + +.labels { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + gap: 1px; +} + +.title { + @include ios.ios-text-subheadline; + color: var(--ios-label-primary); +} + +.sub { + @include ios.ios-text-footnote; + color: var(--ios-label-secondary); +} + +.chevron { + display: flex; + flex-shrink: 0; + color: var(--ios-label-tertiary, rgba(60, 60, 67, 0.3)); +} diff --git a/components/grid-wallet-prod/src/apps/aurora/wallet/PasskeyNudge.tsx b/components/grid-wallet-prod/src/apps/aurora/wallet/PasskeyNudge.tsx new file mode 100644 index 000000000..3c74592ba --- /dev/null +++ b/components/grid-wallet-prod/src/apps/aurora/wallet/PasskeyNudge.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { IconUserKey } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconUserKey'; +import { SfSymbol } from '@/apps/shared/icons'; +import { useSquircleClip } from '@/apps/shared/useSquircleClip'; +import styles from './PasskeyNudge.module.scss'; + +interface PasskeyNudgeProps { + onAdd: () => void; + /** A registration is in flight — the row stays put but can't be re-tapped. */ + busy?: boolean; +} + +/** + * "Add a passkey" — the security nudge a real wallet shows once you're in. A + * Global Account signs in on its EMAIL_OTP credential first; adding the passkey + * is a separate, signed action, so it belongs here rather than in the sign-in + * flow. Shown only while the account has no passkey. + */ +export function PasskeyNudge({ onAdd, busy = false }: PasskeyNudgeProps) { + const clip = useSquircleClip(); + return ( +
+ +
+ ); +} diff --git a/components/grid-wallet-prod/src/apps/aurora/wallet/WalletInsightCards.tsx b/components/grid-wallet-prod/src/apps/aurora/wallet/WalletInsightCards.tsx index b826fa9cf..9266b4b9f 100644 --- a/components/grid-wallet-prod/src/apps/aurora/wallet/WalletInsightCards.tsx +++ b/components/grid-wallet-prod/src/apps/aurora/wallet/WalletInsightCards.tsx @@ -48,7 +48,7 @@ export interface WalletInsightCardsProps { earningsTodayCents?: number; /** A month of accrued yield (daily compounding) — the headline figure. */ earningsMonthCents?: number; - /** APY shown on the earnings card, percent (e.g. 5 → "5.00% APY"). */ + /** APY shown on the earnings card, percent (e.g. 3 → "3.00% APY"). */ apyPercent?: number; } diff --git a/components/grid-wallet-prod/src/apps/aurora/wallet/WalletListItem.tsx b/components/grid-wallet-prod/src/apps/aurora/wallet/WalletListItem.tsx index 7465b7219..59f0e7e26 100644 --- a/components/grid-wallet-prod/src/apps/aurora/wallet/WalletListItem.tsx +++ b/components/grid-wallet-prod/src/apps/aurora/wallet/WalletListItem.tsx @@ -11,6 +11,8 @@ import { IconTag } from '@central-icons-react/round-outlined-radius-3-stroke-1.5 import { IconSofa } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconSofa'; import { IconDeskLamp } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconDeskLamp'; import { IconBasket1 } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconBasket1'; +import { IconArrowDown } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconArrowDown'; +import { IconArrowUp } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconArrowUp'; import { Flag } from '@/apps/shared/Flag'; import type { WalletListItemData, WalletItemAvatar, MerchantCategory } from '@/apps/shared/wallet'; import styles from './WalletListItem.module.scss'; @@ -34,6 +36,9 @@ const MERCHANT_ICONS: Record = { grocery: IconBasket1, }; +// Money in/out with no merchant, brand or person to show — server history rows. +const FLOW_ICONS = { in: IconArrowDown, out: IconArrowUp } as const; + export interface WalletListItemProps extends Omit { /** Pre-formatted relative time label, e.g. "Just now". */ time: string; @@ -50,12 +55,13 @@ export function WalletListItem({ imageSquare, tileCircle, avatar, + flow, title, detail, time, amount, }: WalletListItemProps) { - const MerchantIcon = category ? MERCHANT_ICONS[category] : null; + const MerchantIcon = category ? MERCHANT_ICONS[category] : flow ? FLOW_ICONS[flow] : null; return (
): string { return digits.slice(-4) || raw.slice(-4); } +/** + * A Grid-stored external account → a saved-bank row. `id` IS the ExternalAccount + * id, so selecting one of these quotes against the real account instead of + * re-creating it (session-added rows use a local id and link on save). + * + * Grid doesn't store a bank NAME, so the row is labelled by rail rather than + * inventing one; the beneficiary and the identifier's last 4 are real. + */ +export function savedBankFromExternalAccount(account: { + id: string; + accountInfo?: { + accountType?: string; + accountNumber?: string; + routingNumber?: string; + iban?: string; + beneficiary?: { fullName?: string }; + }; +}): SavedBank | null { + const info = account.accountInfo ?? {}; + const accountType = info.accountType ?? ''; + // Euro accounts carry an IBAN whose first two letters are the country. + const iban = info.iban ?? ''; + const code = + accountType === 'USD_ACCOUNT' + ? 'us' + : iban.slice(0, 2).toLowerCase() || (accountType === 'EUR_ACCOUNT' ? 'de' : ''); + const country = BANK_COUNTRIES.find((c) => c.code === code && c.accountType === accountType); + if (!country) return null; + const values: Record = {}; + if (info.accountNumber) values.accountNumber = info.accountNumber; + if (info.routingNumber) values.routingNumber = info.routingNumber; + if (info.iban) values.iban = info.iban; + if (!Object.keys(values).length) return null; + return { + id: account.id, + country, + bankName: accountType === 'EUR_ACCOUNT' ? 'EUR account' : 'USD account', + values, + beneficiary: info.beneficiary?.fullName ?? DEMO_BENEFICIARY, + }; +} + /** Compact rate for "1 USD = X" — fewer decimals as the magnitude grows. */ export function formatRate(rate: number): string { const max = rate >= 100 ? 0 : rate >= 1 ? 2 : 4; @@ -121,8 +165,9 @@ export function fieldLabel(key: string): string { return FIELD_LABELS[key] ?? key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); } -/** Demo FX — matches the Figma copy (1 MXN = 0.06 USD ⇒ 1 USD ≈ 17.9074 MXN). */ -export const USD_TO_MXN = 17.9074; +/** Demo FX placeholder for the only non-USD rail left (SEPA/EUR): the rate shown + * before a bank is picked. Real rates are runtime (GET /exchange-rates). */ +export const USD_TO_EUR = 0.9174; /** Fake quote-creation beat: Continue spins this long before the confirm step. */ export const QUOTE_MS = 750; /** Validate+save beat: the add bank/recipient CTA spins this long before amount. */ @@ -155,10 +200,13 @@ export const MODES: Record< amount: 'Enter amount', confirm: 'Confirm add', deposit: 'Add from crypto', + // Both titled with the picked country / network (the brain builds them). fundingDetails: '', + depositAddress: '', }, - // Bank → saved-banks list; Crypto → the deposit-address list (send crypto in - // to top up). Cash App / Apple Pay stay inactive (no demo path yet). + // Bank → saved banks → country picker → THAT COUNTRY's deposit instructions, + // which also offer "add an account" (enter your own details to pull from). + // Crypto → the deposit-address list. Cash App / Apple Pay stay inactive. activeSources: [ { id: 'bank', next: 'banks' }, { id: 'crypto', next: 'deposit' }, @@ -166,7 +214,7 @@ export const MODES: Record< sources: ['bank', 'crypto', 'cashapp', 'applepay'], details: [ ['Fee', '$0.60'], - ['Conversion rate', '1 MXN = 0.06 USD'], + ['Conversion rate', '1 EUR = 1.09 USD'], ['Arrives', 'Instantly'], ], }, @@ -183,6 +231,7 @@ export const MODES: Record< confirm: 'Confirm withdrawal', deposit: '', fundingDetails: '', + depositAddress: '', }, sources: ['bank', 'crypto'], activeSources: [ @@ -191,7 +240,7 @@ export const MODES: Record< ], details: [ ['Fee', '$0.60'], - ['Conversion rate', '1 USD = 17.91 MXN'], + ['Conversion rate', '1 USD = 0.92 EUR'], ['Arrives in bank', 'Instantly'], ], }, @@ -208,6 +257,7 @@ export const MODES: Record< confirm: 'Confirm send', deposit: '', fundingDetails: '', + depositAddress: '', }, sources: ['bank', 'crypto'], // The "Add recipient" chooser (the recipient list is the entry): Bank → add a @@ -235,6 +285,7 @@ export const MODES: Record< confirm: '', deposit: 'Receive via', fundingDetails: 'Receive', + depositAddress: '', }, sources: [], activeSources: [], @@ -262,6 +313,16 @@ export const DEPOSIT_CHAINS: DepositChain[] = [ { id: 'btc', name: 'Bitcoin', address: 'bc1qsu2qrhp5vq5csy97qv3w8eku8wrh2l7dtenv7p', logo: '/assets/networks/icon-network-bitcoin.svg', time: '10 min' }, ]; +/** + * Networks Add money accepts USDC on. Deliberately narrower than DEPOSIT_CHAINS + * (which still backs Receive and the outbound crypto picker): these are the three + * the platform funds from, and each has a real Grid-provisioned address. + */ +export const ADD_DEPOSIT_NETWORK_IDS = ['base', 'solana', 'ethereum']; +export const ADD_DEPOSIT_CHAINS: DepositChain[] = ADD_DEPOSIT_NETWORK_IDS.map( + (id) => DEPOSIT_CHAINS.find((c) => c.id === id)!, +); + /** Static demo BTC price — an L1 Bitcoin send shows the amount in BTC and this * rate on confirm (the real quote settles in BTC). */ export const BTC_USD = 65000; diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/types.ts b/components/grid-wallet-prod/src/apps/shared/wallet/types.ts index 79f89976c..cb8178b52 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/types.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/types.ts @@ -82,6 +82,12 @@ export interface WalletListItemData { tileCircle?: boolean; /** Person counterparty — render the initials avatar (wins over icon/image). */ avatar?: WalletItemAvatar; + /** + * Plain money movement with no merchant, logo or person behind it — the + * account's own history from `GET /transactions`. The skin renders a + * directional arrow in the tile ('in' = received/added, 'out' = sent). + */ + flow?: 'in' | 'out'; title: string; /** Merchant detail line, e.g. "Tap to Pay". */ detail: string; diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts b/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts index f093379e1..085c7ee38 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/useMoneySheet.ts @@ -10,6 +10,7 @@ */ import { useEffect, useRef, useState } from 'react'; import type { ExternalAccountInput, TransferDest } from '@/data/apiCalls'; +import type { DepositSection, DepositWallet } from '@/lib/gridReads'; import { currencyFor, recipientNamesFor, type BankCountry } from '@/data/bankCountries'; import { useUsdRates } from '@/hooks/useUsdRates'; import { formatUsdCents, typedToCents } from './format'; @@ -24,7 +25,7 @@ import { QUOTE_MS, RECEIVE_RAIL, SAVE_MS, - USD_TO_MXN, + USD_TO_EUR, accountLast4, firstNameLastInitial, formatRate, @@ -33,6 +34,7 @@ import { type CryptoRecipient, type SavedBank, type SavedRecipient, + type DepositChain, type SendNetwork, type Step, } from './moneySheet'; @@ -46,6 +48,22 @@ export interface UseMoneySheetOptions { /** Face ID running — Confirm shows a spinner and input locks. */ confirming: boolean; onDismiss: () => void; + /** + * Real deposit details for the customer's fiat account (from the host's + * `GET /customers/internal-accounts`) — what Add money → Bank transfer shows. + * Absent/null means the account has none, and the screen says so. + */ + depositInstructions?: { sections: DepositSection[]; wallets?: DepositWallet[] } | null; + /** + * Accounts Grid already holds for this customer (`GET /customers/external-accounts`), + * shown in the saved list above anything added this session. Held as an INPUT, + * never copied into state, so a re-read flows straight through. Their `id` is + * the real ExternalAccount id. + */ + storedBanks?: SavedBank[]; + /** A stored account was selected — the host quotes against THIS id rather than + * creating a new external account. Null when the pick is a session-added one. */ + onSelectStoredBank?: (externalAccountId: string | null) => void; /** Confirm tapped with the typed amount (cents). `activity` carries the real * destination for the Activity row + toast. */ onConfirm: (cents: number, activity: TransferActivity) => void; @@ -61,11 +79,14 @@ export function useMoneySheet({ open, mode, availableCents, + depositInstructions, + storedBanks, confirming, onDismiss, onConfirm, onQuote, onLinkExternalAccount, + onSelectStoredBank, onReceive, }: UseMoneySheetOptions) { const { titles, sources, activeSources, details } = MODES[mode]; @@ -88,6 +109,9 @@ export function useMoneySheet({ const [pickedNetwork, setPickedNetwork] = useState(null); // Withdraw-to-crypto destination — a one-off wallet (not a saved list). const [cryptoDest, setCryptoDest] = useState(null); + // Add-from-crypto: the network picked from the deposit list (drives the amount + // step's target and the address screen). + const [depositChain, setDepositChain] = useState(null); const quoteTimer = useRef(0); const saveTimer = useRef(0); // Invalid-amount signal: bumps on a rejected keypress (past the cap) or an @@ -106,7 +130,13 @@ export function useMoneySheet({ list.some((x) => x.address === w.address) ? list : [...list, w], ); // The active list for the current mode: send = recipients; add/withdraw = banks. - const banks: SavedRecipient[] = isSend ? savedRecipients : savedBanks; + // Stored accounts (from Grid) lead, then anything added this session — deduped + // by id so a re-read after an add doesn't double a row. + const stored = storedBanks ?? []; + const sessionBanks = savedBanks.filter((b) => !stored.some((x) => x.id === b.id)); + const banks: SavedRecipient[] = isSend + ? savedRecipients + : [...sessionBanks, ...stored]; const [selectedBankId, setSelectedBankId] = useState(null); const [pickedCountry, setPickedCountry] = useState(null); const [countryQuery, setCountryQuery] = useState(''); @@ -121,17 +151,26 @@ export function useMoneySheet({ const selectedBank = selected && !('address' in selected) ? selected : null; // Send picks crypto from the saved list; withdraw uses a one-off cryptoDest. const selectedCrypto = cryptoDest ?? (selected && 'address' in selected ? selected : null); - const localCurrency = selectedBank ? currencyFor(selectedBank.country) : 'MXN'; - const cryptoCurrency = selectedCrypto?.currency ?? 'USDC'; + // The real Grid-provisioned address for the picked deposit network (falls back + // to the illustrative one if the account has no wallet on that chain). + const depositWallet = depositChain + ? (depositInstructions?.wallets ?? []).find((w) => w.network === depositChain.id) + : undefined; + const depositAddress = depositWallet?.address ?? depositChain?.address ?? ''; + const depositAsset = depositWallet?.asset ?? 'USDC'; + const localCurrency = selectedBank ? currencyFor(selectedBank.country) : 'EUR'; + const cryptoCurrency = selectedCrypto?.currency ?? (depositChain ? depositAsset : 'USDC'); const isBtcDest = cryptoCurrency === 'BTC'; - const stablecoinDest = !selectedBank && (selectedCrypto != null || mode === 'send'); + // A crypto deposit is stablecoin-denominated too (1:1, no FX to show). + const stablecoinDest = + !selectedBank && (selectedCrypto != null || mode === 'send' || depositChain != null); const fxRate = selectedBank ? rateFor(currencyFor(selectedBank.country), selectedBank.country.usdToLocal) : stablecoinDest ? isBtcDest ? 1 / BTC_USD : 1 - : USD_TO_MXN; + : USD_TO_EUR; // BTC shows fractional precision; stablecoins (and fiat) use 2 decimals. const fxFractionDigits = stablecoinDest && isBtcDest ? 6 : 2; const fxLabel = stablecoinDest ? cryptoCurrency : localCurrency; @@ -156,6 +195,7 @@ export function useMoneySheet({ if (open) { setOpenKey((k) => k + 1); setStep(mode === 'receive' ? 'deposit' : isSend ? 'banks' : 'source'); + setDepositChain(null); setBack(false); setRaw(''); setStarted(false); @@ -189,6 +229,18 @@ export function useMoneySheet({ setStep(next); }; + /** Add-from-crypto: a network row → enter how much is coming, then the address. */ + const pickDepositChain = (chain: DepositChain) => { + setDepositChain(chain); + // Mirror of selectBank: a network pick drops any bank/wallet selection. + setSelectedBankId(null); + setCryptoDest(null); + setRaw(''); + setStarted(false); + setMaxed(false); + go('amount'); + }; + // Pick a crypto network in the secondary sheet — fills the address card. const pickNetwork = (net: SendNetwork) => { setPickedNetwork(net); @@ -238,10 +290,12 @@ export function useMoneySheet({ const openAddBank = () => { setPickedCountry(null); setCountryQuery(''); + setDepositChain(null); go(isSend ? 'source' : 'country'); }; const pickCountry = (country: BankCountry) => { setPickedCountry(country); + setDepositChain(null); const values = sampleValuesFor(country); // The spec's generic example is "Example Bank" — prefill the country's real // demo bank instead (the same pool-cycled name the saved row will get). @@ -262,8 +316,14 @@ export function useMoneySheet({ } else { setFormBeneficiary(DEMO_BENEFICIARY); } - go(mode === 'receive' ? 'fundingDetails' : 'bankForm'); + // Add + Receive both show the country's inbound instructions first; the add + // flow offers "add an account" from there. Withdraw/send go straight to the + // form (they're collecting a destination, not showing one). + go(mode === 'receive' || mode === 'add' ? 'fundingDetails' : 'bankForm'); }; + + /** "Add an account" on the instructions screen → the details form. */ + const addAccountFromDetails = () => go('bankForm'); const updateField = (key: string, value: string) => setFormValues((v) => ({ ...v, [key]: value })); const addBank = () => { @@ -299,6 +359,7 @@ export function useMoneySheet({ isSend ? 'Add recipient' : 'Add bank account', ); setSelectedBankId(bank.id); + setDepositChain(null); setSaving(false); go('amount'); }, SAVE_MS); @@ -340,9 +401,15 @@ export function useMoneySheet({ }; const selectBank = (id: string) => { setSelectedBankId(id); - // A bank pick drops any lingering one-off crypto destination so the two - // never bleed together on the amount/confirm screens. + // Tell the host when the pick is an account Grid already holds, so the quote + // points at that ExternalAccount instead of one created this session. + onSelectStoredBank?.(stored.some((b) => b.id === id) ? id : null); + // A bank pick drops any lingering crypto destination — a one-off wallet AND + // a picked deposit network — so the two never bleed together on the + // amount/confirm screens (picking a bank after visiting Add-from-crypto + // otherwise priced the transfer in USDC and pushed to the address screen). setCryptoDest(null); + setDepositChain(null); go('amount'); }; @@ -405,7 +472,7 @@ export function useMoneySheet({ } : { kind: 'bank', - countryCode: selectedBank?.country.code ?? 'mx', + countryCode: selectedBank?.country.code ?? 'us', bankName: selectedBank?.bankName ?? 'Bank account', last4: selectedBank ? accountLast4(selectedBank.values) : '0000', recipientName: selectedBank?.beneficiary ?? '', @@ -429,8 +496,13 @@ export function useMoneySheet({ } : { confirm: 'amount', - amount: selectedCrypto ? 'recipient' : 'banks', - bankForm: 'country', + amount: depositChain ? 'deposit' : selectedCrypto ? 'recipient' : 'banks', + // Add money puts the country's instructions between the picker and + // the form (country → instructions → form), so back retraces that; + // withdraw goes picker → form with nothing in between. + bankForm: mode === 'add' ? 'fundingDetails' : 'country', + fundingDetails: 'country', + depositAddress: 'amount', country: 'banks', recipient: 'source', banks: 'source', @@ -538,6 +610,16 @@ export function useMoneySheet({ // fee too: what leaves the balance (payCents) is what's capped. const tryContinue = () => { if (confirming || quoting) return; + // Add-from-crypto asks for the amount only so the address screen can state + // what's expected — no quote, nothing to confirm: the payer sends on-chain. + if (depositChain) { + if (cents > 0) { + go('depositAddress'); + return; + } + triggerShake(); + return; + } if (cents > 0 && (mode === 'add' || payCents <= availableCents)) { const dest: TransferDest | undefined = selectedCrypto ? { kind: 'crypto', currency: selectedCrypto.currency } @@ -591,13 +673,47 @@ export function useMoneySheet({ ghost: hasDot ? '0'.repeat(Math.max(0, 2 - fracTyped.length)) : '', }; - // The funding-details step titles itself with the picked country's name. + // The funding-details step serves two flows: Add money shows the customer's + // OWN deposit details (real, from Grid), Receive shows the picked country's + // inbound instructions. Same screen, different source. + const isDepositDetails = step === 'fundingDetails' && mode === 'add'; + // One country, one currency: show the section that matches what a bank in the + // picked country would actually send (USD for the US, EUR across the euro area). + const pickedCurrency = pickedCountry ? currencyFor(pickedCountry) : null; + const fundingSections: DepositSection[] = isDepositDetails + ? (depositInstructions?.sections ?? []).filter((x) => x.label === pickedCurrency) + : pickedCountry + ? [ + { + label: currencyFor(pickedCountry), + rows: receiveFields(pickedCountry, formBeneficiary), + note: 'Share these details with anyone paying you', + }, + ] + : []; + const fundingNote = isDepositDetails + ? 'Transfer from your bank using these details — it lands in your balance.' + : 'Share these details with anyone paying you'; + const displayTitle = - step === 'fundingDetails' && pickedCountry - ? `Receive from ${pickedCountry.name}` + step === 'depositAddress' && depositChain + ? `Add from ${depositChain.name}` + : step === 'fundingDetails' && pickedCountry + ? isDepositDetails + ? `Add from ${pickedCountry.name}` + : `Receive from ${pickedCountry.name}` : titles[step]; return { + // funding-details screen (Add money's deposit details / Receive's per-country) + fundingSections, + fundingNote, + isDepositDetails, + // add-from-crypto + depositChain, + pickDepositChain, + depositAddress, + depositAsset, // flow config (per mode) titles, sources, @@ -665,6 +781,7 @@ export function useMoneySheet({ pickedCountry, pickCountry, openAddBank, + addAccountFromDetails, // bank form formValues, setFormValues, diff --git a/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts b/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts index cfe72de48..f5889f132 100644 --- a/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts +++ b/components/grid-wallet-prod/src/apps/shared/wallet/useWalletHome.ts @@ -57,6 +57,13 @@ const COLD_ENTRY_SETTLE_MS = 700; export interface UseWalletHomeOptions { /** Formatted balance from demo state, e.g. "$0.00". */ balance?: string; + /** + * Real transaction history from the host (`GET /transactions`), newest first. + * Held as an INPUT, never copied into state: the host owns it, so a re-read + * flows straight through, and rows added by this session's own flows still + * merge on top by timestamp. + */ + serverActivity?: WalletListItemData[]; /** Whether the sign-in entrance reveal is playing — gates the cold-jump beat. */ entrance?: boolean; /** Jump command from the sidebar — provision + open a flow out of order. */ @@ -67,8 +74,26 @@ export interface UseWalletHomeOptions { * Balance + activity still update; the toast is suppressed. Default false. */ transferSuccessScreen?: boolean; - /** Transfer confirmed (Face ID) — log execute + settle and move the balance. */ - onTransferExecute?: (mode: WalletTransferMode, cents: number) => void; + /** + * Transfer confirmed (Face ID) — log execute + settle and move the balance. + * For Add money, the third arg is a settle channel: the host calls it back + * on EVERY terminal outcome for THIS add — success (once the real balance + * read lands, or exhausts its retry) or failure (non-200, thrown, or the + * 403 production-keys branch) — so this add's own optimistic bump is + * undone by exactly its own cents the instant its own flow concludes, + * whichever way it goes. Amount-exact and proportional: no waiting on an + * unrelated add's refresh, and no "wait for the in-flight counter to hit + * 0" step. Ignored for withdraw/send. + */ + onTransferExecute?: ( + mode: WalletTransferMode, + cents: number, + onAddSettled?: () => void, + /** `simulated: true` = the sandbox stand-in, not a user-confirmed transfer. + * The host routes them differently (a real add pulls from the account the + * user linked; the stand-in runs the platform on-ramp). */ + opts?: { simulated?: boolean }, + ) => void; /** A tap-to-pay charge landed on the phone. */ onTapToPay?: (cents: number, merchant: string) => void; /** A payment was received (Receive flow) — log the inbound webhook + settle. */ @@ -84,6 +109,7 @@ export interface UseWalletHomeOptions { export function useWalletHome(options: UseWalletHomeOptions = {}) { const { balance = '$0.00', + serverActivity, entrance = false, entry, transferSuccessScreen = false, @@ -110,6 +136,29 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { const pendingCents = useRef(0); const pendingActivity = useRef(null); const availableCents = parseCents(balance) + deltaCents; + // `balance` is the host's source of truth (the real book balance). + // `deltaCents` is ONLY the optimistic bridge for Add money's real (async) + // path: Face-ID-confirm bumps it instantly, well before the sandbox fund + + // GET /customers/internal-accounts round-trip lands and moves `balance` + // itself. Withdraw/Send/Tap/Receive do NOT use it when a host callback is + // wired (see below) — the host moves `balance` SYNCHRONOUSLY in the same + // commit as those, so bumping delta too would double-count for a frame. + // + // Adds no longer bump `deltaCents` at all (the money is announced by Grid's + // arrival webhook, and `balance` re-read from the API), so nothing is in + // flight to reconcile — this counter stays 0 and only the drift guard below + // still reads it. + const pendingAdds = useRef(0); + // Now just a drift guard: under the per-add settle design above, this + // should never actually fire (every increment has a matching settle call). + // Kept as a backstop — if `pendingAdds` is back to 0 (no add believes + // itself in flight) but `deltaCents` is somehow still nonzero when + // `balance` moves, snap it rather than let a stray delta linger + // silently. Deliberately does NOT touch `deltaCents` while `pendingAdds` + // is nonzero — that's still-pending adds' own tracked contribution. + useEffect(() => { + if (pendingAdds.current === 0 && deltaCents !== 0) setDeltaCents(0); + }, [balance]); // Earnings = yield on the live balance, shown as today's accrual. Weekly bars // map the most recent card charges (up to WEEKLY_BAR_COUNT), normalized to the // busiest charge so heights vary by amount. @@ -148,12 +197,17 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { const [toast, setToast] = useState(null); const showToast = (text: string) => setToast({ id: Date.now(), text }); - // Home Activity = money movements + card transactions, newest first. Derived - // (not double-inserted) so each WalletListCard instance keeps its own - // fresh-row bookkeeping — the grow-in insert still runs per list. + // Home Activity = this session's money movements + card transactions + the + // account's real history, newest first. Derived (not double-inserted) so each + // WalletListCard instance keeps its own fresh-row bookkeeping — the grow-in + // insert still runs per list. Server rows carry Grid transaction ids and this + // session's carry local ones, so the id-keyed reveal can't collide. const homeActivity = useMemo( - () => [...activity, ...transactions].sort((a, b) => b.timestamp - a.timestamp), - [activity, transactions], + () => + [...activity, ...transactions, ...(serverActivity ?? [])].sort( + (a, b) => b.timestamp - a.timestamp, + ), + [activity, transactions, serverActivity], ); const isOpen = cardView !== 'closed'; @@ -190,7 +244,9 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { const tx = pendingTapTx.current; // the merchant picked at tap start setTapPhase('idle'); // The card charge comes out of the cash balance, landing with the row. - setDeltaCents((c) => c - parseCents(tx.amount)); + // Self-track only when no host callback is wired — a wired host moves + // `balance` synchronously via `onTapToPay` below, in this same commit. + if (!onTapToPay) setDeltaCents((c) => c - parseCents(tx.amount)); setSpendBars((b) => [...b, parseCents(tx.amount)]); onTapToPay?.(parseCents(tx.amount), tx.title); window.clearTimeout(insertTimer.current); @@ -272,7 +328,12 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { window.clearTimeout(coldOpenTimer.current); if (entry.provision?.issued) setIssued(true); - if (typeof entry.provision?.fundCents === 'number') setDeltaCents(entry.provision.fundCents); + // Relative to the CURRENT base balance, not absolute — so the visible + // total lands on exactly `fundCents` whether `balance` is still "$0.00" + // (no real session) or already a real signed-in balance. + if (typeof entry.provision?.fundCents === 'number') { + setDeltaCents(entry.provision.fundCents - parseCents(balance)); + } const openTarget = () => { switch (entry.open) { @@ -361,37 +422,72 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { // (signed), and drop the Activity row in once the wallet has settled (visible // insert). const sheetInsertTimer = useRef(0); - const finishTransfer = () => { + const finishTransfer = ( + { closeSheet = true, simulated = false }: { closeSheet?: boolean; simulated?: boolean } = {}, + ) => { const cents = pendingCents.current; const mode = sheetMode; const dest = pendingActivity.current; // Receive has no amount/confirm step, so it never reaches finishTransfer — // the guard also narrows `mode` to a transfer mode for the calls below. if (mode === 'receive') return; - onTransferExecute?.(mode, cents); + // No settle channel any more: an add makes NO optimistic bump (the webhook + // announces the arrival and the host re-reads the balance), so there is + // nothing for the host to undo. Passing one that subtracts `cents` would + // drop the displayed balance by the amount being added. + onTransferExecute?.(mode, cents, undefined, { simulated }); setSheetConfirming(false); // A skin with its own success screen keeps the sheet up (Done closes it) and // owns the confirmation — hold the balance move + Activity insert until the // sheet is dismissed so both play out on the visible home (balance ticks, // row grows in) instead of settling silently behind the success screen. + // MUST FIX BEFORE ANY SKIN SETS `transferSuccessScreen`: `settleAdd` above + // is registered NOW (immediately, same as the non-success-screen path), + // but the matching `pendingAdds`/`deltaCents` INCREMENT for this path + // doesn't happen until the settle effect below fires, `SETTLE_DELTA_DELAY_MS` + // later. A fast terminal outcome (very plausible for a failure, and not + // impossible for a success against a quick sandbox) settling BEFORE that + // increment runs will net out wrong (settles a bump that hasn't been + // applied yet, then the delayed effect applies it with nothing left to + // undo it). Not reachable in this app today (no active skin sets + // `transferSuccessScreen`) — but this is a real, known-broken path, not a + // theoretical one; do not enable `transferSuccessScreen` on a live skin + // without fixing this ordering first (e.g. by moving the increment into + // `finishTransfer` itself, ahead of `onTransferExecute`, same as the + // non-success-screen path does). if (transferSuccessScreen) { pendingSettle.current = { mode, cents, dest }; return; } - setDeltaCents((c) => c + (mode === 'add' ? cents : -cents)); - setSheetOpen(false); + if (mode === 'add') { + // Deliberately NO optimistic bump, toast or Activity row here: money + // arriving is announced by Grid's INCOMING_PAYMENT.COMPLETED webhook (the + // host refreshes the balance and toasts off the back of it). Claiming it + // at tap time was a lie for a pull, which Grid can't even execute — the + // payer still has to push. + if (closeSheet) setSheetOpen(false); + return; + } else if (!onTransferExecute) { + // No host wired to move `balance` synchronously — self-track locally + // (this is the only path for a standalone/unwired caller). + setDeltaCents((c) => c - cents); + } + // else: a host callback IS wired, and `onTransferExecute` above already + // moved the real `balance` synchronously, in this same commit — do not + // ALSO apply the delta here, or `availableCents` briefly double-counts + // the movement for one frame (old − 2×cents) before self-correcting. + if (closeSheet) setSheetOpen(false); const sentTo = dest?.kind === 'crypto' ? truncateAddress(dest.address) : dest?.kind === 'bank' ? dest.recipientName || dest.bankName : 'recipient'; + // 'add' returned above — arrivals are announced by the webhook, not here. showToast( - mode === 'add' - ? `${toastUsd(cents)} added to balance` - : mode === 'withdraw' - ? `${toastUsd(cents)} withdrawn from balance` - : `${toastUsd(cents)} sent to ${sentTo}`, + mode === 'withdraw' + ? `${toastUsd(cents)} withdrawn from balance` + : `${toastUsd(cents)} sent to ${sentTo}`, ); window.clearTimeout(sheetInsertTimer.current); sheetInsertTimer.current = window.setTimeout(() => { @@ -414,7 +510,15 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { const { mode, cents, dest } = pendingSettle.current; pendingSettle.current = null; settleDeltaTimer.current = window.setTimeout(() => { - setDeltaCents((c) => c + (mode === 'add' ? cents : -cents)); + // Same add-vs-sync split as `finishTransfer` above (not currently + // reachable in this app — no active skin sets `transferSuccessScreen` + // — kept consistent for any skin that does). + if (mode === 'add') { + pendingAdds.current += 1; + setDeltaCents((c) => c + cents); + } else if (!onTransferExecute) { + setDeltaCents((c) => c - cents); + } }, SETTLE_DELTA_DELAY_MS); window.clearTimeout(sheetInsertTimer.current); sheetInsertTimer.current = window.setTimeout(() => { @@ -431,6 +535,28 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { setSheetConfirming(true); }; + /** + * SANDBOX ONLY (see lib/gridEnv): stand in for the inbound wire the user would + * really send to the deposit details on screen, so Add money still completes. + * Runs the host's real on-ramp — same money path, balance move, settle + * reconciliation and Activity row as a confirmed add; only the trigger is + * simulated. Never call this against production keys. + */ + const simulateBankDeposit = (cents: number, accountLast4: string) => { + pendingCents.current = cents; + pendingActivity.current = { + kind: 'bank', + bankName: 'Bank transfer', + last4: accountLast4, + countryCode: 'us', + recipientName: '', + }; + // Leaves the sheet OPEN: the instructions screen is a fork (wire to these + // details, or add an account to pull from), so yanking it away mid-decision + // would be worse than letting the balance move behind it. + finishTransfer({ closeSheet: false, simulated: true }); + }; + // Receive (Share/Copy in the deposit list): the demo "bullshit mode" payment. // Close the sheet a beat after the tap, then a moment later a payment "lands": // balance bumps, a toast drops, an Activity row inserts, and the inbound @@ -446,7 +572,9 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { window.setTimeout(() => { const cents = randomReceiveCents(); const payer = p.via === 'crypto' ? truncateAddress(p.address) : p.payer; - setDeltaCents((c) => c + cents); + // Self-track only when no host callback is wired — a wired host moves + // `balance` synchronously via `onReceivePayment` below. + if (!onReceivePayment) setDeltaCents((c) => c + cents); showToast( asAdd ? `${toastUsd(cents)} added to balance` : `Received ${toastUsd(cents)} from ${payer}`, ); @@ -482,6 +610,7 @@ export function useWalletHome(options: UseWalletHomeOptions = {}) { toast, setToast, showToast, + simulateBankDeposit, // Derived money / activity availableCents, earningsTodayCents, diff --git a/components/grid-wallet-prod/src/apps/types.ts b/components/grid-wallet-prod/src/apps/types.ts index 11811e07d..2dc0e1ea1 100644 --- a/components/grid-wallet-prod/src/apps/types.ts +++ b/components/grid-wallet-prod/src/apps/types.ts @@ -15,6 +15,9 @@ export interface SkinAuthFlow { sending: boolean; /** The verification-code prompt is up. */ codeActive: boolean; + /** Prefill for the entry step — the real address/number on file when the flow + * is live (the sheet falls back to its own demo placeholder). */ + prefill?: string | null; onSubmit: (value: string) => void; onSubmitCode?: (code: string) => void; /** Code step → back to the entry step (re-prompts the entry). */ @@ -52,6 +55,12 @@ export interface SkinWalletScreenProps { switchedIn?: boolean; /** A virtual card finished issuing on the phone — log the issue call. */ onCardIssued?: () => void; + /** The wallet account's total balance in cents (USDB `totalBalance`), shown + * under the available headline when the two differ. */ + totalCents?: number; + /** Credential state + the action that registers a passkey. Absent (or + * `added: true`) means the wallet shows no passkey nudge. */ + addPasskey?: { added: boolean; onAdd: () => void }; } export type SkinAuthScreen = ComponentType; diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss index b9b9d8084..0c9024b05 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.module.scss @@ -320,7 +320,6 @@ .codeBlockContent { padding: var(--spacing-md); - overflow-x: auto; } .codePre { @@ -330,11 +329,24 @@ font-weight: var(--font-weight-regular); line-height: var(--font-leading-18); color: var(--text-primary); - white-space: pre; + /* Wrap instead of scrolling sideways: the panel is narrow and the interesting + lines (stamps, payloads) are the long ones. `pre-wrap` keeps the curl's own + indentation; `anywhere` breaks base64/JSON blobs that have nowhere to break. */ + white-space: pre-wrap; + overflow-wrap: anywhere; tab-size: 2; font-feature-settings: 'salt' 1; } +/* One block per source line (the highlighters emit no trailing newline — this + display supplies the break), so wrapped remainders sit under a hanging indent + and read as continuations rather than new lines. */ +.codeLine { + display: block; + padding-left: 2ch; + text-indent: -2ch; +} + .syntaxDefault { color: #24292e; @@ -394,3 +406,47 @@ } } + +/* ── Action card ────────────────────────────────────────────────────────────── + Not traffic: a demo affordance that CAUSES traffic (the sandbox funding + stand-in). Dashed edge so it reads as scaffolding next to the real calls. */ +.actionCard { + border-style: dashed; + gap: var(--spacing-sm); + padding-bottom: var(--spacing-md); +} + +.actionNote { + margin: 0 var(--spacing-md); + color: var(--text-secondary); + font-size: 12px; + line-height: var(--font-leading-18); +} + +.actionBtn { + margin: 0 var(--spacing-md); + padding: 8px 14px; + border: none; + border-radius: var(--rounded-md, 8px); + background: var(--color-blue-60, #0b7bff); + color: #ffffff; + font: inherit; + font-size: 12px; + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: opacity 150ms ease, transform 150ms ease; + + &:hover { + opacity: 0.9; + } + + &:active { + transform: scale(0.98); + } + + &:disabled { + background: var(--color-alpha-black-08, rgba(0, 0, 0, 0.08)); + color: var(--text-tertiary); + cursor: default; + } +} diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx index 1d7dc5c01..dd6422ae5 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiCallList.tsx @@ -18,7 +18,6 @@ import { formatAbsoluteTime, formatRelativeTime } from '@/lib/formatRelativeTime import { groupApiEntries } from '@/lib/groupApiEntries'; import { useNowTick } from '@/hooks/useNowTick'; import { cubicBezierCss, easeOutSnappy, easeOutSwift, motionTransition } from '@/lib/easing'; -import { SectionDivider } from '@/components/SectionDivider/SectionDivider'; import type { Entry, EntryGroup } from './types'; import styles from './ApiCallList.module.scss'; @@ -29,6 +28,9 @@ const syntaxClasses = { command: styles.syntaxCommand, flag: styles.syntaxFlag, string: styles.syntaxString, + // Each source line is its own block so a long one (signature headers, request + // bodies) wraps with a hanging indent instead of scrolling sideways. + line: styles.codeLine, }; function methodBadgeVariant(method: ApiCall['method']): 'blue' | 'green' { @@ -275,6 +277,8 @@ interface ApiCallListProps { entries: Entry[]; /** Keys of calls not yet seen — each gets a "new" dot (stacked layout only). */ newKeys: Set; + /** Runs an action card's button (sandbox funding). */ + onAction?: (action: NonNullable) => void; } // New entries/groups animate their real HEIGHT (0 → auto), so the cards below are @@ -292,7 +296,52 @@ const EXPAND_EXIT = { height: 0, opacity: 0 }; // The "new call" bullet is torph-morphed in/out before the title. const TITLE_MORPH_MS = 300; -function FeedEntry({ entry, now, isNew }: { entry: Entry; now: number; isNew: boolean }) { +/** + * An ACTION card — the panel standing in for something the sandbox can't do for + * real. Deliberately looks unlike a request card (no method, path or curl): it + * isn't traffic, it's a button that CAUSES traffic. + */ +function ActionBlock({ + entry, + now, + onAction, +}: { + entry: Entry; + now: number; + onAction?: (action: NonNullable) => void; +}) { + return ( +
+
+ + {stepTitle(entry)} + + +
+ {entry.note &&

{entry.note}

} + +
+ ); +} + +function FeedEntry({ + entry, + now, + isNew, + onAction, +}: { + entry: Entry; + now: number; + isNew: boolean; + onAction?: (action: NonNullable) => void; +}) { return ( - + {entry.action ? ( + + ) : ( + + )} ); @@ -312,10 +365,12 @@ function FeedGroup({ group, now, newKeys, + onAction, }: { group: EntryGroup; now: number; newKeys: Set; + onAction?: (action: NonNullable) => void; }) { return ( -
- -
+ {/* No flow headings: the feed reads as one stream of calls. Groups still + exist (they batch a flow's calls for ordering + the enter animation), + they're just not labelled — restore by rendering a SectionDivider with + `group.groupLabel` here. */}
{/* initial={false}: entries that arrive with a brand-new group don't double-animate — the group's own height-expand reveals them. Calls added to an existing group expand + rise in one at a time. */} {group.entries.map((entry) => ( - + ))}
@@ -349,7 +411,7 @@ function FeedGroup({ ); } -export function ApiCallList({ entries, newKeys }: ApiCallListProps) { +export function ApiCallList({ entries, newKeys, onAction }: ApiCallListProps) { const scrollRef = useRef(null); // Re-sample every second so the live seconds (5s…59s) count up smoothly and a // new call never renders the rest against a stale clock. Cheap: the per-call @@ -380,7 +442,13 @@ export function ApiCallList({ entries, newKeys }: ApiCallListProps) { on the groups below — no snap). */} {groups.map((group) => ( - + ))}
diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx index 73311a65b..d2ab5cbf4 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanel.tsx @@ -14,6 +14,8 @@ import styles from './ApiPanel.module.scss'; interface ApiPanelProps { entries: Entry[]; + /** Runs an action card's button (the sandbox funding stand-in). */ + onAction?: (action: NonNullable) => void; } // Graceful skeleton ⇄ live-calls swap — blur-fade so the first call doesn't pop. @@ -33,7 +35,7 @@ const PILL_IN = { type: 'spring' as const, stiffness: 400, damping: 13, mass: 0. const PILL_PULSE = { duration: 0.4, ease: easeOutSnappy }; const PILL_MORPH_MS = 280; -export function ApiPanel({ entries }: ApiPanelProps) { +export function ApiPanel({ entries, onAction }: ApiPanelProps) { const isEmpty = entries.length === 0; // New-call signifiers (stacked layout only): a count pill on the header + a dot // per unseen call. "Seen" = the calls have scrolled into view — an @@ -167,7 +169,7 @@ export function ApiPanel({ entries }: ApiPanelProps) { animate={SWAP_REST} transition={SWAP_IN} > - + )} diff --git a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanelSkeleton.tsx b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanelSkeleton.tsx index 2e5a24f4e..462f79d19 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/ApiPanelSkeleton.tsx +++ b/components/grid-wallet-prod/src/components/ApiPanel/ApiPanelSkeleton.tsx @@ -1,5 +1,4 @@ import clsx from 'clsx'; -import { SectionDivider } from '@/components/SectionDivider/SectionDivider'; import styles from './ApiPanelSkeleton.module.scss'; interface CodeLine { @@ -13,13 +12,14 @@ interface SkeletonEntry { } interface SkeletonGroup { + /** Empty = no divider, mirroring the real feed's unlabelled sign-in group. */ label: string; entries: SkeletonEntry[]; } const SKELETON_GROUPS: SkeletonGroup[] = [ { - label: 'Sign in', + label: '', entries: [ { titleWidth: '46%', @@ -135,7 +135,6 @@ export function ApiPanelSkeleton() {
{SKELETON_GROUPS.map((group, groupIndex) => (
-
{group.entries.map((entry, entryIndex) => (
diff --git a/components/grid-wallet-prod/src/components/ApiPanel/types.ts b/components/grid-wallet-prod/src/components/ApiPanel/types.ts index a4df9f261..a1d6efe81 100644 --- a/components/grid-wallet-prod/src/components/ApiPanel/types.ts +++ b/components/grid-wallet-prod/src/components/ApiPanel/types.ts @@ -1,10 +1,26 @@ import type { ApiCall } from '@/data/flow'; +/** Sandbox-only actions the panel can offer inline (see ApiCallList). */ +export type EntryAction = 'simulate-funding'; + export interface Entry extends ApiCall { key: string; createdAt: number; groupId: string; + /** Flow name for grouping. Not rendered — the feed has no flow headings. */ groupLabel: string; + /** + * Renders as an ACTION card instead of a request: a line of copy plus a button + * the panel wires to `onAction`. Used where the demo has to stand in for + * something the sandbox can't do for real (an inbound wire). + */ + action?: EntryAction; + /** Button label for `action` cards. */ + actionLabel?: string; + /** The action already ran — the button reads as done and stops firing. */ + actionDone?: boolean; + /** Amount (cents) this action should fund, when the screen stated one. */ + simulateCents?: number; } export interface EntryGroup { diff --git a/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx b/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx index f1b5b3b09..e2ff2f2ed 100644 --- a/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx +++ b/components/grid-wallet-prod/src/components/AppPanel/AppPanel.tsx @@ -8,7 +8,9 @@ import { DEFAULT_OVERLAY_GLASS } from '@/apps/shared/glass'; import type { ActionId, WalletState } from '@/data/actions'; import type { AuthMethod, Persona, PhoneState } from '@/data/flow'; import type { WalletEntry, WalletTransferMode } from '@/apps/aurora/wallet'; +import type { SavedBank } from '@/apps/shared/wallet'; import type { ExternalAccountInput, ReceivePaymentInfo, TransferDest } from '@/data/apiCalls'; +import type { DepositInstructions } from '@/lib/gridReads'; import styles from './AppPanel.module.scss'; export interface DemoLogicPhoneSlice { @@ -31,8 +33,18 @@ export interface DemoLogicPhoneSlice { cancelOtp: () => void; backOtp: () => void; emailActive: boolean; + emailPrefill: string | null; submitEmail: (email: string) => void; cancelEmail: () => void; + passkeyAdded: boolean; + onAddPasskey: () => void; + depositInstructions: DepositInstructions | null; + totalCents: number; + walletToast: { nonce: number; text: string } | null; + storedBanks: SavedBank[]; + onSelectStoredBank: (externalAccountId: string | null) => void; + onDepositView: (view: { label: string; currency: string; cents?: number } | null) => void; + simulateDeposit: { nonce: number; cents: number; last4: string } | null; phoneActive: boolean; submitPhone: (number: string) => void; cancelPhone: () => void; @@ -80,9 +92,21 @@ function toPhoneProps(p: DemoLogicPhoneSlice): PhoneProps { }, email: { active: p.emailActive, + prefill: p.emailPrefill, onSubmit: p.submitEmail, onCancel: p.cancelEmail, }, + addPasskey: { + added: p.passkeyAdded, + onAdd: p.onAddPasskey, + }, + depositInstructions: p.depositInstructions, + totalCents: p.totalCents, + walletToast: p.walletToast, + storedBanks: p.storedBanks, + onSelectStoredBank: p.onSelectStoredBank, + onDepositView: p.onDepositView, + simulateDeposit: p.simulateDeposit, phoneEntry: { active: p.phoneActive, onSubmit: p.submitPhone, diff --git a/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx b/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx index a7a438d3e..10113a930 100644 --- a/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx +++ b/components/grid-wallet-prod/src/components/DemoPhone/DemoPhone.tsx @@ -65,13 +65,23 @@ function DemoScreen(props: PhoneProps, skin: AppSkin) { open: Boolean(entry?.active || sheetSending || props.otp?.active), sending: sheetSending, codeActive: Boolean(props.otp?.active), + // The real address the EMAIL_OTP credential is tied to (email flow only — + // the phone entry has no live equivalent). + prefill: sheetMethod === 'email' ? props.email?.prefill : undefined, onSubmit: entry?.onSubmit ?? (() => {}), onSubmitCode: props.otp?.onSubmit, // The X is BACK past the first step (code → entry re-prompt); the scrim - // still cancels the whole flow. - onBack: props.otp?.onBack, + // still cancels the whole flow. Only the genuine email/phone entry flow + // (sheetMethod set) has an entry step to go back to — the live passkey + // bootstrap's OTP prompt has none, so its sheet gets no onBack and the + // control reads (and acts) as a plain cancel there. + onBack: sheetMethod ? props.otp?.onBack : undefined, onCancel: props.otp?.active ? props.otp?.onCancel : entry?.onCancel, }; + // ONE sheet, rendered on whichever side of the auth ⇄ wallet flip is showing + // (same mapping as the `screen` prop below): sign-in prompts on the auth + // screen, a lapsed-session re-auth prompts over the wallet. + const walletShown = props.phone.screen === 'wallet' || props.phone.screen === 'card'; const authSheet = flowActive && !skin.inlineAuthFlow ? : null; @@ -108,15 +118,27 @@ function DemoScreen(props: PhoneProps, skin: AppSkin) { authFlow={skin.inlineAuthFlow ? flow : undefined} entry={props.walletEntry} walletOptions={skin.walletOptions} + balance={props.phone.balance} + // The account's real history, loaded during sign-in (GET /transactions). + activity={props.phone.activity} + depositInstructions={props.depositInstructions} + totalCents={props.totalCents} + walletToast={props.walletToast} + storedBanks={props.storedBanks} + onSelectStoredBank={props.onSelectStoredBank} + onDepositView={props.onDepositView} + simulateDeposit={props.simulateDeposit} onQuoteCreate={props.onQuoteCreate} onLinkExternalAccount={props.onLinkExternalAccount} onTransferExecute={props.onTransferExecute} onCardIssued={props.onCardIssued} onTapToPay={props.onTapToPay} onReceivePayment={props.onReceivePayment} + addPasskey={props.addPasskey} + walletOverlays={walletShown ? authSheet : null} > {passkeySheet} - {authSheet} + {walletShown ? null : authSheet} ); } diff --git a/components/grid-wallet-prod/src/components/Phone.tsx b/components/grid-wallet-prod/src/components/Phone.tsx index 251a2f8e2..00e903bf1 100644 --- a/components/grid-wallet-prod/src/components/Phone.tsx +++ b/components/grid-wallet-prod/src/components/Phone.tsx @@ -1,7 +1,9 @@ import type { AuthMethod, Persona, PhoneState } from '@/data/flow'; import type { ActionId, WalletState } from '@/data/actions'; import type { WalletEntry, WalletTransferMode } from '@/apps/aurora/wallet'; +import type { SavedBank } from '@/apps/shared/wallet'; import type { ExternalAccountInput, ReceivePaymentInfo, TransferDest } from '@/data/apiCalls'; +import type { DepositInstructions } from '@/lib/gridReads'; /** * The demo phone's prop contract (AppPanel → DemoPhone). The live UI is the @@ -30,7 +32,29 @@ export interface PhoneProps { onCancel?: () => void; onBack?: () => void; }; - email?: { active: boolean; onSubmit: (email: string) => void; onCancel?: () => void }; + email?: { + active: boolean; + /** The address the live EMAIL_OTP credential is tied to — prefills the field. */ + prefill?: string | null; + onSubmit: (email: string) => void; + onCancel?: () => void; + }; + /** The account has no passkey yet + the action that adds one (wallet nudge). */ + addPasskey?: { added: boolean; onAdd: () => void }; + /** Real deposit details for the customer's fiat account (Add money). */ + depositInstructions?: DepositInstructions | null; + /** The wallet account's total balance in cents (USDB `totalBalance`). */ + totalCents?: number; + /** One-shot toast raised by an arrival webhook (nonce bumps per delivery). */ + walletToast?: { nonce: number; text: string } | null; + /** Accounts Grid already holds, seeding the saved-banks list. */ + storedBanks?: SavedBank[]; + /** A stored account was picked — quote against that ExternalAccount id. */ + onSelectStoredBank?: (externalAccountId: string | null) => void; + /** A country's deposit details came into (or left) view. */ + onDepositView?: (view: { label: string; currency: string; cents?: number } | null) => void; + /** Bumped by the panel's "Simulate funding" button. */ + simulateDeposit?: { nonce: number; cents: number; last4: string } | null; /** Phone-number entry (the SMS flow's first step) — mirrors `email`. */ phoneEntry?: { active: boolean; onSubmit: (number: string) => void; onCancel?: () => void }; google?: { nonce: string | null; onCredential: (idToken: string) => void }; diff --git a/components/grid-wallet-prod/src/data/actions.ts b/components/grid-wallet-prod/src/data/actions.ts index 52aff8f0a..059096a28 100644 --- a/components/grid-wallet-prod/src/data/actions.ts +++ b/components/grid-wallet-prod/src/data/actions.ts @@ -1,14 +1,15 @@ /* Action-driven playground model. The user freely triggers actions on the Global Account; each produces a short on-phone sequence + Grid API calls. */ -import type { PhoneState, Tx } from './flow'; +import type { WalletListItemData } from '@/apps/shared/wallet/types'; +import type { PhoneState } from './flow'; export interface WalletState { created: boolean; balanceCents: number; hasCard: boolean; cardActivated: boolean; - activity: Tx[]; + activity: WalletListItemData[]; } export const initialWallet: WalletState = { diff --git a/components/grid-wallet-prod/src/data/apiCalls.ts b/components/grid-wallet-prod/src/data/apiCalls.ts index bd682e8bb..058d1dc5f 100644 --- a/components/grid-wallet-prod/src/data/apiCalls.ts +++ b/components/grid-wallet-prod/src/data/apiCalls.ts @@ -1,24 +1,18 @@ /* ============================================================ - Accurate Grid API call sequences shown in the panel. - Shapes/paths/headers mirror the real sandbox API (verified - against api.lightspark.com/grid/2025-10-13). The demo renders - these alongside the on-phone flow; the ceremonies (Touch ID, - Google) are real, the calls here are representative. + Shapes the wallet flows pass around: an external account to + link, a transfer's destination, a received payment. + + This file used to also BUILD synthesized `ApiCall` entries for + the API panel — fabricated quotes, executes, card + authorizations, and an inbound webhook to a fictional + https://your-app.com/webhooks/grid. All of it is gone. The + panel shows real traffic only: request/response envelopes from + the /api/grid proxy, and webhooks Grid actually delivered to + /api/webhooks. A flow with no client call behind it (card + issuance, tap to pay, the Receive demo event) logs nothing + rather than inventing a request. ============================================================ */ -import type { ApiCall, AuthMethod } from './flow'; - -// Realistic placeholder ids (same formats the sandbox returns). -const ACCOUNT = 'InternalAccount:019e8f48-1135-438c-0000-8b9d28990463'; -const AUTH_METHOD = 'AuthMethod:019e8f48-11a8-0dca-0000-947363f18d5a'; -const BANK = 'ExternalAccount:019e8f4a-781d-7e0c-0000-a0d9afbf1314'; -const CRYPTO = 'ExternalAccount:019e8f4a-9b2e-71f4-0000-3c5e7a2b9f08'; -const CUSTOMER = 'Customer:019e8f47-2a3d-1d02-0000-6b1f0c4e2a91'; -const QUOTE = 'Quote:019e8f49-3c8f-5246-0000-4d75f9a6d1d1'; -const TXN = 'Transaction:019e8f49-3ca4-b78f-0000-1d3e9a411168'; -const WEBHOOK = 'Webhook:019e8f49-7b3e-1d02-0000-9a4c2e7f1d05'; -const PUBKEY = '04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783ad…'; - /** A linked external account to create — a bank (account fields + beneficiary) * or a crypto wallet (just the address). Built by the sheet from the saved * recipient; drives the POST /customers/external-accounts body. */ @@ -39,312 +33,11 @@ export type TransferDest = | { kind: 'bank'; currency: string } | { kind: 'crypto'; currency: string }; -/** Link a recipient — POST /customers/external-accounts. Fires when a bank or - * crypto address is added; returns an ExternalAccount the transfer references. */ -export function externalAccountCreateCall(input: ExternalAccountInput): ApiCall { - if (input.kind === 'crypto') { - return { - method: 'POST', - path: '/customers/external-accounts', - title: 'Create external account', - reqBody: { - customerId: CUSTOMER, - currency: input.currency, - accountInfo: { accountType: input.accountType, address: input.address }, - }, - status: '201 Created', - note: `Linked ${input.network} wallet (${input.currency}) — returns an ExternalAccount id.`, - }; - } - return { - method: 'POST', - path: '/customers/external-accounts', - title: 'Create external account', - reqBody: { - customerId: CUSTOMER, - currency: input.currency, - accountInfo: { - accountType: input.accountType, - ...input.fields, - beneficiary: { beneficiaryType: 'INDIVIDUAL', fullName: input.beneficiary }, - }, - }, - status: '201 Created', - note: `Linked ${input.bankName} (${input.currency}) — returns an ExternalAccount id.`, - }; -} - -/** OTP request (challenge) — fires the moment the phone/email is submitted. */ -export function otpRequestCall(method: 'email_otp' | 'sms', contact?: string): ApiCall { - const where = - method === 'sms' ? `by SMS to ${contact || 'your phone'}` : `to ${contact || 'your email'}`; - return { - method: 'POST', - path: `/auth/credentials/${AUTH_METHOD}/challenge`, - title: 'Request OTP', - reqBody: {}, - status: '200 OK', - note: `One-time code sent ${where}.`, - }; -} - -function otpVerifyRequestBody(method: 'email_otp' | 'sms'): Record { - return { - type: method === 'sms' ? 'SMS_OTP' : 'EMAIL_OTP', - encryptedOtpBundle: '', - }; -} - -/** OTP verify — first leg after the code is submitted. */ -export function otpVerifyCall(method: 'email_otp' | 'sms'): ApiCall { - return { - method: 'POST', - path: `/auth/credentials/${AUTH_METHOD}/verify`, - title: 'Verify OTP', - reqBody: otpVerifyRequestBody(method), - status: '202 Accepted', - note: 'Returns a verificationToken to sign with the TEK keypair.', - }; -} - -/** OTP verify — signed retry that issues the auth session. */ -export function otpSessionIssueCall(method: 'email_otp' | 'sms'): ApiCall { - return { - method: 'POST', - path: `/auth/credentials/${AUTH_METHOD}/verify`, - title: 'Issue auth session', - headers: { - 'Grid-Wallet-Signature': '', - 'Request-Id': '', - }, - reqBody: otpVerifyRequestBody(method), - status: '200 OK', - note: 'Signed retry issues the session; the TEK private key is the session signing key.', - }; -} - -export function otpVerifyCalls(method: 'email_otp' | 'sms'): ApiCall[] { - return [otpVerifyCall(method), otpSessionIssueCall(method)]; -} - -/** Passkey challenge — fires when the passkey ceremony starts. */ -export function passkeyChallengeCall(): ApiCall { - return { - method: 'POST', - path: `/auth/credentials/${AUTH_METHOD}/challenge`, - title: 'Start passkey challenge', - reqBody: { clientPublicKey: PUBKEY }, - status: '200 OK', - note: 'Returns a WebAuthn challenge + requestId.', - }; -} - -/** Passkey verify — fires after the assertion (Face ID) completes. */ -export function passkeyVerifyCall(): ApiCall { - return { - method: 'POST', - path: `/auth/credentials/${AUTH_METHOD}/verify`, - title: 'Verify passkey', - headers: { 'Request-Id': '' }, - reqBody: { type: 'PASSKEY', assertion: '' }, - status: '200 OK', - note: 'Assertion verified; encryptedSessionSigningKey returned.', - }; -} - -/** OAuth verify — fires after the provider returns an id_token. */ -export function oauthVerifyCall(method: 'oauth' | 'apple'): ApiCall { - return { - method: 'POST', - path: `/auth/credentials/${AUTH_METHOD}/verify`, - title: 'Verify OAuth token', - reqBody: { - type: 'OAUTH', - oidcToken: method === 'apple' ? '' : '', - clientPublicKey: PUBKEY, - }, - status: '200 OK', - note: 'Fresh OIDC token verified; encryptedSessionSigningKey returned.', - }; -} - -/** Full sign-in sequence for a method — used for the fast-forward setup group. */ -export function signInCalls(method: AuthMethod, contact?: string): ApiCall[] { - if (method === 'email_otp') return [otpRequestCall('email_otp', contact), ...otpVerifyCalls('email_otp')]; - if (method === 'sms') return [otpRequestCall('sms', contact), ...otpVerifyCalls('sms')]; - if (method === 'passkey') return [passkeyChallengeCall(), passkeyVerifyCall()]; - return [oauthVerifyCall(method)]; -} - export type TransferMode = 'add' | 'withdraw' | 'send'; -/** Step 1 of a transfer — POST /quotes. Fires when the amount is committed. - * `dest` lets a send reference the recipient's bank or crypto wallet. */ -export function transferQuoteCall(mode: TransferMode, cents: number, dest?: TransferDest): ApiCall { - if (mode === 'add') { - const fundingCurrency = dest?.kind === 'bank' ? dest.currency : 'USD'; - return { - method: 'POST', - path: `/quotes`, - title: 'Create pay-in quote', - reqBody: { - source: { sourceType: 'REALTIME_FUNDING', customerId: CUSTOMER, currency: fundingCurrency }, - destination: { destinationType: 'ACCOUNT', accountId: ACCOUNT }, - lockedCurrencySide: 'RECEIVING', - lockedCurrencyAmount: cents, - }, - status: '201 Created', - note: `Pay-in quote funded by ${fundingCurrency} payment instructions; credits the Global Account in USDB.`, - }; - } - if (mode === 'withdraw') { - if (dest?.kind === 'crypto') { - return { - method: 'POST', - path: `/quotes`, - title: 'Create quote', - reqBody: { - source: { sourceType: 'ACCOUNT', accountId: ACCOUNT }, - destination: { destinationType: 'ACCOUNT', accountId: CRYPTO, currency: dest.currency }, - lockedCurrencySide: 'SENDING', - lockedCurrencyAmount: cents, - }, - status: '201 Created', - note: `Withdrawal to a crypto wallet (USDB → ${dest.currency}) with a payloadToSign.`, - }; - } - return { - method: 'POST', - path: `/quotes`, - title: 'Create quote', - reqBody: { - source: { sourceType: 'ACCOUNT', accountId: ACCOUNT }, - destination: { destinationType: 'ACCOUNT', accountId: BANK, currency: 'USD' }, - lockedCurrencySide: 'SENDING', - lockedCurrencyAmount: cents, - }, - status: '201 Created', - note: 'Off-ramp quote (USDB → USD) with a payloadToSign.', - }; - } - // Send: off-ramp to the recipient's bank, or USDC to their crypto wallet. No - // dest = the seed's historical UMA send. - const sendDestination = - dest?.kind === 'bank' - ? { destinationType: 'ACCOUNT', accountId: BANK, currency: dest.currency } - : dest?.kind === 'crypto' - ? { destinationType: 'ACCOUNT', accountId: CRYPTO, currency: dest.currency } - : { destinationType: 'UMA_ADDRESS', umaAddress: '$leo@grid.app' }; - const sendNote = - dest?.kind === 'bank' - ? "Off-ramp quote to the recipient's bank, with a payloadToSign." - : dest?.kind === 'crypto' - ? `${dest.currency} quote to the recipient wallet, with a payloadToSign.` - : 'Quote returns a payloadToSign for the embedded wallet.'; - return { - method: 'POST', - path: `/quotes`, - title: 'Create quote', - reqBody: { - source: { sourceType: 'ACCOUNT', accountId: ACCOUNT }, - destination: sendDestination, - lockedCurrencySide: 'SENDING', - lockedCurrencyAmount: cents, - }, - status: '201 Created', - note: sendNote, - }; -} - -/** Step 2 of an outbound transfer — execute + settle. Fires on Face ID confirm. */ -export function transferExecuteCalls(mode: Exclude): ApiCall[] { - const execute: ApiCall = { - method: 'POST', - path: `/quotes/${QUOTE}/execute`, - title: 'Execute quote', - headers: { 'Grid-Wallet-Signature': '' }, - reqBody: {}, - status: '200 OK', - note: 'Grid-Wallet-Signature header — stamped by the session key.', - }; - const settleNote = - mode === 'withdraw' - ? 'Paid out via RTP — COMPLETED.' - : 'Delivered — COMPLETED.'; - return [ - execute, - { - method: 'GET', - path: `/transactions/${TXN}`, - title: 'Get transaction', - status: '200 OK', - note: settleNote, - }, - ]; -} - -/** Add money — after the pay-in quote, Grid detects the external deposit and - * posts the incoming-payment webhook; there is no quote execute call. */ -export function addMoneySettlementCalls(cents: number, fundingCurrency = 'USD'): ApiCall[] { - return receivePaymentCalls({ - amountCents: cents, - viaCrypto: false, - sourceCurrency: fundingCurrency, - counterparty: 'Pat Teehantri', - paymentRail: 'RTP', - intent: 'add', - }); -} - -/** Add money — external real-time funding into the USDB Global Account. */ -export function addMoneyCalls(cents: number): ApiCall[] { - return [transferQuoteCall('add', cents), ...addMoneySettlementCalls(cents)]; -} - -/** Send — pay a UMA address from the embedded wallet (signed). */ -export function sendCalls(cents: number): ApiCall[] { - return [transferQuoteCall('send', cents), ...transferExecuteCalls('send')]; -} - -/** Issue a virtual card against the Global Account. */ -export function cardCalls(): ApiCall[] { - return [ - { - method: 'POST', - path: `/cards`, - title: 'Create card', - reqBody: { accountId: ACCOUNT, type: 'VIRTUAL', currency: 'USDB' }, - status: '201 Created', - note: 'Virtual card issued — provisionable to Apple/Google Wallet.', - }, - ]; -} - -/** Tap to pay — a card authorization lands on the Global Account. */ -export function tapCalls(merchant: string, cents: number): ApiCall[] { - return [ - { - method: 'GET', - path: `/transactions/${TXN}`, - title: 'Get transaction', - status: '200 OK', - note: `transaction.authorized — $${(cents / 100).toFixed(2)} at ${merchant}.`, - }, - ]; -} - -/** Withdraw — cash out to a linked bank (signed). */ -export function withdrawCalls(cents: number): ApiCall[] { - return [transferQuoteCall('withdraw', cents), ...transferExecuteCalls('withdraw')]; -} - -/** Where Grid POSTs inbound webhooks — your own endpoint. Shown as a full URL so - * the curl reads as Grid → you, not an outbound call to the Grid API. */ -const WEBHOOK_ENDPOINT = 'https://your-app.com/webhooks/grid'; - /** An inbound payment the customer received. There's no client-initiated call to * "receive" — Grid POSTs an INCOMING_PAYMENT webhook to your endpoint when funds - * land, and you read the settled transaction. */ + * land, and the panel shows that delivery when it arrives. */ export interface ReceivePaymentInfo { amountCents: number; /** Crypto deposit (USDC; sender = wallet address) vs. fiat (sender = name). */ @@ -356,62 +49,6 @@ export interface ReceivePaymentInfo { /** The fiat rail the funds arrived on (PaymentRail enum) — omitted for crypto. */ paymentRail?: string; /** 'add' = topping up your own balance from a crypto wallet; 'receive' = a - * payment from someone else. Drives the API-panel group + sidebar checkmark. */ + * payment from someone else. Drives the sidebar checkmark. */ intent?: 'add' | 'receive'; } - -/** Receive — the inbound webhook Grid pushes to you + the GET that confirms it. - * The body is an IncomingTransaction (openapi/components/.../IncomingTransaction): - * `source` is REALTIME_FUNDING (external funds landing — the originator fields - * are populated best-effort). Demo: the event itself is simulated. */ -export function receivePaymentCalls(info: ReceivePaymentInfo): ApiCall[] { - const reference = `REF-${Math.random().toString(36).slice(2, 10).toUpperCase()}`; - // The originator (sender). Crypto: the wallet address is the account - // identifier on its rail; fiat: the payer name + the rail it arrived on. - const source = info.viaCrypto - ? { sourceType: 'REALTIME_FUNDING', currency: 'USDC', accountIdentifier: info.counterparty } - : { - sourceType: 'REALTIME_FUNDING', - currency: info.sourceCurrency ?? 'USD', - accountHolderName: info.counterparty, - paymentRail: info.paymentRail ?? 'RTP', - }; - const data = { - id: TXN, - type: 'INCOMING', - status: 'COMPLETED', - customerId: CUSTOMER, - platformCustomerId: '18d3e5f7b4a9c2', - destination: { destinationType: 'ACCOUNT', accountId: ACCOUNT }, - source, - receivedAmount: { - amount: info.amountCents, - currency: { code: 'USD', name: 'United States Dollar', symbol: '$', decimals: 2 }, - }, - reconciliationInstructions: { reference }, - }; - return [ - { - method: 'POST', - path: WEBHOOK_ENDPOINT, - inbound: true, - title: 'Incoming payment', - headers: { 'X-Grid-Signature': '' }, - reqBody: { - id: WEBHOOK, - type: 'INCOMING_PAYMENT.COMPLETED', - timestamp: new Date().toISOString(), - data, - }, - status: '200 OK', - note: 'Simulated — Grid POSTs this to your webhook endpoint when funds land.', - }, - { - method: 'GET', - path: `/transactions/${TXN}`, - title: 'Get transaction', - status: '200 OK', - note: 'Inbound transfer settled — COMPLETED.', - }, - ]; -} diff --git a/components/grid-wallet-prod/src/data/apiPanelSeed.ts b/components/grid-wallet-prod/src/data/apiPanelSeed.ts deleted file mode 100644 index fa4609db8..000000000 --- a/components/grid-wallet-prod/src/data/apiPanelSeed.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Entry } from '@/components/ApiPanel/types'; -import type { ApiCall } from '@/data/flow'; -import { addMoneyCalls, sendCalls, signInCalls, withdrawCalls } from './apiCalls'; - -/** Flip false (or delete this file) when API panel styling is done. */ -export const SEED_API_PANEL = false; - -interface SeedGroup { - label: string; - calls: ApiCall[]; - agoMs: number; -} - -export function seedApiEntries(): Entry[] { - const now = Date.now(); - const groups: SeedGroup[] = [ - { label: 'Sign in', calls: signInCalls('oauth'), agoMs: 8 * 60 * 1000 }, - { label: 'Add money', calls: addMoneyCalls(500_000), agoMs: 3 * 60 * 1000 }, - { label: 'Send payment', calls: sendCalls(250_000), agoMs: 90 * 1000 }, - { label: 'Withdraw', calls: withdrawCalls(200_000), agoMs: 12 * 1000 }, - ]; - - return groups.flatMap((group, groupIndex) => { - const groupId = `seed-group-${groupIndex}`; - const groupTime = now - group.agoMs; - - return group.calls.map((call, callIndex) => ({ - ...call, - key: `seed-${groupIndex}-${callIndex}`, - createdAt: groupTime + callIndex * 400, - groupId, - groupLabel: group.label, - })); - }); -} diff --git a/components/grid-wallet-prod/src/data/bankCountries.ts b/components/grid-wallet-prod/src/data/bankCountries.ts index 70bee0c4e..737a5a001 100644 --- a/components/grid-wallet-prod/src/data/bankCountries.ts +++ b/components/grid-wallet-prod/src/data/bankCountries.ts @@ -1,5 +1,7 @@ -// Curated country list for the bank picker, seeded from the Grid docs' -// country-support table (mintlify/snippets/country-support.mdx - 55 countries). +// Country list for the bank picker: the US plus the euro area (20), seeded from +// the Grid docs' country-support table (mintlify/snippets/country-support.mdx). +// The production demo settles on two rails only — ACH/wire/RTP for USD and SEPA +// for EUR — so the other corridors in that table are deliberately not offered. // // Accuracy split: // - `accountType` (and `region` for the CFA zones) are SPEC-BOUND: each must @@ -43,74 +45,28 @@ export interface BankCountry { sampleOverrides?: Record; } -export const BANK_COUNTRIES: BankCountry[] = [ +export const BANK_COUNTRIES: BankCountry[] = [] = [ { code: 'at', name: 'Austria', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Erste Bank' }, { code: 'be', name: 'Belgium', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'KBC' }, - { code: 'bj', name: 'Benin', accountType: 'XOF_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 605, bankName: 'Bank of Africa', region: 'BJ' }, - { - code: 'br', - name: 'Brazil', - accountType: 'BRL_ACCOUNT', - rail: 'PIX', - usdToLocal: 5.4, - bankName: 'Nubank', - popularRank: 5, - banks: ['Nubank', 'Itaú', 'Bradesco', 'Banco do Brasil'], - // Spec example pairs an email pixKey with pixKeyType CPF; make it consistent. - sampleOverrides: { pixKey: '12345678901', pixKeyType: 'CPF', taxId: '12345678901' }, - }, - { code: 'bg', name: 'Bulgaria', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'DSK Bank' }, - { code: 'cm', name: 'Cameroon', accountType: 'XAF_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 605, bankName: 'Afriland First Bank', region: 'CM' }, - { code: 'cn', name: 'China', accountType: 'CNY_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 7.25, bankName: 'ICBC', popularRank: 8, banks: ['ICBC', 'Bank of China', 'China Construction Bank', 'Agricultural Bank of China'] }, { code: 'hr', name: 'Croatia', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Zagrebacka banka' }, { code: 'cy', name: 'Cyprus', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Bank of Cyprus' }, - { code: 'cz', name: 'Czech Republic', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Komercni banka' }, - { code: 'dk', name: 'Denmark', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Danske Bank' }, { code: 'ee', name: 'Estonia', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Swedbank' }, { code: 'fi', name: 'Finland', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Nordea' }, - { code: 'fr', name: 'France', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'BNP Paribas' }, - { code: 'de', name: 'Germany', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Deutsche Bank', popularRank: 7, banks: ['Deutsche Bank', 'Commerzbank', 'N26', 'DKB'] }, + { code: 'fr', name: 'France', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'BNP Paribas', popularRank: 3, banks: ['BNP Paribas', 'Crédit Agricole', 'Société Générale', 'La Banque Postale'] }, + { code: 'de', name: 'Germany', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Deutsche Bank', banks: ['Deutsche Bank', 'Commerzbank', 'N26', 'DKB'], popularRank: 2 }, { code: 'gr', name: 'Greece', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Alpha Bank' }, - { code: 'hu', name: 'Hungary', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'OTP Bank' }, - { code: 'is', name: 'Iceland', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Landsbankinn' }, - { code: 'in', name: 'India', accountType: 'INR_ACCOUNT', rail: 'UPI', usdToLocal: 83.3, bankName: 'HDFC Bank', popularRank: 2, banks: ['HDFC Bank', 'ICICI Bank', 'State Bank of India', 'Axis Bank'] }, - { code: 'id', name: 'Indonesia', accountType: 'IDR_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 15800, bankName: 'Bank Mandiri' }, { code: 'ie', name: 'Ireland', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'AIB' }, - { code: 'it', name: 'Italy', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'UniCredit' }, - { code: 'ci', name: 'Ivory Coast', accountType: 'XOF_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 605, bankName: 'Societe Generale', region: 'CI' }, - { code: 'ke', name: 'Kenya', accountType: 'KES_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 129, bankName: 'Equity Bank' }, + { code: 'it', name: 'Italy', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'UniCredit', popularRank: 5, banks: ['UniCredit', 'Intesa Sanpaolo', 'Banco BPM', 'BPER Banca'] }, { code: 'lv', name: 'Latvia', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Swedbank' }, - { code: 'li', name: 'Liechtenstein', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'LGT Bank' }, { code: 'lt', name: 'Lithuania', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'SEB' }, { code: 'lu', name: 'Luxembourg', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'BIL' }, - { code: 'mw', name: 'Malawi', accountType: 'MWK_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 1730, bankName: 'National Bank of Malawi' }, - { code: 'my', name: 'Malaysia', accountType: 'MYR_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 4.7, bankName: 'Maybank' }, { code: 'mt', name: 'Malta', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Bank of Valletta' }, - { code: 'mx', name: 'Mexico', accountType: 'MXN_ACCOUNT', rail: 'SPEI', usdToLocal: 17.9, bankName: 'Nu México', popularRank: 1, banks: ['Nu México', 'BBVA', 'Santander', 'Citibanamex'] }, - { code: 'nl', name: 'Netherlands', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'ING' }, - { code: 'ng', name: 'Nigeria', accountType: 'NGN_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 1500, bankName: 'GTBank', popularRank: 4, banks: ['GTBank', 'Access Bank', 'Zenith Bank', 'First Bank'] }, - { code: 'no', name: 'Norway', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'DNB' }, - { code: 'ph', name: 'Philippines', accountType: 'PHP_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 58, bankName: 'BDO', popularRank: 3, banks: ['BDO', 'BPI', 'Metrobank', 'UnionBank'] }, - { code: 'pl', name: 'Poland', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'PKO Bank Polski' }, + { code: 'nl', name: 'Netherlands', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'ING', popularRank: 6, banks: ['ING', 'Rabobank', 'ABN AMRO', 'bunq'] }, { code: 'pt', name: 'Portugal', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Millennium BCP' }, - { code: 'ro', name: 'Romania', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Banca Transilvania' }, - { code: 'rw', name: 'Rwanda', accountType: 'RWF_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 1300, bankName: 'Bank of Kigali' }, - { code: 'sn', name: 'Senegal', accountType: 'XOF_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 605, bankName: 'CBAO', region: 'SN' }, - { code: 'sg', name: 'Singapore', accountType: 'SGD_ACCOUNT', rail: 'PayNow', usdToLocal: 1.35, bankName: 'DBS' }, { code: 'sk', name: 'Slovakia', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Slovenska sporitelna' }, { code: 'si', name: 'Slovenia', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'NLB' }, - { code: 'za', name: 'South Africa', accountType: 'ZAR_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 18.5, bankName: 'Standard Bank' }, - { code: 'es', name: 'Spain', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Santander' }, - { code: 'se', name: 'Sweden', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'SEB' }, - { code: 'ch', name: 'Switzerland', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'UBS' }, - { code: 'tz', name: 'Tanzania', accountType: 'TZS_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 2600, bankName: 'CRDB Bank' }, - { code: 'th', name: 'Thailand', accountType: 'THB_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 36, bankName: 'Bangkok Bank' }, - { code: 'ug', name: 'Uganda', accountType: 'UGX_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 3800, bankName: 'Stanbic Bank' }, - { code: 'ae', name: 'United Arab Emirates', accountType: 'AED_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 3.67, bankName: 'Emirates NBD' }, - { code: 'gb', name: 'United Kingdom', accountType: 'GBP_ACCOUNT', rail: 'Faster Payments', usdToLocal: 0.79, bankName: 'Barclays', popularRank: 6, banks: ['Barclays', 'HSBC', 'Lloyds', 'Monzo'] }, - { code: 'us', name: 'United States', accountType: 'USD_ACCOUNT', rail: 'ACH', usdToLocal: 1, bankName: 'Chase' }, - { code: 'vn', name: 'Vietnam', accountType: 'VND_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 25400, bankName: 'Vietcombank' }, - { code: 'zm', name: 'Zambia', accountType: 'ZMW_ACCOUNT', rail: 'Bank Transfer', usdToLocal: 26, bankName: 'Zanaco' }, + { code: 'es', name: 'Spain', accountType: 'EUR_ACCOUNT', rail: 'SEPA Instant', usdToLocal: 0.92, bankName: 'Santander', popularRank: 4, banks: ['Santander', 'BBVA', 'CaixaBank', 'Banco Sabadell'] }, + { code: 'us', name: 'United States', accountType: 'USD_ACCOUNT', rail: 'ACH', usdToLocal: 1, bankName: 'Chase', popularRank: 1, banks: ['Chase', 'Bank of America', 'Wells Fargo', 'Citi'] }, ]; /** ISO 4217 currency for a country, derived from the spec schema (not stored). */ @@ -122,33 +78,24 @@ export function currencyFor(country: BankCountry): string { * recipient rows). NOT spec data — purely for the demo's "send to someone * else's bank" story. Falls back to a neutral name. */ const DEMO_RECIPIENTS: Record = { - at: 'Lukas Gruber', be: 'Lucas Peeters', bj: 'Kossi Adjavon', br: 'Lucas Silva', - bg: 'Georgi Ivanov', cm: 'Jean Mbarga', hr: 'Ivan Horvat', cy: 'Andreas Georgiou', - cn: 'Li Wei', cz: 'Jan Novák', dk: 'Mads Jensen', ee: 'Kristjan Tamm', fi: 'Mikko Virtanen', - fr: 'Lucas Martin', de: 'Anna Müller', gr: 'Giorgos Papadopoulos', hu: 'Bence Nagy', - is: 'Jón Jónsson', in: 'Priya Sharma', id: 'Budi Santoso', ie: 'Conor Murphy', - it: 'Giulia Rossi', ci: 'Kouadio Yao', ke: 'Wanjiru Kamau', lv: 'Jānis Bērziņš', - li: 'Thomas Frick', lt: 'Tomas Kazlauskas', lu: 'Marc Weber', mw: 'Chimwemwe Banda', - my: 'Nurul Abdullah', mt: 'Joseph Borg', mx: 'Carlos Herrera', nl: 'Daan de Vries', - ng: 'Chidi Okafor', no: 'Henrik Hansen', ph: 'Maria Santos', pl: 'Jakub Kowalski', - pt: 'João Silva', ro: 'Andrei Popescu', rw: 'Eric Mugisha', sn: 'Abdou Diop', - sg: 'Wei Lim', sk: 'Martin Horváth', si: 'Luka Novak', za: 'Thabo Nkosi', - es: 'Javier García', se: 'Erik Andersson', ch: 'Luca Meier', tz: 'Juma Mwita', - th: 'Somchai Suwan', ug: 'David Okello', ae: 'Ahmed Al Mansoori', gb: 'James Smith', - us: 'Emily Johnson', vn: 'Minh Nguyen', zm: 'Mwila Phiri', + at: 'Lukas Gruber', be: 'Lucas Peeters', hr: 'Ivan Horvat', cy: 'Andreas Georgiou', + ee: 'Kristjan Tamm', fi: 'Mikko Virtanen', fr: 'Lucas Martin', de: 'Anna Müller', + gr: 'Giorgos Papadopoulos', ie: 'Conor Murphy', it: 'Giulia Rossi', lv: 'Jānis Bērziņš', + lt: 'Tomas Kazlauskas', lu: 'Marc Weber', mt: 'Joseph Borg', nl: 'Daan de Vries', + pt: 'João Silva', sk: 'Martin Horváth', si: 'Luka Novak', es: 'Javier García', + us: 'Emily Johnson' }; /** Illustrative recipient-name POOLS for the popular corridors — repeat sends to * the same country cycle through these so recipients don't duplicate (mirrors the * bank-name pools). Others fall back to the single DEMO_RECIPIENTS name. */ const DEMO_RECIPIENT_POOLS: Record = { - mx: ['Carlos Herrera', 'Sofía Ramírez', 'Diego Torres', 'Valentina Cruz'], - in: ['Priya Sharma', 'Arjun Patel', 'Ananya Iyer', 'Rohan Gupta'], - ph: ['Maria Santos', 'Jose Reyes', 'Andrea Cruz', 'Mark Dela Rosa'], - ng: ['Chidi Okafor', 'Aisha Bello', 'Emeka Eze', 'Ngozi Adeyemi'], - br: ['Lucas Silva', 'Mariana Costa', 'Gabriel Souza', 'Beatriz Oliveira'], - gb: ['James Smith', 'Olivia Brown', 'Oliver Jones', 'Emily Wilson'], + us: ['Emily Johnson', 'Michael Chen', 'Sarah Miller', 'David Nguyen'], de: ['Anna Müller', 'Lukas Schmidt', 'Lena Wagner', 'Felix Becker'], + fr: ['Lucas Martin', 'Camille Bernard', 'Hugo Petit', 'Léa Moreau'], + es: ['Javier García', 'Lucía Fernández', 'Pablo Ruiz', 'Marta Díaz'], + it: ['Giulia Rossi', 'Marco Ferrari', 'Chiara Russo', 'Alessandro Conti'], + nl: ['Daan de Vries', 'Sanne Bakker', 'Lars Visser', 'Emma Jansen'], }; /** Demo recipient-name pool for a country's send flow — cycle by saved count so diff --git a/components/grid-wallet-prod/src/data/flow.ts b/components/grid-wallet-prod/src/data/flow.ts index 272f7d283..0b20658b6 100644 --- a/components/grid-wallet-prod/src/data/flow.ts +++ b/components/grid-wallet-prod/src/data/flow.ts @@ -23,20 +23,17 @@ export type ScreenId = | 'card-reveal' | 'tap'; -export interface Tx { - kind: 'bank' | 'card' | 'coffee' | 'send'; - name: string; - sub: string; - amount: string; - positive?: boolean; -} +/** Activity rows are the wallet brain's own row shape, so the real + * `GET /transactions` rows pass through demo state to the skin that renders + * them unchanged (type-only import from a leaf module: no cycle). */ +import type { WalletListItemData } from '@/apps/shared/wallet/types'; export interface PhoneState { screen: ScreenId; balance: string; hasCard: boolean; cardActivated: boolean; - activity: Tx[]; + activity: WalletListItemData[]; note?: string; } @@ -53,6 +50,11 @@ export interface ApiCall { /** Inbound webhook (Grid → your endpoint): `path` is your full URL, and the * curl drops the Grid `Authorization` header (Grid signs it instead). */ inbound?: boolean; + /** Real Grid response body (Phase 2). When set, the panel renders THIS + * instead of the synthesized stub. */ + resBody?: unknown; + /** Real HTTP status code (Phase 2), used to tint error responses. */ + realStatus?: number; } const AUTH_LABEL: Record = { diff --git a/components/grid-wallet-prod/src/data/placeholderDeposit.ts b/components/grid-wallet-prod/src/data/placeholderDeposit.ts new file mode 100644 index 000000000..2eebb057e --- /dev/null +++ b/components/grid-wallet-prod/src/data/placeholderDeposit.ts @@ -0,0 +1,26 @@ +/** + * PLACEHOLDER euro deposit details — the ONLY invented values on the Add money + * screen. Everything else there is read from Grid. + * + * Why this exists: Grid provisions this customer a single USD `INTERNAL_FIAT` + * account, so there are no live SEPA details to read, and the euro half of a + * US + euro-area wallet would otherwise be missing. + * + * TO REPLACE: delete this file and drop the `PLACEHOLDER_EUR_DEPOSIT` spread in + * `useWalletDemoLogic`. `fetchDepositInstructions` already reads the `iban` field + * and returns one section per fiat account, so a real EUR account appears on its + * own the moment the customer has one — no other change needed. + */ +import type { DepositSection } from '@/lib/gridReads'; + +export const PLACEHOLDER_EUR_DEPOSIT: DepositSection = { + label: 'EUR', + rows: [ + ['IBAN', 'DE89 3704 0044 0532 0130 00'], + ['BIC', 'COBADEFFXXX'], + ['Rails', 'SEPA · SEPA Instant'], + ['Reference', 'GGA-EUR-7Q4K2X'], + ], + note: 'Include the reference in the payment reference field.', + placeholder: true, +}; diff --git a/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts b/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts index 6a93c57a2..90e50e774 100644 --- a/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts +++ b/components/grid-wallet-prod/src/hooks/useWalletDemoLogic.ts @@ -1,7 +1,8 @@ 'use client'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AuthMethod, Persona, ScreenId, ApiCall } from '@/data/flow'; +import type { WalletListItemData } from '@/apps/shared/wallet/types'; import { primaryAuthMethod } from '@/data/configure'; import { initialCompleted, @@ -11,27 +12,55 @@ import { type CompletedFlows, type WalletState, } from '@/data/actions'; -import { - addMoneySettlementCalls, - cardCalls, - externalAccountCreateCall, - oauthVerifyCall, - otpRequestCall, - otpVerifyCalls, - passkeyChallengeCall, - passkeyVerifyCall, - receivePaymentCalls, - tapCalls, - transferExecuteCalls, - transferQuoteCall, - type ExternalAccountInput, - type ReceivePaymentInfo, - type TransferDest, +import type { + ExternalAccountInput, + ReceivePaymentInfo, + TransferDest, } from '@/data/apiCalls'; -import { oauthNonce, passkeyCeremony } from '@/lib/auth'; +import { oauthNonce } from '@/lib/auth'; +import { + signIn as gridSignIn, + addPasskey as gridAddPasskey, + hasPasskey, + deviceHasPasskey, + ensureSession, + clearSession, + getAccount, +} from '@/lib/gridSession'; +import { envelopeToApiCall } from '@/lib/gridEntry'; +import { + fetchBalance, + fetchBalanceCents, + fetchActivity, + fetchDepositInstructions, + fetchExternalAccounts, + transactionToRow, + type DepositInstructions, + type RawTransaction, +} from '@/lib/gridReads'; +import { + resolvePlatformUsdAccountId, + sandboxFundPlatform, + onRampQuoteBodyFor, + sandboxSendForQuote, +} from '@/lib/gridFunding'; +import { + ensureExternalAccount, + createQuote, + executeQuote, + executeQuoteUnsigned, + pollTransaction, + quoteBodyFor, + pullQuoteBodyFor, + isCompletionStatus, +} from '@/lib/gridTransfer'; import type { WalletEntry, WalletTransferMode } from '@/apps/aurora/wallet'; +import { savedBankFromExternalAccount, type SavedBank } from '@/apps/shared/wallet'; import type { Entry } from '@/components/ApiPanel/types'; -import { SEED_API_PANEL, seedApiEntries } from '@/data/apiPanelSeed'; +import { PLACEHOLDER_EUR_DEPOSIT } from '@/data/placeholderDeposit'; +import { IS_SANDBOX, SANDBOX_DEPOSIT_CENTS } from '@/lib/gridEnv'; +import { useWebhookStream } from '@/hooks/useWebhookStream'; +import type { EntryAction } from '@/components/ApiPanel/types'; const TRANSFER_LABEL: Record = { add: 'Add money', @@ -47,6 +76,10 @@ const FAST_FORWARD_FUND_CENTS = 500_000; /** A transfer's transaction settles a beat after execute, so calls land 1-by-1. */ const SETTLE_DELAY_MS = 650; +/** After a successful sandbox fund, one retry delay before giving up on the + * balance re-read (the money already landed either way — see onTransferExecute). */ +const REFRESH_RETRY_DELAY_MS = 600; + interface Transient { screen: ScreenId; note?: string; @@ -58,13 +91,25 @@ interface Session { email?: string; phone?: string; expiresAt?: number; + loadedBalanceCents?: number; + loadedActivity?: WalletListItemData[]; } const SESSION_MS = 15 * 60 * 1000; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -// Production demo: use case pinned to Fintech, sign-in pinned to passkey. -const PROD_AUTH_METHODS: AuthMethod[] = ['passkey']; +// Sign-in's calls still share one group (so they stay together and animate as a +// batch) but carry NO label — the panel draws no divider for it. Every other +// flow keeps its heading. +const SIGN_IN_GROUP = ''; + +// Production demo: use case pinned to Fintech, and the sign-in CTA advertises +// the credential `gridSession.signIn` will actually use. A Global Account is born +// with only EMAIL_OTP, so a fresh device says "Continue with email"; once THIS +// device has registered a passkey (added later from the wallet), the button says +// passkey — the label must never promise one credential and run another. +const FIRST_RUN_METHODS: AuthMethod[] = ['email_otp']; +const RETURNING_METHODS: AuthMethod[] = ['passkey']; /** * Demo interaction logic — preserved for phase 2 UI wiring. @@ -72,16 +117,22 @@ const PROD_AUTH_METHODS: AuthMethod[] = ['passkey']; */ export function useWalletDemoLogic() { const persona: Persona = 'fintech'; - const methods = PROD_AUTH_METHODS; + // Starts on the first-run label and flips after mount: `deviceHasPasskey` + // reads localStorage, which the server render can't see, so deciding during + // render would mismatch on hydration. + const [methods, setMethods] = useState(FIRST_RUN_METHODS); + useEffect(() => { + if (deviceHasPasskey()) setMethods(RETURNING_METHODS); + }, []); const method = useMemo(() => primaryAuthMethod(methods), [methods]); const [wallet, setWallet] = useState(initialWallet); // Sticky sidebar checkmarks — "have you ever run this flow". Separate from // `wallet` so replaying "Sign in" (which resets the session wallet) keeps them; // only Reset wipes them. const [completed, setCompleted] = useState(initialCompleted); - const [entries, setEntries] = useState(() => - SEED_API_PANEL ? seedApiEntries() : [], - ); + // Starts empty and only ever holds real traffic (proxy envelopes + delivered + // webhooks) plus the sandbox action card, which is visibly not a request. + const [entries, setEntries] = useState([]); const [transient, setTransient] = useState(null); const [running, setRunning] = useState(false); @@ -90,6 +141,40 @@ export function useWalletDemoLogic() { const [faceIdActive, setFaceIdActive] = useState(false); const [otpActive, setOtpActive] = useState(false); const [emailActive, setEmailActive] = useState(false); + // The address the live EMAIL_OTP credential is tied to (discovered from the + // credential's nickname) — prefills the entry step instead of a placeholder. + const [emailPrefill, setEmailPrefill] = useState(null); + // No PASSKEY credential on the account yet — the wallet shows the "add a + // passkey" nudge until one exists. + const [passkeyAdded, setPasskeyAdded] = useState(false); + // Toast the wallet should show, bumped by an arrival webhook (the brain owns + // the toast surface, so it rides a nonce down like the other one-shots). + const [walletToast, setWalletToast] = useState<{ nonce: number; text: string } | null>(null); + // Accounts Grid already holds for this customer — the saved-banks list starts + // from these rather than from an empty session. + const [storedBanks, setStoredBanks] = useState([]); + // The USDB account's total balance — shown under the available headline, which + // stays on what a transfer could actually move. + const [totalCents, setTotalCents] = useState(0); + // Real deposit details for the customer's fiat account (Add money → Bank + // transfer shows these); null until sign-in has read them. + const [depositInstructions, setDepositInstructions] = useState(null); + // Bumped when the panel's "Simulate funding" button runs — the wallet brain + // watches it and drives the same money path a confirmed add would. + const [simulateDeposit, setSimulateDeposit] = useState<{ + nonce: number; + cents: number; + last4: string; + } | null>(null); + // Keys of action cards already offered, so re-viewing the screen doesn't stack + // duplicates in the feed. + const depositActionKey = useRef(null); + // A quote sourced from the customer's external account, waiting to be pushed. + const pendingPullQuote = useRef<{ + quoteId: string; + transactionId: string | null; + cents: number; + } | null>(null); const [phoneActive, setPhoneActive] = useState(false); const [gNonce, setGNonce] = useState(null); const [aNonce, setANonce] = useState(null); @@ -107,6 +192,18 @@ export function useWalletDemoLogic() { // stream into one API-panel group. const transferGroup = useRef(null); const transferFundingCurrency = useRef(null); + // The most recently linked ExternalAccount id (real, from ensureExternalAccount) — + // set once onLinkExternalAccount's real call resolves, consumed by the next + // outbound onQuoteCreate. Only meaningful for withdraw/send. + const pendingExternalAccountId = useRef(null); + // The just-created real quote (withdraw/send) — stashed here between + // onQuoteCreate (create-quote beat) and onTransferExecute (Face ID confirm). + const pendingQuote = useRef<{ + quoteId: string; + payloadToSign: string | null; + transactionId: string | null; + idem: string; + } | null>(null); // Pending "transaction settled" pushes (so execute and the GET land 1-by-1); // cleared on reset so a late push can't re-add a row to a wiped panel. const settleTimers = useRef>>(new Set()); @@ -117,12 +214,21 @@ export function useWalletDemoLogic() { null, ); const otpPrompt = useRef<{ resolve: (c: string) => void; reject: (e: Error) => void } | null>(null); + // Does the live OTP prompt have an entry step behind it (sign-in), or is it a + // bare re-auth (ensureSession)? Decides whether the sheet's top control can + // step BACK or only cancel. + const otpHasEntryStep = useRef(false); const emailPrompt = useRef<{ resolve: (e: string) => void; reject: (e: Error) => void } | null>(null); const phonePrompt = useRef<{ resolve: (n: string) => void; reject: (e: Error) => void } | null>(null); const googlePrompt = useRef<{ resolve: (t: string) => void; reject: (e: Error) => void } | null>(null); const applePrompt = useRef<{ resolve: (t: string) => void; reject: (e: Error) => void } | null>(null); - const promptEmail = useCallback((): Promise => { + /** Arms the email-entry step. `onFile` (the EMAIL_OTP credential's nickname) + * prefills the field — Grid mails the code to that address whatever is typed, + * so the prefill is what keeps the step honest. */ + const promptEmail = useCallback((onFile?: string | null): Promise => { + if (onFile) setEmailPrefill(onFile); + otpHasEntryStep.current = true; setEmailActive(true); return new Promise((resolve, reject) => (emailPrompt.current = { resolve, reject })); }, []); @@ -160,19 +266,27 @@ export function useWalletDemoLogic() { otpPrompt.current = null; p?.reject(new Error('cancelled')); }, []); - /** OTP step → back to the entry step (authenticate's OTP loop re-prompts). */ + /** OTP step → back to the entry step (gridSession's collectOtp re-prompts and + * issues a fresh challenge). */ const backOtp = useCallback(() => { + // Only the sign-in flow arms an entry step. A mid-transfer re-auth + // (ensureSession) prompts for the code alone, with no entry step to go back + // to — treat the sheet's top control as a plain cancel there, or the user is + // left on a dead entry screen with no prompt in flight. + if (!otpHasEntryStep.current) { + cancelOtp(); + return; + } setOtpActive(false); - // Re-arm the ACTIVE method's entry step IN THE SAME RENDER: the loop's - // re-prompt arrives a beat later, and without this the sheet's `open` - // (entry || otp) blips false for a frame — the dismiss animation starts - // and the sheet visibly jumps as it recovers. - if ((session.current.method ?? method) === 'sms') setPhoneActive(true); - else setEmailActive(true); + // Re-arm the entry step IN THE SAME RENDER: the loop's re-prompt arrives a + // beat later, and without this the sheet's `open` (entry || otp) blips false + // for a frame — the dismiss animation starts and the sheet visibly jumps as + // it recovers. + setEmailActive(true); const p = otpPrompt.current; otpPrompt.current = null; p?.reject(new Error('back')); - }, [method]); + }, [cancelOtp]); const confirmPasskey = useCallback(() => { // Leave the sheet up — it stays through the credential ceremony (the passcode / @@ -257,6 +371,157 @@ export function useWalletDemoLogic() { ]); }, []); + /** + * SANDBOX ONLY: the user is looking at a country's deposit details, and no real + * wire is coming. Offer the stand-in as a card IN THE REQUEST LIST — the panel + * is where the demo's seams belong, and the button is what produces the fund → + * quote → execute traffic right below it. One card per visit to the screen. + */ + const onDepositView = useCallback( + (view: { label: string; currency: string; cents?: number } | null) => { + if (!IS_SANDBOX || !view) return; + const key = `deposit-${view.label}-${view.cents ?? ''}`; + if (depositActionKey.current === key) return; + depositActionKey.current = key; + const baseTime = Date.now(); + setEntries((prev) => [ + ...prev, + { + method: 'POST', + path: '/sandbox/internal-accounts/{id}/fund', + title: 'Simulate an inbound transfer', + note: `No real ${view.currency} transfer is going to arrive in sandbox. Run the platform on-ramp that stands in for one — the calls it makes appear below.`, + simulateCents: view.cents, + status: '200', + action: 'simulate-funding' as EntryAction, + actionLabel: 'Simulate funding', + key: `action-${baseTime}`, + createdAt: baseTime, + groupId: `action-${baseTime}`, + groupLabel: TRANSFER_LABEL.add, + }, + ]); + }, + [], + ); + + /** The action card's button: hand the wallet brain the same job a confirmed + * add does, and mark the card done so it can't double-fund. */ + const onPanelAction = useCallback( + (action: EntryAction) => { + if (action !== 'simulate-funding') return; + // A pull quote is waiting on a push: fund THAT quote (Grid's own sandbox + // affordance) rather than running the platform on-ramp. + const pull = pendingPullQuote.current; + if (pull) { + pendingPullQuote.current = null; + setEntries((prev) => + prev.map((e) => (e.action === 'simulate-funding' ? { ...e, actionDone: true } : e)), + ); + const gid = newGroupId(); + void (async () => { + try { + const res = await sandboxSendForQuote( + pull.quoteId, + 'USD', + pull.cents, + logEnvelope(TRANSFER_LABEL.add, gid), + ); + if (!res.ok) throw new Error(`sandbox send failed: ${res.status}`); + let status: string | null = null; + if (pull.transactionId) { + status = await pollTransaction(pull.transactionId, logEnvelope(TRANSFER_LABEL.add, gid)); + } + await refreshBalance(TRANSFER_LABEL.add, gid); + if (isCompletionStatus(200, status)) setCompleted((c) => ({ ...c, add: true })); + } catch (e) { + console.error('[grid-demo] simulate push', e); + } + })(); + return; + } + setEntries((prev) => { + // Crypto deposits state their amount on the address screen; the bank + // instructions don't ask for one, so those fall back to the fixed amount. + const pending = prev.find((e) => e.action === 'simulate-funding' && !e.actionDone); + setSimulateDeposit((prevSim) => ({ + nonce: (prevSim?.nonce ?? 0) + 1, + cents: pending?.simulateCents ?? SANDBOX_DEPOSIT_CENTS, + last4: depositInstructions?.last4 ?? '', + })); + return prev.map((e) => (e.action === 'simulate-funding' ? { ...e, actionDone: true } : e)); + }); + }, + [depositInstructions], + ); + + // Real webhooks, pushed over SSE the moment Grid delivers them (ngrok → + // /api/webhooks → verified → stream). They land in the feed as inbound calls, + // which is what they are: Grid → your endpoint. + useWebhookStream( + useCallback( + (event) => { + // A completed inbound payment is how the wallet learns money ARRIVED — + // not the tap that requested it. Grid's payload `data` is a transaction + // in the same shape the Activity list already reads, so the row, the + // toast and the balance re-read all come from the delivery itself. + const data = event.data as RawTransaction | undefined; + const completed = event.type?.endsWith('.COMPLETED') && data?.status === 'COMPLETED'; + const credit = data?.direction ? data.direction === 'CREDIT' : data?.type === 'INCOMING'; + if (completed && data && credit) { + const row = transactionToRow(data); + setWallet((w) => ({ + ...w, + activity: [row, ...w.activity.filter((r) => r.id !== row.id)], + })); + setWalletToast((t) => ({ nonce: (t?.nonce ?? 0) + 1, text: `${row.amount} added to balance` })); + setCompleted((c) => ({ ...c, add: true })); + void refreshBalance(TRANSFER_LABEL.add, newGroupId()).catch((e) => + console.error('[grid-demo] webhook balance refresh', e), + ); + } + pushCalls( + [ + { + method: 'POST', + path: '/api/webhooks', + title: event.type ? `Webhook · ${event.type}` : 'Webhook received', + inbound: true, + status: '200', + resBody: { ok: true }, + realStatus: 200, + reqBody: { + ...(event.id ? { id: event.id } : {}), + ...(event.type ? { type: event.type } : {}), + ...(event.timestamp ? { timestamp: event.timestamp } : {}), + ...(event.data !== undefined ? { data: event.data } : {}), + }, + }, + ], + 'Webhook', + ); + }, + [pushCalls], + ), + ); + + // Turns each real Grid {request,response} envelope into a panel entry within + // a named group, as the live sign-in flow fires its calls. + const logEnvelope = useCallback( + (groupLabel: string, groupId: string) => (env: import('@/lib/gridClient').GridEnvelope) => { + pushCalls([envelopeToApiCall(env)], groupLabel, groupId); + }, + [pushCalls], + ); + + // Guards the live sign-in call (gridSignIn) against concurrent invocation: a + // second tap landing before `running` has re-rendered would otherwise fire a + // second WebAuthn ceremony on top of the first (double passkey prompts). + const signInInFlight = useRef(false); + // Same guard for the add-passkey action (a second tap must not open a second + // WebAuthn registration dialog). + const addPasskeyInFlight = useRef(false); + const startSession = useCallback(() => { session.current.expiresAt = Date.now() + SESSION_MS; }, []); @@ -271,49 +536,52 @@ export function useWalletDemoLogic() { */ async (firstTime: boolean, popup?: Promise) => { const m = session.current.method ?? method; - if (m === 'email_otp' || m === 'sms') { - // ONE loop for both OTP methods — only the entry prompt and the - // session field differ. The OTP step can come BACK to the entry step - // (the sheet's X): the prompt rejects with 'back' and the loop - // re-prompts the entry. - const field = m === 'sms' ? ('phone' as const) : ('email' as const); - const promptEntry = m === 'sms' ? promptPhone : promptEmail; - // Request + verify stream into ONE "Sign in" group as they actually fire. - const gid = newGroupId(); - let needEntry = firstTime; - for (;;) { - if (needEntry || !session.current[field]) { - session.current[field] = await promptEntry(); - } - // Submitting the phone/email fires the OTP request right away. - pushCalls([otpRequestCall(m, session.current[field])], 'Sign in', gid); - setTransient({ screen: 'creating', note: 'Sending you a code…' }); - await sleep(600); - try { - await promptOtp(); - break; - } catch (e) { - if ((e as Error)?.message !== 'back') throw e; - setTransient(null); - needEntry = true; - } + if (m === 'email_otp' || m === 'passkey') { + // A sign-in is already live (e.g. a second tap landed before `running` + // re-rendered) — never start a second WebAuthn ceremony on top of it. + if (signInInFlight.current) return; + signInInFlight.current = true; + try { + const gid = newGroupId(); + // Real Grid sign-in on whatever credential the account has: EMAIL_OTP + // until a passkey has been added, the passkey challenge/verify after. + // Every call is logged truthfully. + const s = await gridSignIn({ + log: logEnvelope(SIGN_IN_GROUP, gid), + // The aurora sheet's entry step, prefilled with the email on file. + promptEmail, + // The aurora OTP sheet collects the code; sandbox magic code is 000000. + promptOtp, + // Play the iOS Face ID animation around the WebAuthn assertion. + onFaceId: () => playFaceId(), + }); + setPasskeyAdded(s.via === 'passkey' || hasPasskey()); + // Load the real book balance, history, and the fiat account's deposit + // details (what Add money → Bank transfer shows) into the same group. + const [walletBalance, activity, deposit, externalAccounts] = await Promise.all([ + fetchBalance(logEnvelope(SIGN_IN_GROUP, gid)), + fetchActivity(logEnvelope(SIGN_IN_GROUP, gid)), + fetchDepositInstructions(logEnvelope(SIGN_IN_GROUP, gid)), + fetchExternalAccounts(logEnvelope(SIGN_IN_GROUP, gid)), + ]); + session.current.loadedBalanceCents = walletBalance.spendableCents; + setTotalCents(walletBalance.totalCents); + session.current.loadedActivity = activity; + // Grid gives us the real USD section; the euro one is a stand-in until + // the customer has an EUR internal account (see placeholderDeposit). + setDepositInstructions( + deposit && { ...deposit, sections: [...deposit.sections, PLACEHOLDER_EUR_DEPOSIT] }, + ); + // Rows Grid can actually quote against; anything unmappable is dropped + // rather than shown as a bank the flows would fail on. + setStoredBanks( + externalAccounts + .map((a) => savedBankFromExternalAccount(a)) + .filter((b): b is SavedBank => b !== null), + ); + } finally { + signInInFlight.current = false; } - // Code accepted → verify fires; then let the sheet's dismiss VISIBLY - // finish (transient clears first so the auth screen is back underneath - // the departing sheet) before the wallet flip starts the intro. - pushCalls(otpVerifyCalls(m), 'Sign in', gid); - setTransient(null); - await sleep(400); - } else if (m === 'passkey') { - const gid = newGroupId(); - // No "Save a passkey?" sheet — the entry-point tap starts the challenge - // directly, and the system passkey dialog IS the save ceremony. Firing - // create() in the tap's own chain also keeps its user activation. - pushCalls([passkeyChallengeCall()], 'Sign in', gid); - await passkeyCeremony(); - await playFaceId(); - // Assertion verified after the Face ID ceremony. - pushCalls([passkeyVerifyCall()], 'Sign in', gid); } else if (m === 'oauth' || m === 'apple') { if (popup) { // The popup is already open — the phone stays untouched until it @@ -324,15 +592,27 @@ export function useWalletDemoLogic() { await prompt(await oauthNonce()); } // The same post-resolve beat as the OTP sheet's dismiss: a breath - // between the ceremony finishing and the wallet flip's intro. + // between the ceremony finishing and the wallet flip's intro. No call is + // logged: this demo has no live OAuth credential path, so there is no + // real request to show. await sleep(400); - pushCalls([oauthVerifyCall(m)], 'Sign in'); } else { throw new Error(`Sign-in method "${m}" is not available.`); } startSession(); }, - [method, promptEmail, promptPhone, promptOtp, promptGoogle, promptApple, playFaceId, pushCalls, startSession], + [ + method, + promptEmail, + promptOtp, + promptGoogle, + promptApple, + playFaceId, + pushCalls, + startSession, + logEnvelope, + gridSignIn, + ], ); const signInWithMethod = useCallback( @@ -355,7 +635,12 @@ export function useWalletDemoLogic() { session.current = { method: m }; setSignInMethod(m); await authenticate(true, popup); - setWallet((w) => ({ ...w, created: true, balanceCents: 0 })); + setWallet((w) => ({ + ...w, + created: true, + balanceCents: session.current.loadedBalanceCents ?? 0, + activity: session.current.loadedActivity ?? [], + })); setCompleted((c) => ({ ...c, signIn: true })); setTransient(null); } catch (e: unknown) { @@ -378,72 +663,376 @@ export function useWalletDemoLogic() { (mode: WalletTransferMode, cents: number, dest?: TransferDest) => { const gid = newGroupId(); transferGroup.current = gid; - transferFundingCurrency.current = - mode === 'add' && dest?.kind === 'bank' ? dest.currency : null; - pushCalls([transferQuoteCall(mode, cents, dest)], TRANSFER_LABEL[mode], gid); + if (mode === 'add') { + // No client-side quote call to log here — the real on-ramp envelopes + // (fund → quote → execute → poll) are logged in onTransferExecute, + // into this same group (transferGroup.current, set above). + transferFundingCurrency.current = dest?.kind === 'bank' ? dest.currency : null; + return; + } + // Outbound (withdraw/send): a REAL POST /quotes, source = the embedded + // wallet, destination = the ExternalAccount linked moments earlier. + transferFundingCurrency.current = null; + const acct = getAccount(); + if (!acct) return; + void (async () => { + const destCurrency = dest?.currency ?? 'USD'; + // The external account was created on link (onLinkExternalAccount); + // resolve its id from that ref. If the user picked a saved recipient + // from an earlier session without re-linking this session, this can + // be stale/null — see the task report's known-limitation note. + const externalAccountId = pendingExternalAccountId.current; + if (!externalAccountId) return; + const idem = crypto.randomUUID(); + const quote = await createQuote( + quoteBodyFor(acct.accountId, externalAccountId, cents, destCurrency), + logEnvelope(TRANSFER_LABEL[mode], gid), + idem, + ); + pendingQuote.current = { + quoteId: quote.quoteId, + payloadToSign: quote.payloadToSign, + transactionId: quote.transactionId, + idem, + }; + })().catch((e) => console.error('[grid-demo] create quote', e)); }, - [pushCalls], + [pushCalls, logEnvelope], ); // Linking a recipient (a bank account or a crypto address) — its own group, - // logged the moment "Add bank account" / "Add recipient" is confirmed. + // logged the moment "Add bank account" / "Add recipient" is confirmed. Real + // POST /customers/external-accounts (reused, not recreated, per destination + // for the rest of the session). + /** A stored account was picked in the sheet: quote against ITS id (no create). */ + const onSelectStoredBank = useCallback((externalAccountId: string | null) => { + if (externalAccountId) pendingExternalAccountId.current = externalAccountId; + }, []); + const onLinkExternalAccount = useCallback( (input: ExternalAccountInput, label: string) => { - pushCalls([externalAccountCreateCall(input)], label); + const gid = newGroupId(); + void ensureExternalAccount(input, logEnvelope(label, gid)) + .then((id) => { + pendingExternalAccountId.current = id; + }) + .catch((e) => console.error('[grid-demo] link external account', e)); }, - [pushCalls], + [logEnvelope], ); const onTransferExecute = useCallback( - (mode: WalletTransferMode, cents: number) => { + ( + mode: WalletTransferMode, + cents: number, + onAddSettled?: () => void, + opts?: { simulated?: boolean }, + ) => { const gid = transferGroup.current ?? newGroupId(); transferGroup.current = null; if (mode === 'add') { - const fundingCurrency = transferFundingCurrency.current ?? 'USD'; transferFundingCurrency.current = null; - const [webhookCall, ...settleCalls] = addMoneySettlementCalls(cents, fundingCurrency); - pushCalls([webhookCall], TRANSFER_LABEL[mode], gid); - if (settleCalls.length) { - const timer = setTimeout(() => { - settleTimers.current.delete(timer); - pushCalls(settleCalls, TRANSFER_LABEL[mode], gid); - }, SETTLE_DELAY_MS); - settleTimers.current.add(timer); + const acct = getAccount(); + // The user linked their OWN bank account for this add: the quote sources + // from THAT account, and Grid will not let us pull it — the money has to + // be pushed. So create the quote and stop; a real wire (or, in sandbox, + // the panel's simulate button → POST /sandbox/send) settles it. + const externalAccountId = !opts?.simulated ? pendingExternalAccountId.current : null; + if (acct && externalAccountId) { + void (async () => { + try { + const idem = crypto.randomUUID(); + const quote = await createQuote( + pullQuoteBodyFor(externalAccountId, acct.accountId, cents), + logEnvelope(TRANSFER_LABEL[mode], gid), + idem, + ); + // Nothing has arrived yet — release the optimistic bump so the + // balance keeps telling the truth while the transfer is pending. + onAddSettled?.(); + pendingPullQuote.current = { quoteId: quote.quoteId, transactionId: quote.transactionId, cents }; + if (IS_SANDBOX) { + // Offer the push as an action card, tied to THIS quote. + const baseTime = Date.now(); + setEntries((prev) => [ + // Supersede any un-run stand-in from the instructions screen: + // the user picked the pull path, so THIS quote is what a push + // would fund, and two live buttons would be ambiguous. + ...prev.filter((e) => !(e.action === 'simulate-funding' && !e.actionDone)), + { + method: 'POST', + path: '/sandbox/send', + title: 'Simulate the incoming transfer', + note: `Grid can't pull from an external account — the quote stays PENDING until funds are pushed. Sandbox stands in for that push.`, + status: '200', + action: 'simulate-funding' as EntryAction, + actionLabel: 'Simulate funding', + simulateCents: cents, + key: `action-pull-${baseTime}`, + createdAt: baseTime, + groupId: gid, + groupLabel: TRANSFER_LABEL[mode], + }, + ]); + } + } catch (e) { + console.error('[grid-demo] pull quote', e); + onAddSettled?.(); + } + })(); + return; + } + if (acct) { + void (async () => { + // Any failure here (a non-200/403 fund response, a failed/errored + // quote or execute, or a thrown exception anywhere along the + // chain — network error, bad JSON, etc.) must not become an + // unhandled rejection or leave `completed.add` set INCORRECTLY. + // Every envelope is logged inside the helpers below before any of + // them can throw; there's no dedicated busy/running state on this + // path to unstick (the sheet already closed synchronously in + // finishTransfer). It also must not leave the phone showing + // phantom money forever OR double-counted alongside a concurrent + // add: EVERY terminal outcome for THIS add — success (after the + // balance re-read lands or exhausts its retry) or failure (else / + // catch / 403) — calls `onAddSettled` exactly once, undoing this + // add's own optimistic bump by exactly its own cents. + // + // `POST /sandbox/internal-accounts/{id}/fund` only mints BOOK + // balance — a direct fund of the customer's own wallet leaves it + // with no on-chain USDB, so any later outbound quote fails with + // INSUFFICIENT_FUNDS. The real on-ramp (fund the platform's USD + // account, then quote+execute platform -> customer wallet) is the + // only way "Add money" lands real, spendable balance. + try { + const platformId = await resolvePlatformUsdAccountId( + logEnvelope(TRANSFER_LABEL[mode], gid), + ); + const res = await sandboxFundPlatform( + platformId, + cents, + logEnvelope(TRANSFER_LABEL[mode], gid), + ); + if (res.ok) { + const idem = crypto.randomUUID(); + const quote = await createQuote( + onRampQuoteBodyFor(platformId, acct.accountId, cents), + logEnvelope(TRANSFER_LABEL[mode], gid), + idem, + ); + const execEnv = await executeQuoteUnsigned( + quote.quoteId, + logEnvelope(TRANSFER_LABEL[mode], gid), + idem, + ); + let transactionStatus: string | null = null; + if (execEnv.response.status === 200 && quote.transactionId) { + // Polls to a terminal status (COMPLETED expected — the + // platform-sourced on-ramp settles fast in sandbox). + transactionStatus = await pollTransaction( + quote.transactionId, + logEnvelope(TRANSFER_LABEL[mode], gid), + ); + } + if (isCompletionStatus(execEnv.response.status, transactionStatus)) { + // The fund + on-ramp genuinely reached COMPLETED + // server-side — the add DID happen, so `completed.add` + // reflects that regardless of whether the balance + // re-read below succeeds. + setCompleted((c) => ({ ...c, add: true })); + } else { + // Execute itself failed, or the transaction never + // confirmed COMPLETED (FAILED/REJECTED/REFUNDED/EXPIRED, + // still PROCESSING at the poll deadline, or no + // transactionId to track). A real, truthful outcome — + // every envelope is already logged in the panel, so + // don't fabricate the checkmark. `onAddSettled` below + // still rolls back the optimistic bump regardless, same + // as every other failure path in this branch. + console.error( + '[grid-demo]', + new Error( + `on-ramp did not complete: execute ${execEnv.response.status}, transaction ${transactionStatus ?? 'untracked'}`, + ), + ); + } + try { + await refreshBalance(TRANSFER_LABEL[mode], gid); // real GET /customers/internal-accounts + } catch (e) { + // One retry after a short delay before giving up — a + // transient read failure right after a real, successful + // fund shouldn't leave the panel stuck on a stale balance + // if a second try would have landed fine. + await sleep(REFRESH_RETRY_DELAY_MS); + try { + await refreshBalance(TRANSFER_LABEL[mode], gid); + } catch (e2) { + // Both reads failed: truthful log, no fabricated + // balance. `onAddSettled` below still fires — the + // optimistic bump must not outlive the flow either way — + // so the display will under-count this add's real money + // until some LATER balance change (another flow, or a + // fresh sign-in) catches it up. That's the truthful + // choice, not a bug: we don't know the exact new total, + // so we stop pretending we do. + console.error('[grid-demo]', e2); + } + } + // Settle THIS add's bump now, in the SAME continuation as + // the refresh above (whether it landed or exhausted its + // retry) — same React batch as refreshBalance's setWallet, + // so `balance` and `deltaCents` move together with no + // intermediate frame where both count (the bug a concurrent + // second add's still-pending bump would otherwise hit). + onAddSettled?.(); + } else if (res.status === 403) { + // Production keys: sandbox fund is forbidden — no real money + // moved. Keep the sidebar checkmark (the flow WAS attempted) + // truthful, but settle (roll back) the phone's optimistic + // bump: this is the EXPECTED path once this demo runs on + // production keys, so it must not leave a permanent phantom + // balance. + setCompleted((c) => ({ ...c, add: true })); + onAddSettled?.(); + } else { + console.error('[grid-demo]', new Error(`sandbox fund failed: ${res.status}`)); + onAddSettled?.(); + } + } catch (e) { + console.error('[grid-demo]', e); + onAddSettled?.(); + } + })(); } - setWallet((w) => ({ ...w, balanceCents: w.balanceCents + cents })); - setCompleted((c) => ({ ...c, add: true })); return; } - // Calls land one at a time: execute now, the transaction settles a beat - // later (real interactions only — fast-forward batches its setup group). + // Outbound (withdraw | send): stamp the quote's payloadToSign, execute + // it for real, then poll the transaction to a terminal status. The + // sheet has already closed optimistically (finishTransfer, above this + // callback) — no balance changes here; `refreshBalance` below (once the + // transaction settles) is the sole source of truth for the new balance. transferFundingCurrency.current = null; - const [executeCall, ...settleCalls] = transferExecuteCalls(mode); - pushCalls([executeCall], TRANSFER_LABEL[mode], gid); - if (settleCalls.length) { - const timer = setTimeout(() => { - settleTimers.current.delete(timer); - pushCalls(settleCalls, TRANSFER_LABEL[mode], gid); - }, SETTLE_DELAY_MS); - settleTimers.current.add(timer); + const pq = pendingQuote.current; + pendingQuote.current = null; + const acct = getAccount(); + if (acct && pq?.payloadToSign) { + void (async () => { + try { + // A bare re-auth: the code sheet comes up with no entry step behind it. + otpHasEntryStep.current = false; + const priv = await ensureSession({ + log: logEnvelope(TRANSFER_LABEL[mode], gid), + promptOtp, + onFaceId: () => playFaceId(), + }); + const execEnv = await executeQuote( + pq.quoteId, + pq.payloadToSign!, + priv, + logEnvelope(TRANSFER_LABEL[mode], gid), + pq.idem, + ); + if (execEnv.response.status === 200) { + let transactionStatus: string | null = null; + if (pq.transactionId) { + // Polls to a terminal status (COMPLETED expected in sandbox, + // 60–180s) — a status-polling loop, not an auth retry. + transactionStatus = await pollTransaction( + pq.transactionId, + logEnvelope(TRANSFER_LABEL[mode], gid), + ); + } + await refreshBalance(TRANSFER_LABEL[mode], gid); // real GET /customers/internal-accounts + if (isCompletionStatus(execEnv.response.status, transactionStatus)) { + setCompleted((c) => ({ ...c, [mode]: true })); + } else { + // FAILED/REJECTED/REFUNDED/EXPIRED, still PROCESSING at the + // poll deadline, or no transactionId to track — a real, + // truthful outcome. The panel already logged every envelope; + // don't fabricate the checkmark. The sheet already closed + // optimistically before this callback ran, so the phone has + // no busy state to unstick — it's already recoverable. + console.error( + '[grid-demo]', + new Error(`transfer did not complete: transaction ${transactionStatus ?? 'untracked'}`), + ); + } + } else { + // Real error (e.g. insufficient funds, an expired quote). The + // panel already logged the truthful error via logEnvelope; the + // phone recovers with no balance change and no checkmark. + console.warn('[grid-demo] execute failed', execEnv.response.status); + } + } catch (e) { + console.error('[grid-demo] execute', e); + } + })(); + } else { + console.warn('[grid-demo] no pending quote/payload for outbound transfer'); } - setWallet((w) => ({ - ...w, - balanceCents: Math.max(0, w.balanceCents - cents), - })); - setCompleted((c) => ({ ...c, [mode]: true })); }, - [pushCalls], + // `refreshBalance` is referenced inside the async body above but + // deliberately omitted here: it's declared further down in this same + // component (below `onTransferExecute`), so naming it in this array would + // read it before its `const` initializer runs (TDZ) at render time. The + // closure inside the callback body only reads it once invoked, well after + // the component has finished rendering, so this is safe — same pattern + // already used by the 'add' branch above. + [logEnvelope], ); + // Re-pull the real book balance after a money movement (used by Tasks 7-8). + const refreshBalance = useCallback( + async (groupLabel: string, groupId: string) => { + const b = await fetchBalance(logEnvelope(groupLabel, groupId)); + setWallet((w) => ({ ...w, balanceCents: b.spendableCents })); + setTotalCents(b.totalCents); + }, + [logEnvelope], + ); + + /** + * "Add a passkey", from inside the wallet — the docs' bootstrap order: the + * EMAIL_OTP session that got you in is what authorizes the new credential + * (POST /auth/credentials 202 → stamped retry → 201), and the passkey then + * mints the session that replaces it. Every call is real and logged; the + * nudge disappears only if it actually succeeded. + */ + const onAddPasskey = useCallback(() => { + if (addPasskeyInFlight.current) return; + addPasskeyInFlight.current = true; + const gid = newGroupId(); + void (async () => { + try { + // A lapsed session re-auths inside addPasskey — no entry step there. + otpHasEntryStep.current = false; + await gridAddPasskey({ + log: logEnvelope('Add a passkey', gid), + promptOtp, + onFaceId: () => playFaceId(), + }); + setPasskeyAdded(true); + // The next sign-in on this device is a passkey one; the CTA must say so. + setMethods(RETURNING_METHODS); + } catch (e) { + // Cancelled at the system dialog, or a real failure — the panel already + // logged whatever went out; the nudge stays so it can be retried. + if ((e as Error)?.message !== 'cancelled') console.error('[grid-demo] add passkey', e); + } finally { + addPasskeyInFlight.current = false; + } + })(); + }, [logEnvelope, promptOtp, playFaceId]); + + /** Card issuance is on-phone only (no /cards call is made) — nothing to log. */ const onCardIssued = useCallback(() => { - pushCalls(cardCalls(), 'Issue a card'); setWallet((w) => ({ ...w, hasCard: true })); setCompleted((c) => ({ ...c, card: true })); - }, [pushCalls]); + }, []); const onTapToPay = useCallback( (cents: number, merchant: string) => { - pushCalls(tapCalls(merchant, cents), 'Tap to pay'); + // On-phone only: no card authorization call exists in this demo. setWallet((w) => ({ ...w, cardActivated: true, @@ -451,38 +1040,28 @@ export function useWalletDemoLogic() { })); setCompleted((c) => ({ ...c, tap: true })); }, - [pushCalls], + [], ); - // A payment landed (Receive flow): no client call to make — Grid POSTs the - // inbound webhook, then we read the transaction. The webhook lands now and the - // GET a beat later (same 1-by-1 cadence as a transfer's settle). - const onReceivePayment = useCallback( - (info: ReceivePaymentInfo) => { - // Add-from-crypto is the same inbound webhook, just grouped + checked as the - // Add flow (you topped up your own balance) rather than Receive. - const isAdd = info.intent === 'add'; - const label = isAdd ? 'Add money' : 'Receive payment'; - const gid = newGroupId(); - const [webhookCall, ...rest] = receivePaymentCalls(info); - pushCalls([webhookCall], label, gid); - if (rest.length) { - const timer = setTimeout(() => { - settleTimers.current.delete(timer); - pushCalls(rest, label, gid); - }, SETTLE_DELAY_MS); - settleTimers.current.add(timer); - } - setWallet((w) => ({ ...w, balanceCents: w.balanceCents + info.amountCents })); - setCompleted((c) => ({ ...c, [isAdd ? 'add' : 'receive']: true })); - }, - [pushCalls], - ); + /** + * A payment "landed" in the Receive flow. This is a demo-side event with NO + * client call behind it, so it logs NOTHING: the panel only ever shows real + * traffic. A genuine inbound payment shows up as the webhook Grid delivers to + * /api/webhooks (streamed into the panel above) — it used to also synthesize a + * POST to a fictional https://your-app.com/webhooks/grid, which read as a real + * request and wasn't one. + */ + const onReceivePayment = useCallback((info: ReceivePaymentInfo) => { + const isAdd = info.intent === 'add'; + setWallet((w) => ({ ...w, balanceCents: w.balanceCents + info.amountCents })); + setCompleted((c) => ({ ...c, [isAdd ? 'add' : 'receive']: true })); + }, []); // "Sign in again" — drop back to the auth screen to replay the flow. Resets // the session wallet to fresh, but KEEPS the API log and the sidebar // checkmarks (completed flows) — only "Reset" wipes those. const returnToSignIn = useCallback(() => { + setMethods(deviceHasPasskey() ? RETURNING_METHODS : FIRST_RUN_METHODS); for (const p of [passkeyPrompt, otpPrompt, emailPrompt, phonePrompt, googlePrompt, applePrompt]) { p.current?.reject(new Error('cancelled')); p.current = null; @@ -493,6 +1072,7 @@ export function useWalletDemoLogic() { faceIdPrompt.current = null; } session.current = {}; + clearSession(); transferGroup.current = null; transferFundingCurrency.current = null; settleTimers.current.forEach((t) => clearTimeout(t)); @@ -566,6 +1146,7 @@ export function useWalletDemoLogic() { faceIdPrompt.current = null; } session.current = {}; + clearSession(); transferGroup.current = null; transferFundingCurrency.current = null; settleTimers.current.forEach((t) => clearTimeout(t)); @@ -621,8 +1202,19 @@ export function useWalletDemoLogic() { cancelOtp, backOtp, emailActive, + emailPrefill, submitEmail, cancelEmail, + passkeyAdded, + onAddPasskey, + depositInstructions, + totalCents, + walletToast, + storedBanks, + onSelectStoredBank, + onDepositView, + onPanelAction, + simulateDeposit, phoneActive, submitPhone, cancelPhone, @@ -639,5 +1231,6 @@ export function useWalletDemoLogic() { onCardIssued, onTapToPay, onReceivePayment, + refreshBalance, }; } diff --git a/components/grid-wallet-prod/src/hooks/useWebhookStream.ts b/components/grid-wallet-prod/src/hooks/useWebhookStream.ts new file mode 100644 index 000000000..4dcbe8c37 --- /dev/null +++ b/components/grid-wallet-prod/src/hooks/useWebhookStream.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import type { WebhookEvent } from '@/lib/webhookEvents'; + +/** + * Subscribes to /api/webhooks/stream and hands each verified webhook to `onEvent` + * as it arrives. EventSource reconnects on its own if the tunnel or dev server + * blips, so there's no retry logic here. + */ +export function useWebhookStream(onEvent: (event: WebhookEvent) => void): void { + // Keep the latest callback without resubscribing (a new EventSource per render + // would drop events mid-flight). + const handler = useRef(onEvent); + handler.current = onEvent; + + useEffect(() => { + if (typeof window === 'undefined' || !('EventSource' in window)) return; + const source = new EventSource('/api/webhooks/stream'); + source.onmessage = (e) => { + try { + handler.current(JSON.parse(e.data) as WebhookEvent); + } catch { + // A frame we can't parse isn't worth breaking the stream over. + } + }; + source.onerror = () => { + // EventSource retries by itself; nothing to do but let it. + }; + return () => source.close(); + }, []); +} diff --git a/components/grid-wallet-prod/src/lib/apiCodeFormat.tsx b/components/grid-wallet-prod/src/lib/apiCodeFormat.tsx index 0ab122e7e..f5791c9a6 100644 --- a/components/grid-wallet-prod/src/lib/apiCodeFormat.tsx +++ b/components/grid-wallet-prod/src/lib/apiCodeFormat.tsx @@ -41,340 +41,16 @@ export function formatCurlString(entry: ApiCall): string { return lines.join('\n'); } -function objectField(value: unknown, key: string): unknown { - return typeof value === 'object' && value !== null - ? (value as Record)[key] - : undefined; -} - -function isRealtimeFundingQuote(entry: ApiCall): boolean { - if (!entry.path.endsWith('/quotes') || entry.method !== 'POST') return false; - return objectField(objectField(entry.reqBody, 'source'), 'sourceType') === 'REALTIME_FUNDING'; -} - -function isQuoteCreate(entry: ApiCall): boolean { - return entry.method === 'POST' && entry.path.endsWith('/quotes'); -} - -function isQuoteExecute(entry: ApiCall): boolean { - return entry.method === 'POST' && /\/quotes\/[^/]+\/execute$/.test(entry.path); -} - -function quoteIdFromPath(path: string): string { - const match = path.match(/\/quotes\/([^/]+)\/execute$/); - return match?.[1] ?? 'Quote:019e8f49-3c8f-5246-0000-4d75f9a6d1d1'; -} - -function quoteFundingCurrency(entry: ApiCall): string { - const currency = objectField(objectField(entry.reqBody, 'source'), 'currency'); - return typeof currency === 'string' ? currency : 'USD'; -} - -function currencyResponse(code: string): Record { - const currencies: Record> = { - USDB: { code: 'USDB', name: 'USD Balance', symbol: '$', decimals: 6 }, - USD: { code: 'USD', name: 'United States Dollar', symbol: '$', decimals: 2 }, - EUR: { code: 'EUR', name: 'Euro', symbol: '\u20ac', decimals: 2 }, - BTC: { code: 'BTC', name: 'Bitcoin', symbol: 'BTC', decimals: 8 }, - USDC: { code: 'USDC', name: 'USD Coin', symbol: 'USDC', decimals: 6 }, - }; - return currencies[code] ?? { code, name: code, symbol: code, decimals: 2 }; -} - -function quoteSource(entry: ApiCall): unknown { - return ( - objectField(entry.reqBody, 'source') ?? { - sourceType: 'ACCOUNT', - accountId: 'InternalAccount:019e8f48-1135-438c-0000-8b9d28990463', - } - ); -} - -function quoteDestination(entry: ApiCall): unknown { - return ( - objectField(entry.reqBody, 'destination') ?? { - destinationType: 'ACCOUNT', - accountId: 'ExternalAccount:019e8f4a-781d-7e0c-0000-a0d9afbf1314', - currency: 'USD', - } - ); -} - -function quoteCurrencyCodes(entry: ApiCall): { sending: string; receiving: string } { - const source = quoteSource(entry); - const destination = quoteDestination(entry); - const sourceType = objectField(source, 'sourceType'); - - if (sourceType === 'REALTIME_FUNDING') { - return { - sending: typeof objectField(source, 'currency') === 'string' ? (objectField(source, 'currency') as string) : 'USD', - receiving: 'USDB', - }; - } - - const destinationCurrency = objectField(destination, 'currency'); - return { - sending: 'USDB', - receiving: typeof destinationCurrency === 'string' ? destinationCurrency : 'USD', - }; -} - -function quoteAmount(entry: ApiCall): number { - const amount = objectField(entry.reqBody, 'lockedCurrencyAmount'); - return typeof amount === 'number' ? amount : 20000; -} - -function quotePaymentInstructions(entry: ApiCall): Record[] { - if (isRealtimeFundingQuote(entry)) { - const fundingCurrency = quoteFundingCurrency(entry); - return [ - { - instructionsNotes: 'Include the reference code in the transfer memo', - accountOrWalletInfo: { - accountType: `${fundingCurrency}_ACCOUNT`, - reference: 'PAYIN-8F49', - accountNumber: '9876543210', - routingNumber: '021000021', - bankName: 'JP Morgan Chase', - accountHolderName: 'Lightspark Payments FBO Customer', - }, - }, - ]; - } - - return [ - { - accountOrWalletInfo: { - accountType: 'EMBEDDED_WALLET', - payloadToSign: - '{"type":"ACTIVITY_TYPE_SIGN_TRANSACTION_V2","timestampMs":"1770408000000","organizationId":"org_abc123","parameters":{"signWith":"wallet_abc123","unsignedTransaction":""},"generateAppProofs":true}', - }, - }, - ]; -} - -function quoteResponse(entry: ApiCall, status: 'PENDING' | 'PROCESSING'): Record { - const { sending, receiving } = quoteCurrencyCodes(entry); - const amount = quoteAmount(entry); - - return { - id: quoteIdFromPath(entry.path), - status, - createdAt: '2026-06-05T12:00:00Z', - expiresAt: '2026-06-05T12:05:00Z', - source: quoteSource(entry), - destination: quoteDestination(entry), - sendingCurrency: currencyResponse(sending), - receivingCurrency: currencyResponse(receiving), - totalSendingAmount: amount, - totalReceivingAmount: amount, - exchangeRate: 1, - feesIncluded: 0, - paymentInstructions: quotePaymentInstructions(entry), - transactionId: 'Transaction:019e8f49-3ca4-b78f-0000-1d3e9a411168', - }; -} - -function transactionIdFromPath(path: string): string { - const match = path.match(/\/transactions\/([^/?]+)/); - return match?.[1] ?? 'Transaction:019e8f49-3ca4-b78f-0000-1d3e9a411168'; -} - -function isTransactionGet(entry: ApiCall): boolean { - return entry.method === 'GET' && /\/transactions\/[^/?]+$/.test(entry.path); -} - -function transactionResponse(entry: ApiCall): Record { - return { - id: transactionIdFromPath(entry.path), - status: 'COMPLETED', - type: 'OUTGOING', - source: { - sourceType: 'ACCOUNT', - accountId: 'InternalAccount:019e8f48-1135-438c-0000-8b9d28990463', - currency: 'USDB', - }, - destination: { - destinationType: 'ACCOUNT', - accountId: 'ExternalAccount:019e8f4a-781d-7e0c-0000-a0d9afbf1314', - currency: 'USD', - }, - customerId: 'Customer:019e8f47-2a3d-1d02-0000-6b1f0c4e2a91', - platformCustomerId: 'customer_demo_001', - createdAt: '2026-06-05T12:00:05Z', - updatedAt: '2026-06-05T12:00:12Z', - settledAt: '2026-06-05T12:00:12Z', - quoteId: 'Quote:019e8f49-3c8f-5246-0000-4d75f9a6d1d1', - sentAmount: { - amount: 20000, - currency: currencyResponse('USDB'), - }, - receivedAmount: { - amount: 20000, - currency: currencyResponse('USD'), - }, - exchangeRate: 1, - fees: 0, - }; -} - -function authCredentialIdFromPath(path: string): string { - const match = path.match(/\/auth\/credentials\/([^/]+)\/challenge$/); - return match?.[1] ?? 'AuthMethod:019e8f48-11a8-0dca-0000-947363f18d5a'; -} - -function isAuthCredentialChallenge(entry: ApiCall): boolean { - return ( - entry.method === 'POST' && - /\/auth\/credentials\/[^/]+\/challenge$/.test(entry.path) - ); -} - -function isPasskeyChallenge(entry: ApiCall): boolean { - return typeof objectField(entry.reqBody, 'clientPublicKey') === 'string'; -} - -function isAuthCredentialVerify(entry: ApiCall): boolean { - return ( - entry.method === 'POST' && - /\/auth\/credentials\/[^/]+\/verify$/.test(entry.path) - ); -} - -function credentialVerifyType(entry: ApiCall): string { - const type = objectField(entry.reqBody, 'type'); - return typeof type === 'string' ? type : 'OAUTH'; -} - -function isOtpVerifyType(type: string): boolean { - return type === 'EMAIL_OTP' || type === 'SMS_OTP'; -} - -function hasWalletSignature(entry: ApiCall): boolean { - return typeof entry.headers?.['Grid-Wallet-Signature'] === 'string'; -} - -function isExternalAccountCreate(entry: ApiCall): boolean { - return entry.method === 'POST' && entry.path === '/customers/external-accounts'; -} - -function isWalletAccountInfo(accountInfo: unknown): boolean { - const accountType = objectField(accountInfo, 'accountType'); - return ( - typeof objectField(accountInfo, 'address') === 'string' || - (typeof accountType === 'string' && accountType.includes('WALLET')) - ); -} - -function externalAccountResponse(entry: ApiCall): Record { - const accountInfo = objectField(entry.reqBody, 'accountInfo'); - const platformAccountId = - objectField(entry.reqBody, 'platformAccountId') ?? objectField(accountInfo, 'platformAccountId'); - - return { - id: isWalletAccountInfo(accountInfo) - ? 'ExternalAccount:019e8f4a-9b2e-71f4-0000-3c5e7a2b9f08' - : 'ExternalAccount:019e8f4a-781d-7e0c-0000-a0d9afbf1314', - customerId: entry.reqBody?.customerId, - status: 'ACTIVE', - ...(typeof platformAccountId === 'string' ? { platformAccountId } : {}), - currency: entry.reqBody?.currency, - defaultUmaDepositAccount: false, - accountInfo, - }; -} - -function authSessionResponse(entry: ApiCall, includeEncryptedSessionKey: boolean): Record { - const type = credentialVerifyType(entry); - const isPasskey = type === 'PASSKEY'; - const isApple = objectField(entry.reqBody, 'oidcToken') === ''; - - return { - id: 'Session:019e8f48-11d2-7e48-0000-d9ac1f2a9f04', - accountId: 'InternalAccount:019e8f48-1135-438c-0000-8b9d28990463', - type, - ...(isPasskey - ? { - credentialId: - 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', - } - : {}), - nickname: isPasskey ? 'iPhone Face-ID' : isApple ? 'demo@privaterelay.appleid.com' : 'demo@lightspark.com', - createdAt: '2026-06-05T12:00:00Z', - updatedAt: '2026-06-05T12:00:00Z', - ...(includeEncryptedSessionKey - ? { - encryptedSessionSigningKey: - 'w99a5xV6A75TfoAUkZn869fVyDYvgVsKrawMALZXmrauZd8hEv66EkPU1Z42CUaHESQjcA5bqd8dynTGBMLWB9ewtXWPEVbZvocB4Tw2K1vQVp7uwjf', - } - : {}), - expiresAt: '2026-06-05T12:15:00Z', - }; -} - -export function stubResponseBody(entry: ApiCall): Record { - if (isExternalAccountCreate(entry)) { - return externalAccountResponse(entry); - } - if (isAuthCredentialChallenge(entry)) { - const id = authCredentialIdFromPath(entry.path); - const accountId = 'InternalAccount:019e8f48-1135-438c-0000-8b9d28990463'; - const timestamps = { - createdAt: '2026-06-05T12:00:00Z', - updatedAt: '2026-06-05T12:00:00Z', - }; - - if (isPasskeyChallenge(entry)) { - return { - id, - accountId, - type: 'PASSKEY', - credentialId: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', - nickname: 'iPhone Face-ID', - ...timestamps, - challenge: '6b35a4c41d9aa7a2a0e742f9f9e7a1c2d65a2db33a3fb748f6d4f1ce78d9a729', - requestId: 'Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21', - expiresAt: '2026-06-05T12:05:00Z', - }; - } - - return { - id, - accountId, - type: 'EMAIL_OTP', - nickname: 'demo@lightspark.com', - otpEncryptionTargetBundle: - '{"version":"v1.0.0","data":"7b227461726765745075626c6963...","dataSignature":"30450221...","enclaveQuorumPublic":"04a1b2c3..."}', - ...timestamps, - }; - } - if (isAuthCredentialVerify(entry)) { - const type = credentialVerifyType(entry); - if (isOtpVerifyType(type) && !hasWalletSignature(entry)) { - return { - type, - payloadToSign: - 'eyJhbGciOiJFUzI1NiIsImtpZCI6InR1cm5rZXkifQ.eyJzdWIiOiJUWnk2NkVPa1RGYTd2NkpXZ0VxaVgyZGFXOENXc2pMQzVDVU9aRUlGY3hzIiwiaWF0IjoxNzc5NDA3MjIxLCJleHAiOjE3Nzk0MTA4MjF9.gKX9MWYGkw8Y55bgzsgrRftvUHFruIe8yu0w9Kpjp5qnrZnXcTV71WVoltGPsr015IY_oRTOkIFLHmiGNG9zBw', - requestId: 'Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21', - expiresAt: '2026-06-05T12:05:00Z', - }; - } - return authSessionResponse(entry, !isOtpVerifyType(type)); - } - if (isQuoteCreate(entry)) { - return quoteResponse(entry, 'PENDING'); - } - if (isQuoteExecute(entry)) { - return quoteResponse(entry, 'PROCESSING'); - } - if (isTransactionGet(entry)) { - return transactionResponse(entry); - } - return { status: 'ok' }; -} - +/** + * The Response tab. Every entry in the panel comes from real traffic — a proxy + * envelope or a delivered webhook — so `resBody` is what Grid actually returned. + * There is deliberately NO fallback that fabricates a plausible body: this file + * used to synthesize quotes, transactions and auth sessions for entries without + * one, which rendered as if the API had said it. + */ export function formatResponseString(entry: ApiCall): string { - return JSON.stringify(stubResponseBody(entry), null, 2); + if (entry.resBody !== undefined) return JSON.stringify(entry.resBody, null, 2); + return '// No response recorded for this entry.'; } type SyntaxClass = { @@ -382,6 +58,9 @@ type SyntaxClass = { command: string; flag: string; string: string; + /** Per-line wrapper class. Rendered as a BLOCK by the panel, which is what + * supplies the line break — so the lines carry no trailing newline. */ + line: string; }; export function highlightCurl(code: string, s: SyntaxClass): ReactNode[] { @@ -392,6 +71,14 @@ export function highlightCurl(code: string, s: SyntaxClass): ReactNode[] { let partKey = 0; while (remaining.length > 0) { + // The line-continuation backslash, glued to the word before it with a + // NBSP so a wrapped line can't orphan it onto a row of its own. Display + // only — the copy text comes from formatCurlString, which keeps the space. + if (/^ \\$/.test(remaining)) { + parts.push({'\u00a0\\'}); + break; + } + const wsMatch = remaining.match(/^(\s+)/); if (wsMatch) { parts.push({wsMatch[1]}); @@ -444,9 +131,8 @@ export function highlightCurl(code: string, s: SyntaxClass): ReactNode[] { } return ( - + {parts} - {lineIdx < lines.length - 1 ? '\n' : null} ); }); @@ -487,9 +173,8 @@ export function highlightJson(code: string, s: SyntaxClass): ReactNode[] { } return ( - + {parts} - {lineIdx < lines.length - 1 ? '\n' : null} ); }); diff --git a/components/grid-wallet-prod/src/lib/gridClient.ts b/components/grid-wallet-prod/src/lib/gridClient.ts new file mode 100644 index 000000000..1d3cc3204 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridClient.ts @@ -0,0 +1,42 @@ +'use client'; + +export interface GridEnvelope { + request: { + method: string; + path: string; + headers: Record; + body?: unknown; + }; + response: { + status: number; + body: unknown; + headers?: Record; + }; +} + +export interface GridFetchOpts { + body?: unknown; + /** Extra headers forwarded verbatim (Grid-Wallet-Signature, Request-Id, Idempotency-Key). */ + headers?: Record; +} + +/** + * Call the same-origin proxy. `path` is the Grid path (may contain the + * {customerId} placeholder), e.g. "/customers/internal-accounts?customerId={customerId}". + * Returns the {request, response} envelope; response.status mirrors Grid. + */ +export async function gridFetch( + method: 'GET' | 'POST', + path: string, + opts: GridFetchOpts = {}, +): Promise { + const res = await fetch(`/api/grid${path}`, { + method, + headers: { + ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(opts.headers ?? {}), + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + return (await res.json()) as GridEnvelope; +} diff --git a/components/grid-wallet-prod/src/lib/gridCrypto.test.ts b/components/grid-wallet-prod/src/lib/gridCrypto.test.ts new file mode 100644 index 000000000..c43c0ce12 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridCrypto.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { p256 } from '@noble/curves/nist.js'; +import { generateP256KeyPair, hpkeDecrypt } from '@turnkey/crypto'; +import { + genTek, compressedPubHex, stamp, parseStamp, verifyStamp, + encryptOtpBundle, bytesToHex, hexToBytes, base64urlToBytes, bytesToBase64url, +} from './gridCrypto'; + +// Fixed key so the browser module and the node script sign the SAME payload. +const PRIV = 'a3f1c2d4e5b6978012345678909876543210fedcba0011223344556677889900'; +const PAYLOAD = '{"type":"ACTIVITY_TYPE_SIGN_TRANSACTION_V2","timestampMs":"1770408000000"}'; +const SCRIPT = path.resolve(__dirname, '../../../../scripts/embedded-wallet-sign.js'); + +describe('gridCrypto TEK + encoding', () => { + it('genTek yields uncompressed pub (04 + 128 hex) and 64-hex priv', () => { + const { privHex, pubHex } = genTek(); + expect(privHex).toMatch(/^[0-9a-f]{64}$/); + expect(pubHex).toMatch(/^04[0-9a-f]{128}$/); + }); + it('compressedPubHex yields compressed SEC1 (02/03 + 64 hex)', () => { + expect(compressedPubHex(PRIV)).toMatch(/^0[23][0-9a-f]{64}$/); + }); + it('base64url round-trips', () => { + const b = new Uint8Array([1, 2, 3, 250, 251, 252]); + expect(Array.from(base64urlToBytes(bytesToBase64url(b)))).toEqual(Array.from(b)); + }); +}); + +describe('stamp cross-check (browser module vs scripts helper)', () => { + it('browser stamp is structurally valid and verifies', async () => { + const s = await stamp(PRIV, PAYLOAD); + const parsed = parseStamp(s); + expect(parsed.scheme).toBe('SIGNATURE_SCHEME_TK_API_P256'); + expect(parsed.publicKey).toMatch(/^0[23][0-9a-f]{64}$/); + expect(parsed.signature).toMatch(/^[0-9a-f]+$/); + expect(verifyStamp(s, PAYLOAD)).toBe(true); + }); + + it('scripts helper stamp has identical structure and verifies against the same payload', () => { + // Requires: cd ../../scripts && npm install (deps confirmed present 2026-07-22). + const out = execFileSync('node', [SCRIPT, 'stamp', PRIV, PAYLOAD], { + encoding: 'utf8', + }).trim(); + const parsed = parseStamp(out); + expect(parsed.scheme).toBe('SIGNATURE_SCHEME_TK_API_P256'); + expect(parsed.publicKey).toBe(compressedPubHex(PRIV)); // same key -> same pub + // ECDSA is randomized, so signatures differ byte-wise; assert it verifies. + expect(verifyStamp(out, PAYLOAD)).toBe(true); + }); +}); + +describe('encryptOtpBundle (HPKE round-trip + single-encoding)', () => { + it('produces a single-encoded {encappedPublic,ciphertext} envelope that HPKE-decrypts to the exact otp_code/public_key pair', () => { + // Throwaway "target" keypair standing in for the server-held key behind + // otpEncryptionTargetBundle. @turnkey/crypto's own generateP256KeyPair + // gives us privateKey + publicKeyUncompressed hex directly, so we can + // build a synthetic bundle without needing a real Grid API call. + const target = generateP256KeyPair(); + + // Shape mirrors what encryptOtpBundle parses: { data: hex(JSON({targetPublic})) } + const innerJson = JSON.stringify({ targetPublic: target.publicKeyUncompressed }); + const dataHex = bytesToHex(new TextEncoder().encode(innerJson)); + const otpEncryptionTargetBundle = JSON.stringify({ data: dataHex }); + + const tekPubHex = genTek().pubHex; + const otpCode = '000000'; + + const result = encryptOtpBundle(otpEncryptionTargetBundle, tekPubHex, otpCode); + + // Single-encoding: the returned value IS the raw {encappedPublic,ciphertext} + // JSON text (per EmailOtpCredentialVerifyRequestFields.encryptedOtpBundle's + // example in the OpenAPI spec), not a re-quoted/escaped JSON string. + // A double-encoded value would start with `"{\"` (a JSON string literal + // wrapping the object), not `{"`. + expect(result.startsWith('{"')).toBe(true); + const envelope = JSON.parse(result); + expect(typeof envelope.encappedPublic).toBe('string'); + expect(typeof envelope.ciphertext).toBe('string'); + + // Round-trip: HPKE-decrypt with the throwaway target's private key and + // confirm the recovered plaintext is exactly {otp_code, public_key}. + const decrypted = hpkeDecrypt({ + ciphertextBuf: hexToBytes(envelope.ciphertext), + encappedKeyBuf: hexToBytes(envelope.encappedPublic), + receiverPriv: target.privateKey, + }); + const plaintext = JSON.parse(new TextDecoder().decode(decrypted)); + expect(plaintext).toEqual({ otp_code: otpCode, public_key: tekPubHex }); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridCrypto.ts b/components/grid-wallet-prod/src/lib/gridCrypto.ts new file mode 100644 index 000000000..a7fac0676 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridCrypto.ts @@ -0,0 +1,119 @@ +'use client'; + +/** + * Browser wrapper over the exact primitives scripts/embedded-wallet-sign.js + * uses in Node. Runs in the client bundle (Turnkey libs are isomorphic). + * Private keys are generated and kept here; they never reach the server. + */ + +import { p256 } from '@noble/curves/nist.js'; +import { bytesToHex as nobleBytesToHex, hexToBytes as nobleHexToBytes } from '@noble/hashes/utils.js'; +import { base64urlnopad } from '@scure/base'; +import { hpkeEncrypt, formatHpkeBuf, decryptCredentialBundle } from '@turnkey/crypto'; +import { ApiKeyStamper } from '@turnkey/api-key-stamper'; + +export function bytesToHex(b: Uint8Array): string { + return nobleBytesToHex(b); +} +export function hexToBytes(hex: string): Uint8Array { + return nobleHexToBytes(hex); +} +export function base64urlToBytes(s: string): Uint8Array { + // Accept padded or unpadded base64url (WebAuthn ids are unpadded). + return base64urlnopad.decode(s.replace(/=+$/, '')); +} +export function bytesToBase64url(b: Uint8Array): string { + return base64urlnopad.encode(b); +} + +/** Ephemeral P-256 TEK. pubHex = uncompressed SEC1 (04||X||Y), privHex = d. */ +export function genTek(): { privHex: string; pubHex: string } { + const priv = p256.utils.randomSecretKey(); + return { + privHex: bytesToHex(priv), + pubHex: bytesToHex(p256.getPublicKey(priv, false)), // 04 + 64 bytes + }; +} + +/** Compressed SEC1 public key hex — the form the Grid wallet signature expects. */ +export function compressedPubHex(privHex: string): string { + return bytesToHex(p256.getPublicKey(hexToBytes(privHex), true)); +} + +/** + * HPKE-encrypt {otp_code, public_key} against the target key inside + * otpEncryptionTargetBundle. Returns the encryptedOtpBundle JSON string. + * (Mirrors scripts encrypt-otp exactly.) + */ +export function encryptOtpBundle( + otpEncryptionTargetBundle: string, + tekPubHex: string, + otpCode: string, +): string { + const clean = otpEncryptionTargetBundle.trim().replace(/^'/, '').replace(/'$/, ''); + const { data } = JSON.parse(clean) as { data: string }; + const dataJson = new TextDecoder().decode(hexToBytes(data)); + const { targetPublic } = JSON.parse(dataJson) as { targetPublic: string }; + + const plainText = JSON.stringify({ otp_code: otpCode, public_key: tekPubHex }); + const plainTextBuf = new TextEncoder().encode(plainText); + const targetKeyBuf = hexToBytes(targetPublic); + + const encryptedBuf = hpkeEncrypt({ plainTextBuf, targetKeyBuf }); + // formatHpkeBuf already returns JSON.stringify({ encappedPublic, ciphertext }) + // (confirmed by reading @turnkey/crypto's crypto.js) — this IS the + // encryptedOtpBundle string value per the OpenAPI schema's example + // ('{"encappedPublic":"...","ciphertext":"..."}'). Wrapping it in another + // JSON.stringify (as scripts/embedded-wallet-sign.js's CLI does, purely to + // print a shell-splice-safe literal) would double-encode it here, where the + // caller assigns this directly into a request body object it JSON.stringifies + // once itself. + return formatHpkeBuf(encryptedBuf); +} + +/** HPKE-open the session signing key (PASSKEY/OAUTH). Returns session priv hex. */ +export async function decryptSessionKey( + encryptedSessionSigningKey: string, + clientPrivHex: string, +): Promise { + return await decryptCredentialBundle(encryptedSessionSigningKey, clientPrivHex); +} + +/** Build the Grid-Wallet-Signature stamp over `payload` (byte-for-byte). */ +export async function stamp(sessionPrivHex: string, payload: string): Promise { + const stamper = new ApiKeyStamper({ + apiPublicKey: compressedPubHex(sessionPrivHex), + apiPrivateKey: sessionPrivHex, + }); + const { stampHeaderValue } = await stamper.stamp(payload); + return stampHeaderValue; +} + +/** Test/inspection helper: parse a stampHeaderValue into its JSON object. */ +export function parseStamp(stampHeaderValue: string): { + publicKey: string; + scheme: string; + signature: string; +} { + return JSON.parse(new TextDecoder().decode(base64urlToBytes(stampHeaderValue))); +} + +/** + * Test helper: verify a stamp's ECDSA signature over `payload`. + * @noble/curves v2's `verify(signature, message, publicKey, opts)` accepts the + * raw DER-encoded signature bytes directly (format: 'der') and hashes + * `message` internally with the curve's bound hash (sha256 for p256) when + * `prehash` is true (the default) — so no manual sha256/Signature.fromDER + * step is needed here, unlike the v1 API scripts/embedded-wallet-sign.js + * was written against. + * `lowS: false` because Node's `crypto.sign()` (used by both the script's + * `stamp` command and ApiKeyStamper's node runtime) does not canonicalize + * signatures to low-S; @noble/curves v2 defaults `lowS: true` and would + * reject ~50% of otherwise-valid signatures. + */ +export function verifyStamp(stampHeaderValue: string, payload: string): boolean { + const { publicKey, signature } = parseStamp(stampHeaderValue); + const sigBytes = hexToBytes(signature); + const message = new TextEncoder().encode(payload); + return p256.verify(sigBytes, message, hexToBytes(publicKey), { format: 'der', lowS: false }); +} diff --git a/components/grid-wallet-prod/src/lib/gridEntry.ts b/components/grid-wallet-prod/src/lib/gridEntry.ts new file mode 100644 index 000000000..503d57376 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridEntry.ts @@ -0,0 +1,29 @@ +import type { ApiCall } from '@/data/flow'; +import type { GridEnvelope } from './gridClient'; + +const STATUS_TEXT: Record = { + 200: 'OK', 201: 'Created', 202: 'Accepted', 400: 'Bad Request', + 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', + 429: 'Too Many Requests', 500: 'Internal Server Error', 502: 'Bad Gateway', +}; + +/** Map a proxy {request,response} envelope to the panel's ApiCall shape. */ +export function envelopeToApiCall(env: GridEnvelope, title?: string): ApiCall { + const method = env.request.method === 'GET' ? 'GET' : 'POST'; + const path = env.request.path.split('?')[0]; // panel shows the clean path + const code = env.response.status; + // Drop the redacted Authorization; the curl formatter re-adds "Basic $GRID_KEY". + const headers = { ...env.request.headers }; + delete headers.Authorization; + delete (headers as Record).authorization; + return { + method, + path, + title, + headers: Object.keys(headers).length ? headers : undefined, + reqBody: (env.request.body as Record | undefined) ?? undefined, + resBody: env.response.body, + realStatus: code, + status: `${code} ${STATUS_TEXT[code] ?? ''}`.trim(), + }; +} diff --git a/components/grid-wallet-prod/src/lib/gridEnv.ts b/components/grid-wallet-prod/src/lib/gridEnv.ts new file mode 100644 index 000000000..f78caee3e --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridEnv.ts @@ -0,0 +1,14 @@ +/** + * Which Grid environment the demo is pointed at. The base URL is the same for + * both — sandbox vs production is decided by the API key — so it can't be + * sniffed at runtime and has to be declared. + * + * Set `NEXT_PUBLIC_GRID_SANDBOX=true` alongside sandbox keys. It gates demo + * affordances that must NEVER run against real money: today, the simulated + * inbound bank deposit on the Add money screen (a real deployment waits for a + * real wire instead). + */ +export const IS_SANDBOX = process.env.NEXT_PUBLIC_GRID_SANDBOX === 'true'; + +/** Amount the sandbox pretends arrived by wire, so Add money still completes. */ +export const SANDBOX_DEPOSIT_CENTS = 2500; diff --git a/components/grid-wallet-prod/src/lib/gridFunding.test.ts b/components/grid-wallet-prod/src/lib/gridFunding.test.ts new file mode 100644 index 000000000..e5fc51581 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridFunding.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { platformFundAmountForCents, onRampQuoteBodyFor } from './gridFunding'; + +describe('platformFundAmountForCents', () => { + it('maps cents 1:1 to the USD platform account smallest unit (no 6-decimal conversion)', () => { + expect(platformFundAmountForCents(200)).toEqual({ amount: 200 }); + expect(platformFundAmountForCents(5000)).toEqual({ amount: 5000 }); + expect(platformFundAmountForCents(0)).toEqual({ amount: 0 }); + }); +}); + +describe('onRampQuoteBodyFor', () => { + it('builds a platform -> customer-wallet USD->USDB quote locked on the sending (USD) side', () => { + expect(onRampQuoteBodyFor('InternalAccount:platform', 'InternalAccount:customer', 2000)).toEqual({ + source: { sourceType: 'ACCOUNT', accountId: 'InternalAccount:platform' }, + destination: { + destinationType: 'ACCOUNT', + accountId: 'InternalAccount:customer', + currency: 'USDB', + }, + lockedCurrencySide: 'SENDING', + lockedCurrencyAmount: 2000, + }); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridFunding.ts b/components/grid-wallet-prod/src/lib/gridFunding.ts new file mode 100644 index 000000000..42e1c8bbb --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridFunding.ts @@ -0,0 +1,92 @@ +'use client'; + +import { gridFetch } from './gridClient'; +import type { LogFn } from './gridSession'; +import type { QuoteBody } from './gridTransfer'; + +/** + * Sandbox fund body for the PLATFORM's USD internal account. USD's smallest + * unit is cents, so the app's "cents" map straight through — unlike the + * embedded wallet's own USDB balance (6 decimals, see gridUnits.ts), there is + * no conversion here. + */ +export function platformFundAmountForCents(cents: number): { amount: number } { + return { amount: cents }; +} + +// Resolved once per session (module-level — the platform account doesn't +// change between "Add money" runs). +let platformUsdAccountId: string | null = null; + +/** + * Resolve (and cache) the platform's USD internal account id — the real + * money source for "Add money"'s on-ramp leg. `POST /sandbox/.../fund` only + * mints BOOK balance on the customer's own wallet (no on-chain USDB), so + * outbound quotes from it fail with INSUFFICIENT_FUNDS; funding the platform + * account and on-ramping from it into the customer's wallet is the only way + * to land real, spendable balance in sandbox. + */ +export async function resolvePlatformUsdAccountId(log: LogFn): Promise { + if (platformUsdAccountId) return platformUsdAccountId; + const env = await gridFetch('GET', '/platform/internal-accounts?currency=USD'); + log(env); + if (env.response.status !== 200) { + throw new Error(`list platform internal accounts: ${env.response.status}`); + } + const body = env.response.body as { data: { id: string }[] }; + const acct = body.data[0]; + if (!acct) throw new Error('No platform USD internal account found'); + platformUsdAccountId = acct.id; + return acct.id; +} + +/** Sandbox-fund the platform's USD account (the on-ramp's source leg). */ +export async function sandboxFundPlatform( + platformAccountId: string, + cents: number, + log: LogFn, +): Promise<{ ok: boolean; status: number }> { + const env = await gridFetch('POST', `/sandbox/internal-accounts/${platformAccountId}/fund`, { + body: platformFundAmountForCents(cents), + }); + log(env); + return { ok: env.response.status === 200, status: env.response.status }; +} + +/** + * Platform -> customer-wallet on-ramp quote: USD in (2 decimals, same cents + * as the fund above), USDB out to the customer's embedded wallet. Executing + * this quote (see gridTransfer.executeQuoteUnsigned) needs no wallet + * signature — the source is the platform account, not the customer's. + */ +export function onRampQuoteBodyFor( + platformAccountId: string, + customerAccountId: string, + cents: number, +): QuoteBody { + return { + source: { sourceType: 'ACCOUNT', accountId: platformAccountId }, + destination: { destinationType: 'ACCOUNT', accountId: customerAccountId, currency: 'USDB' }, + lockedCurrencySide: 'SENDING', + lockedCurrencyAmount: cents, + }; +} + +/** + * SANDBOX ONLY: stand in for the customer pushing funds to a quote that sources + * from their external account (which can't be executed — see pullQuoteBodyFor). + * This is Grid's own affordance for it, named in the execute error, and it + * settles the quote's transaction the same way a real wire would. + */ +export async function sandboxSendForQuote( + quoteId: string, + currencyCode: string, + cents: number, + log: LogFn, +): Promise<{ ok: boolean; status: number }> { + const env = await gridFetch('POST', '/sandbox/send', { + body: { quoteId, currencyCode, currencyAmount: cents }, + }); + log(env); + return { ok: env.response.status === 200, status: env.response.status }; +} diff --git a/components/grid-wallet-prod/src/lib/gridReads.test.ts b/components/grid-wallet-prod/src/lib/gridReads.test.ts new file mode 100644 index 000000000..56495e1c8 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridReads.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { gridFetch } from './gridClient'; +import { + transactionToRow, + fetchActivity, + fetchDepositInstructions, + fundingInstructionRows, + type RawTransaction, +} from './gridReads'; + +vi.mock('./gridClient', () => ({ gridFetch: vi.fn() })); + +const mockedFetch = vi.mocked(gridFetch); +const usdb = (amount: number) => ({ amount, currency: { code: 'USDB', decimals: 6 } }); +const mxn = (amount: number) => ({ amount, currency: { code: 'MXN', decimals: 2 } }); + +describe('transactionToRow', () => { + it('maps an inbound credit to a "+" row with its real id and time', () => { + const t: RawTransaction = { + id: 'Transaction:1', + type: 'INCOMING', + direction: 'CREDIT', + status: 'COMPLETED', + createdAt: '2026-07-24T17:17:28.405573Z', + receivedAmount: usdb(5_000_000), + }; + const row = transactionToRow(t); + expect(row.id).toBe('Transaction:1'); + expect(row.amount).toBe('+$5.00'); + expect(row.detail).toBe('Added to balance'); + expect(row.timestamp).toBe(Date.parse('2026-07-24T17:17:28.405573Z')); + }); + + it('shows an outbound cash-out at what LEFT the wallet, noting the payout currency', () => { + const t: RawTransaction = { + id: 'Transaction:2', + type: 'OUTGOING', + direction: 'DEBIT', + status: 'COMPLETED', + createdAt: '2026-07-24T17:35:47.038270Z', + sentAmount: usdb(5_000_000), + receivedAmount: mxn(8389), + }; + const row = transactionToRow(t); + expect(row.amount).toBe('$5.00'); // no "+", and not the MXN leg + expect(row.detail).toBe('Sent as MXN'); + }); + + it('surfaces a non-settled status in the detail line', () => { + const base: RawTransaction = { + id: 'Transaction:3', + type: 'OUTGOING', + direction: 'DEBIT', + status: 'PROCESSING', + sentAmount: usdb(1_500_000), + }; + expect(transactionToRow(base).detail).toBe('Sent from balance · Processing'); + expect(transactionToRow({ ...base, status: 'FAILED' }).detail).toBe('Sent from balance · Failed'); + }); + + it('prefers the counterparty name when Grid supplies one', () => { + const t: RawTransaction = { + id: 'Transaction:4', + type: 'INCOMING', + direction: 'CREDIT', + status: 'COMPLETED', + receivedAmount: usdb(2_000_000), + counterpartyInformation: { FULL_NAME: 'Pat Teehantri' }, + }; + expect(transactionToRow(t).title).toBe('Pat Teehantri'); + }); + + it('falls back to type when direction is absent', () => { + const t: RawTransaction = { + id: 'Transaction:5', + type: 'INCOMING', + status: 'COMPLETED', + receivedAmount: usdb(0), + }; + expect(transactionToRow(t).amount).toBe('+$0.00'); + }); +}); + +describe('fetchDepositInstructions', () => { + beforeEach(() => mockedFetch.mockReset()); + + const reply = (status: number, body: unknown) => + mockedFetch.mockResolvedValue({ + request: { method: 'GET' as const, path: '/customers/internal-accounts', headers: {} }, + response: { status, body }, + }); + + // Exactly the shape the live sandbox returns for the USD fiat account. + it('turns the fiat account funding instruction into display rows', async () => { + reply(200, { + data: [ + { + id: 'InternalAccount:1', + balance: { currency: { code: 'USD' } }, + fundingPaymentInstructions: [ + { + accountOrWalletInfo: { + accountType: 'USD_ACCOUNT', + accountNumber: '7752061236', + routingNumber: '943515368', + paymentRails: ['ACH', 'WIRE', 'RTP', 'FEDNOW'], + reference: 'InternalAccount:1', + }, + instructionsNotes: 'Include the reference code in the memo', + }, + ], + }, + ], + }); + + const d = await fetchDepositInstructions(() => {}); + expect(d?.sections).toEqual([ + { + label: 'USD', + rows: [ + ['Account number', '7752061236'], + ['Routing number', '943515368'], + ['Rails', 'ACH · WIRE · RTP · FEDNOW'], + ['Reference', 'InternalAccount:1'], + ], + note: 'Include the reference code in the memo', + }, + ]); + expect(d?.last4).toBe('1236'); // labels the simulated deposit's activity row + }); + + // When the customer gets a real EUR account, its IBAN must appear with no code + // change — that's what retires data/placeholderDeposit. + it('returns one section per fiat account', async () => { + reply(200, { + data: [ + { + id: 'InternalAccount:1', + balance: { currency: { code: 'USD' } }, + fundingPaymentInstructions: [{ accountOrWalletInfo: { accountNumber: '7752061236' } }], + }, + { + id: 'InternalAccount:2', + balance: { currency: { code: 'EUR' } }, + fundingPaymentInstructions: [{ accountOrWalletInfo: { iban: 'DE89370400440532013000' } }], + }, + ], + }); + const d = await fetchDepositInstructions(() => {}); + expect(d?.sections.map((x) => x.label)).toEqual(['USD', 'EUR']); + expect(d?.last4).toBe('1236'); // the first account's, where the sandbox deposit lands + }); + + it('is null when the account has no funding instructions to show', async () => { + reply(200, { + data: [{ id: 'InternalAccount:1', balance: { currency: { code: 'USD' } } }], + }); + expect(await fetchDepositInstructions(() => {})).toBeNull(); + }); + + // Nothing is invented: a field Grid didn't send doesn't appear as a row. + it('omits rows Grid did not return', async () => { + expect(fundingInstructionRows({ accountOrWalletInfo: { iban: 'DE89370400440532013000' } })).toEqual([ + ['IBAN', 'DE89370400440532013000'], + ]); + expect(fundingInstructionRows({})).toEqual([]); + }); +}); + +describe('fetchActivity', () => { + beforeEach(() => mockedFetch.mockReset()); + + const envelope = (status: number, body: unknown) => ({ + request: { method: 'GET' as const, path: '/transactions', headers: {} }, + response: { status, body }, + }); + + // EXPIRED transactions are quotes that timed out — no money moved, so they + // must not appear as history. + it('drops EXPIRED rows and orders the rest newest first', async () => { + mockedFetch.mockResolvedValue( + envelope(200, { + data: [ + { id: 'Transaction:old', type: 'INCOMING', direction: 'CREDIT', status: 'COMPLETED', createdAt: '2026-07-24T10:00:00Z', receivedAmount: usdb(1_000_000) }, + { id: 'Transaction:expired', type: 'OUTGOING', direction: 'DEBIT', status: 'EXPIRED', createdAt: '2026-07-24T12:00:00Z', sentAmount: usdb(2_000_000) }, + { id: 'Transaction:new', type: 'OUTGOING', direction: 'DEBIT', status: 'COMPLETED', createdAt: '2026-07-24T18:00:00Z', sentAmount: usdb(3_000_000) }, + ], + }), + ); + const rows = await fetchActivity(() => {}); + expect(rows.map((r) => r.id)).toEqual(['Transaction:new', 'Transaction:old']); + }); + + it('returns nothing when the read fails, rather than throwing at the caller', async () => { + mockedFetch.mockResolvedValue(envelope(403, { error: { code: 'PROXY_NOT_ALLOWED' } })); + expect(await fetchActivity(() => {})).toEqual([]); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridReads.ts b/components/grid-wallet-prod/src/lib/gridReads.ts new file mode 100644 index 000000000..5138f730b --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridReads.ts @@ -0,0 +1,283 @@ +'use client'; + +// Type-only, and from the leaf module (apps/shared/wallet/types imports nothing) +// so the wallet brain's row shape can be built here without an import cycle. +import type { WalletListItemData } from '@/apps/shared/wallet/types'; +import { gridFetch } from './gridClient'; +import type { LogFn } from './gridSession'; +import { amountToCents, formatCents } from './gridUnits'; + +const CUSTOMER = '{customerId}'; + +export interface RawCurrencyAmount { + amount: number; + currency: { code: string; decimals: number }; +} +export interface RawTransaction { + id: string; + type: 'INCOMING' | 'OUTGOING'; + direction?: 'CREDIT' | 'DEBIT'; + status: string; + createdAt?: string; + sentAmount?: RawCurrencyAmount; + receivedAmount?: RawCurrencyAmount; + // Grid's CounterpartyInformation is a free-form key/value bag + // (openapi/components/schemas/transactions/CounterpartyInformation.yaml); + // FULL_NAME is the field the API actually populates for a person's name. + counterpartyInformation?: { FULL_NAME?: string }; +} + +export interface RawFundingInstruction { + accountOrWalletInfo?: { + accountType?: string; + accountNumber?: string; + routingNumber?: string; + iban?: string; + reference?: string; + paymentRails?: string[]; + beneficiaryName?: string; + /** Crypto rails: the on-chain address funds are sent to. */ + address?: string; + assetType?: string; + }; + instructionsNotes?: string; +} + +/** A real on-chain deposit address, one per network Grid provisioned. */ +export interface DepositWallet { + /** Network id matching the UI's chain list ('base' | 'solana' | …). */ + network: string; + accountType: string; + address: string; + asset: string; +} + +/** accountType → the chain id the wallet UI uses. */ +const WALLET_NETWORKS: Record = { + SOLANA_WALLET: 'solana', + BASE_WALLET: 'base', + ETHEREUM_WALLET: 'ethereum', + POLYGON_WALLET: 'polygon', + SPARK_WALLET: 'spark', + TRON_WALLET: 'tron', + BITCOIN_WALLET: 'btc', +}; + +/** One currency's inbound details — one per fiat account Grid returns. */ +export interface DepositSection { + /** Currency code, shown above the rows when there's more than one section. */ + label: string; + /** Label/value rows, exactly the fields Grid returned — nothing invented. */ + rows: Array<[string, string]>; + /** Grid's own note (e.g. "include the reference in the memo"), when present. */ + note: string | null; + /** Stand-in values, not read from Grid (see data/placeholderDeposit). */ + placeholder?: boolean; +} + +/** Where money comes IN: the customer's own accounts, ready to display. */ +export interface DepositInstructions { + accountId: string; + /** Bank details, one section per fiat account. */ + sections: DepositSection[]; + /** On-chain addresses, one per network on the crypto account. */ + wallets: DepositWallet[]; + /** Trailing digits of the account the deposit lands in (activity row label). */ + last4: string; +} + +/** Pure: one fiat account's funding instruction -> display rows. */ +export function fundingInstructionRows(inst: RawFundingInstruction): Array<[string, string]> { + const info = inst.accountOrWalletInfo ?? {}; + const rows: Array<[string, string]> = []; + if (info.beneficiaryName) rows.push(['Beneficiary', info.beneficiaryName]); + if (info.accountNumber) rows.push(['Account number', info.accountNumber]); + if (info.routingNumber) rows.push(['Routing number', info.routingNumber]); + if (info.iban) rows.push(['IBAN', info.iban]); + if (info.paymentRails?.length) rows.push(['Rails', info.paymentRails.join(' · ')]); + if (info.reference) rows.push(['Reference', info.reference]); + return rows; +} + +/** + * Everything the customer can be paid into: bank details per fiat account, and + * on-chain addresses per network on the crypto account. All real values from Grid + * — the demo displays them and invents nothing. Null when there's nothing to show. + */ +export async function fetchDepositInstructions(log: LogFn): Promise { + const env = await gridFetch('GET', `/customers/internal-accounts?customerId=${CUSTOMER}`); + log(env); + if (env.response.status !== 200) return null; + const body = env.response.body as { + data: { + id: string; + balance: { currency: { code: string } }; + fundingPaymentInstructions?: RawFundingInstruction[]; + }[]; + }; + const sections: DepositSection[] = []; + const wallets: DepositWallet[] = []; + let accountId = ''; + let last4 = ''; + for (const account of body.data ?? []) { + for (const inst of account.fundingPaymentInstructions ?? []) { + const info = inst.accountOrWalletInfo ?? {}; + // Crypto rails carry an address; bank rails carry account identifiers. + if (info.address) { + const network = info.accountType ? WALLET_NETWORKS[info.accountType] : undefined; + if (network) { + wallets.push({ + network, + accountType: info.accountType!, + address: info.address, + asset: info.assetType ?? account.balance.currency.code, + }); + } + continue; + } + const rows = fundingInstructionRows(inst); + if (!rows.length) continue; + sections.push({ + label: account.balance.currency.code, + rows, + note: inst.instructionsNotes ?? null, + }); + if (!accountId) { + accountId = account.id; + const number = info.accountNumber ?? info.iban ?? ''; + last4 = number.replace(/\s/g, '').slice(-4); + } + } + } + return sections.length || wallets.length ? { accountId, sections, wallets, last4 } : null; +} + +/** + * The USDB embedded wallet's two figures. `balance` is spendable; `totalBalance` + * is the book total, which in sandbox runs ahead of it because + * `POST /sandbox/internal-accounts/{id}/fund` mints book balance that can't be + * moved. The wallet leads with spendable so it never promises money a transfer + * would reject, and surfaces the gap separately. + */ +export interface WalletBalance { + spendableCents: number; + totalCents: number; +} + +export async function fetchBalance(log: LogFn): Promise { + const env = await gridFetch( + 'GET', + `/customers/internal-accounts?customerId=${CUSTOMER}&type=EMBEDDED_WALLET`, + ); + log(env); + if (env.response.status !== 200) return { spendableCents: 0, totalCents: 0 }; + const body = env.response.body as { + data: { + balance: { amount: number; currency: { decimals: number } }; + totalBalance?: { amount: number; currency: { decimals: number } }; + }[]; + }; + const account = body.data[0]; + const b = account?.balance; + const t = account?.totalBalance; + const spendableCents = b ? amountToCents(b.amount, b.currency.decimals) : 0; + return { + spendableCents, + totalCents: t ? amountToCents(t.amount, t.currency.decimals) : spendableCents, + }; +} + +export async function fetchBalanceCents(log: LogFn): Promise { + return (await fetchBalance(log)).spendableCents; +} + +/** + * Which transactions belong on the phone's Activity list. EXPIRED is a quote + * that timed out before execution — no money ever moved, and a real wallet + * doesn't list abandoned attempts. Everything else (settled, in flight, failed) + * is a real event the customer would expect to see. + */ +export function isDisplayableTransaction(t: RawTransaction): boolean { + return t.status !== 'EXPIRED'; +} + +/** Status → the row's detail suffix. COMPLETED reads as the action itself. */ +function statusLabel(status: string): string | null { + if (status === 'COMPLETED') return null; + if (status === 'PENDING' || status === 'PROCESSING') return 'Processing'; + // FAILED / REJECTED / REFUNDED / anything new: show it as-is, sentence case. + return status.charAt(0) + status.slice(1).toLowerCase(); +} + +/** Pure: one Grid transaction -> an Activity row (the shape every skin renders). */ +export function transactionToRow(t: RawTransaction): WalletListItemData { + const credit = t.direction ? t.direction === 'CREDIT' : t.type === 'INCOMING'; + // Inbound: what landed. Outbound: what left the wallet (the destination leg + // can be another currency entirely — a USDB → MXN cash-out). + const money = credit ? t.receivedAmount : t.sentAmount; + const cents = money ? amountToCents(money.amount, money.currency.decimals) : 0; + const settledAs = credit ? undefined : t.receivedAmount?.currency.code; + const converted = settledAs && settledAs !== t.sentAmount?.currency.code ? settledAs : null; + + const action = credit ? 'Added to balance' : converted ? `Sent as ${converted}` : 'Sent from balance'; + const status = statusLabel(t.status); + return { + id: t.id, + // No merchant, brand or person on these — the tile shows the direction. + flow: credit ? 'in' : 'out', + title: t.counterpartyInformation?.FULL_NAME ?? (credit ? 'Money added' : 'Money sent'), + detail: status ? `${action} · ${status}` : action, + // Inbound carries the "+"; outbound shows the plain amount (makeTransferRow's + // convention, so server rows and in-session rows read identically). + amount: credit ? `+${formatCents(cents)}` : formatCents(cents), + timestamp: t.createdAt ? Date.parse(t.createdAt) : 0, + }; +} + +export async function fetchActivity(log: LogFn): Promise { + const env = await gridFetch('GET', `/transactions?customerId=${CUSTOMER}&limit=20`); + log(env); + if (env.response.status !== 200) return []; + const body = env.response.body as { data: RawTransaction[] }; + return (body.data ?? []) + .filter(isDisplayableTransaction) + .map(transactionToRow) + .sort((a, b) => b.timestamp - a.timestamp); +} + +/** One of the customer's registered payout/funding accounts, as Grid stores it. */ +export interface RawExternalAccount { + id: string; + currency?: string; + status?: string; + accountInfo?: { + accountType?: string; + accountNumber?: string; + routingNumber?: string; + iban?: string; + beneficiary?: { fullName?: string }; + }; +} + +/** + * The customer's external accounts, so the saved-banks list shows what Grid + * actually holds instead of only what was added this session. Filtered to the + * corridors this wallet settles (USD/EUR) and to ACTIVE accounts — the customer + * may have older accounts on rails the app no longer offers, and a row that + * can't be quoted is worse than no row. Newest first. + */ +export async function fetchExternalAccounts(log: LogFn): Promise { + const env = await gridFetch('GET', `/customers/external-accounts?customerId=${CUSTOMER}`); + log(env); + if (env.response.status !== 200) return []; + const body = env.response.body as { data: RawExternalAccount[] }; + const supported = new Set(['USD_ACCOUNT', 'EUR_ACCOUNT']); + return (body.data ?? []) + .filter( + (a) => + (a.status ?? 'ACTIVE') === 'ACTIVE' && + a.accountInfo?.accountType && + supported.has(a.accountInfo.accountType), + ) + .reverse(); +} diff --git a/components/grid-wallet-prod/src/lib/gridSession.test.ts b/components/grid-wallet-prod/src/lib/gridSession.test.ts new file mode 100644 index 000000000..96d0f1925 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridSession.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { gridFetch } from './gridClient'; +import { pickCredentials, signIn, addPasskey, clearSession, getSession } from './gridSession'; + +// Mocked at the system boundaries only: the Grid network (gridClient) and the +// HPKE/Turnkey crypto wrapper (gridCrypto). Everything in between is the real +// sign-in flow. +vi.mock('./gridClient', () => ({ gridFetch: vi.fn() })); +vi.mock('./gridCrypto', () => ({ + genTek: () => ({ privHex: 'tek-priv', pubHex: 'tek-pub' }), + compressedPubHex: () => 'tek-pub', + encryptOtpBundle: () => 'encrypted-otp-bundle', + decryptSessionKey: async () => 'passkey-session-priv', + base64urlToBytes: () => new Uint8Array([1, 2, 3]), + bytesToBase64url: () => 'b64url', + stamp: async () => 'stamp-signature', +})); + +const mockedFetch = vi.mocked(gridFetch); +const FUTURE = '2099-01-01T00:00:00Z'; + +type Cred = { id: string; type: string; nickname?: string; credentialId?: string }; + +const EMAIL_CRED: Cred = { + id: 'AuthMethod:email', + type: 'EMAIL_OTP', + nickname: 'pat@example.com', +}; +const PASSKEY_CRED: Cred = { + id: 'AuthMethod:passkey', + type: 'PASSKEY', + credentialId: 'b64url', +}; + +function envelope(method: string, path: string, status: number, body: unknown) { + return { request: { method: method as 'GET', path, headers: {} }, response: { status, body } }; +} + +/** Stands in for Grid: routes each call to a canned response. */ +function serveGrid(credentials: Cred[]) { + const creds = [...credentials]; + mockedFetch.mockImplementation(async (method: string, path: string, init?: unknown) => { + const opts = (init ?? {}) as { body?: Record; headers?: Record }; + const signed = Boolean(opts.headers?.['Grid-Wallet-Signature']); + const reply = (status: number, body: unknown) => envelope(method, path, status, body); + + if (method === 'GET' && path.startsWith('/customers/internal-accounts')) { + return reply(200, { + data: [ + { + id: 'InternalAccount:1', + customerId: 'Customer:1', + balance: { amount: 1_500_000, currency: { decimals: 6 } }, + }, + ], + }); + } + if (method === 'GET' && path.startsWith('/auth/credentials')) { + return reply(200, { data: creds }); + } + // Register a new credential: 202 challenge, then 201 once signed. + if (method === 'POST' && path === '/auth/credentials') { + if (!signed) return reply(202, { payloadToSign: 'payload', requestId: 'req-create' }); + creds.push(PASSKEY_CRED); + return reply(201, { id: PASSKEY_CRED.id, credentialId: PASSKEY_CRED.credentialId }); + } + if (method === 'POST' && path.endsWith('/challenge')) { + // The passkey challenge seals a client key; the email one mails a code. + return opts.body && 'clientPublicKey' in opts.body + ? reply(200, { credentialId: 'b64url', challenge: 'abc123', requestId: 'req-passkey' }) + : reply(200, { otpEncryptionTargetBundle: 'target-bundle' }); + } + if (method === 'POST' && path.endsWith('/verify')) { + if (opts.body?.type === 'PASSKEY') { + return reply(200, { encryptedSessionSigningKey: 'sealed', expiresAt: FUTURE }); + } + // EMAIL_OTP verify is two legs: 202 payloadToSign, then 200 once stamped. + return signed + ? reply(200, { expiresAt: FUTURE }) + : reply(202, { payloadToSign: 'payload', requestId: 'req-verify' }); + } + throw new Error(`unrouted call: ${method} ${path}`); + }); +} + +/** A browser with (or without) a passkey previously registered on THIS device. */ +function stubDevice(storedPasskey?: string) { + const store = new Map(); + if (storedPasskey) store.set('grid.passkey.InternalAccount:1', storedPasskey); + Object.defineProperty(globalThis, 'window', { + value: { + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + }, + }, + configurable: true, + writable: true, + }); + return store; +} + +/** WebAuthn + rp-id stubs (the platform boundary registerPasskey/passkeyAuth sit on). */ +function stubWebAuthn() { + const buf = () => new ArrayBuffer(8); + const create = vi.fn(async () => ({ + rawId: buf(), + response: { clientDataJSON: buf(), attestationObject: buf(), getTransports: () => ['internal'] }, + })); + const get = vi.fn(async () => ({ + rawId: buf(), + response: { clientDataJSON: buf(), authenticatorData: buf(), signature: buf(), userHandle: null }, + })); + Object.defineProperty(globalThis, 'navigator', { + value: { credentials: { create, get } }, + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, 'location', { + value: { hostname: 'localhost' }, + configurable: true, + writable: true, + }); + return { create, get }; +} + +function callsTo(method: string, match: (path: string) => boolean): number { + return mockedFetch.mock.calls.filter( + (c) => c[0] === method && match(String(c[1])), + ).length; +} + +function callbacks(overrides: Partial[0]> = {}) { + return { + log: () => {}, + promptOtp: async () => '000000', + ...overrides, + } as Parameters[0]; +} + +describe('pickCredentials', () => { + it('finds EMAIL_OTP and first PASSKEY', () => { + const r = pickCredentials([ + { id: 'AuthMethod:1', type: 'EMAIL_OTP' }, + { id: 'AuthMethod:2', type: 'PASSKEY' }, + { id: 'AuthMethod:3', type: 'PASSKEY' }, + ]); + expect(r.emailOtpId).toBe('AuthMethod:1'); + expect(r.passkeyId).toBe('AuthMethod:2'); + }); + it('returns nulls when absent', () => { + const r = pickCredentials([{ id: 'AuthMethod:9', type: 'OAUTH' }]); + expect(r.emailOtpId).toBeNull(); + expect(r.passkeyId).toBeNull(); + expect(r.emailOtpNickname).toBeNull(); + }); + it('reads the email on file from the EMAIL_OTP nickname', () => { + const r = pickCredentials([ + { id: 'AuthMethod:1', type: 'EMAIL_OTP', nickname: 'pat@example.com' }, + ]); + expect(r.emailOtpNickname).toBe('pat@example.com'); + }); +}); + +describe('sign-in order', () => { + beforeEach(() => { + mockedFetch.mockReset(); + clearSession(); + stubWebAuthn(); + stubDevice(); // a fresh browser unless a test says otherwise + }); + + // A Global Account is born with ONLY an EMAIL_OTP credential, so the first + // sign-in has to run on that alone — no passkey, no credential registration. + it('first sign-in uses email OTP and registers nothing', async () => { + serveGrid([EMAIL_CRED]); + const session = await signIn(callbacks()); + + expect(session.via).toBe('email_otp'); + expect(callsTo('POST', (p) => p === '/auth/credentials')).toBe(0); + }); + + it('offers the email on file to the entry step before mailing a code', async () => { + serveGrid([EMAIL_CRED]); + const seen: (string | null)[] = []; + await signIn( + callbacks({ + promptEmail: async (onFile) => { + // The code must not have gone out yet when the entry step is up. + expect(callsTo('POST', (p) => p.endsWith('/challenge'))).toBe(0); + seen.push(onFile); + return onFile ?? ''; + }, + }), + ); + + expect(seen).toEqual(['pat@example.com']); + expect(callsTo('POST', (p) => p.endsWith('/challenge'))).toBe(1); + }); + + it('adding a passkey is a signed action authorized by the live session', async () => { + serveGrid([EMAIL_CRED]); + await signIn(callbacks()); + mockedFetch.mockClear(); + const promptOtp = vi.fn(async () => 'unused'); + + await addPasskey(callbacks({ promptOtp })); + + // Registered (202 → signed retry → 201) and nothing else: no re-prompt, and + // no challenge/verify on the new credential — the session that authorized + // the change is still the session you're in. + expect(callsTo('POST', (p) => p === '/auth/credentials')).toBe(2); + expect(promptOtp).not.toHaveBeenCalled(); + expect(callsTo('POST', (p) => p.endsWith('/challenge') || p.endsWith('/verify'))).toBe(0); + expect(getSession()?.via).toBe('email_otp'); + }); + + // One device ceremony per tap. Two (register + immediately authenticate) makes + // the user approve twice for a single action. + it('prompts the authenticator exactly once', async () => { + serveGrid([EMAIL_CRED]); + const webauthn = stubWebAuthn(); + await signIn(callbacks()); + await addPasskey(callbacks()); + + expect(webauthn.create).toHaveBeenCalledTimes(1); + expect(webauthn.get).not.toHaveBeenCalled(); + }); + + // Browsers require credentials.create() to run under the user activation from + // the tap, so it must not sit behind a re-auth round-trip. + it('runs the ceremony before re-authenticating a lapsed session', async () => { + serveGrid([EMAIL_CRED]); + await signIn(callbacks()); + clearSession(); // 15-minute session expired while the wallet sat open + const order: string[] = []; + mockedFetch.mockClear(); + const webauthn = stubWebAuthn(); + webauthn.create.mockImplementation(async () => { + order.push('ceremony'); + return { + rawId: new ArrayBuffer(8), + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + getTransports: () => [], + }, + }; + }); + const promptOtp = vi.fn(async () => { + order.push('otp'); + return '000000'; + }); + + await addPasskey(callbacks({ promptOtp })); + + expect(order).toEqual(['ceremony', 'otp']); + expect(promptOtp).toHaveBeenCalledTimes(1); // re-auth happened, once + expect(callsTo('POST', (p) => p === '/auth/credentials')).toBe(2); + }); + + it('signs in with the passkey once THIS device has one', async () => { + stubDevice(PASSKEY_CRED.credentialId); + serveGrid([EMAIL_CRED, PASSKEY_CRED]); + const promptOtp = vi.fn(async () => '000000'); + const session = await signIn(callbacks({ promptOtp })); + + expect(session.via).toBe('passkey'); + expect(promptOtp).not.toHaveBeenCalled(); + }); + + // Passkeys are device-bound: one registered on another device is listed on the + // account but unusable here, so a new browser must fall back to the email OTP + // rather than launching a WebAuthn assertion that can never resolve. + it('ignores a passkey this device did not register', async () => { + serveGrid([EMAIL_CRED, PASSKEY_CRED]); + const session = await signIn(callbacks()); + + expect(session.via).toBe('email_otp'); + }); + + it('remembers the passkey it just added for the next sign-in', async () => { + const store = stubDevice(); + serveGrid([EMAIL_CRED]); + await signIn(callbacks()); + await addPasskey(callbacks()); + clearSession(); + + expect(store.get('grid.passkey.InternalAccount:1')).toBe(PASSKEY_CRED.credentialId); + expect((await signIn(callbacks())).via).toBe('passkey'); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridSession.ts b/components/grid-wallet-prod/src/lib/gridSession.ts new file mode 100644 index 000000000..8ec3b2ab4 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridSession.ts @@ -0,0 +1,418 @@ +'use client'; + +import { gridFetch, type GridEnvelope } from './gridClient'; +import { + genTek, compressedPubHex, encryptOtpBundle, decryptSessionKey, + base64urlToBytes, bytesToBase64url, +} from './gridCrypto'; +import { amountToCents, USDB_DECIMALS } from './gridUnits'; + +export type LogFn = (env: GridEnvelope) => void; + +export interface AuthCallbacks { + /** Resolves with the OTP code the user typed (sandbox magic code "000000"). */ + promptOtp: () => Promise; + /** Every real request/response is handed here so the panel can render it. */ + log: LogFn; + /** Optional: play the Face ID animation around the WebAuthn assertion. */ + onFaceId?: () => Promise; + /** + * Optional email-entry step shown BEFORE the OTP is sent. Receives the + * address the EMAIL_OTP credential is actually tied to (the credential's + * nickname) so the field can be prefilled truthfully — Grid mails the code to + * the address on file regardless of what's typed here, so the prefill is what + * makes the step honest. + */ + promptEmail?: (emailOnFile: string | null) => Promise; +} + +export interface WalletAccount { + customerId: string; + accountId: string; + emailOtpCredentialId: string; + /** Address the EMAIL_OTP credential is tied to (its `nickname`). */ + email: string | null; + passkeyCredentialId: string | null; + balanceCents: number; +} + +export interface Session { + privHex: string; + accountId: string; + expiresAt: number; + via: 'passkey' | 'email_otp'; +} + +const CUSTOMER_PLACEHOLDER = '{customerId}'; +const SESSION_SKEW_MS = 30_000; // treat as expired 30s early +const PASSKEY_LS_PREFIX = 'grid.passkey.'; // + accountId -> credentialId + +let session: Session | null = null; +let account: WalletAccount | null = null; + +export function getSession(): Session | null { + return session && session.expiresAt - SESSION_SKEW_MS > Date.now() ? session : null; +} +export function clearSession(): void { + session = null; +} + +/** Pure: choose the EMAIL_OTP + first PASSKEY credential from the list. The + * EMAIL_OTP credential's `nickname` IS the email address it's tied to. */ +export function pickCredentials( + data: { id: string; type: string; nickname?: string }[], +): { emailOtpId: string | null; emailOtpNickname: string | null; passkeyId: string | null } { + const emailOtp = data.find((c) => c.type === 'EMAIL_OTP'); + return { + emailOtpId: emailOtp?.id ?? null, + emailOtpNickname: emailOtp?.nickname ?? null, + passkeyId: data.find((c) => c.type === 'PASSKEY')?.id ?? null, + }; +} + +function assertOk(env: GridEnvelope, expected: number[], where: string): void { + if (!expected.includes(env.response.status)) { + const body = env.response.body as { error?: { code?: string; message?: string } }; + throw new Error( + `${where}: expected ${expected.join('/')}, got ${env.response.status} ` + + `(${body?.error?.code ?? ''} ${body?.error?.message ?? ''})`.trim(), + ); + } +} + +export async function loadWalletAccount(log: LogFn): Promise { + const acctEnv = await gridFetch( + 'GET', + `/customers/internal-accounts?customerId=${CUSTOMER_PLACEHOLDER}&type=EMBEDDED_WALLET`, + ); + log(acctEnv); + assertOk(acctEnv, [200], 'load internal accounts'); + const acctBody = acctEnv.response.body as { + data: { id: string; customerId?: string; balance: { amount: number; currency: { decimals: number } } }[]; + }; + const wallet = acctBody.data[0]; + if (!wallet) throw new Error('No EMBEDDED_WALLET internal account for customer'); + const accountId = wallet.id; + const balanceCents = amountToCents(wallet.balance.amount, wallet.balance.currency.decimals); + + const credEnv = await gridFetch('GET', `/auth/credentials?accountId=${accountId}`); + log(credEnv); + assertOk(credEnv, [200], 'list credentials'); + const credBody = credEnv.response.body as { + data: { id: string; type: string; nickname?: string; credentialId?: string }[]; + }; + const { emailOtpId, emailOtpNickname } = pickCredentials(credBody.data); + if (!emailOtpId) throw new Error('No EMAIL_OTP credential on the wallet'); + + const stored = typeof window !== 'undefined' + ? window.localStorage.getItem(PASSKEY_LS_PREFIX + accountId) + : null; + + account = { + customerId: wallet.customerId ?? '', + accountId, + emailOtpCredentialId: emailOtpId, + email: emailOtpNickname, + passkeyCredentialId: thisDevicePasskey(stored, credBody.data), + balanceCents, + }; + return account; +} + +/** + * Which passkey can THIS browser actually sign in with. Passkeys are + * device-bound: the account may list one registered on another device, and + * `navigator.credentials.get` here would never find it — so only a credential + * this device registered (localStorage) counts, and only while the account still + * lists it. Anything else means "new device": sign in with the email OTP and add + * a passkey from the wallet. + */ +export function thisDevicePasskey( + stored: string | null, + credentials: { type: string; credentialId?: string }[], +): string | null { + if (!stored) return null; + const onAccount = credentials.some((c) => c.type === 'PASSKEY' && c.credentialId === stored); + return onAccount ? stored : null; +} + +/** + * Entry step (when the skin has one) → challenge → code. The code step can go + * BACK to the entry step: promptOtp rejects with 'back', and the next pass + * issues a FRESH challenge, so a new code genuinely goes out. Each iteration's + * bundle belongs to that iteration's challenge — they can't be split apart. + */ +async function collectOtp( + acct: WalletAccount, + cb: AuthCallbacks, +): Promise<{ bundle: string; code: string }> { + const id = acct.emailOtpCredentialId; + for (;;) { + // The code only goes out once the user commits to the address (prefilled + // with the one the credential is actually tied to). + if (cb.promptEmail) await cb.promptEmail(acct.email); + const chal = await gridFetch('POST', `/auth/credentials/${id}/challenge`, { body: {} }); + cb.log(chal); + assertOk(chal, [200], 'email otp challenge'); + const bundle = (chal.response.body as { otpEncryptionTargetBundle: string }) + .otpEncryptionTargetBundle; + try { + return { bundle, code: await cb.promptOtp() }; + } catch (e) { + // 'back' only has somewhere to go when there IS an entry step. + if ((e as Error)?.message !== 'back' || !cb.promptEmail) throw e; + } + } +} + +/** EMAIL_OTP two-leg verify -> session (TEK priv IS the session key). */ +async function emailOtpAuth(acct: WalletAccount, cb: AuthCallbacks): Promise { + const id = acct.emailOtpCredentialId; + const { bundle, code } = await collectOtp(acct, cb); + const tek = genTek(); + const encryptedOtpBundle = encryptOtpBundle(bundle, tek.pubHex, code); + const verifyBody = { type: 'EMAIL_OTP', encryptedOtpBundle }; + + // Leg 1: 202 payloadToSign + requestId. + const leg1 = await gridFetch('POST', `/auth/credentials/${id}/verify`, { body: verifyBody }); + cb.log(leg1); + assertOk(leg1, [202], 'email otp verify (leg 1)'); + const { payloadToSign, requestId } = leg1.response.body as { payloadToSign: string; requestId: string }; + + // Leg 2: stamp payloadToSign with the TEK priv, retry. + const { stamp } = await import('./gridCrypto'); + const sig = await stamp(tek.privHex, payloadToSign); + const leg2 = await gridFetch('POST', `/auth/credentials/${id}/verify`, { + body: verifyBody, + headers: { 'Grid-Wallet-Signature': sig, 'Request-Id': requestId }, + }); + cb.log(leg2); + assertOk(leg2, [200], 'email otp verify (leg 2)'); + const sess = leg2.response.body as { expiresAt: string }; + return { privHex: tek.privHex, accountId: acct.accountId, expiresAt: Date.parse(sess.expiresAt), via: 'email_otp' }; +} + +interface NewCredential { + challenge: string; + attestation: { + credentialId: string; + clientDataJson: string; + attestationObject: string; + transports: string[]; + }; +} + +/** + * The WebAuthn half of registration — ONE device ceremony (Touch ID / Face ID), + * no network. Split out and called FIRST so it runs inside the tap that started + * it: browsers require `credentials.create` to happen under a live user + * activation, and an intervening re-auth round-trip would spend it. + */ +async function createPasskeyCredential(): Promise { + // Self-issued WebAuthn registration challenge (no integrator backend here). + const regChallenge = crypto.getRandomValues(new Uint8Array(32)); + const regChallengeB64 = bytesToBase64url(regChallenge); + const cred = (await navigator.credentials.create({ + publicKey: { + challenge: regChallenge, + rp: { id: location.hostname, name: 'Grid Wallet' }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: 'wallet@lightspark.com', + displayName: 'Grid Wallet', + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], // ES256 + authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, + timeout: 60_000, + }, + })) as PublicKeyCredential; + const att = cred.response as AuthenticatorAttestationResponse; + const attestation = { + credentialId: bytesToBase64url(new Uint8Array(cred.rawId)), + clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)), + attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), + transports: att.getTransports?.() ?? [], + }; + return { challenge: regChallengeB64, attestation }; +} + +/** + * The network half: POST the new credential, then repeat it signed by the + * session key that authorizes the change (the email-OTP one on a first run). + */ +async function registerPasskey( + acct: WalletAccount, + created: NewCredential, + sessionPrivHex: string, + cb: AuthCallbacks, +): Promise { + const { challenge, attestation } = created; + const createBody = { + type: 'PASSKEY', + accountId: acct.accountId, + nickname: 'This device', + challenge, + attestation, + }; + + // POST /auth/credentials -> 202 payloadToSign + requestId. + const leg1 = await gridFetch('POST', '/auth/credentials', { body: createBody }); + cb.log(leg1); + assertOk(leg1, [202], 'passkey create (leg 1)'); + const { payloadToSign, requestId } = leg1.response.body as { payloadToSign: string; requestId: string }; + + // Stamp with the CURRENT session key, retry -> 201 AuthMethod. + const { stamp } = await import('./gridCrypto'); + const sig = await stamp(sessionPrivHex, payloadToSign); + const leg2 = await gridFetch('POST', '/auth/credentials', { + body: createBody, + headers: { 'Grid-Wallet-Signature': sig, 'Request-Id': requestId }, + }); + cb.log(leg2); + assertOk(leg2, [201], 'passkey create (leg 2)'); + const authMethod = leg2.response.body as { id: string; credentialId?: string }; + + const credentialId = authMethod.credentialId ?? attestation.credentialId; + if (typeof window !== 'undefined') { + window.localStorage.setItem(PASSKEY_LS_PREFIX + acct.accountId, credentialId); + } + // The account now HAS a passkey — reflected in place so hasPasskey() and the + // next signIn() take the passkey path without a reload. + acct.passkeyCredentialId = credentialId; + return authMethod.id; +} + +/** Passkey authenticate: /challenge (clientPublicKey) -> get() -> /verify -> session. */ +async function passkeyAuth(acct: WalletAccount, credentialAuthMethodId: string, cb: AuthCallbacks): Promise { + const tek = genTek(); // ephemeral client key sealed into the session + const chal = await gridFetch('POST', `/auth/credentials/${credentialAuthMethodId}/challenge`, { + body: { clientPublicKey: tek.pubHex }, + }); + cb.log(chal); + assertOk(chal, [200], 'passkey challenge'); + const pk = chal.response.body as { credentialId: string; challenge: string; requestId: string }; + + if (cb.onFaceId) await cb.onFaceId(); + const assertionCred = (await navigator.credentials.get({ + publicKey: { + challenge: new TextEncoder().encode(pk.challenge), // hex string -> UTF-8 bytes (per PasskeyAuthChallenge) + rpId: location.hostname, + userVerification: 'required', + // TS 5.9's DOM lib types BufferSource as ArrayBufferView; the + // decoded id is a plain Uint8Array (base64urlnopad has no + // narrower typing) — cast, no runtime behavior change. + allowCredentials: [{ type: 'public-key', id: base64urlToBytes(pk.credentialId) as BufferSource }], + timeout: 60_000, + }, + })) as PublicKeyCredential; + const asr = assertionCred.response as AuthenticatorAssertionResponse; + const assertion = { + credentialId: bytesToBase64url(new Uint8Array(assertionCred.rawId)), + clientDataJson: bytesToBase64url(new Uint8Array(asr.clientDataJSON)), + authenticatorData: bytesToBase64url(new Uint8Array(asr.authenticatorData)), + signature: bytesToBase64url(new Uint8Array(asr.signature)), + ...(asr.userHandle ? { userHandle: bytesToBase64url(new Uint8Array(asr.userHandle)) } : {}), + }; + + const verify = await gridFetch('POST', `/auth/credentials/${credentialAuthMethodId}/verify`, { + body: { type: 'PASSKEY', assertion }, + headers: { 'Request-Id': pk.requestId }, + }); + cb.log(verify); + assertOk(verify, [200], 'passkey verify'); + const sess = verify.response.body as { encryptedSessionSigningKey: string; expiresAt: string }; + const privHex = await decryptSessionKey(sess.encryptedSessionSigningKey, tek.privHex); + return { privHex, accountId: acct.accountId, expiresAt: Date.parse(sess.expiresAt), via: 'passkey' }; +} + +/** Resolve the AuthMethod id of the account's passkey (needed for challenge/verify). */ +async function findPasskeyAuthMethodId(acct: WalletAccount, cb: AuthCallbacks): Promise { + // GET /auth/credentials returned AuthMethod ids AND passkey credentialIds; + // re-list to resolve the AuthMethod id for the stored/discovered passkey. + const credEnv = await gridFetch('GET', `/auth/credentials?accountId=${acct.accountId}`); + cb.log(credEnv); + const list = (credEnv.response.body as { data: { id: string; type: string; credentialId?: string }[] }).data; + const passkey = + list.find( + (c) => c.type === 'PASSKEY' && (c.credentialId === acct.passkeyCredentialId || !acct.passkeyCredentialId), + ) ?? list.find((c) => c.type === 'PASSKEY'); + if (!passkey) throw new Error('Passkey credential vanished; clear localStorage and retry'); + return passkey.id; +} + +/** + * Sign in with whatever the account already has. Global Accounts are born with + * only an EMAIL_OTP credential, so the FIRST sign-in is always email OTP — that + * session is what later authorizes adding a passkey (see addPasskey). Once a + * passkey exists, it takes over as the sign-in path. + */ +export async function signIn(cb: AuthCallbacks): Promise { + const acct = await loadWalletAccount(cb.log); + if (acct.passkeyCredentialId) { + session = await passkeyAuth(acct, await findPasskeyAuthMethodId(acct, cb), cb); + return session; + } + session = await emailOtpAuth(acct, cb); + return session; +} + +/** + * Add a passkey to the account, later, as its own action — the docs' bootstrap + * order: "you can add passkeys or OAuth credentials later, but adding + * credentials is itself a signed action". The signature comes from the session + * you're ALREADY in (the email-OTP one on a first run), so this is exactly one + * device ceremony: register the credential and stop. It is not authenticated + * here — no challenge/verify, no second prompt — the passkey gets exercised at + * the next sign-in, and the current session stays as it is. + * + * Returns the new credential's AuthMethod id. + */ +export async function addPasskey(cb: AuthCallbacks): Promise { + // The device ceremony goes FIRST, while the user activation from the tap is + // still live — a re-auth round-trip in front of it would invalidate it. + const created = await createPasskeyCredential(); + // A live session is the authorization; only re-auth when it has lapsed. + const live = getSession(); + const privHex = live ? live.privHex : (await signIn(cb)).privHex; + const acct = account ?? (await loadWalletAccount(cb.log)); + return registerPasskey(acct, created, privHex, cb); +} + +/** Has this account got a passkey yet? Drives the wallet's "add a passkey" nudge. */ +export function hasPasskey(): boolean { + return Boolean(account?.passkeyCredentialId); +} + +/** + * Did THIS browser register a passkey (for any account)? Answered from + * localStorage alone, with no network call, so the sign-in screen can label its + * button for the credential `signIn` will actually use before anything is + * fetched. Optimistic by design: if the credential has since been revoked + * server-side, `loadWalletAccount` discovers that mid-flow and the email OTP + * takes over (see thisDevicePasskey). + */ +export function deviceHasPasskey(): boolean { + if (typeof window === 'undefined') return false; + try { + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key?.startsWith(PASSKEY_LS_PREFIX) && window.localStorage.getItem(key)) return true; + } + } catch { + // Storage blocked (private mode / embed) — treat as a fresh device. + } + return false; +} + +export async function ensureSession(cb: AuthCallbacks): Promise { + const live = getSession(); + if (live) return live.privHex; + // Silent single re-auth on whatever credential the account has (passkey once + // one has been added; the email OTP prompt before that). + const s = await signIn(cb); + return s.privHex; +} + +export function getAccount(): WalletAccount | null { + return account; +} diff --git a/components/grid-wallet-prod/src/lib/gridTransfer.test.ts b/components/grid-wallet-prod/src/lib/gridTransfer.test.ts new file mode 100644 index 000000000..b65c2451c --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridTransfer.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { gridFetch } from './gridClient'; +import { + findEmbeddedWalletPayload, + destSignature, + quoteBodyFor, + isTerminalStatus, + isCompletionStatus, + pollTransaction, +} from './gridTransfer'; + +vi.mock('./gridClient', () => ({ gridFetch: vi.fn() })); + +const mockedFetch = vi.mocked(gridFetch); +const envelope = (status: number, body: unknown) => ({ + request: { method: 'GET' as const, path: '/transactions/Transaction:1', headers: {} }, + response: { status, body }, +}); +const noopLog = () => {}; + +describe('gridTransfer pure helpers', () => { + it('isTerminalStatus recognizes terminal vs in-flight states', () => { + expect(isTerminalStatus('COMPLETED')).toBe(true); + expect(isTerminalStatus('FAILED')).toBe(true); + expect(isTerminalStatus('PROCESSING')).toBe(false); + expect(isTerminalStatus('PENDING')).toBe(false); + }); + it('finds the EMBEDDED_WALLET payloadToSign', () => { + const q = { + id: 'Quote:1', status: 'PENDING', + paymentInstructions: [ + { accountOrWalletInfo: { accountType: 'USD_ACCOUNT' } }, + { accountOrWalletInfo: { accountType: 'EMBEDDED_WALLET', payloadToSign: '{"x":1}' } }, + ], + }; + expect(findEmbeddedWalletPayload(q)).toBe('{"x":1}'); + }); + it('returns null when there is no embedded-wallet instruction', () => { + expect(findEmbeddedWalletPayload({ id: 'Quote:2', status: 'PENDING', paymentInstructions: [] })).toBeNull(); + }); + it('destSignature dedupes identical destinations', () => { + const a = { kind: 'crypto', address: 'x', network: 'SPARK', accountType: 'SPARK_WALLET', currency: 'BTC' } as const; + expect(destSignature(a)).toBe(destSignature({ ...a })); + }); + it('quoteBodyFor locks USDB sending in micro-units', () => { + const b = quoteBodyFor('InternalAccount:1', 'ExternalAccount:2', 200, 'USD'); + expect(b.lockedCurrencySide).toBe('SENDING'); + expect(b.lockedCurrencyAmount).toBe(2_000_000); + expect(b.source).toEqual({ sourceType: 'ACCOUNT', accountId: 'InternalAccount:1' }); + expect(b.destination).toEqual({ destinationType: 'ACCOUNT', accountId: 'ExternalAccount:2', currency: 'USD' }); + }); + it('isCompletionStatus is true only when execute succeeded AND the transaction reached COMPLETED', () => { + expect(isCompletionStatus(200, 'COMPLETED')).toBe(true); + expect(isCompletionStatus(200, 'FAILED')).toBe(false); + expect(isCompletionStatus(200, 'PROCESSING')).toBe(false); + expect(isCompletionStatus(200, null)).toBe(false); + expect(isCompletionStatus(400, 'COMPLETED')).toBe(false); + }); +}); + +describe('pollTransaction', () => { + beforeEach(() => { + mockedFetch.mockReset(); + }); + + it('resolves COMPLETED on the first terminal poll (no extra fetches, no sleep)', async () => { + mockedFetch.mockResolvedValueOnce(envelope(200, { status: 'COMPLETED' })); + const status = await pollTransaction('Transaction:1', noopLog); + expect(status).toBe('COMPLETED'); + expect(mockedFetch).toHaveBeenCalledTimes(1); + }); + + it('FAILED short-circuits the loop just like COMPLETED', async () => { + mockedFetch.mockResolvedValueOnce(envelope(200, { status: 'FAILED' })); + const status = await pollTransaction('Transaction:1', noopLog); + expect(status).toBe('FAILED'); + expect(mockedFetch).toHaveBeenCalledTimes(1); + }); + + it('returns the last-seen non-terminal status once the deadline has already passed, without looping again', async () => { + mockedFetch.mockResolvedValueOnce(envelope(200, { status: 'PROCESSING' })); + // A deadline in the past guarantees the very first Date.now() check after + // the fetch trips the "give up" branch — exercises the timeout path + // deterministically without fake timers or a real multi-second wait. + const status = await pollTransaction('Transaction:1', noopLog, { timeoutMs: -1_000, intervalMs: 50_000 }); + expect(status).toBe('PROCESSING'); + expect(mockedFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridTransfer.ts b/components/grid-wallet-prod/src/lib/gridTransfer.ts new file mode 100644 index 000000000..a714ddba6 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridTransfer.ts @@ -0,0 +1,208 @@ +'use client'; + +import { gridFetch, type GridEnvelope } from './gridClient'; +import type { ExternalAccountInput } from '@/data/apiCalls'; +import type { LogFn } from './gridSession'; +import { stamp } from './gridCrypto'; +import { centsToAmount, USDB_DECIMALS } from './gridUnits'; + +export type OutboundMode = 'withdraw' | 'send'; + +export interface RawQuote { + id: string; + status: string; + transactionId?: string; + paymentInstructions?: { accountOrWalletInfo?: { accountType?: string; payloadToSign?: string } }[]; +} + +const CUSTOMER = '{customerId}'; +const externalAccountCache = new Map(); // destSignature -> ExternalAccount id + +/** Stable signature for a destination so we create it at most once per session. */ +export function destSignature(input: ExternalAccountInput): string { + return input.kind === 'crypto' + ? `crypto:${input.network}:${input.currency}:${input.address}` + : `bank:${input.currency}:${input.bankName}:${JSON.stringify(input.fields)}`; +} + +function externalAccountBody(input: ExternalAccountInput): Record { + if (input.kind === 'crypto') { + return { + customerId: CUSTOMER, + currency: input.currency, + accountInfo: { accountType: input.accountType, address: input.address }, + }; + } + return { + customerId: CUSTOMER, + currency: input.currency, + accountInfo: { + accountType: input.accountType, + ...input.fields, + beneficiary: { beneficiaryType: 'INDIVIDUAL', fullName: input.beneficiary }, + }, + }; +} + +export async function ensureExternalAccount(input: ExternalAccountInput, log: LogFn): Promise { + const sig = destSignature(input); + const cached = externalAccountCache.get(sig); + if (cached) return cached; + const env = await gridFetch('POST', '/customers/external-accounts', { body: externalAccountBody(input) }); + log(env); + if (env.response.status !== 201) { + const b = env.response.body as { error?: { message?: string } }; + throw new Error(`create external account: ${env.response.status} ${b?.error?.message ?? ''}`); + } + const id = (env.response.body as { id: string }).id; + externalAccountCache.set(sig, id); + return id; +} + +export interface QuoteBody { + source: Record; + destination: Record; + lockedCurrencySide: 'SENDING' | 'RECEIVING'; + lockedCurrencyAmount: number; +} + +/** + * INBOUND quote body for funding from the customer's own external account: their + * bank is the SOURCE, the embedded wallet the destination. + * + * Two things the API enforces here, both verified against the sandbox: + * - `lockedCurrencyAmount` is in the SENDING currency's minor units — USD cents, + * not the wallet's USDB micro-units (contrast `quoteBodyFor` below). + * - The quote must NOT be executed. `POST /quotes/{id}/execute` returns + * INVALID_INPUT: "funds must be pushed to Lightspark from the source account + * (e.g. via wire transfer) rather than pulled via this endpoint". It stays + * PENDING until the payment lands — in sandbox, via `sandboxSendForQuote`. + */ +export function pullQuoteBodyFor( + externalAccountId: string, + walletAccountId: string, + cents: number, +): QuoteBody { + return { + source: { sourceType: 'ACCOUNT', accountId: externalAccountId, customerId: CUSTOMER }, + destination: { destinationType: 'ACCOUNT', accountId: walletAccountId, currency: 'USDB' }, + lockedCurrencySide: 'SENDING', + lockedCurrencyAmount: cents, + }; +} + +/** Outbound (embedded-wallet source) quote body. USDB source, external destination. */ +export function quoteBodyFor( + accountId: string, + externalAccountId: string, + cents: number, + destCurrency: string, +): QuoteBody { + return { + source: { sourceType: 'ACCOUNT', accountId }, + destination: { destinationType: 'ACCOUNT', accountId: externalAccountId, currency: destCurrency }, + lockedCurrencySide: 'SENDING', + lockedCurrencyAmount: centsToAmount(cents, USDB_DECIMALS), + }; +} + +export function findEmbeddedWalletPayload(quote: RawQuote): string | null { + const inst = (quote.paymentInstructions ?? []).find( + (p) => p.accountOrWalletInfo?.accountType === 'EMBEDDED_WALLET', + ); + return inst?.accountOrWalletInfo?.payloadToSign ?? null; +} + +export async function createQuote( + body: QuoteBody, + log: LogFn, + idempotencyKey: string, +): Promise<{ quoteId: string; transactionId: string | null; payloadToSign: string | null; env: GridEnvelope }> { + const env = await gridFetch('POST', '/quotes', { body, headers: { 'Idempotency-Key': idempotencyKey } }); + log(env); + // The OpenAPI spec documents 201 (and 202 for SCA); live sandbox has been + // observed returning 200 for a platform-account-sourced (on-ramp) quote — + // accept both 2xx success shapes rather than assume the strict 201. + if (env.response.status !== 200 && env.response.status !== 201) { + const b = env.response.body as { error?: { message?: string } }; + throw new Error(`create quote: ${env.response.status} ${b?.error?.message ?? ''}`); + } + const q = env.response.body as RawQuote; + return { quoteId: q.id, transactionId: q.transactionId ?? null, payloadToSign: findEmbeddedWalletPayload(q), env }; +} + +export async function executeQuote( + quoteId: string, + payloadToSign: string, + sessionPrivHex: string, + log: LogFn, + idempotencyKey: string, +): Promise { + const sig = await stamp(sessionPrivHex, payloadToSign); // byte-for-byte over payloadToSign + const env = await gridFetch('POST', `/quotes/${quoteId}/execute`, { + body: {}, + headers: { 'Grid-Wallet-Signature': sig, 'Idempotency-Key': idempotencyKey }, + }); + log(env); + return env; // caller inspects status: 200 = PROCESSING; 4xx = truthful error rendered in the panel +} + +/** + * Execute a quote with no wallet signature — valid when the quote's source is + * a PLATFORM account, not the customer's embedded wallet (e.g. the on-ramp + * funding leg of "Add money"), so there's nothing to stamp. + */ +export async function executeQuoteUnsigned( + quoteId: string, + log: LogFn, + idempotencyKey: string, +): Promise { + const env = await gridFetch('POST', `/quotes/${quoteId}/execute`, { + body: {}, + headers: { 'Idempotency-Key': idempotencyKey }, + }); + log(env); + return env; +} + +const TERMINAL = new Set(['COMPLETED', 'FAILED', 'REJECTED', 'REFUNDED', 'EXPIRED']); +export function isTerminalStatus(status: string): boolean { + return TERMINAL.has(status); +} + +/** + * The one gate every "mark this flow complete" checkmark must pass: the + * execute call itself returned 200 AND the transaction the caller polled + * actually reached COMPLETED — not just any terminal status, and not a + * still-PROCESSING poll that gave up at the deadline. FAILED/REJECTED/ + * REFUNDED/EXPIRED, a still-in-flight poll, or a missing transactionId + * (`transactionStatus` null) are all "don't check the box" outcomes; the + * caller still logs/refreshes truthfully, it just doesn't fabricate success. + */ +export function isCompletionStatus(executeStatus: number, transactionStatus: string | null): boolean { + return executeStatus === 200 && transactionStatus === 'COMPLETED'; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Poll GET /transactions/{id} until terminal or timeout. Sandbox off-ramp settles in 60–180s. */ +export async function pollTransaction( + txnId: string, + log: LogFn, + opts: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 180_000; + const intervalMs = opts.intervalMs ?? 4_000; + const deadline = Date.now() + timeoutMs; + let status = 'UNKNOWN'; + for (;;) { + const env = await gridFetch('GET', `/transactions/${txnId}`); + log(env); + if (env.response.status === 200) { + status = (env.response.body as { status: string }).status; + if (isTerminalStatus(status)) return status; + } + if (Date.now() >= deadline) return status; // give up; caller renders the last-seen status + await sleep(intervalMs); + } +} diff --git a/components/grid-wallet-prod/src/lib/gridUnits.test.ts b/components/grid-wallet-prod/src/lib/gridUnits.test.ts new file mode 100644 index 000000000..ebcd75d2b --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridUnits.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { amountToCents, centsToAmount, formatCents, USDB_DECIMALS } from './gridUnits'; + +describe('gridUnits', () => { + it('maps USDB (6dp) micro-units to cents', () => { + expect(amountToCents(2_000_000, USDB_DECIMALS)).toBe(200); // 2 USDB -> $2.00 + expect(amountToCents(1_500_000, USDB_DECIMALS)).toBe(150); + expect(amountToCents(0, USDB_DECIMALS)).toBe(0); + }); + it('maps USD (2dp) amount straight through', () => { + expect(amountToCents(20000, 2)).toBe(20000); + }); + it('maps BTC (8dp) down to cents', () => { + expect(amountToCents(100_000_000, 8)).toBe(100); // 1.00000000 -> "$1.00" scale + }); + it('round-trips cents -> USDB -> cents', () => { + for (const c of [0, 1, 200, 99, 123456]) { + expect(amountToCents(centsToAmount(c, USDB_DECIMALS), USDB_DECIMALS)).toBe(c); + } + }); + it('centsToAmount produces USDB micro-units', () => { + expect(centsToAmount(200, USDB_DECIMALS)).toBe(2_000_000); + }); + it('formats cents', () => { + expect(formatCents(200)).toBe('$2.00'); + expect(formatCents(123456)).toBe('$1,234.56'); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridUnits.ts b/components/grid-wallet-prod/src/lib/gridUnits.ts new file mode 100644 index 000000000..2705fd5ca --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridUnits.ts @@ -0,0 +1,31 @@ +/** + * Grid `CurrencyAmount.amount` is an integer in the currency's smallest unit + * (openapi/components/schemas/common/CurrencyAmount.yaml). USDB has 6 decimals + * (openapi .../PaymentEmbeddedWalletInfo + currencyResponse in apiCodeFormat). + * The wallet UI models money as integer "cents" (2 decimals). This layer maps + * between a Grid amount at any decimals and the app's cents. + */ + +export const USDB_DECIMALS = 6; + +/** Grid smallest-unit amount -> app cents (2 dp). e.g. 2_000_000 USDB micro -> 200. */ +export function amountToCents(amount: number, decimals: number): number { + if (!Number.isFinite(amount)) return 0; + if (decimals <= 2) return Math.round(amount * Math.pow(10, 2 - decimals)); + return Math.round(amount / Math.pow(10, decimals - 2)); +} + +/** App cents (2 dp) -> Grid smallest-unit amount at `decimals`. e.g. 200 -> 2_000_000 USDB micro. */ +export function centsToAmount(cents: number, decimals: number): number { + if (!Number.isFinite(cents)) return 0; + if (decimals <= 2) return Math.round(cents / Math.pow(10, 2 - decimals)); + return Math.round(cents * Math.pow(10, decimals - 2)); +} + +/** "$1,234.50" style — mirrors the app's existing fmt() in src/data/actions.ts. */ +export function formatCents(cents: number): string { + return `$${(cents / 100).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} diff --git a/components/grid-wallet-prod/src/lib/gridWebhook.test.ts b/components/grid-wallet-prod/src/lib/gridWebhook.test.ts new file mode 100644 index 000000000..b69da178f --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridWebhook.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { createSign, generateKeyPairSync, type KeyObject } from 'node:crypto'; +import { verifyGridSignature, parseSignatureHeader } from './gridWebhook'; + +function sign(body: string, key: KeyObject): string { + const s = createSign('SHA256'); + s.update(body, 'utf8'); + s.end(); + return s.sign(key).toString('base64'); // DER, matching SHA256withECDSA +} + +describe('verifyGridSignature', () => { + const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const pem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const body = JSON.stringify({ id: 'Webhook:1', type: 'INCOMING_PAYMENT.COMPLETED', data: { amount: 1 } }); + + // THE shape Grid actually sends (docs.lightspark.com/api-reference/webhooks): + // short field names, and a STRING version. Getting this wrong 401s every real + // delivery while a self-signed test using the wrong shape still passes. + it('accepts Grid’s {"v","s"} header', () => { + const h = JSON.stringify({ v: '1', s: sign(body, privateKey) }); + expect(verifyGridSignature(body, h, pem)).toBe(true); + }); + + // The docs' Python example treats the header as the base64 signature itself. + it('accepts a bare base64 header', () => { + expect(verifyGridSignature(body, sign(body, privateKey), pem)).toBe(true); + }); + + it('still accepts a {version, signature} header', () => { + const h = JSON.stringify({ version: 1, signature: sign(body, privateKey) }); + expect(verifyGridSignature(body, h, pem)).toBe(true); + }); + + it('rejects a tampered body', () => { + const h = JSON.stringify({ v: '1', s: sign(body, privateKey) }); + expect(verifyGridSignature(body + ' ', h, pem)).toBe(false); + }); + + it('rejects a corrupted signature', () => { + const buf = Buffer.from(sign(body, privateKey), 'base64'); + buf[10] ^= 0xff; + const h = JSON.stringify({ v: '1', s: buf.toString('base64') }); + expect(verifyGridSignature(body, h, pem)).toBe(false); + }); + + it('rejects a wrong signer', () => { + const other = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey; + const h = JSON.stringify({ v: '1', s: sign(body, other) }); + expect(verifyGridSignature(body, h, pem)).toBe(false); + }); + + it('rejects an unsupported signature version', () => { + const h = JSON.stringify({ v: '2', s: sign(body, privateKey) }); + expect(verifyGridSignature(body, h, pem)).toBe(false); + }); + + it('rejects junk without throwing', () => { + expect(verifyGridSignature(body, 'not-a-signature', pem)).toBe(false); + expect(verifyGridSignature(body, '', pem)).toBe(false); + expect(verifyGridSignature(body, JSON.stringify({ v: '1' }), pem)).toBe(false); + }); + + describe('parseSignatureHeader', () => { + it('reads both JSON field namings', () => { + expect(parseSignatureHeader('{"v":"1","s":"AAA"}')).toEqual({ version: '1', signature: 'AAA' }); + expect(parseSignatureHeader('{"version":1,"signature":"AAA"}')).toEqual({ + version: '1', + signature: 'AAA', + }); + }); + it('treats a non-JSON header as the signature itself', () => { + expect(parseSignatureHeader('AAA')).toEqual({ version: null, signature: 'AAA' }); + }); + it('returns null when there is no signature to read', () => { + expect(parseSignatureHeader('')).toBeNull(); + expect(parseSignatureHeader('{"v":"1"}')).toBeNull(); + }); + }); +}); diff --git a/components/grid-wallet-prod/src/lib/gridWebhook.ts b/components/grid-wallet-prod/src/lib/gridWebhook.ts new file mode 100644 index 000000000..921c503e2 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/gridWebhook.ts @@ -0,0 +1,61 @@ +import { createVerify } from 'node:crypto'; + +export interface GridSignatureHeader { + /** Present as a string in Grid's own format ("1"); absent for a bare header. */ + version: string | null; + signature: string; // base64 DER-encoded ECDSA +} + +/** + * `X-Grid-Signature` comes in two shapes, both documented at + * docs.lightspark.com/api-reference/webhooks: + * + * {"v": "1", "s": ""} ← what Grid sends + * ← bare, per the Python example + * + * `v` is a STRING, and the fields are `v`/`s` — not `version`/`signature`. An + * earlier version of this parser required the long names and a numeric version, + * which rejected every real delivery before verification ran (401 on all of + * them). `{version, signature}` is still accepted so nothing that already + * signs that way breaks. + */ +export function parseSignatureHeader(headerValue: string): GridSignatureHeader | null { + const raw = headerValue.trim(); + if (!raw) return null; + try { + const h = JSON.parse(raw) as Record; + const signature = typeof h.s === 'string' ? h.s : typeof h.signature === 'string' ? h.signature : null; + if (!signature) return null; + const v = h.v ?? h.version; + return { version: v === undefined || v === null ? null : String(v), signature }; + } catch { + // Not JSON — the header is the base64 signature itself. + return { version: null, signature: raw }; + } +} + +/** Only v1 signatures are understood; an unlabelled header is treated as v1. */ +function versionSupported(version: string | null): boolean { + return version === null || version === '1'; +} + +/** + * Verify a Grid webhook signature: SHA256withECDSA over the RAW body, base64 + * DER signature, P-256 X.509 (SPKI) PEM public key. + */ +export function verifyGridSignature( + rawBody: string, + signatureHeader: string, + publicKeyPem: string, +): boolean { + const parsed = parseSignatureHeader(signatureHeader); + if (!parsed || !versionSupported(parsed.version) || !publicKeyPem) return false; + try { + const verify = createVerify('SHA256'); + verify.update(rawBody, 'utf8'); + verify.end(); + return verify.verify(publicKeyPem, Buffer.from(parsed.signature, 'base64')); // DER by default + } catch { + return false; + } +} diff --git a/components/grid-wallet-prod/src/lib/webhookEvents.ts b/components/grid-wallet-prod/src/lib/webhookEvents.ts new file mode 100644 index 000000000..1c91222c0 --- /dev/null +++ b/components/grid-wallet-prod/src/lib/webhookEvents.ts @@ -0,0 +1,68 @@ +export interface WebhookEvent { + id?: string; + type?: string; + timestamp?: string; + data?: unknown; + receivedAt: number; +} + +const RING = 50; + +/** + * Live subscribers (the SSE stream at /api/webhooks/stream). Verified events are + * PUSHED to the panel as they arrive — nothing polls. In-process only, which is + * all the demo needs: the receiver and the stream run in the same Next server. A + * multi-instance deployment would need a shared channel (Redis pub/sub) instead. + */ +type Listener = (event: WebhookEvent) => void; + +/** + * Pinned to globalThis, NOT module scope: Next gives each route handler its own + * module instance (and HMR re-evaluates them), so a module-level Set would leave + * /api/webhooks publishing into a different registry than /api/webhooks/stream + * subscribes to — the stream would connect and then stay silent forever. + */ +interface WebhookBus { + events: WebhookEvent[]; + listeners: Set; +} +const globalBus = globalThis as typeof globalThis & { __gridWebhookBus?: WebhookBus }; +const bus: WebhookBus = (globalBus.__gridWebhookBus ??= { + events: [], + listeners: new Set(), +}); + +export function subscribe(listener: Listener): () => void { + bus.listeners.add(listener); + return () => { + bus.listeners.delete(listener); + }; +} + +export function pushEvent(raw: unknown): void { + const e = raw as { id?: string; type?: string; timestamp?: string; data?: unknown }; + const event: WebhookEvent = { + id: e.id, + type: e.type, + timestamp: e.timestamp, + data: e.data, + receivedAt: Date.now(), + }; + bus.events.push(event); + if (bus.events.length > RING) bus.events = bus.events.slice(-RING); + // One broken subscriber must not stop the others, or the 200 back to Grid. + bus.listeners.forEach((listener) => { + try { + listener(event); + } catch { + /* ignore */ + } + }); +} + +export function listEvents(): WebhookEvent[] { + return bus.events; +} +export function clearEvents(): void { + bus.events = []; +} diff --git a/components/grid-wallet-prod/vitest.config.ts b/components/grid-wallet-prod/vitest.config.ts new file mode 100644 index 000000000..e19180f55 --- /dev/null +++ b/components/grid-wallet-prod/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { '@': path.resolve(__dirname, 'src') }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});