diff --git a/.env.example b/.env.example index 21f8619..d781a8b 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ SLACK_CLIENT_ID= # Printed by `pnpm slack:app create` SLACK_CLIENT_SECRET= # Printed by `pnpm slack:app create` SLACK_SIGNING_SECRET= # Printed by `pnpm slack:app create` TEMPO_API_KEY= # Tempo API key with data:read and rpc-relay:sponsor scopes +TEMPO_ID_CLIENT_SECRET= # Client secret of the `tipbot` app in Tempo ID (`tempo-id admin apps`) TWITTER_ACCESS_TOKEN= # Twitter/X OAuth 1.0a access token for posting replies TWITTER_ACCESS_TOKEN_SECRET= # Twitter/X OAuth 1.0a access token secret for posting replies TWITTER_API_URL= # Defaults to https://api.twitter.com diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9bcdf0f..5a243e0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -12,6 +12,8 @@ permissions: contents: read env: + # Pinned Tempo ID image of the end-to-end suite, by the `tempoxyz/id` commit that built it. + ID_DEV_IMAGE: ghcr.io/tempoxyz/id-dev:cbdd6c0dbfbbf47930b90bf4f0e277e9425f1033 # Pinned known-good Tempo Docker image. Supports tags or sha256 digests. VITE_TEMPO_IMAGE: sha256:80769b5615556fcdb9f158de0c699523613fb3901c2df3e58bc770dab106ead1 WRANGLER_SEND_METRICS: false @@ -139,9 +141,11 @@ jobs: e2e: name: E2E runs-on: ubuntu-latest + # The id-dev image is private to the organization, so the job reads it with its token. permissions: contents: read id-token: write + packages: read steps: - name: Secure runner uses: tempoxyz/gh-actions/actions/secure-runner@35019557465e3c38ee3468be171771fd73577584 # 2026-10-20T06-22-38Z-35019557 @@ -163,6 +167,11 @@ jobs: (cd node_modules/better-sqlite3 && ./node_modules/.bin/prebuild-install || node-gyp rebuild --release) node -e "new (require('better-sqlite3'))(':memory:').close()" + - name: Log in to GHCR + env: + GITHUB_TOKEN: ${{ github.token }} + run: echo "$GITHUB_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + - name: Run E2E tests run: pnpm test:e2e diff --git a/package.json b/package.json index 138bb95..6c3c3c7 100644 --- a/package.json +++ b/package.json @@ -35,17 +35,16 @@ "@tanstack/react-router": "1.170.44", "@tanstack/react-start": "1.169.2", "@tanstack/router-plugin": "1.168.46", - "accounts": "^0.15.2", + "accounts": "1.0.0-alpha.1", "chat": "4.27.0", "hono": "4.13.13", "kysely": "0.28.17", "nanoid": "^5.1.16", - "ox": "0.14.20", + "ox": "0.14.59", "react": "19.2.5", "react-dom": "19.2.5", "tapimo": "0.5.2", - "viem": "^2.55.10", - "wagmi": "^3.6.14", + "viem": "^2.57.12", "zod": "4.6.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a795be..6b82684 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: 1.168.46 version: 1.168.46(@tanstack/react-router@1.170.44(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@1.3.0(@types/node@22.19.17)(@typescript/native-preview@7.0.0-dev.20260505.1)(@vitest/ui@5.0.5)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.13.6(@types/node@22.19.17)(typescript@6.0.3))(playwright@1.59.1)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.8.4))(esbuild@0.28.1)(rollup@4.62.2) accounts: - specifier: ^0.15.2 - version: 0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(@wagmi/core@3.4.11)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(wagmi@3.6.14) + specifier: 1.0.0-alpha.1 + version: 1.0.0-alpha.1(react@19.2.5)(typescript@6.0.3)(viem@2.57.16(typescript@6.0.3)(zod@4.6.6))(zod@4.6.6) chat: specifier: 4.27.0 version: 4.27.0 @@ -111,8 +111,8 @@ importers: specifier: ^5.1.16 version: 5.1.16 ox: - specifier: 0.14.20 - version: 0.14.20(typescript@6.0.3)(zod@4.6.6) + specifier: 0.14.59 + version: 0.14.59(typescript@6.0.3)(zod@4.6.6) react: specifier: 19.2.5 version: 19.2.5 @@ -121,13 +121,10 @@ importers: version: 19.2.5(react@19.2.5) tapimo: specifier: 0.5.2 - version: 0.5.2(c1458eeeaf225851985db699c3faa73c) + version: 0.5.2(5f716ab8d62222fd6aae3904840d3e9e) viem: - specifier: ^2.55.10 - version: 2.55.10(typescript@6.0.3)(zod@4.6.6) - wagmi: - specifier: ^3.6.14 - version: 3.6.14(@tanstack/query-core@5.100.10)(@tanstack/react-query@5.100.10(react@19.2.5))(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) + specifier: ^2.57.12 + version: 2.57.16(typescript@6.0.3)(zod@4.6.6) zod: specifier: 4.6.6 version: 4.6.6 @@ -2848,55 +2845,6 @@ packages: peerDependencies: vue: 3.5.42 - '@wagmi/connectors@8.0.13': - resolution: {integrity: sha512-uRWutZygoX5K1Vk4svKeMxmyeTEjdZn8f/+EDKQEQaFMvy6yMVlLpy5DKFK0NRjtTIinRrqVccDqJQYCcC62hw==} - peerDependencies: - '@base-org/account': ^2.5.1 - '@coinbase/wallet-sdk': ^4.3.6 - '@metamask/connect-evm': ^1.0.0 - '@safe-global/safe-apps-provider': ~0.18.6 - '@safe-global/safe-apps-sdk': ^9.1.0 - '@wagmi/core': 3.4.11 - '@walletconnect/ethereum-provider': ^2.21.1 - accounts: ~0.10 - porto: ~0.2.35 - typescript: '>=5.7.3' - viem: 2.x - peerDependenciesMeta: - '@base-org/account': - optional: true - '@coinbase/wallet-sdk': - optional: true - '@metamask/connect-evm': - optional: true - '@safe-global/safe-apps-provider': - optional: true - '@safe-global/safe-apps-sdk': - optional: true - '@walletconnect/ethereum-provider': - optional: true - accounts: - optional: true - porto: - optional: true - typescript: - optional: true - - '@wagmi/core@3.4.11': - resolution: {integrity: sha512-dmrZr0fKxCmG3pyWO9//oYtE5cgWRrgaQjC0+jX6IiuufE4MSW7gSL2MKdK8bvT3jg4Ra8cdRIJpveHJSHKdNw==} - peerDependencies: - '@tanstack/query-core': '>=5.0.0' - accounts: ~0.8.1 - typescript: '>=5.7.3' - viem: 2.x - peerDependenciesMeta: - '@tanstack/query-core': - optional: true - accounts: - optional: true - typescript: - optional: true - '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} @@ -3034,6 +2982,30 @@ packages: wagmi: optional: true + accounts@1.0.0-alpha.1: + resolution: {integrity: sha512-227kyV5klSZD0aC1vcC3J6sW9XyPuwr9KNW/fYNKfYrhC5ALFeCVGdNo0ZlZzpmtkXYIGu1ECMUKZq+crBkhgQ==} + peerDependencies: + '@wagmi/core': ^3.6.7 + expo-secure-store: '>=14' + expo-web-browser: '>=14' + mppx: '>=0.17.1' + react: '>=18' + react-native-get-random-values: '>=1' + viem: ^2.57.12 + peerDependenciesMeta: + '@wagmi/core': + optional: true + expo-secure-store: + optional: true + expo-web-browser: + optional: true + mppx: + optional: true + react: + optional: true + react-native-get-random-values: + optional: true + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -4135,6 +4107,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -4922,14 +4897,6 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - ox@0.14.20: - resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} - peerDependencies: - typescript: '>=5.4.0' - peerDependenciesMeta: - typescript: - optional: true - ox@0.14.30: resolution: {integrity: sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==} peerDependencies: @@ -4938,8 +4905,8 @@ packages: typescript: optional: true - ox@0.14.33: - resolution: {integrity: sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==} + ox@0.14.59: + resolution: {integrity: sha512-Y+DPEQScTEGKteQATjtytE1JRQ5cg1R2+DvYQ/lbhUCm/xS9n0Jnkkf2AqQyIntefQxs8xY6dZ+kyNZ7zf2FzA==} peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -5982,11 +5949,6 @@ packages: urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} - use-sync-external-store@1.4.0: - resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -6025,11 +5987,14 @@ packages: typescript: optional: true - viem@2.55.10: - resolution: {integrity: sha512-Q9Ba+/ma81U2M5o5P2AQ7Ux8rTIwmCZvUcr8rKdQ22bV0IBFHllM2m5gWDP8hFaUN2nH2oW3QG44amRazflYNQ==} + viem@2.57.16: + resolution: {integrity: sha512-+4uaUw3NfkqjaqsZp8Zci94K33oc9rv7mIUgvPduJxsY/tI0ASi2YkaXoUVSBGfdIw/QPWuVnIf9LTFf35IoMg==} peerDependencies: + mppx: '>=0.17.0' typescript: '>=5.0.4' peerDependenciesMeta: + mppx: + optional: true typescript: optional: true @@ -6154,17 +6119,6 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - wagmi@3.6.14: - resolution: {integrity: sha512-kHRaaD9DeFaUN6WcVMEKApTwVwImt0fGINh04Z4WYXLJhkb0rBxG4js+3t8EzXsV/veec5dV90j7jfGwjGSsqQ==} - peerDependencies: - '@tanstack/react-query': '>=5.0.0' - react: '>=18' - typescript: '>=5.7.3' - viem: 2.x - peerDependenciesMeta: - typescript: - optional: true - wata@0.4.0: resolution: {integrity: sha512-VpO6aG6COD/TmiGEhSO4SzV8eWfOEgKUKWTT339xRQIfIiQWAlJ1c6WQ0DM8bMaaRnUNHegsdg1rt+u21xwqLg==} peerDependencies: @@ -6251,6 +6205,9 @@ packages: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} + xstate@5.33.3: + resolution: {integrity: sha512-AnDRjS/GyKHmbA1PyhVCYBqYsA7EGe3KGgeD0ht3NFsXzoz7leX/HQDFX9QlHFJ/ltLHPsgChJYwPzRvB/0b4g==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -6309,24 +6266,6 @@ packages: zod@4.6.6: resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} - zustand@5.0.0: - resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - zustand@5.0.13: resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} engines: {node: '>=12.20.0'} @@ -7233,7 +7172,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) hono: 4.13.13 - jose: 6.2.3 + jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -8961,30 +8900,6 @@ snapshots: dependencies: vue: 3.5.42(typescript@6.0.3) - '@wagmi/connectors@8.0.13(@wagmi/core@3.4.11)(accounts@0.15.2)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))': - dependencies: - '@wagmi/core': 3.4.11(@tanstack/query-core@5.100.10)(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) - viem: 2.55.10(typescript@6.0.3)(zod@4.6.6) - optionalDependencies: - accounts: 0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(@wagmi/core@3.4.11)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(wagmi@3.6.14) - typescript: 6.0.3 - - '@wagmi/core@3.4.11(@tanstack/query-core@5.100.10)(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))': - dependencies: - eventemitter3: 5.0.1 - mipd: 0.0.7(typescript@6.0.3) - viem: 2.55.10(typescript@6.0.3)(zod@4.6.6) - zustand: 5.0.0(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.4.0(react@19.2.5)) - optionalDependencies: - '@tanstack/query-core': 5.100.10 - accounts: 0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(@wagmi/core@3.4.11)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(wagmi@3.6.14) - typescript: 6.0.3 - transitivePeerDependencies: - - '@types/react' - - immer - - react - - use-sync-external-store - '@workflow/serde@4.1.0-beta.2': {} '@yuku-core/binding-android-arm64@0.19.0': @@ -9044,23 +8959,21 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - accounts@0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(@wagmi/core@3.4.11)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.54.6(typescript@6.0.3)(zod@4.6.6))(wagmi@3.6.14): + accounts@0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.7.0(react@19.2.5))(viem@2.54.6(typescript@6.0.3)(zod@4.6.6)): dependencies: hono: 4.13.13 idb-keyval: 6.2.2 jose: 6.2.3 mipd: 0.0.7(typescript@6.0.3) mppx: 0.8.9(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(express@5.2.1)(hono@4.13.13)(typescript@6.0.3)(viem@2.54.6(typescript@6.0.3)(zod@4.6.6)) - ox: 0.14.33(typescript@6.0.3)(zod@4.6.6) + ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) wata: 0.4.0(react@19.2.5)(typescript@6.0.3) webauthx: 0.1.2(typescript@6.0.3)(zod@4.6.6) zod: 4.6.6 - zustand: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.4.0(react@19.2.5)) + zustand: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.7.0(react@19.2.5)) optionalDependencies: - '@wagmi/core': 3.4.11(@tanstack/query-core@5.100.10)(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) react: 19.2.5 viem: 2.54.6(typescript@6.0.3)(zod@4.6.6) - wagmi: 3.6.14(@tanstack/query-core@5.100.10)(@tanstack/react-query@5.100.10(react@19.2.5))(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - '@types/react' @@ -9071,32 +8984,17 @@ snapshots: - typescript - use-sync-external-store - accounts@0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(@wagmi/core@3.4.11)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(wagmi@3.6.14): + accounts@1.0.0-alpha.1(react@19.2.5)(typescript@6.0.3)(viem@2.57.16(typescript@6.0.3)(zod@4.6.6))(zod@4.6.6): dependencies: - hono: 4.13.13 - idb-keyval: 6.2.2 - jose: 6.2.3 - mipd: 0.0.7(typescript@6.0.3) - mppx: 0.8.9(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(express@5.2.1)(hono@4.13.13)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) - ox: 0.14.33(typescript@6.0.3)(zod@4.6.6) - wata: 0.4.0(react@19.2.5)(typescript@6.0.3) - webauthx: 0.1.2(typescript@6.0.3)(zod@4.6.6) - zod: 4.6.6 - zustand: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.4.0(react@19.2.5)) + jose: 6.2.12 + ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) + viem: 2.57.16(typescript@6.0.3)(zod@4.6.6) + xstate: 5.33.3 optionalDependencies: - '@wagmi/core': 3.4.11(@tanstack/query-core@5.100.10)(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) react: 19.2.5 - viem: 2.55.10(typescript@6.0.3)(zod@4.6.6) - wagmi: 3.6.14(@tanstack/query-core@5.100.10)(@tanstack/react-query@5.100.10(react@19.2.5))(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - '@types/react' - - elysia - - expo-crypto - - express - - immer - typescript - - use-sync-external-store + - zod acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -10291,6 +10189,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + jose@6.2.3: {} js-base64@3.9.1: {} @@ -11050,21 +10950,6 @@ snapshots: transitivePeerDependencies: - typescript - mppx@0.8.9(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(express@5.2.1)(hono@4.13.13)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)): - dependencies: - '@stripe/stripe-js': 9.9.0 - eventsource-parser: 3.1.0 - incur: 0.4.19 - ox: 0.14.30(typescript@6.0.3)(zod@4.6.6) - viem: 2.55.10(typescript@6.0.3)(zod@4.6.6) - zod: 4.6.6 - optionalDependencies: - '@modelcontextprotocol/sdk': 1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6) - express: 5.2.1 - hono: 4.13.13 - transitivePeerDependencies: - - typescript - mrmime@2.0.1: {} ms@2.1.3: {} @@ -11172,21 +11057,6 @@ snapshots: outvariant@1.4.3: {} - ox@0.14.20(typescript@6.0.3)(zod@4.6.6): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@6.0.3)(zod@4.6.6) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 6.0.3 - transitivePeerDependencies: - - zod - ox@0.14.30(typescript@6.0.3)(zod@4.6.6): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -11202,7 +11072,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.14.33(typescript@6.0.3)(zod@4.6.6): + ox@0.14.59(typescript@6.0.3)(zod@4.6.6): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -11210,7 +11080,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@6.0.3)(zod@4.6.6) + abitype: 1.3.0(typescript@6.0.3)(zod@4.6.6) eventemitter3: 5.0.1 optionalDependencies: typescript: 6.0.3 @@ -12125,11 +11995,11 @@ snapshots: tapable@2.3.3: {} - tapimo@0.5.2(c1458eeeaf225851985db699c3faa73c): + tapimo@0.5.2(5f716ab8d62222fd6aae3904840d3e9e): dependencies: '@hono/node-server': 2.1.3(hono@4.13.13) '@hono/standard-validator': 0.2.3(@standard-schema/spec@1.1.0)(hono@4.13.13) - accounts: 0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(@wagmi/core@3.4.11)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.54.6(typescript@6.0.3)(zod@4.6.6))(wagmi@3.6.14) + accounts: 0.15.2(@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(zod@4.6.6))(@types/react@19.2.14)(express@5.2.1)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.7.0(react@19.2.5))(viem@2.54.6(typescript@6.0.3)(zod@4.6.6)) hono: 4.13.13 hono-openapi: 1.3.1(@hono/standard-validator@0.2.3(@standard-schema/spec@1.1.0)(hono@4.13.13))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.6.6))(zod@4.6.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.6.6))(zod@4.6.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.6.6))(@types/json-schema@7.0.15)(hono@4.13.13)(openapi-types@12.1.3) incur: 0.4.19 @@ -12309,7 +12179,7 @@ snapshots: abitype: 1.3.0(typescript@6.0.3)(zod@4.6.6) kysely: 0.28.17 mitt: 3.0.1 - ox: 0.14.30(typescript@6.0.3)(zod@4.6.6) + ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) zod: 4.6.6 transitivePeerDependencies: - typescript @@ -12589,10 +12459,6 @@ snapshots: urlpattern-polyfill@10.1.0: {} - use-sync-external-store@1.4.0(react@19.2.5): - dependencies: - react: 19.2.5 - use-sync-external-store@1.6.0(react@19.2.5): dependencies: react: 19.2.5 @@ -12639,7 +12505,7 @@ snapshots: - utf-8-validate - zod - viem@2.55.10(typescript@6.0.3)(zod@4.6.6): + viem@2.57.16(typescript@6.0.3)(zod@4.6.6): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -12647,7 +12513,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@6.0.3)(zod@4.6.6) isows: 1.0.7(ws@8.21.0) - ox: 0.14.33(typescript@6.0.3)(zod@4.6.6) + ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) ws: 8.21.0 optionalDependencies: typescript: 6.0.3 @@ -12895,36 +12761,13 @@ snapshots: w3c-keyname@2.2.8: {} - wagmi@3.6.14(@tanstack/query-core@5.100.10)(@tanstack/react-query@5.100.10(react@19.2.5))(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)): - dependencies: - '@tanstack/react-query': 5.100.10(react@19.2.5) - '@wagmi/connectors': 8.0.13(@wagmi/core@3.4.11)(accounts@0.15.2)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) - '@wagmi/core': 3.4.11(@tanstack/query-core@5.100.10)(@types/react@19.2.14)(accounts@0.15.2)(react@19.2.5)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.55.10(typescript@6.0.3)(zod@4.6.6)) - react: 19.2.5 - use-sync-external-store: 1.4.0(react@19.2.5) - viem: 2.55.10(typescript@6.0.3)(zod@4.6.6) - optionalDependencies: - typescript: 6.0.3 - transitivePeerDependencies: - - '@base-org/account' - - '@coinbase/wallet-sdk' - - '@metamask/connect-evm' - - '@safe-global/safe-apps-provider' - - '@safe-global/safe-apps-sdk' - - '@tanstack/query-core' - - '@types/react' - - '@walletconnect/ethereum-provider' - - accounts - - immer - - porto - wata@0.4.0(react@19.2.5)(typescript@6.0.3): dependencies: '@hono/node-server': 2.1.3(hono@4.13.13) '@noble/ciphers': 2.2.0 '@noble/hashes': 2.2.0 hono: 4.13.13 - ox: 0.14.20(typescript@6.0.3)(zod@4.6.6) + ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) zod: 4.6.6 optionalDependencies: react: 19.2.5 @@ -12937,7 +12780,7 @@ snapshots: webauthx@0.1.2(typescript@6.0.3)(zod@4.6.6): dependencies: - ox: 0.14.20(typescript@6.0.3)(zod@4.6.6) + ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) transitivePeerDependencies: - typescript - zod @@ -13017,6 +12860,8 @@ snapshots: '@oozcitak/util': 10.0.0 js-yaml: 4.3.2 + xstate@5.33.3: {} + xtend@4.0.2: {} y18n@5.0.8: {} @@ -13088,17 +12933,11 @@ snapshots: zod@4.6.6: {} - zustand@5.0.0(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.4.0(react@19.2.5)): + zustand@5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.7.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.14 react: 19.2.5 - use-sync-external-store: 1.4.0(react@19.2.5) - - zustand@5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.4.0(react@19.2.5)): - optionalDependencies: - '@types/react': 19.2.14 - react: 19.2.5 - use-sync-external-store: 1.4.0(react@19.2.5) + use-sync-external-store: 1.7.0(react@19.2.5) zwitch@2.0.4: {} @@ -13121,7 +12960,6 @@ time: chat@4.27.0: '2026-04-30T20:46:13.887Z' emulate@0.5.0: '2026-04-29T05:49:35.977Z' kysely@0.28.17: '2026-05-03T01:05:34.179Z' - ox@0.14.20: '2026-04-21T20:34:36.837Z' react-dom@19.2.5: '2026-04-08T18:39:31.423Z' react@19.2.5: '2026-04-08T18:39:24.455Z' tailwindcss@4.2.4: '2026-04-21T13:15:55.494Z' diff --git a/src/api.ts b/src/api.ts index ff9eb06..28c23b5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,4 @@ -import { env } from 'cloudflare:workers' -import { Hono } from 'hono' +import { type Context, Hono } from 'hono' import { Address, Base64, Hex } from 'ox' import * as chat from 'chat' import { z } from 'zod' @@ -11,66 +10,247 @@ import * as hono from '#/lib/hono.ts' import * as Nanoid from '#/lib/nanoid.ts' import * as Slack from '#/lib/slack.ts' import * as Tempo from '#/lib/tempo.ts' +import * as TempoId from '#/lib/tempoId.ts' import * as Tip from '#/lib/tip.ts' import * as Twitter from '#/lib/twitter.ts' -import { auth } from '#/lib/auth.ts' import * as DB from '#db/client.ts' -export const api = new Hono<{ +type AppEnv = { Bindings: Env Variables: { db: DB.Type } -}>() +} + +export const api = new Hono() .use(async (c, next) => { c.set('db', DB.create(c.env.DB)) await next() }) - .all('/api/auth/:path{.*}?', async (c) => { - const corsHeaders = (() => { - // Only auth-capable wallet and app origins may receive credentialed CORS headers. - const headers = new Headers() - const origin = c.req.raw.headers.get('origin') - if (!origin) return headers - const allowed = (() => { - try { - const url = new URL(origin) - const path = new URL(c.req.raw.url).pathname - return ( - (path === '/api/auth/challenge' && url.protocol === 'https:') || - url.hostname === env.HOST || - url.hostname === 'accounts.tempo.xyz' || - url.hostname === 'wallet.tempo.xyz' || - (env.HOST.endsWith('.localhost') && url.hostname.endsWith('.localhost')) - ) - } catch { - return false - } - })() - if (!allowed) return headers + .get('/api/auth/start', async (c) => + c.redirect(await TempoId.start(c, { intent: { kind: 'dash' } }), 302), + ) + .get('/api/auth/callback', async (c) => { + const result = await TempoId.complete(c).catch((error) => { + console.error('Tempo ID callback failed:', error) + return undefined + }) + if (!result) return c.redirect('/', 302) + const intent = result.intent + if (intent.kind === 'dash') return c.redirect('/dash', 302) + + const key = await TempoId.adopt(c.env, result.session) + if (intent.kind === 'link') { + // The link verifies the key authorization against the access key that its row names. + await c.var.db + .updateTable('account_link_token') + .set({ + access_key_address: key.address, + access_key_ciphertext: key.ciphertext, + access_key_public_key: key.publicKey, + }) + .where('token_hash', '=', await AccountLink.hashToken(c.env, intent.token)) + .where('used_at', 'is', null) + .execute() + const response = await linkAccount(c, { + address: key.rootAddress, + keyAuthorization: key.keyAuthorization, + token: intent.token, + }) + return c.redirect(await getResultUrl(`/connect/${intent.token}`, response), 302) + } + if (intent.kind === 'confirm') { + const response = await confirmPayment(c, { + accessKey: { address: key.address, privateKey: key.privateKey }, + address: key.rootAddress, + keyAuthorization: key.keyAuthorization, + token: intent.token, + }) + // The receipt link names the payment's chain, which the result page cannot read. + const chainId = await Tip.getConfirmationData(c.env, intent.token) + .then((data) => data.payload.chainId) + .catch(() => undefined) + const resultUrl = await getResultUrl(`/confirm/${intent.token}`, response) + return c.redirect(chainId ? `${resultUrl}&chain=${chainId}` : resultUrl, 302) + } - headers.set('access-control-allow-origin', origin) - headers.set('access-control-allow-credentials', 'true') - headers.set( - 'access-control-allow-headers', - c.req.raw.headers.get('access-control-request-headers') ?? 'authorization,content-type', + // An X link verifies the key authorization against the access key that its challenge names. + await c.var.db + .updateTable('provider_link_challenge') + .set({ + access_key_address: key.address, + access_key_ciphertext: key.ciphertext, + access_key_public_key: key.publicKey, + }) + .where('id', '=', intent.challengeId) + .where('used_at', 'is', null) + .execute() + const redirectUrl = new URL('/link/x', c.req.url) + try { + if (intent.kind === 'x_oauth') { + const oauth = await Twitter.createOAuthAuthorizationUrl(c.env, { + address: key.rootAddress, + challengeId: intent.challengeId, + keyAuthorization: key.keyAuthorization, + redirectUri: new URL('/api/link/twitter/oauth/callback', c.req.url).toString(), + }) + return c.redirect(oauth.authorizationUrl, 302) + } + const proof = await Twitter.createProof(c.env, { + address: key.rootAddress, + challengeId: intent.challengeId, + keyAuthorization: key.keyAuthorization, + }) + redirectUrl.searchParams.set('challenge', intent.challengeId) + redirectUrl.searchParams.set('proof', proof.proof) + return c.redirect(redirectUrl.toString(), 302) + } catch (error) { + console.error('Twitter link after Tempo ID failed:', error) + redirectUrl.searchParams.set( + 'error', + intent.kind === 'x_oauth' ? 'oauth_failed' : 'link_failed', ) - headers.set('access-control-allow-methods', 'POST,OPTIONS') - headers.set('vary', 'Origin') - return headers - })() - if (c.req.raw.method === 'OPTIONS') - return new Response(null, { headers: corsHeaders, status: 204 }) + return c.redirect(redirectUrl.toString(), 302) + } + }) + .post('/api/auth/logout', async (c) => { + await TempoId.end(c) + return c.redirect('/', 303) + }) + .get('/api/account/link/:token/start', async (c) => { + const link = await c.var.db + .selectFrom('account_link_token') + .innerJoin('member', 'member.id', 'account_link_token.member_id') + .innerJoin('workspace', 'workspace.id', 'member.workspace_id') + .select([ + 'account_link_token.access_key_expires_at', + 'account_link_token.expires_at', + 'account_link_token.used_at', + 'workspace.chain_id', + 'workspace.default_token_address', + ]) + .where( + 'account_link_token.token_hash', + '=', + await AccountLink.hashToken(c.env, c.req.param('token')), + ) + .executeTakeFirst() + if (!link || link.used_at || Date.now() > new Date(link.expires_at).getTime()) + return c.redirect(`/connect/${c.req.param('token')}`, 302) - const response = await auth.fetch(c.req.raw) - const headers = new Headers(response.headers) - for (const [key, value] of corsHeaders) headers.set(key, value) - return new Response(response.body, { - headers, - status: response.status, - statusText: response.statusText, + const url = await TempoId.start(c, { + accessKey: TempoId.permissions({ + chainId: link.chain_id, + expiresAt: link.access_key_expires_at, + limit: BigInt(AccountLink.reusableAccessKeyLimit), + periodSeconds: AccountLink.reusableAccessKeyPeriodSeconds, + tokenAddress: link.default_token_address ?? Tempo.addressLookup.pathUsd, + }), + funding: TempoId.funding(link.chain_id), + intent: { kind: 'link', token: c.req.param('token') }, }) + return c.redirect(url, 302) + }) + .post('/api/account/link/:token/resolve', async (c) => { + // Connects again with the session's access key, and moves the wallet off the other account. + const session = await TempoId.get(c) + if (!session?.key) return c.redirect(`/api/account/link/${c.req.param('token')}/start`, 303) + const key = await TempoId.adopt(c.env, session) + const response = await linkAccount(c, { + address: key.rootAddress, + disconnectExistingAccount: true, + keyAuthorization: key.keyAuthorization, + token: c.req.param('token'), + }) + return c.redirect(await getResultUrl(`/connect/${c.req.param('token')}`, response), 303) + }) + .get('/api/confirm/:token/start', async (c) => { + // An expired or invalid link shows the page's expired state. + const data = await Tip.getConfirmationData(c.env, c.req.param('token')).catch(() => undefined) + if (!data) return c.redirect(`/confirm/${c.req.param('token')}`, 302) + try { + const payload = data.payload + const accessKey = await (async () => { + if (payload.kind === 'reusable_access_key') + return TempoId.permissions({ + chainId: payload.chainId, + expiresAt: payload.accessKeyExpiresAt ?? payload.expiresAt, + limit: BigInt(payload.accessKeyLimit ?? AccountLink.reusableAccessKeyLimit), + periodSeconds: AccountLink.reusableAccessKeyPeriodSeconds, + tokenAddress: payload.tokenAddress, + }) + // A one-time payment authorizes an access key for this payment alone: its total, to its + // recipients, until the link expires. + const recipients = await Tip.getConfirmationRecipientAddresses(c.env, c.req.param('token')) + return TempoId.permissions({ + chainId: payload.chainId, + expiresAt: payload.expiresAt, + limit: BigInt(payload.amount) * BigInt(recipients.length), + periodSeconds: AccountLink.confirmationLinkTtlMs / 1000, // 10 minutes + recipients, + tokenAddress: payload.tokenAddress, + }) + })() + const url = await TempoId.start(c, { + accessKey, + funding: TempoId.funding(data.payload.chainId), + intent: { kind: 'confirm', token: c.req.param('token') }, + }) + return c.redirect(url, 302) + } catch (error) { + const url = new URL(`/confirm/${c.req.param('token')}`, c.req.url) + url.searchParams.set( + 'error', + error instanceof Error ? error.message : 'This payment link is invalid or expired.', + ) + return c.redirect(url.toString(), 302) + } }) + .get('/api/link/twitter/oauth/begin', async (c) => { + try { + const challenge = await Twitter.createOAuthLinkChallenge(c.env, {}) + return c.redirect( + await TempoId.start(c, { + accessKey: getTwitterPermissions(challenge), + intent: { challengeId: challenge.challengeId, kind: 'x_oauth' }, + }), + 302, + ) + } catch (error) { + console.error('Twitter OAuth start failed:', error) + return c.redirect('/link/x?error=oauth_failed', 302) + } + }) + .get( + '/api/link/twitter/proof/begin', + hono.validator( + 'query', + z.object({ + username: z + .string() + .min(1) + .max(16) + .regex(/^@?[A-Za-z0-9_]{1,15}$/), + }), + ), + async (c) => { + try { + const challenge = await Twitter.createLinkChallenge(c.env, { + username: c.req.valid('query').username, + }) + return c.redirect( + await TempoId.start(c, { + accessKey: getTwitterPermissions(challenge), + intent: { challengeId: challenge.challengeId, kind: 'x_proof' }, + }), + 302, + ) + } catch (error) { + console.error('Twitter proof start failed:', error) + return c.redirect('/link/x?error=link_failed', 302) + } + }, + ) .get('/api/account/link/:token', async (c) => { const link = await c.var.db .selectFrom('account_link_token') @@ -128,254 +308,7 @@ export const api = new Hono<{ keyAuthorization: z.unknown(), }), ), - async (c) => { - const body = c.req.valid('json') - const link = await c.var.db - .selectFrom('account_link_token') - .innerJoin('member', 'member.id', 'account_link_token.member_id') - .innerJoin('workspace', 'workspace.id', 'member.workspace_id') - .select([ - 'account_link_token.access_key_address', - 'account_link_token.access_key_ciphertext', - 'account_link_token.access_key_expires_at', - 'account_link_token.access_key_public_key', - 'account_link_token.expires_at', - 'account_link_token.id', - 'account_link_token.member_id', - 'account_link_token.provider_channel_id', - 'account_link_token.channel_provider_id', - 'account_link_token.used_at', - 'member.provider_identity_id as member_provider_identity_id', - 'member.provider_user_id as member_provider_user_id', - 'workspace.chain_id', - 'workspace.default_token_address', - 'workspace.id as workspace_id', - 'workspace.provider_id', - ]) - .where( - 'account_link_token.token_hash', - '=', - await AccountLink.hashToken(c.env, c.req.param('token')), - ) - .executeTakeFirst() - if (!link || link.used_at || Date.now() > new Date(link.expires_at).getTime()) - return c.json( - { - code: 'invalid_account_link' as const, - message: 'This connection link is invalid or expired.', - ok: false as const, - }, - 404, - ) - - try { - const verified = await AccountLink.verifyKeyAuthorization({ - accessKeyAddress: link.access_key_address, - chainId: link.chain_id, - env: c.env, - expiresAt: link.access_key_expires_at, - keyAuthorization: body.keyAuthorization, - rootAddress: body.address, - tokenAddress: Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd), - }) - const now = new Date().toISOString() - const existingAccount = await c.var.db - .selectFrom('account') - .selectAll() - .where('address', '=', verified.rootAddress) - .executeTakeFirst() - const account = await (async () => { - if (existingAccount) { - await c.var.db - .updateTable('account') - .set({ updated_at: now }) - .where('id', '=', existingAccount.id) - .execute() - return existingAccount - } - - const id = Nanoid.generate() - await c.var.db - .insertInto('account') - .values({ - address: verified.rootAddress, - created_at: now, - id, - updated_at: now, - }) - .execute() - return await c.var.db - .selectFrom('account') - .selectAll() - .where('id', '=', id) - .executeTakeFirstOrThrow() - })() - - const duplicate = await c.var.db - .selectFrom('member') - .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') - .select(['member.id', 'member.provider_identity_id']) - .where('member.workspace_id', '=', link.workspace_id) - .where('member.id', '!=', link.member_id) - .where('provider_identity.account_id', '=', account.id) - .executeTakeFirst() - if (duplicate && !body.disconnectExistingAccount) - return c.json( - { - code: 'account_already_connected' as const, - message: - 'This wallet is already connected to another Slack account in this workspace.', - }, - 409, - ) - if (duplicate && body.disconnectExistingAccount) { - const duplicateIdentities = await c.var.db - .selectFrom('member') - .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') - .select('member.provider_identity_id') - .where('member.workspace_id', '=', link.workspace_id) - .where('member.id', '!=', link.member_id) - .where('provider_identity.account_id', '=', account.id) - .execute() - const duplicateIdentityIds = duplicateIdentities.map((row) => row.provider_identity_id) - await c.var.db - .updateTable('provider_identity') - .set({ account_id: null, updated_at: now }) - .where('id', 'in', duplicateIdentityIds) - .execute() - } - - await c.var.db - .deleteFrom('access_key') - .where('account_id', '=', account.id) - .where('chain_id', '=', link.chain_id) - .where( - 'token_address', - '=', - Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd), - ) - .execute() - await c.var.db - .deleteFrom('access_key') - .where('account_id', '=', account.id) - .where('chain_id', '=', link.chain_id) - .where('token_address', 'is', null) - .execute() - await c.var.db - .insertInto('access_key') - .values({ - account_id: account.id, - address: link.access_key_address, - authorization: verified.serialized, - chain_id: link.chain_id, - ciphertext: link.access_key_ciphertext, - created_at: now, - expires_at: verified.expiresAt, - id: Nanoid.generate(), - revoked_at: null, - token_address: Address.checksum( - link.default_token_address ?? Tempo.addressLookup.pathUsd, - ), - updated_at: now, - }) - .execute() - if (link.member_provider_identity_id) - await c.var.db - .updateTable('provider_identity') - .set({ account_id: account.id, updated_at: now }) - .where('id', '=', link.member_provider_identity_id) - .execute() - await c.var.db - .updateTable('account_link_token') - .set({ - access_key_authorization: verified.serialized, - account_id: account.id, - used_at: now, - }) - .where('id', '=', link.id) - .execute() - c.executionCtx.waitUntil( - (async () => { - const pendingTips = await c.var.db - .selectFrom('pending_tip') - .select('id') - .where('recipient_member_id', '=', link.member_id) - .where('status', '=', 'pending') - .execute() - for (const pendingTip of pendingTips) - await c.env.PENDING_TIP_QUEUE.send({ pendingTipId: pendingTip.id }) - })().catch((error) => { - console.error('Failed to enqueue pending tips after wallet connection:', error) - }), - ) - - if (link.provider_channel_id) - c.executionCtx.waitUntil( - (async () => { - await Chat.getChat().initialize() - const installation = await Chat.getSlack().getInstallation( - link.channel_provider_id ?? link.provider_id, - ) - if (!installation) return - - const reactionTipConfigs = await c.var.db - .selectFrom('reaction_tip_config') - .select(['amount', 'emoji']) - .where('workspace_id', '=', link.workspace_id) - .orderBy('amount', 'asc') - .orderBy('emoji', 'asc') - .execute() - const reactionTipConfigsText = ( - reactionTipConfigs.length ? reactionTipConfigs : Tip.defaultReactionTipConfigs - ) - .map( - (config) => - `:${config.emoji}: \`:${config.emoji}:\` (${formatAmount(config.amount)})`, - ) - .join(', ') - - const channelRef = Chat.getChat().channel( - link.provider_channel_id!.startsWith('slack:') - ? link.provider_channel_id! - : `slack:${link.provider_channel_id!}`, - ) - const truncatedAddress = `${account.address.slice(0, 6)}…${account.address.slice(-4)}` - const explorerUrl = Tempo.explorerLink(link.chain_id, account.address) - - await Chat.getSlack().withBotToken(installation.botToken, () => - channelRef.postEphemeral( - link.member_provider_user_id, - { - card: chat.Card({ - children: [ - chat.CardText(`Connected \`${truncatedAddress}\` <${explorerUrl}|View>`), - chat.CardText( - `Mention \`@${getSlackBotDisplayName(c.env.HOST)} @user\` or use \`${getSlackCommand(c.env.HOST)} @user\` to send a payment. React with ${reactionTipConfigsText} to tip a message.`, - { style: 'muted' }, - ), - ], - }), - fallbackText: `Connected\nWallet: ${account.address}\nUse ${getSlackCommand(c.env.HOST)} @user to send your first tip.`, - }, - { fallbackToDM: false }, - ), - ) - })().catch((error) => { - console.error('Failed to notify Slack member after wallet connection:', error) - }), - ) - - return c.json({ ok: true as const }) - } catch (error) { - return c.json( - { - code: 'invalid_key_authorization' as const, - message: error instanceof Error ? error.message : 'Invalid key authorization.', - }, - 400, - ) - } - }, + async (c) => await linkAccount(c, { ...c.req.valid('json'), token: c.req.param('token') }), ) .post( '/api/link/twitter/challenge', @@ -681,10 +614,6 @@ export const api = new Hono<{ tokenAddress: Address.checksum(data.payload.tokenAddress), tokenCurrency: metadata.currency, tokenSymbol: metadata.symbol, - transactionRequest: - data.payload.kind === 'onetime_payment' - ? await Tip.getConfirmationTransactionRequest(c.env, c.req.param('token')) - : undefined, }) } catch (error) { return c.json( @@ -703,331 +632,10 @@ export const api = new Hono<{ 'json', z.object({ address: z.string().min(1), - keyAuthorization: z.unknown().optional(), - signedTransaction: z.string().min(1).optional(), + keyAuthorization: z.unknown(), }), ), - async (c) => { - try { - const body = c.req.valid('json') - const data = await Tip.getConfirmationData(c.env, c.req.param('token')) - const result = await Tip.confirmTipRequest(c.env, { - address: body.address, - keyAuthorization: body.keyAuthorization, - signedTransaction: body.signedTransaction as `0x${string}` | undefined, - token: c.req.param('token'), - }) - if (!result.ok) - return c.json( - { - code: result.code, - message: result.code === 'failed' ? 'Payment failed. Try again.' : result.message, - ok: false as const, - }, - 400, - ) - - if (result.status === 'sent' && data.payload.providerId === Twitter.twitterProviderId) - c.executionCtx.waitUntil( - Twitter.postConfirmationReceipt(c.env, { - providerChannelId: data.payload.providerChannelId, - providerThreadId: data.payload.providerThreadId, - result, - }).catch((error) => { - console.error('Failed to post Twitter receipt after confirmation:', error) - }), - ) - else if ( - result.status === 'sent' && - Chat.isReactionTipIdempotencyKey(data.payload.idempotencyKey) - ) - c.executionCtx.waitUntil( - (async () => { - await Chat.getChat().initialize() - const db = DB.create(c.env.DB) - const reactionTip = await db - .selectFrom('reaction_tip') - .innerJoin('workspace', 'workspace.id', 'reaction_tip.workspace_id') - .innerJoin('tip', 'tip.idempotency_key', 'reaction_tip.idempotency_key') - .select([ - 'reaction_tip.channel_id', - 'reaction_tip.id', - 'reaction_tip.reaction', - 'reaction_tip.thread_ts', - 'reaction_tip.workspace_id', - 'tip.id as tip_id', - ]) - .where('reaction_tip.workspace_id', '=', data.payload.workspaceId) - .where('reaction_tip.idempotency_key', '=', data.payload.idempotencyKey) - .where('tip.confirmed_at', 'is not', null) - .executeTakeFirst() - if (!reactionTip) return - - await db - .updateTable('reaction_tip') - .set({ tip_id: reactionTip.tip_id, updated_at: new Date().toISOString() }) - .where('id', '=', reactionTip.id) - .where('tip_id', 'is', null) - .execute() - await Chat.updateReactionTipAggregate(data.payload.providerId, { - channelId: reactionTip.channel_id, - threadTs: reactionTip.thread_ts, - workspaceId: reactionTip.workspace_id, - }) - })().catch((error) => { - console.error( - 'Failed to update Slack reaction tip aggregate after confirmation:', - error, - ) - }), - ) - else if ( - result.status === 'sent' && - Chat.isReceiptBoostIdempotencyKey(data.payload.idempotencyKey) - ) - c.executionCtx.waitUntil( - (async () => { - await Chat.getChat().initialize() - const installation = await Chat.getSlack().getInstallation(data.payload.providerId) - if (!installation) return - - const [, workspaceId, channelId, messageTs] = data.payload.idempotencyKey.split(':') - if (!workspaceId || !channelId || !messageTs) return - const receipt = await c.var.db - .selectFrom('tip_receipt_message') - .select(['message_ts', 'thread_ts']) - .where('workspace_id', '=', workspaceId) - .where('channel_id', '=', channelId) - .where('message_ts', '=', messageTs) - .executeTakeFirst() - if (!receipt) return - - if (receipt.thread_ts !== receipt.message_ts) { - await Chat.updateReceiptBoostAggregate(data.payload.providerId, { - channelId, - threadTs: receipt.thread_ts, - workspaceId, - }) - if (data.payload.skippedRecipients?.length) { - const skippedBody = new URLSearchParams() - const recipientCount = data.payload.recipients?.length ?? 1 - const skippedCount = data.payload.skippedRecipients.length - skippedBody.set('channel', channelId) - skippedBody.set( - 'text', - `Boost sent to ${recipientCount} ${recipientCount === 1 ? 'account' : 'accounts'}. Skipped ${skippedCount} ${skippedCount === 1 ? 'account' : 'accounts'} that can no longer receive payments.`, - ) - skippedBody.set('thread_ts', receipt.thread_ts) - skippedBody.set('user', data.payload.senderProviderUserId) - await Chat.getSlack().withBotToken(installation.botToken, () => - fetch(`${c.env.SLACK_API_URL}/chat.postEphemeral`, { - body: skippedBody, - headers: { authorization: `Bearer ${installation.botToken}` }, - method: 'POST', - }), - ) - } - return - } - - const originalReceiptLink = (() => { - if (receipt.thread_ts === receipt.message_ts) return '' - const url = new URL('slack://channel') - url.searchParams.set('team', data.payload.providerId) - url.searchParams.set('id', channelId) - url.searchParams.set('message', receipt.message_ts) - return ` <${url}|this message>` - })() - const receiptLink = `<${Tempo.formatTxLink(result.chainId, result.transactionHash)}|Receipt>` - const text = `<@${result.senderProviderUserId}> boosted${originalReceiptLink} · ${receiptLink}` - const body = new URLSearchParams() - body.set( - 'blocks', - JSON.stringify([ - { - text: { text, type: 'mrkdwn' }, - type: 'section', - }, - ]), - ) - body.set('channel', channelId) - body.set('text', text.replace(receiptLink, 'Receipt')) - body.set('thread_ts', receipt.thread_ts) - body.set('unfurl_links', 'false') - body.set('unfurl_media', 'false') - const response = await Chat.getSlack().withBotToken(installation.botToken, () => - fetch(`${c.env.SLACK_API_URL}/chat.postMessage`, { - body, - headers: { authorization: `Bearer ${installation.botToken}` }, - method: 'POST', - }), - ) - const json = z.parse( - z.object({ - error: z.string().optional(), - ok: z.boolean().optional(), - ts: z.string().optional(), - }), - await response.json(), - ) - if (!json.ok || !json.ts) - throw new Error(json.error ?? 'Slack API chat.postMessage failed.') - await Chat.recordSlackReceiptMessageForTransaction(c.var.db, { - channelId, - messageTs: json.ts, - threadTs: receipt.thread_ts, - transactionHash: result.transactionHash, - }) - if (data.payload.skippedRecipients?.length) { - const skippedBody = new URLSearchParams() - const recipientCount = data.payload.recipients?.length ?? 1 - const skippedCount = data.payload.skippedRecipients.length - skippedBody.set('channel', channelId) - skippedBody.set( - 'text', - `Boost sent to ${recipientCount} ${recipientCount === 1 ? 'account' : 'accounts'}. Skipped ${skippedCount} ${skippedCount === 1 ? 'account' : 'accounts'} that can no longer receive payments.`, - ) - skippedBody.set('thread_ts', receipt.thread_ts) - skippedBody.set('user', data.payload.senderProviderUserId) - await Chat.getSlack().withBotToken(installation.botToken, () => - fetch(`${c.env.SLACK_API_URL}/chat.postEphemeral`, { - body: skippedBody, - headers: { authorization: `Bearer ${installation.botToken}` }, - method: 'POST', - }), - ) - } - })().catch((error) => { - console.error('Failed to post Slack boost receipt after confirmation:', error) - }), - ) - else if ( - result.status === 'sent' && - Chat.isTipAskIdempotencyKey(data.payload.idempotencyKey) - ) - c.executionCtx.waitUntil( - (async () => { - const tipAskId = Chat.getTipAskIdFromIdempotencyKey(data.payload.idempotencyKey) - if (!tipAskId) return - await Chat.updateTipAskMessage(data.payload.providerId, { tipAskId }) - })().catch((error) => { - console.error('Failed to update Slack tip jar after confirmation:', error) - }), - ) - else if (result.status === 'sent') - c.executionCtx.waitUntil( - (async () => { - await Chat.getChat().initialize() - const installation = await Chat.getSlack().getInstallation(data.payload.providerId) - if (!installation) return - - const amount = result.isDefaultToken - ? formatCurrencyAmount(result.amount, result.tokenCurrency) - : formatTipAmount(result.amount, result.tokenCurrency, result.tokenSymbol) - const text = - 'recipients' in result - ? `<@${result.senderProviderUserId}> ${result.memo ? 'sent' : 'tipped'} ${data.payload.groupId ? ` ` : ''}${Chat.formatProviderUserMentionSummary(result.recipients.map((recipient) => recipient.recipientProviderUserId))} ${amount} each${result.memo ? ` for ${result.memo}` : ''}.\n${[ - ...result.recipients.map( - (recipient) => `• <@${recipient.recipientProviderUserId}>`, - ), - ...(data.payload.skippedRecipients ?? []) - .slice(0, 10) - .map( - (recipient) => - `• <@${recipient.recipientProviderUserId}> (${recipient.reason === 'you' ? 'you' : 'not connected yet'})`, - ), - ...((data.payload.skippedRecipients?.length ?? 0) > 10 - ? [ - `…and ${(data.payload.skippedRecipients?.length ?? 0) - 10} more not connected yet`, - ] - : []), - ].join('\n')}` - : `<@${result.senderProviderUserId}> ${result.memo ? 'sent' : 'tipped'} <@${result.recipientProviderUserId}> ${amount}${result.memo ? ` for ${result.memo}` : ''}.` - const receiptText = text.replace(/\.$/, '') - const receiptLink = `<${Tempo.formatTxLink(result.chainId, result.transactionHash)}|Receipt>` - const receiptMessage = (() => { - const lineBreakIndex = receiptText.indexOf('\n') - if (lineBreakIndex === -1) return `${receiptText} · ${receiptLink}` - return `${receiptText.slice(0, lineBreakIndex).replace(/\.$/, '')} · ${receiptLink}${receiptText.slice(lineBreakIndex)}` - })() - const threadId = data.payload.providerThreadId - const body = new URLSearchParams() - body.set( - 'blocks', - JSON.stringify([ - { - text: { - text: receiptMessage, - type: 'mrkdwn', - }, - type: 'section', - }, - ]), - ) - body.set('channel', data.payload.providerChannelId.replace(/^slack:/, '')) - body.set('text', receiptMessage.replace(receiptLink, 'Receipt')) - if (threadId) body.set('thread_ts', threadId) - body.set('unfurl_links', 'false') - body.set('unfurl_media', 'false') - try { - const response = await Chat.getSlack().withBotToken(installation.botToken, () => - fetch(`${c.env.SLACK_API_URL}/chat.postMessage`, { - body, - headers: { authorization: `Bearer ${installation.botToken}` }, - method: 'POST', - }), - ) - const json = z.parse( - z.object({ - error: z.string().optional(), - ok: z.boolean().optional(), - ts: z.string().optional(), - }), - await response.json(), - ) - if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postMessage failed.') - if (json.ts) - await Chat.recordSlackReceiptMessageForTransaction(c.var.db, { - channelId: data.payload.providerChannelId.replace(/^slack:/, ''), - messageTs: json.ts, - threadTs: threadId ?? json.ts, - transactionHash: result.transactionHash, - }) - } finally { - if (threadId) { - const statusBody = new URLSearchParams() - statusBody.set( - 'channel_id', - data.payload.providerChannelId.replace(/^slack:/, ''), - ) - statusBody.set('thread_ts', threadId) - statusBody.set('status', '') - await fetch(`${c.env.SLACK_API_URL}/assistant.threads.setStatus`, { - body: statusBody, - headers: { authorization: `Bearer ${installation.botToken}` }, - method: 'POST', - }).catch(() => { - // Best effort only. Confirmed payment flow must not depend on Slack assistant UI cleanup. - }) - } - } - })().catch((error) => { - console.error('Failed to post Slack receipt after confirmation:', error) - }), - ) - if (!('transactionHash' in result)) throw new Error('Payment failed.') - return c.json({ ok: true as const, transactionHash: result.transactionHash }) - } catch (error) { - return c.json( - { - code: 'confirmation_failed' as const, - message: error instanceof Error ? error.message : 'Payment failed.', - ok: false as const, - }, - 400, - ) - } - }, + async (c) => await confirmPayment(c, { ...c.req.valid('json'), token: c.req.param('token') }), ) .get('/api/chat/twitter', async (c) => { const crcToken = c.req.query('crc_token') @@ -1512,3 +1120,601 @@ export const api = new Hono<{ await c.env.DB.prepare('SELECT 1').first() return c.json({ ok: true }) }) + +async function linkAccount( + c: Context, + body: { + address: string + disconnectExistingAccount?: boolean | undefined + keyAuthorization: unknown + token: string + }, +) { + const link = await c.var.db + .selectFrom('account_link_token') + .innerJoin('member', 'member.id', 'account_link_token.member_id') + .innerJoin('workspace', 'workspace.id', 'member.workspace_id') + .select([ + 'account_link_token.access_key_address', + 'account_link_token.access_key_ciphertext', + 'account_link_token.access_key_expires_at', + 'account_link_token.access_key_public_key', + 'account_link_token.expires_at', + 'account_link_token.id', + 'account_link_token.member_id', + 'account_link_token.provider_channel_id', + 'account_link_token.channel_provider_id', + 'account_link_token.used_at', + 'member.provider_identity_id as member_provider_identity_id', + 'member.provider_user_id as member_provider_user_id', + 'workspace.chain_id', + 'workspace.default_token_address', + 'workspace.id as workspace_id', + 'workspace.provider_id', + ]) + .where('account_link_token.token_hash', '=', await AccountLink.hashToken(c.env, body.token)) + .executeTakeFirst() + if (!link || link.used_at || Date.now() > new Date(link.expires_at).getTime()) + return c.json( + { + code: 'invalid_account_link' as const, + message: 'This connection link is invalid or expired.', + ok: false as const, + }, + 404, + ) + + try { + const verified = await AccountLink.verifyKeyAuthorization({ + accessKeyAddress: link.access_key_address, + chainId: link.chain_id, + env: c.env, + expiresAt: link.access_key_expires_at, + keyAuthorization: body.keyAuthorization, + rootAddress: body.address, + tokenAddress: Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd), + }) + const now = new Date().toISOString() + const existingAccount = await c.var.db + .selectFrom('account') + .selectAll() + .where('address', '=', verified.rootAddress) + .executeTakeFirst() + const account = await (async () => { + if (existingAccount) { + await c.var.db + .updateTable('account') + .set({ updated_at: now }) + .where('id', '=', existingAccount.id) + .execute() + return existingAccount + } + + const id = Nanoid.generate() + await c.var.db + .insertInto('account') + .values({ + address: verified.rootAddress, + created_at: now, + id, + updated_at: now, + }) + .execute() + return await c.var.db + .selectFrom('account') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow() + })() + + const duplicate = await c.var.db + .selectFrom('member') + .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') + .select(['member.id', 'member.provider_identity_id']) + .where('member.workspace_id', '=', link.workspace_id) + .where('member.id', '!=', link.member_id) + .where('provider_identity.account_id', '=', account.id) + .executeTakeFirst() + if (duplicate && !body.disconnectExistingAccount) + return c.json( + { + code: 'account_already_connected' as const, + message: 'This wallet is already connected to another Slack account in this workspace.', + }, + 409, + ) + if (duplicate && body.disconnectExistingAccount) { + const duplicateIdentities = await c.var.db + .selectFrom('member') + .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') + .select('member.provider_identity_id') + .where('member.workspace_id', '=', link.workspace_id) + .where('member.id', '!=', link.member_id) + .where('provider_identity.account_id', '=', account.id) + .execute() + const duplicateIdentityIds = duplicateIdentities.map((row) => row.provider_identity_id) + await c.var.db + .updateTable('provider_identity') + .set({ account_id: null, updated_at: now }) + .where('id', 'in', duplicateIdentityIds) + .execute() + } + + await c.var.db + .deleteFrom('access_key') + .where('account_id', '=', account.id) + .where('chain_id', '=', link.chain_id) + .where( + 'token_address', + '=', + Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd), + ) + .execute() + await c.var.db + .deleteFrom('access_key') + .where('account_id', '=', account.id) + .where('chain_id', '=', link.chain_id) + .where('token_address', 'is', null) + .execute() + await c.var.db + .insertInto('access_key') + .values({ + account_id: account.id, + address: link.access_key_address, + authorization: verified.serialized, + chain_id: link.chain_id, + ciphertext: link.access_key_ciphertext, + created_at: now, + expires_at: verified.expiresAt, + id: Nanoid.generate(), + revoked_at: null, + token_address: Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd), + updated_at: now, + }) + .execute() + if (link.member_provider_identity_id) + await c.var.db + .updateTable('provider_identity') + .set({ account_id: account.id, updated_at: now }) + .where('id', '=', link.member_provider_identity_id) + .execute() + await c.var.db + .updateTable('account_link_token') + .set({ + access_key_authorization: verified.serialized, + account_id: account.id, + used_at: now, + }) + .where('id', '=', link.id) + .execute() + c.executionCtx.waitUntil( + (async () => { + const pendingTips = await c.var.db + .selectFrom('pending_tip') + .select('id') + .where('recipient_member_id', '=', link.member_id) + .where('status', '=', 'pending') + .execute() + for (const pendingTip of pendingTips) + await c.env.PENDING_TIP_QUEUE.send({ pendingTipId: pendingTip.id }) + })().catch((error) => { + console.error('Failed to enqueue pending tips after wallet connection:', error) + }), + ) + + if (link.provider_channel_id) + c.executionCtx.waitUntil( + (async () => { + await Chat.getChat().initialize() + const installation = await Chat.getSlack().getInstallation( + link.channel_provider_id ?? link.provider_id, + ) + if (!installation) return + + const reactionTipConfigs = await c.var.db + .selectFrom('reaction_tip_config') + .select(['amount', 'emoji']) + .where('workspace_id', '=', link.workspace_id) + .orderBy('amount', 'asc') + .orderBy('emoji', 'asc') + .execute() + const reactionTipConfigsText = ( + reactionTipConfigs.length ? reactionTipConfigs : Tip.defaultReactionTipConfigs + ) + .map( + (config) => + `:${config.emoji}: \`:${config.emoji}:\` (${formatAmount(config.amount)})`, + ) + .join(', ') + + const channelRef = Chat.getChat().channel( + link.provider_channel_id!.startsWith('slack:') + ? link.provider_channel_id! + : `slack:${link.provider_channel_id!}`, + ) + const truncatedAddress = `${account.address.slice(0, 6)}…${account.address.slice(-4)}` + const explorerUrl = Tempo.explorerLink(link.chain_id, account.address) + + await Chat.getSlack().withBotToken(installation.botToken, () => + channelRef.postEphemeral( + link.member_provider_user_id, + { + card: chat.Card({ + children: [ + chat.CardText(`Connected \`${truncatedAddress}\` <${explorerUrl}|View>`), + chat.CardText( + `Mention \`@${getSlackBotDisplayName(c.env.HOST)} @user\` or use \`${getSlackCommand(c.env.HOST)} @user\` to send a payment. React with ${reactionTipConfigsText} to tip a message.`, + { style: 'muted' }, + ), + ], + }), + fallbackText: `Connected\nWallet: ${account.address}\nUse ${getSlackCommand(c.env.HOST)} @user to send your first tip.`, + }, + { fallbackToDM: false }, + ), + ) + })().catch((error) => { + console.error('Failed to notify Slack member after wallet connection:', error) + }), + ) + + return c.json({ ok: true as const }) + } catch (error) { + return c.json( + { + code: 'invalid_key_authorization' as const, + message: error instanceof Error ? error.message : 'Invalid key authorization.', + }, + 400, + ) + } +} + +async function confirmPayment( + c: Context, + body: { + accessKey?: { address: string; privateKey: `0x${string}` } | undefined + address: string + keyAuthorization: unknown + token: string + }, +) { + try { + const data = await Tip.getConfirmationData(c.env, body.token) + const result = await Tip.confirmTipRequest(c.env, { + accessKey: body.accessKey, + address: body.address, + keyAuthorization: body.keyAuthorization, + token: body.token, + }) + if (!result.ok) + return c.json( + { + code: result.code, + message: result.code === 'failed' ? 'Payment failed. Try again.' : result.message, + ok: false as const, + }, + 400, + ) + + if (result.status === 'sent' && data.payload.providerId === Twitter.twitterProviderId) + c.executionCtx.waitUntil( + Twitter.postConfirmationReceipt(c.env, { + providerChannelId: data.payload.providerChannelId, + providerThreadId: data.payload.providerThreadId, + result, + }).catch((error) => { + console.error('Failed to post Twitter receipt after confirmation:', error) + }), + ) + else if ( + result.status === 'sent' && + Chat.isReactionTipIdempotencyKey(data.payload.idempotencyKey) + ) + c.executionCtx.waitUntil( + (async () => { + await Chat.getChat().initialize() + const db = DB.create(c.env.DB) + const reactionTip = await db + .selectFrom('reaction_tip') + .innerJoin('workspace', 'workspace.id', 'reaction_tip.workspace_id') + .innerJoin('tip', 'tip.idempotency_key', 'reaction_tip.idempotency_key') + .select([ + 'reaction_tip.channel_id', + 'reaction_tip.id', + 'reaction_tip.reaction', + 'reaction_tip.thread_ts', + 'reaction_tip.workspace_id', + 'tip.id as tip_id', + ]) + .where('reaction_tip.workspace_id', '=', data.payload.workspaceId) + .where('reaction_tip.idempotency_key', '=', data.payload.idempotencyKey) + .where('tip.confirmed_at', 'is not', null) + .executeTakeFirst() + if (!reactionTip) return + + await db + .updateTable('reaction_tip') + .set({ tip_id: reactionTip.tip_id, updated_at: new Date().toISOString() }) + .where('id', '=', reactionTip.id) + .where('tip_id', 'is', null) + .execute() + await Chat.updateReactionTipAggregate(data.payload.providerId, { + channelId: reactionTip.channel_id, + threadTs: reactionTip.thread_ts, + workspaceId: reactionTip.workspace_id, + }) + })().catch((error) => { + console.error('Failed to update Slack reaction tip aggregate after confirmation:', error) + }), + ) + else if ( + result.status === 'sent' && + Chat.isReceiptBoostIdempotencyKey(data.payload.idempotencyKey) + ) + c.executionCtx.waitUntil( + (async () => { + await Chat.getChat().initialize() + const installation = await Chat.getSlack().getInstallation(data.payload.providerId) + if (!installation) return + + const [, workspaceId, channelId, messageTs] = data.payload.idempotencyKey.split(':') + if (!workspaceId || !channelId || !messageTs) return + const receipt = await c.var.db + .selectFrom('tip_receipt_message') + .select(['message_ts', 'thread_ts']) + .where('workspace_id', '=', workspaceId) + .where('channel_id', '=', channelId) + .where('message_ts', '=', messageTs) + .executeTakeFirst() + if (!receipt) return + + if (receipt.thread_ts !== receipt.message_ts) { + await Chat.updateReceiptBoostAggregate(data.payload.providerId, { + channelId, + threadTs: receipt.thread_ts, + workspaceId, + }) + if (data.payload.skippedRecipients?.length) { + const skippedBody = new URLSearchParams() + const recipientCount = data.payload.recipients?.length ?? 1 + const skippedCount = data.payload.skippedRecipients.length + skippedBody.set('channel', channelId) + skippedBody.set( + 'text', + `Boost sent to ${recipientCount} ${recipientCount === 1 ? 'account' : 'accounts'}. Skipped ${skippedCount} ${skippedCount === 1 ? 'account' : 'accounts'} that can no longer receive payments.`, + ) + skippedBody.set('thread_ts', receipt.thread_ts) + skippedBody.set('user', data.payload.senderProviderUserId) + await Chat.getSlack().withBotToken(installation.botToken, () => + fetch(`${c.env.SLACK_API_URL}/chat.postEphemeral`, { + body: skippedBody, + headers: { authorization: `Bearer ${installation.botToken}` }, + method: 'POST', + }), + ) + } + return + } + + const originalReceiptLink = (() => { + if (receipt.thread_ts === receipt.message_ts) return '' + const url = new URL('slack://channel') + url.searchParams.set('team', data.payload.providerId) + url.searchParams.set('id', channelId) + url.searchParams.set('message', receipt.message_ts) + return ` <${url}|this message>` + })() + const receiptLink = `<${Tempo.formatTxLink(result.chainId, result.transactionHash)}|Receipt>` + const text = `<@${result.senderProviderUserId}> boosted${originalReceiptLink} · ${receiptLink}` + const body = new URLSearchParams() + body.set( + 'blocks', + JSON.stringify([ + { + text: { text, type: 'mrkdwn' }, + type: 'section', + }, + ]), + ) + body.set('channel', channelId) + body.set('text', text.replace(receiptLink, 'Receipt')) + body.set('thread_ts', receipt.thread_ts) + body.set('unfurl_links', 'false') + body.set('unfurl_media', 'false') + const response = await Chat.getSlack().withBotToken(installation.botToken, () => + fetch(`${c.env.SLACK_API_URL}/chat.postMessage`, { + body, + headers: { authorization: `Bearer ${installation.botToken}` }, + method: 'POST', + }), + ) + const json = z.parse( + z.object({ + error: z.string().optional(), + ok: z.boolean().optional(), + ts: z.string().optional(), + }), + await response.json(), + ) + if (!json.ok || !json.ts) + throw new Error(json.error ?? 'Slack API chat.postMessage failed.') + await Chat.recordSlackReceiptMessageForTransaction(c.var.db, { + channelId, + messageTs: json.ts, + threadTs: receipt.thread_ts, + transactionHash: result.transactionHash, + }) + if (data.payload.skippedRecipients?.length) { + const skippedBody = new URLSearchParams() + const recipientCount = data.payload.recipients?.length ?? 1 + const skippedCount = data.payload.skippedRecipients.length + skippedBody.set('channel', channelId) + skippedBody.set( + 'text', + `Boost sent to ${recipientCount} ${recipientCount === 1 ? 'account' : 'accounts'}. Skipped ${skippedCount} ${skippedCount === 1 ? 'account' : 'accounts'} that can no longer receive payments.`, + ) + skippedBody.set('thread_ts', receipt.thread_ts) + skippedBody.set('user', data.payload.senderProviderUserId) + await Chat.getSlack().withBotToken(installation.botToken, () => + fetch(`${c.env.SLACK_API_URL}/chat.postEphemeral`, { + body: skippedBody, + headers: { authorization: `Bearer ${installation.botToken}` }, + method: 'POST', + }), + ) + } + })().catch((error) => { + console.error('Failed to post Slack boost receipt after confirmation:', error) + }), + ) + else if (result.status === 'sent' && Chat.isTipAskIdempotencyKey(data.payload.idempotencyKey)) + c.executionCtx.waitUntil( + (async () => { + const tipAskId = Chat.getTipAskIdFromIdempotencyKey(data.payload.idempotencyKey) + if (!tipAskId) return + await Chat.updateTipAskMessage(data.payload.providerId, { tipAskId }) + })().catch((error) => { + console.error('Failed to update Slack tip jar after confirmation:', error) + }), + ) + else if (result.status === 'sent') + c.executionCtx.waitUntil( + (async () => { + await Chat.getChat().initialize() + const installation = await Chat.getSlack().getInstallation(data.payload.providerId) + if (!installation) return + + const amount = result.isDefaultToken + ? formatCurrencyAmount(result.amount, result.tokenCurrency) + : formatTipAmount(result.amount, result.tokenCurrency, result.tokenSymbol) + const text = + 'recipients' in result + ? `<@${result.senderProviderUserId}> ${result.memo ? 'sent' : 'tipped'} ${data.payload.groupId ? ` ` : ''}${Chat.formatProviderUserMentionSummary(result.recipients.map((recipient) => recipient.recipientProviderUserId))} ${amount} each${result.memo ? ` for ${result.memo}` : ''}.\n${[ + ...result.recipients.map( + (recipient) => `• <@${recipient.recipientProviderUserId}>`, + ), + ...(data.payload.skippedRecipients ?? []) + .slice(0, 10) + .map( + (recipient) => + `• <@${recipient.recipientProviderUserId}> (${recipient.reason === 'you' ? 'you' : 'not connected yet'})`, + ), + ...((data.payload.skippedRecipients?.length ?? 0) > 10 + ? [ + `…and ${(data.payload.skippedRecipients?.length ?? 0) - 10} more not connected yet`, + ] + : []), + ].join('\n')}` + : `<@${result.senderProviderUserId}> ${result.memo ? 'sent' : 'tipped'} <@${result.recipientProviderUserId}> ${amount}${result.memo ? ` for ${result.memo}` : ''}.` + const receiptText = text.replace(/\.$/, '') + const receiptLink = `<${Tempo.formatTxLink(result.chainId, result.transactionHash)}|Receipt>` + const receiptMessage = (() => { + const lineBreakIndex = receiptText.indexOf('\n') + if (lineBreakIndex === -1) return `${receiptText} · ${receiptLink}` + return `${receiptText.slice(0, lineBreakIndex).replace(/\.$/, '')} · ${receiptLink}${receiptText.slice(lineBreakIndex)}` + })() + const threadId = data.payload.providerThreadId + const body = new URLSearchParams() + body.set( + 'blocks', + JSON.stringify([ + { + text: { + text: receiptMessage, + type: 'mrkdwn', + }, + type: 'section', + }, + ]), + ) + body.set('channel', data.payload.providerChannelId.replace(/^slack:/, '')) + body.set('text', receiptMessage.replace(receiptLink, 'Receipt')) + if (threadId) body.set('thread_ts', threadId) + body.set('unfurl_links', 'false') + body.set('unfurl_media', 'false') + try { + const response = await Chat.getSlack().withBotToken(installation.botToken, () => + fetch(`${c.env.SLACK_API_URL}/chat.postMessage`, { + body, + headers: { authorization: `Bearer ${installation.botToken}` }, + method: 'POST', + }), + ) + const json = z.parse( + z.object({ + error: z.string().optional(), + ok: z.boolean().optional(), + ts: z.string().optional(), + }), + await response.json(), + ) + if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postMessage failed.') + if (json.ts) + await Chat.recordSlackReceiptMessageForTransaction(c.var.db, { + channelId: data.payload.providerChannelId.replace(/^slack:/, ''), + messageTs: json.ts, + threadTs: threadId ?? json.ts, + transactionHash: result.transactionHash, + }) + } finally { + if (threadId) { + const statusBody = new URLSearchParams() + statusBody.set('channel_id', data.payload.providerChannelId.replace(/^slack:/, '')) + statusBody.set('thread_ts', threadId) + statusBody.set('status', '') + await fetch(`${c.env.SLACK_API_URL}/assistant.threads.setStatus`, { + body: statusBody, + headers: { authorization: `Bearer ${installation.botToken}` }, + method: 'POST', + }).catch(() => { + // Best effort only. Confirmed payment flow must not depend on Slack assistant UI cleanup. + }) + } + } + })().catch((error) => { + console.error('Failed to post Slack receipt after confirmation:', error) + }), + ) + if (!('transactionHash' in result)) throw new Error('Payment failed.') + return c.json({ ok: true as const, transactionHash: result.transactionHash }) + } catch (error) { + return c.json( + { + code: 'confirmation_failed' as const, + message: error instanceof Error ? error.message : 'Payment failed.', + ok: false as const, + }, + 400, + ) + } +} + +function getTwitterPermissions(challenge: { + accessKeyExpiry: string + chainId: number + tokenAddress: string +}) { + return TempoId.permissions({ + chainId: challenge.chainId, + expiresAt: challenge.accessKeyExpiry, + limit: BigInt(AccountLink.reusableAccessKeyLimit), + periodSeconds: AccountLink.reusableAccessKeyPeriodSeconds, + tokenAddress: challenge.tokenAddress, + }) +} + +/** Returns the page URL that shows a link or confirmation response: its status, error, and hash. */ +async function getResultUrl(path: string, response: Response) { + const url = new URL(path, 'http://tip.bot') + const json = (await response + .clone() + .json() + .catch(() => ({}))) as { code?: string; message?: string; transactionHash?: string } + if (response.ok) { + url.searchParams.set('status', json.transactionHash ? 'sent' : 'connected') + if (json.transactionHash) url.searchParams.set('tx', json.transactionHash) + } else if (json.code === 'account_already_connected') url.searchParams.set('status', 'conflict') + else url.searchParams.set('error', json.message ?? 'Something went wrong. Try again.') + return url.pathname + url.search +} diff --git a/src/api.workers.test.ts b/src/api.workers.test.ts index f59035c..baa6b2b 100644 --- a/src/api.workers.test.ts +++ b/src/api.workers.test.ts @@ -1,10 +1,9 @@ -import { Provider, dangerous_secp256k1 } from 'accounts' import { WebClient } from '@slack/web-api' import { env } from 'cloudflare:workers' import { testClient } from 'hono/testing' import { HttpResponse, http as mswHttp } from 'msw' import { Address, Secp256k1 } from 'ox' -import { createClient, http, parseUnits, toHex } from 'viem' +import { createClient, http, parseUnits } from 'viem' import { Account, Actions } from 'viem/tempo' import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { api } from '#/api.ts' @@ -76,47 +75,6 @@ test('/api/health returns ok', async () => { await expect(response.json()).resolves.toEqual({ ok: true }) }) -test('/api/auth responds to Tempo wallet CORS preflight', async () => { - const response = await api.fetch( - new Request('https://tipbot.localhost/api/auth/challenge', { - headers: { - 'access-control-request-headers': 'x-accounts-request,content-type', - 'access-control-request-method': 'POST', - origin: 'https://embedded.wallet.example', - }, - method: 'OPTIONS', - }), - env, - executionCtx, - ) - - expect(response.status).toBe(204) - expect(response.headers.get('access-control-allow-origin')).toBe( - 'https://embedded.wallet.example', - ) - expect(response.headers.get('access-control-allow-credentials')).toBe('true') - expect(response.headers.get('access-control-allow-headers')).toBe( - 'x-accounts-request,content-type', - ) -}) - -test('/api/auth limits non-challenge CORS preflight to trusted origins', async () => { - const response = await api.fetch( - new Request('https://tipbot.localhost/api/auth/logout', { - headers: { - 'access-control-request-method': 'POST', - origin: 'https://embedded.wallet.example', - }, - method: 'OPTIONS', - }), - env, - executionCtx, - ) - - expect(response.status).toBe(204) - expect(response.headers.get('access-control-allow-origin')).toBeNull() -}) - describe('/api/chat/twitter', () => { test('responds to Twitter CRC challenge', async () => { const response = await api.fetch( @@ -1977,10 +1935,10 @@ describe('/api/confirm/:token', () => { workspaceId: workspace.id, } satisfies Confirmation.Payload const token = await Confirmation.encrypt(env, payload) - const signedTransaction = await signConfirmationTransaction({ payload, token }) + const keyAuthorization = await authorizeConfirmation({ payload, token }) const response = await client.api.confirm[':token'].$post({ - json: { address: senderRoot.address, signedTransaction }, + json: { address: senderRoot.address, keyAuthorization }, param: { token }, }) await Promise.all(waitUntil) @@ -2013,10 +1971,10 @@ describe('/api/confirm/:token', () => { botUserId: Constants.slack.botUserId, teamName: Constants.slack.teamName, }) - const signedTransaction = await signConfirmationTransaction(confirmation) + const keyAuthorization = await authorizeConfirmation(confirmation) const response = await client.api.confirm[':token'].$post({ - json: { address: confirmation.senderRoot.address, signedTransaction }, + json: { address: confirmation.senderRoot.address, keyAuthorization }, param: { token: confirmation.token }, }) await Promise.all(waitUntil) @@ -2060,10 +2018,10 @@ describe('/api/confirm/:token', () => { botUserId: Constants.slack.botUserId, teamName: Constants.slack.teamName, }) - const signedTransaction = await signConfirmationTransaction(confirmation) + const keyAuthorization = await authorizeConfirmation(confirmation) const response = await client.api.confirm[':token'].$post({ - json: { address: confirmation.senderRoot.address, signedTransaction }, + json: { address: confirmation.senderRoot.address, keyAuthorization }, param: { token: confirmation.token }, }) await Promise.all(waitUntil) @@ -2106,11 +2064,11 @@ describe('/api/confirm/:token', () => { tip_id: null, workspace_id: confirmation.workspace.id, }) - const signedTransaction = await signConfirmationTransaction(confirmation) + const keyAuthorization = await authorizeConfirmation(confirmation) const initialize = vi.spyOn(Chat.getChat(), 'initialize') const response = await client.api.confirm[':token'].$post({ - json: { address: confirmation.senderRoot.address, signedTransaction }, + json: { address: confirmation.senderRoot.address, keyAuthorization }, param: { token: confirmation.token }, }) await Promise.all(waitUntil) @@ -2199,10 +2157,10 @@ describe('/api/confirm/:token', () => { botUserId: Constants.slack.botUserId, teamName: Constants.slack.teamName, }) - const signedTransaction = await signConfirmationTransaction(confirmation) + const keyAuthorization = await authorizeConfirmation(confirmation) const response = await client.api.confirm[':token'].$post({ - json: { address: confirmation.senderRoot.address, signedTransaction }, + json: { address: confirmation.senderRoot.address, keyAuthorization }, param: { token: confirmation.token }, }) await Promise.all(waitUntil) @@ -2275,8 +2233,8 @@ describe('/api/confirm/:token', () => { token_address: confirmation.payload.tokenAddress, workspace_id: confirmation.workspace.id, }) - const signedTransaction = await signConfirmationTransaction(confirmation) - const json = { address: confirmation.senderRoot.address, signedTransaction } + const keyAuthorization = await authorizeConfirmation(confirmation) + const json = { address: confirmation.senderRoot.address, keyAuthorization } const first = await client.api.confirm[':token'].$post({ json, @@ -2656,33 +2614,27 @@ async function findOrCreateAccount(address: string) { return await factory.account.insert({ address }) } -async function signConfirmationTransaction(confirmation: { +async function authorizeConfirmation(confirmation: { payload: Confirmation.Payload token: string }) { + // The sender's account authorizes the link's access key for this payment's total alone. const response = await client.api.confirm[':token'].$get({ param: { token: confirmation.token }, }) const json = await response.json() - if (!json.ok || !json.transactionRequest) throw new Error('Expected transaction request.') - const provider = Provider.create({ - adapter: dangerous_secp256k1({ privateKey: Constants.tip.senderRootPrivateKey }), - chains: [ - { - ...Tempo.getChain(confirmation.payload.chainId), - rpcUrls: { default: { http: [env.RPC_URL_TESTNET!] } }, - }, - ], - transports: { [confirmation.payload.chainId]: http(env.RPC_URL_TESTNET) }, - }) - await provider.request({ - method: 'wallet_connect', - params: [{ capabilities: { method: 'register' } }], - }) - return await provider.request({ - method: 'eth_signTransaction', - params: [{ ...json.transactionRequest, chainId: toHex(confirmation.payload.chainId) } as never], - }) + if (!json.ok) throw new Error('Expected confirmation metadata.') + return await AccountLink.signKeyAuthorization( + Account.fromSecp256k1(Constants.tip.senderRootPrivateKey), + { + accessKeyAddress: json.accessKeyAddress, + chainId: json.chainId, + expiresAt: confirmation.payload.expiresAt, + limit: BigInt(confirmation.payload.amount) * BigInt(json.recipients.length), + periodSeconds: AccountLink.confirmationLinkTtlMs / 1000, // 10 minutes + tokenAddress: json.tokenAddress, + }, + ) } async function deleteSlackOauthWorkspace() { diff --git a/src/chat.ts b/src/chat.ts index 34a5209..36da819 100644 --- a/src/chat.ts +++ b/src/chat.ts @@ -6069,7 +6069,8 @@ async function postConnectLink(event: TipEvent, ctx: HandlerContext) { const accessKey = AccessKey.generate() const linkButtonLabel = accountId ? 'Refresh connection' : 'Connect to Tipbot' const linkDescription = 'Link expires in 10 minutes.' - const linkUrl = `https://${env.HOST}/connect/${token}` + // The link starts the Tempo ID sign-in, whose approval page names the access key's limits. + const linkUrl = `https://${env.HOST}/api/account/link/${token}/start` const linkText = `${accountId ? 'Refresh Tipbot connection' : 'Connect to Tipbot'}: ${linkUrl}\n${linkDescription}` const linkExpiresAt = new Date(now.getTime() + 10 * 60 * 1000).toISOString() // 10 minutes const accessKeyExpiresAt = new Date( diff --git a/src/chat.workers.test.ts b/src/chat.workers.test.ts index 60431a4..24d500b 100644 --- a/src/chat.workers.test.ts +++ b/src/chat.workers.test.ts @@ -1,5 +1,4 @@ import * as DB from '#db/client.ts' -import { Provider, dangerous_secp256k1 } from 'accounts' import * as AccessKey from '#/lib/accessKey.ts' import * as AccountLink from '#/lib/accountLink.ts' import * as Chat from '#/chat.ts' @@ -20,7 +19,7 @@ import { env } from 'cloudflare:workers' import { testClient } from 'hono/testing' import { AbiFunction, Address } from 'ox' import { KeyAuthorization } from 'ox/tempo' -import { createClient, http, parseUnits, toHex } from 'viem' +import { createClient, http, parseUnits } from 'viem' import { Account, Actions } from 'viem/tempo' import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { api } from '#/api.ts' @@ -1021,7 +1020,6 @@ describe('/tip @account', () => { }) if (!json.ok) throw new Error('Expected confirmation metadata.') expect(json.recipients).toHaveLength(20) - expect(json.transactionRequest?.calls).toHaveLength(20) }, 20_000) // 20 seconds test('rejects 101 recipients', async () => { @@ -1076,7 +1074,6 @@ describe('/tip @account', () => { { recipientProviderUserId: Constants.slack.memberUserId }, { recipientProviderUserId: unconnectedProviderUserId }, ], - transactionRequest: expect.objectContaining({ calls: expect.any(Array) }), }) }) @@ -6941,29 +6938,20 @@ test('receipt boost confirms after approval', async () => { const confirmation = await client.api.confirm[':token'].$get({ param: { token } }) const confirmationJson = await confirmation.json() if (!confirmationJson.ok) throw new Error('Expected confirmation metadata.') - if (!confirmationJson.transactionRequest) throw new Error('Expected transaction request.') - const provider = Provider.create({ - adapter: dangerous_secp256k1({ privateKey: Constants.tip.senderRootPrivateKey }), - chains: [ - { - ...Tempo.getChain(confirmationJson.chainId), - rpcUrls: { default: { http: [env.RPC_URL_TESTNET!] } }, - }, - ], - transports: { [confirmationJson.chainId]: http(env.RPC_URL_TESTNET) }, - }) - await provider.request({ - method: 'wallet_connect', - params: [{ capabilities: { method: 'register' } }], - }) - const signedTransaction = await provider.request({ - method: 'eth_signTransaction', - params: [ - { ...confirmationJson.transactionRequest, chainId: toHex(confirmationJson.chainId) } as never, - ], - }) + const confirmationData = await Tip.getConfirmationData(env, token) + const keyAuthorization = await AccountLink.signKeyAuthorization( + Account.fromSecp256k1(Constants.tip.senderRootPrivateKey), + { + accessKeyAddress: confirmationJson.accessKeyAddress, + chainId: confirmationJson.chainId, + expiresAt: confirmationData.payload.expiresAt, + limit: BigInt(confirmationData.payload.amount) * BigInt(confirmationJson.recipients.length), + periodSeconds: AccountLink.confirmationLinkTtlMs / 1000, // 10 minutes + tokenAddress: confirmationJson.tokenAddress, + }, + ) const confirmResponse = await client.api.confirm[':token'].$post({ - json: { address: connected.senderAccount.address, signedTransaction }, + json: { address: connected.senderAccount.address, keyAuthorization }, param: { token }, }) await drainWaitUntil() @@ -7035,31 +7023,22 @@ test('threaded receipt boost confirms into aggregate after approval', async () = const confirmation = await client.api.confirm[':token'].$get({ param: { token } }) const confirmationJson = await confirmation.json() if (!confirmationJson.ok) throw new Error('Expected confirmation metadata.') - if (!confirmationJson.transactionRequest) throw new Error('Expected transaction request.') await expectSlackThreadMessageNotContaining(parentTs, 'Boosts received in this thread:') - const provider = Provider.create({ - adapter: dangerous_secp256k1({ privateKey: Constants.tip.senderRootPrivateKey }), - chains: [ - { - ...Tempo.getChain(confirmationJson.chainId), - rpcUrls: { default: { http: [env.RPC_URL_TESTNET!] } }, - }, - ], - transports: { [confirmationJson.chainId]: http(env.RPC_URL_TESTNET) }, - }) - await provider.request({ - method: 'wallet_connect', - params: [{ capabilities: { method: 'register' } }], - }) - const signedTransaction = await provider.request({ - method: 'eth_signTransaction', - params: [ - { ...confirmationJson.transactionRequest, chainId: toHex(confirmationJson.chainId) } as never, - ], - }) + const confirmationData = await Tip.getConfirmationData(env, token) + const keyAuthorization = await AccountLink.signKeyAuthorization( + Account.fromSecp256k1(Constants.tip.senderRootPrivateKey), + { + accessKeyAddress: confirmationJson.accessKeyAddress, + chainId: confirmationJson.chainId, + expiresAt: confirmationData.payload.expiresAt, + limit: BigInt(confirmationData.payload.amount) * BigInt(confirmationJson.recipients.length), + periodSeconds: AccountLink.confirmationLinkTtlMs / 1000, // 10 minutes + tokenAddress: confirmationJson.tokenAddress, + }, + ) const confirmResponse = await client.api.confirm[':token'].$post({ - json: { address: connected.senderAccount.address, signedTransaction }, + json: { address: connected.senderAccount.address, keyAuthorization }, param: { token }, }) await drainWaitUntil() @@ -9016,8 +8995,8 @@ async function getLatestConnectToken(options: { channelId?: string; threadTs?: s ts: options.threadTs, }) : await slack.conversations.history({ channel: options.channelId ?? Constants.slack.channelId }) - const message = history.messages?.find((message) => message.text?.includes('/connect/')) - const token = message?.text?.match(/\/connect\/([A-Za-z0-9_-]+)/)?.[1] + const message = history.messages?.find((message) => message.text?.includes('/api/account/link/')) + const token = message?.text?.match(/\/api\/account\/link\/([A-Za-z0-9_-]+)\/start/)?.[1] if (!token) throw new Error('Expected connection token in Slack message.') return token } diff --git a/src/components/WalletProviders.tsx b/src/components/WalletProviders.tsx deleted file mode 100644 index 3c91ba8..0000000 --- a/src/components/WalletProviders.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import * as React from 'react' -import { WagmiProvider, cookieStorage, createConfig, createStorage, http } from 'wagmi' -import { tempo, tempoLocalnet, tempoModerato } from 'wagmi/chains' -import { dangerous_secp256k1, tempoWallet } from 'wagmi/tempo' - -const queryClient = new QueryClient() - -function Root( - props: React.PropsWithChildren<{ auth?: string | undefined; feePayer?: string | undefined }>, -) { - const [config] = React.useState(() => - createConfig({ - chains: [tempo, tempoModerato, tempoLocalnet], - connectors: [ - __PLAYWRIGHT_ACCOUNT_PRIVATE_KEY__ - ? dangerous_secp256k1({ - ...(props.feePayer ? { feePayer: props.feePayer } : {}), - privateKey: __PLAYWRIGHT_ACCOUNT_PRIVATE_KEY__, - }) - : tempoWallet({ - ...(props.auth ? { auth: props.auth } : {}), - ...(props.feePayer ? { feePayer: props.feePayer } : {}), - }), - ], - multiInjectedProviderDiscovery: false, - ssr: true, - storage: createStorage({ storage: cookieStorage }), - transports: { - [tempo.id]: http(), - [tempoLocalnet.id]: http(), - [tempoModerato.id]: http(), - }, - }), - ) - - return ( - - {props.children} - - ) -} - -export const WalletProviders = { Root } diff --git a/src/entry-server.ts b/src/entry-server.ts index 81db6d3..2e12b7a 100644 --- a/src/entry-server.ts +++ b/src/entry-server.ts @@ -1,8 +1,8 @@ import serverEntry from '@tanstack/react-start/server-entry' -import { Kv as AccountsKv } from 'accounts/server' import { api } from '#/api.ts' import { closeExpired } from '#/crons/close.ts' import { rpc } from '#/lib/rpc.ts' +import * as TempoId from '#/lib/tempoId.ts' import { processPendingTipMessage } from '#/queues/pendingTip.ts' import { z } from 'zod' @@ -76,4 +76,4 @@ declare module '@tanstack/react-router' { } export { TipbotChatStateDO } from '#/objects/chatState.ts' -export class NonceStorage extends AccountsKv.NonceStorage {} +export class TempoIdSessions extends TempoId.Sessions {} diff --git a/src/function/dash.ts b/src/function/dash.ts index c7cd3c3..2cf9da9 100644 --- a/src/function/dash.ts +++ b/src/function/dash.ts @@ -2,13 +2,12 @@ import { createServerFn } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' import { env } from 'cloudflare:workers' import { sql } from 'kysely' -import { Address } from 'ox' import * as z from 'zod/mini' import * as Chat from '#/chat.ts' import { formatAmount, formatCurrencyAmount } from '#/lib/format.ts' -import { auth } from '#/lib/auth.ts' import * as Slack from '#/lib/slack.ts' import * as Tempo from '#/lib/tempo.ts' +import * as TempoId from '#/lib/tempoId.ts' import * as Twitter from '#/lib/twitter.ts' import * as DB from '#db/client.ts' @@ -19,14 +18,14 @@ export const getDashboardData = createServerFn({ method: 'GET' }).handler(async export const disconnectDashboardAccount = createServerFn({ method: 'POST' }) .validator(z.object({ identityId: z.string().check(z.minLength(1)) })) .handler(async ({ data }) => { - const session = await auth.getSession(getRequest()) - if (!session) throw new Error('Connect your wallet to manage connected accounts.') + const address = await TempoId.getAddress(env, getRequest()) + if (!address) throw new Error('Sign in with Tempo ID to manage connected accounts.') const db = DB.create(env.DB) const account = await db .selectFrom('account') .selectAll() - .where('address', '=', Address.checksum(session.address)) + .where('address', '=', address) .executeTakeFirst() if (!account) throw new Error('Wallet is not connected.') @@ -55,10 +54,9 @@ export const disconnectDashboardAccount = createServerFn({ method: 'POST' }) }) async function loadDashboardData(request: Request) { - const session = await auth.getSession(request) - if (!session) return { ok: false as const } + const address = await TempoId.getAddress(env, request) + if (!address) return { ok: false as const } - const address = Address.checksum(session.address) const db = DB.create(env.DB) const account = await db .selectFrom('account') diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index 045e7fb..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Handler, Kv } from 'accounts/server' -import { env } from 'cloudflare:workers' - -export const auth = Handler.auth({ - domain: env.HOST, - path: '/api/auth', - store: Kv.durableObject(env.AUTH_NONCE as unknown as Kv.durableObject.Namespace), -}) diff --git a/src/lib/confirmationWallet.test.ts b/src/lib/confirmationWallet.test.ts deleted file mode 100644 index 48fdfc2..0000000 --- a/src/lib/confirmationWallet.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { isLostWalletSession } from './confirmationWallet.ts' - -test('recognizes SDK rejection after the wallet invalidates the local account', async () => { - const provider = { request: vi.fn().mockResolvedValue([]) } - expect(await isLostWalletSession({ cause: { code: 4001 } }, provider)).toBe(true) - expect(provider.request).toHaveBeenCalledWith({ method: 'eth_accounts' }) -}) - -test('does not turn a genuine decline with an active account into reconnection', async () => { - expect( - await isLostWalletSession({ code: 4001 }, { request: vi.fn().mockResolvedValue(['0x123']) }), - ).toBe(false) -}) - -test('does not treat transaction failures or an unavailable provider as an expired session', async () => { - const request = vi.fn().mockResolvedValue([]) - expect(await isLostWalletSession({ code: -32000 }, { request })).toBe(false) - expect(request).not.toHaveBeenCalled() - expect( - await isLostWalletSession({ code: 4001 }, { request: vi.fn().mockRejectedValue(new Error()) }), - ).toBe(false) -}) - -test('handles cyclic error causes without hanging', async () => { - const error: { cause?: unknown } = {} - error.cause = error - expect(await isLostWalletSession(error, { request: vi.fn() })).toBe(false) -}) diff --git a/src/lib/confirmationWallet.ts b/src/lib/confirmationWallet.ts deleted file mode 100644 index 33d6e00..0000000 --- a/src/lib/confirmationWallet.ts +++ /dev/null @@ -1,22 +0,0 @@ -// The dialog SDK also returns 4001 when its remembered account is absent -// from the wallet. That path clears eth_accounts without showing approval. -export async function isLostWalletSession( - error: unknown, - provider: { request: (parameters: { method: string }) => Promise }, -) { - const seen = new Set() - let current = error - let rejected = false - while (current && typeof current === 'object' && !seen.has(current)) { - seen.add(current) - if ('code' in current && current.code === 4001) rejected = true - current = 'cause' in current ? current.cause : undefined - } - if (!rejected) return false - try { - const accounts = await provider.request({ method: 'eth_accounts' }) - return Array.isArray(accounts) && accounts.length === 0 - } catch { - return false - } -} diff --git a/src/lib/tempoId.ts b/src/lib/tempoId.ts new file mode 100644 index 0000000..30d4421 --- /dev/null +++ b/src/lib/tempoId.ts @@ -0,0 +1,191 @@ +import { Accounts } from 'accounts' +import { DurableObject } from 'accounts/cloudflare' +import { backend } from 'accounts/plugins' +import type { Context } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { AbiFunction, Address, Base64, PublicKey, Secp256k1 } from 'ox' +import { z } from 'zod' +import * as AccessKey from '#/lib/accessKey.ts' +import * as Tempo from '#/lib/tempo.ts' + +// Holds each Tempo ID session in its own Durable Object, so concurrent requests of a session share +// one refresh, and the refresh token and access key never reach the browser. +export const Sessions = DurableObject.sessions((env: Env) => ({ + clientSecret: env.TEMPO_ID_CLIENT_SECRET, + url: env.TEMPO_ID_URL, +})) + +const cookieOptions = { + httpOnly: true, + path: '/', + prefix: 'host', + sameSite: 'Lax', + secure: true, +} as const + +const sessionCookie = 'tempo_id' +const intentCookie = 'tempo_id_intent' + +// What the callback does once Tempo ID returns, set at the start of each sign-in. +const intentSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('confirm'), token: z.string().min(1) }), + z.object({ kind: z.literal('dash') }), + z.object({ kind: z.literal('link'), token: z.string().min(1) }), + z.object({ challengeId: z.string().min(1), kind: z.literal('x_oauth') }), + z.object({ challengeId: z.string().min(1), kind: z.literal('x_proof') }), +]) + +export type Intent = z.infer + +export function create(env: Env) { + return Accounts.create({ + clientId: env.TEMPO_ID_CLIENT_ID, + clientSecret: env.TEMPO_ID_CLIENT_SECRET, + plugins: [backend({ namespace: env.TEMPO_ID_SESSIONS })], + url: env.TEMPO_ID_URL, + }) +} + +/** + * Returns the permissions of a Tipbot access key: a limit on one token, with `transfer` and + * `transferWithMemo` on it, to `recipients` alone when it names them. + */ +export function permissions(input: { + chainId: number + expiresAt: string + limit: bigint + periodSeconds: number + recipients?: readonly string[] | undefined + tokenAddress: string +}) { + const token = Address.checksum(input.tokenAddress) + const recipients = input.recipients?.map((recipient) => Address.checksum(recipient)) + return { + chainId: input.chainId, + expiry: Math.floor(new Date(input.expiresAt).getTime() / 1000), + limits: [{ limit: input.limit, period: input.periodSeconds, token }], + // Named scopes replace the defaults, which also allow the MPP channel reserve. + scopes: ['transfer(address,uint256)', 'transferWithMemo(address,uint256,bytes32)'].map( + (signature) => ({ + address: token, + selector: AbiFunction.getSelector(signature), + ...(recipients ? { recipients } : {}), + }), + ), + } +} + +/** + * Returns the funding hint of a sign-in: $1, in USDC.e on mainnet. Other chains list no USDC.e, so + * any US dollar token counts there. + */ +export function funding(chainId: number) { + return chainId === Tempo.chainLookup.mainnet + ? { amount: '1', token: 'USDC.e' } + : { amount: '1', currency: 'USD' } +} + +/** Starts a Tempo ID sign-in, and returns the URL of Tempo ID's page. */ +export async function start( + c: Context, + input: { + accessKey?: ReturnType | undefined + funding?: ReturnType | undefined + intent: Intent + }, +) { + const started = await create(c.env).backend.start({ + accessKey: input.accessKey ?? false, + ...(input.funding ? { funding: input.funding } : {}), + redirectUri: new URL('/api/auth/callback', c.req.url).toString(), + }) + setCookie(c, sessionCookie, started.id, cookieOptions) + setCookie( + c, + intentCookie, + Base64.fromString(JSON.stringify(input.intent), { url: true }), + cookieOptions, + ) + return started.url +} + +/** + * Redeems Tempo ID's callback, and returns its intent and session. Returns `undefined` without a + * sign-in in progress, such as for a callback that arrives twice. + */ +export async function complete(c: Context) { + const id = getCookie(c, sessionCookie, 'host') + const intent = readIntent(getCookie(c, intentCookie, 'host')) + deleteCookie(c, intentCookie, cookieOptions) + if (!id || !intent) return undefined + const session = await create(c.env).backend.complete({ id, url: c.req.url }) + return { intent, session } +} + +/** Returns the session of the request's cookie, or `undefined` without one. */ +export async function get(c: Context) { + const id = getCookie(c, sessionCookie, 'host') + if (!id) return undefined + return await create(c.env).backend.get({ id }) +} + +/** Signs out: revokes the session's refresh token, and deletes the cookie. */ +export async function end(c: Context) { + const id = getCookie(c, sessionCookie, 'host') + if (id) + await create(c.env) + .backend.end({ id }) + .catch(() => undefined) + deleteCookie(c, sessionCookie, cookieOptions) +} + +/** + * Returns the session's access key as Tipbot stores it: its address, public key, encrypted private + * key, signed key authorization, and the Tempo Account it acts for. + */ +export async function adopt(env: Pick, session: { key?: Key | undefined }) { + const key = session.key + if (!key?.privateKey) throw new Error('Tempo ID did not authorize an access key.') + const publicKey = Secp256k1.getPublicKey({ privateKey: key.privateKey }) + return { + address: Address.checksum(Address.fromPublicKey(publicKey)), + ciphertext: await AccessKey.encrypt(env, key.privateKey), + keyAuthorization: key.keyAuthorization as unknown, + privateKey: key.privateKey, + publicKey: PublicKey.toHex(publicKey), + rootAddress: Address.checksum(key.account), + } +} + +type Key = { account: string; keyAuthorization: unknown; privateKey?: `0x${string}` | undefined } + +/** Returns the signed-in account's address, or `undefined` without a Tempo ID session. */ +export async function getAddress(env: Env, request: Request) { + const id = readCookie(request, sessionCookie) + if (!id) return undefined + const accounts = create(env) + const session = await accounts.backend.get({ id }).catch(() => undefined) + if (!session) return undefined + if (session.key) return Address.checksum(session.key.account) + // A sign-in without an access key reads no account, so the session's access token reads it. + const account = await accounts.account.get({ accessToken: session.accessToken }) + return account ? Address.checksum(account.address) : undefined +} + +function readIntent(value: string | undefined) { + if (!value) return undefined + try { + return intentSchema.parse(JSON.parse(Base64.toString(value))) + } catch { + return undefined + } +} + +function readCookie(request: Request, name: string) { + const prefix = `__Host-${name}=` + for (const part of (request.headers.get('cookie') ?? '').split(';')) { + const value = part.trim() + if (value.startsWith(prefix)) return decodeURIComponent(value.slice(prefix.length)) + } + return undefined +} diff --git a/src/lib/tip.ts b/src/lib/tip.ts index cd1bda2..3df34cc 100644 --- a/src/lib/tip.ts +++ b/src/lib/tip.ts @@ -9,7 +9,6 @@ import * as Tapimo from '#/lib/tapimo.ts' import * as Tempo from '#/lib/tempo.ts' import type { DB as Database } from '#db/types.gen.ts' import { AbiFunction, Address, Hash, Hex } from 'ox' -import { TxEnvelopeTempo } from 'ox/tempo' import { KeyAuthorization } from 'ox/tempo' import { BaseError, @@ -842,7 +841,7 @@ export async function getConfirmationData(env: Env, token: string) { return { accessKey, payload } } -export async function getConfirmationTransactionRequest(env: Env, token: string) { +export async function getConfirmationRecipientAddresses(env: Env, token: string) { const db = DB.create(env.DB) const { payload } = await getConfirmationData(env, token) const workspace = await db @@ -850,8 +849,6 @@ export async function getConfirmationTransactionRequest(env: Env, token: string) .selectAll() .where('id', '=', payload.workspaceId) .executeTakeFirstOrThrow() - const sender = await getConnectedMember(db, workspace.id, payload.senderProviderUserId) - if (!sender) throw new Error('Reconnect Tipbot and try again.') const recipients = payload.recipients ?? [ { recipientProviderLabel: payload.recipientProviderLabel, @@ -859,27 +856,29 @@ export async function getConfirmationTransactionRequest(env: Env, token: string) recipientProviderWorkspaceId: payload.recipientProviderWorkspaceId, }, ] - const connectedRecipients = [] as ConnectedMember[] + const addresses = [] as Address.Address[] for (const recipient of recipients) { const connected = await getConnectedRecipient(db, workspace, payload.provider, recipient) if (!connected) throw new Error('Recipient needs to connect Tipbot before receiving payments.') - connectedRecipients.push(connected) + addresses.push(Address.checksum(connected.account.address)) } - - return createSignedTransactionRequest(payload, sender, connectedRecipients) + return addresses } export async function confirmTipRequest( env: Env, input: { + /** The access key that Tempo ID authorized, in place of the one that the link derives. */ + accessKey?: { address: string; privateKey: `0x${string}` } | undefined address: string keyAuthorization?: unknown - signedTransaction?: `0x${string}` | undefined token: string }, ) { const db = DB.create(env.DB) - const { accessKey, payload } = await getConfirmationData(env, input.token) + const confirmation = await getConfirmationData(env, input.token) + const payload = confirmation.payload + const accessKey = input.accessKey ?? confirmation.accessKey if (!Tempo.isAllowedToken(payload.chainId, payload.tokenAddress)) throw new Error('This token is not supported on this network.') @@ -903,36 +902,7 @@ export async function confirmTipRequest( if (!connected) throw new Error('Recipient needs to connect Tipbot before receiving payments.') connectedRecipients.push(connected) } - if (payload.kind === 'onetime_payment') { - if (!input.signedTransaction) throw new Error('Tempo Wallet did not approve this payment.') - if (recipients.length > 1) - return await submitSignedTipBatch(env, db, { - address: input.address, - connectedRecipients, - idempotencyKey: payload.idempotencyKey, - memo: payload.memo, - payload, - recipients, - sender, - signedTransaction: input.signedTransaction, - tokenAddress: Address.checksum(payload.tokenAddress), - workspace, - }) - const recipient = connectedRecipients[0] - if (!recipient) throw new Error('Tempo Wallet did not approve this payment.') - return await submitSignedTip(env, db, { - address: input.address, - idempotencyKey: payload.idempotencyKey, - memo: payload.memo, - payload, - recipient, - sender, - signedTransaction: input.signedTransaction, - tokenAddress: Address.checksum(payload.tokenAddress), - workspace, - }) - } - if (!input.keyAuthorization) throw new Error('Tempo Wallet did not approve this payment.') + if (!input.keyAuthorization) throw new Error('Tempo ID did not approve this payment.') const accessKeyExpiresAt = payload.kind === 'reusable_access_key' ? (payload.accessKeyExpiresAt ?? payload.expiresAt) @@ -940,7 +910,7 @@ export async function confirmTipRequest( const accessKeyLimit = payload.kind === 'reusable_access_key' ? BigInt(payload.accessKeyLimit ?? AccountLink.reusableAccessKeyLimit) - : BigInt(payload.amount) + : BigInt(payload.amount) * BigInt(recipients.length) const accessKeyPeriodSeconds = payload.kind === 'reusable_access_key' ? AccountLink.reusableAccessKeyPeriodSeconds @@ -1840,7 +1810,7 @@ async function createConfirmationRequired( return { chainId: workspace.chain_id, code: 'confirmation_required', - confirmUrl: `https://${env.HOST}/confirm/${token}`, + confirmUrl: `https://${env.HOST}/api/confirm/${token}/start`, ok: false, } } @@ -2185,431 +2155,6 @@ async function submitTip( } } -async function submitSignedTipBatch( - env: Env, - db: DB.Type, - input: { - address: string - connectedRecipients: ConnectedMember[] - idempotencyKey: string - memo: string | null - payload: Confirmation.Payload - recipients: TipRecipientInput[] - sender: ConnectedMember - signedTransaction: `0x${string}` - tokenAddress: string - workspace: Database.Selectable.workspace - }, -): Promise { - const existing = await getExistingTipBatchResult( - env, - db, - { - idempotencyKey: input.idempotencyKey, - senderProviderUserId: input.payload.senderProviderUserId, - }, - Address.checksum(input.workspace.default_token_address ?? Tempo.addressLookup.pathUsd), - ) - if (existing) return existing - - const firstRecipient = input.connectedRecipients[0] - if (!firstRecipient) throw new Error('Tempo Wallet did not approve this payment.') - validateSignedTransaction({ - address: input.address, - connectedRecipients: input.connectedRecipients, - payload: input.payload, - recipient: firstRecipient, - sender: input.sender, - signedTransaction: input.signedTransaction, - tokenAddress: input.tokenAddress, - }) - - const batchId = Nanoid.generate() - const now = new Date().toISOString() - const tipRows = await Promise.all( - input.connectedRecipients.map(async (recipient, index) => { - const id = Nanoid.generate() - const idempotencyKey = `${input.idempotencyKey}:${input.recipients[index]!.recipientProviderUserId}` - return { - access_key_id: null, - amount: input.payload.amount, - batch_id: batchId, - chain_id: input.workspace.chain_id, - confirmed_at: null, - created_at: now, - failed_at: null, - failure_reason: null, - id, - idempotency_key: idempotencyKey, - memo: input.memo, - recipient_id: recipient.account.id, - recipient_member_id: recipient.member.id, - sender_id: input.sender.account.id, - sender_member_id: input.sender.member.id, - sponsorship_memo: await createSponsorshipMemo(env, { - amount: input.payload.amount, - chainId: input.workspace.chain_id, - id, - idempotencyKey, - recipient: recipient.account.address, - sender: input.sender.account.address, - token: input.tokenAddress, - }), - token_address: input.tokenAddress, - transfer_log_index: null, - updated_at: now, - workspace_id: input.workspace.id, - } satisfies Database.Insertable.tip - }), - ) - await db - .insertInto('tip_batch') - .values({ - amount_each: input.payload.amount, - created_at: now, - failure_reason: null, - id: batchId, - idempotency_key: input.idempotencyKey, - memo: input.memo, - provider: input.payload.provider, - provider_channel_id: input.payload.providerChannelId, - provider_id: input.payload.providerId, - provider_thread_id: input.payload.providerThreadId ?? null, - recipient_count: input.connectedRecipients.length, - sender_member_id: input.sender.member.id, - source: input.payload.source ?? 'command', - status: 'pending', - token_address: input.tokenAddress, - total_amount: input.payload.amount * input.connectedRecipients.length, - updated_at: now, - workspace_id: input.workspace.id, - }) - .execute() - for (let index = 0; index < tipRows.length; index += 4) { - // D1 has a low SQL variable limit; each tip row has many columns, so keep multi-row inserts - // small even when a group tip has up to 100 paid recipients. - await db - .insertInto('tip') - .values(tipRows.slice(index, index + 4)) - .execute() - } - - try { - await db - .updateTable('tip_batch') - .set({ status: 'submitting', updated_at: new Date().toISOString() }) - .where('id', '=', batchId) - .execute() - const client = createClient({ - chain: Tempo.getChain(input.workspace.chain_id), - transport: http(Tempo.getRpcUrl(env, input.workspace.chain_id)), - }) - const balance = await Actions.token.getBalance(client, { - account: input.sender.account.address as Address.Address, - token: input.tokenAddress as Address.Address, - }) - if (balance.amount < BigInt(input.payload.amount * input.connectedRecipients.length)) - throw new InsufficientFundsError() - - const receipt = (await client.request({ - method: 'eth_sendRawTransactionSync' as never, - params: [input.signedTransaction] as never, - })) as { status: `0x${string}`; transactionHash?: `0x${string}` } - if (receipt.status !== '0x1' || !receipt.transactionHash) - throw new Error('Tempo transaction failed.') - await db - .updateTable('tip_batch') - .set({ - status: 'confirmed', - transaction_hash: receipt.transactionHash, - updated_at: new Date().toISOString(), - }) - .where('id', '=', batchId) - .execute() - await db - .updateTable('tip') - .set({ confirmed_at: new Date().toISOString(), updated_at: new Date().toISOString() }) - .where('batch_id', '=', batchId) - .execute() - - const tokenMetadata = await Tapimo.getTokenMetadata( - env, - input.workspace.chain_id, - input.tokenAddress, - ) - return { - amount: formatAmount(input.payload.amount), - chainId: input.workspace.chain_id, - feePayer: 'sender', - isDefaultToken: Address.isEqual( - Address.checksum(input.tokenAddress), - Address.checksum(input.workspace.default_token_address ?? Tempo.addressLookup.pathUsd), - ), - memo: input.memo, - ok: true, - recipients: input.recipients, - senderProviderUserId: input.payload.senderProviderUserId, - skippedRecipients: input.payload.skippedRecipients, - status: 'sent', - tokenCurrency: tokenMetadata.currency, - tokenSymbol: tokenMetadata.symbol, - transactionHash: receipt.transactionHash, - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Tip submission failed.' - const code = isInsufficientFundsError(error) ? 'insufficient_funds' : 'failed' - await db - .updateTable('tip_batch') - .set({ failure_reason: message, status: 'failed', updated_at: new Date().toISOString() }) - .where('id', '=', batchId) - .execute() - await db - .updateTable('tip') - .set({ - failed_at: new Date().toISOString(), - failure_reason: message, - updated_at: new Date().toISOString(), - }) - .where('batch_id', '=', batchId) - .execute() - return { chainId: input.workspace.chain_id, code, message, ok: false } - } -} - -async function submitSignedTip( - env: Env, - db: DB.Type, - input: { - address: string - idempotencyKey: string - memo: string | null - payload: Confirmation.Payload - recipient: ConnectedMember - sender: ConnectedMember - signedTransaction: `0x${string}` - tokenAddress: string - workspace: Database.Selectable.workspace - }, -): Promise { - const existing = await getExistingTipResult( - env, - db, - { - idempotencyKey: input.idempotencyKey, - recipientProviderUserId: input.payload.recipientProviderUserId, - senderProviderUserId: input.payload.senderProviderUserId, - }, - input.tokenAddress, - ) - if (existing) return existing - - validateSignedTransaction(input) - const id = Nanoid.generate() - const batchId = Nanoid.generate() - const sponsorshipMemo = await createSponsorshipMemo(env, { - amount: input.payload.amount, - chainId: input.workspace.chain_id, - id, - idempotencyKey: input.idempotencyKey, - recipient: input.recipient.account.address, - sender: input.sender.account.address, - token: input.tokenAddress, - }) - const now = new Date().toISOString() - await db - .insertInto('tip_batch') - .values({ - amount_each: input.payload.amount, - created_at: now, - failure_reason: null, - id: batchId, - idempotency_key: input.idempotencyKey, - memo: input.memo, - provider: input.payload.provider, - provider_channel_id: input.payload.providerChannelId, - provider_id: input.payload.providerId, - provider_thread_id: input.payload.providerThreadId ?? null, - recipient_count: 1, - sender_member_id: input.sender.member.id, - source: input.payload.source ?? 'command', - status: 'pending', - token_address: input.tokenAddress, - total_amount: input.payload.amount, - updated_at: now, - workspace_id: input.workspace.id, - }) - .execute() - await db - .insertInto('tip') - .values({ - access_key_id: null, - amount: input.payload.amount, - batch_id: batchId, - chain_id: input.workspace.chain_id, - confirmed_at: null, - created_at: now, - failed_at: null, - failure_reason: null, - id, - idempotency_key: input.idempotencyKey, - memo: input.memo, - recipient_id: input.recipient.account.id, - recipient_member_id: input.recipient.member.id, - sender_id: input.sender.account.id, - sender_member_id: input.sender.member.id, - sponsorship_memo: sponsorshipMemo, - token_address: input.tokenAddress, - transfer_log_index: null, - updated_at: now, - workspace_id: input.workspace.id, - }) - .execute() - - try { - await db - .updateTable('tip_batch') - .set({ status: 'submitting', updated_at: new Date().toISOString() }) - .where('id', '=', batchId) - .execute() - const client = createClient({ - chain: Tempo.getChain(input.workspace.chain_id), - transport: http(Tempo.getRpcUrl(env, input.workspace.chain_id)), - }) - const balance = await Actions.token.getBalance(client, { - account: input.sender.account.address as Address.Address, - token: input.tokenAddress as Address.Address, - }) - if (balance.amount < BigInt(input.payload.amount)) throw new InsufficientFundsError() - - const receipt = (await client.request({ - method: 'eth_sendRawTransactionSync' as never, - params: [input.signedTransaction] as never, - })) as { status: `0x${string}`; transactionHash?: `0x${string}` } - if (receipt.status !== '0x1' || !receipt.transactionHash) - throw new Error('Tempo transaction failed.') - await db - .updateTable('tip_batch') - .set({ - status: 'confirmed', - transaction_hash: receipt.transactionHash, - updated_at: new Date().toISOString(), - }) - .where('id', '=', batchId) - .execute() - await db - .updateTable('tip') - .set({ - confirmed_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - .where('id', '=', id) - .execute() - - const tokenMetadata = await Tapimo.getTokenMetadata( - env, - input.workspace.chain_id, - input.tokenAddress, - ) - return { - amount: formatAmount(input.payload.amount), - chainId: input.workspace.chain_id, - feePayer: 'sender', - isDefaultToken: Address.isEqual( - Address.checksum(input.tokenAddress), - Address.checksum(input.workspace.default_token_address ?? Tempo.addressLookup.pathUsd), - ), - memo: input.memo, - ok: true, - recipientProviderUserId: input.payload.recipientProviderUserId, - senderProviderUserId: input.payload.senderProviderUserId, - status: 'sent', - tokenCurrency: tokenMetadata.currency, - tokenSymbol: tokenMetadata.symbol, - transactionHash: receipt.transactionHash, - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Tip submission failed.' - const code = isInsufficientFundsError(error) ? 'insufficient_funds' : 'failed' - await db - .updateTable('tip_batch') - .set({ failure_reason: message, status: 'failed', updated_at: new Date().toISOString() }) - .where('id', '=', batchId) - .execute() - await db - .updateTable('tip') - .set({ - failed_at: new Date().toISOString(), - failure_reason: message, - updated_at: new Date().toISOString(), - }) - .where('id', '=', id) - .execute() - return { chainId: input.workspace.chain_id, code, message, ok: false } - } -} - -function createSignedTransactionRequest( - payload: Confirmation.Payload, - sender: ConnectedMember, - recipients: ConnectedMember[], -) { - const calls = recipients.map((recipient) => { - const call = Actions.token.transfer.call({ - amount: BigInt(payload.amount), - ...(payload.memo ? { memo: encodeTransferMemo(payload.memo) } : {}), - to: recipient.account.address as Address.Address, - token: Address.checksum(payload.tokenAddress), - }) - return { data: call.data, to: call.to } - }) - return { - calls, - chainId: payload.chainId, - feeToken: Address.checksum(payload.tokenAddress), - from: sender.account.address, - } -} - -function validateSignedTransaction(input: { - address: string - connectedRecipients?: ConnectedMember[] - payload: Confirmation.Payload - recipient: ConnectedMember - sender: ConnectedMember - signedTransaction: `0x${string}` - tokenAddress: string -}) { - const transaction = TxEnvelopeTempo.deserialize(input.signedTransaction as `0x76${string}`) - const expected = createSignedTransactionRequest( - input.payload, - input.sender, - input.connectedRecipients ?? [input.recipient], - ) - if ( - !Address.isEqual( - input.address as Address.Address, - input.sender.account.address as Address.Address, - ) - ) - throw new Error('Reconnect Tipbot and try again.') - if (!transaction.from) throw new Error('Payment approval is invalid.') - if (!Address.isEqual(transaction.from, input.sender.account.address as Address.Address)) - throw new Error('Reconnect Tipbot and try again.') - if (transaction.chainId !== input.payload.chainId) throw new Error('Payment approval is invalid.') - if (transaction.calls.length !== expected.calls.length) - throw new Error('Payment approval is invalid.') - for (const [index, expectedCall] of expected.calls.entries()) { - const call = transaction.calls[index] - if (!call || !call.to || !Address.isEqual(call.to, expectedCall.to as Address.Address)) - throw new Error('Payment approval is invalid.') - if ((call.data ?? '0x').toLowerCase() !== expectedCall.data.toLowerCase()) - throw new Error('Payment approval is invalid.') - if (call.value && call.value !== 0n) throw new Error('Payment approval is invalid.') - } - if (transaction.keyAuthorization) throw new Error('Payment approval is invalid.') - if (transaction.feePayerSignature) throw new Error('Payment approval is invalid.') -} - function supportsTransferMemo( authorization: KeyAuthorization.KeyAuthorization, tokenAddress: string, diff --git a/src/lib/twitter.ts b/src/lib/twitter.ts index e6bb395..425da75 100644 --- a/src/lib/twitter.ts +++ b/src/lib/twitter.ts @@ -346,6 +346,37 @@ export async function createProof( .where('id', '=', challenge.id) .execute() + return { + ...formatProofTweet(env, proof), + avatarUrl: twitterAccount.profile_image_url, + name: twitterAccount.name, + proof, + username: twitterAccount.username, + } +} + +/** + * Returns the proof tweet of a challenge that `createProof` prepared, for the page that the Tempo ID + * callback returns to. Returns `undefined` for an unknown, used, or expired challenge, or another + * proof. + */ +export async function getProof(env: Env, input: { challengeId: string; proof: string }) { + const db = DB.create(env.DB) + const challenge = await db + .selectFrom('provider_link_challenge') + .select(['expected_provider_handle', 'expires_at', 'proof_hash', 'used_at']) + .where('id', '=', input.challengeId) + .where('provider', '=', 'twitter') + .executeTakeFirst() + if (!challenge || challenge.used_at || challenge.expires_at <= new Date().toISOString()) + return undefined + if (!challenge.proof_hash || !challenge.expected_provider_handle) return undefined + if ((await AccountLink.hashToken(env, input.proof)) !== challenge.proof_hash) return undefined + const username = challenge.expected_provider_handle.replace(/^@/, '') + return { ...formatProofTweet(env, input.proof), username } +} + +function formatProofTweet(env: Pick, proof: string) { const tweetText = [ `Verifying my identity for @${env.TWITTER_BOT_HANDLE.replace(/^@+/, '')}`, '', @@ -355,14 +386,7 @@ export async function createProof( ].join('\n') const intentUrl = new URL('/intent/tweet', 'https://twitter.com') intentUrl.searchParams.set('text', tweetText) - return { - avatarUrl: twitterAccount.profile_image_url, - intentUrl: intentUrl.toString(), - name: twitterAccount.name, - proof, - tweetText, - username: twitterAccount.username, - } + return { intentUrl: intentUrl.toString(), tweetText } } export async function verifyLinkChallenge( diff --git a/src/router.tsx b/src/router.tsx index e435c21..b0dd56d 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,12 +1,18 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen.ts' export function getRouter() { + // One client per router, so a server render shares no cache across requests. + const queryClient = new QueryClient() const router = createTanStackRouter({ routeTree, scrollRestoration: true, defaultPreload: 'intent', defaultPreloadStaleTime: 0, + Wrap: (props) => ( + {props.children} + ), }) return router } diff --git a/src/routes/confirm/$token.tsx b/src/routes/confirm/$token.tsx index 8f4535c..0629ac8 100644 --- a/src/routes/confirm/$token.tsx +++ b/src/routes/confirm/$token.tsx @@ -1,207 +1,13 @@ import { createFileRoute } from '@tanstack/react-router' -import { createServerFn } from '@tanstack/react-start' -import { env } from 'cloudflare:workers' -import type { InferResponseType } from 'hono/client' -import * as React from 'react' -import { parseUnits, toHex } from 'viem' -import { useConnect, useConnection, useConnectors } from 'wagmi' import * as z from 'zod/mini' -import { api } from '#/api.ts' -import { WalletProviders } from '#/components/WalletProviders.tsx' import { slackCommand, tipbotImagePath } from '#/lib/app.ts' -import { isLostWalletSession } from '#/lib/confirmationWallet.ts' -import { getErrorMessage } from '#/lib/error.ts' -import { formatCurrencyAmount } from '#/lib/format.ts' -import { rpc } from '#/lib/rpc.ts' import * as Tempo from '#/lib/tempo.ts' -export const Route = createFileRoute('/confirm/$token')({ - component: Component, - async loader(options) { - return await getConfirmData({ data: options.params.token }) - }, -}) - +// Slack links to the Tempo ID sign-in, which authorizes the payment's access key, and the callback +// pays. This page shows only where that ends: sent, an error, or an expired link. function Component() { - const data = Route.useLoaderData() const params = Route.useParams() - - if (!data.ok) - return ( -
-
- Tipbot -
-

Confirmation link expired

-

{data.message}

-

Run `{slackCommand}` again in Slack.

-
-
-
- ) - - return ( - - - - ) -} - -function ConfirmPanel(props: { - data: Extract, { ok: true }> - token: string -}) { - const connect = useConnect() - const connection = useConnection() - const connectors = useConnectors() - const data = props.data - const [error, setError] = React.useState(null) - const [reconnectRequired, setReconnectRequired] = React.useState(false) - const [status, setStatus] = React.useState<'idle' | 'confirming' | 'sent'>('idle') - const [transactionHash, setTransactionHash] = React.useState(null) - const recipients: Array<{ - recipientAvatarUrl?: string | null - recipientProviderLabel?: string | null - recipientProviderUserId: string - }> = data.recipients ?? [ - { - recipientAvatarUrl: data.recipientAvatarUrl, - recipientProviderLabel: data.recipientProviderLabel, - recipientProviderUserId: data.recipientProviderUserId, - }, - ] - const skippedRecipients: Array<{ - reason: 'not_connected' | 'you' - recipientProviderLabel?: string | null - recipientProviderUserId: string - }> = data.skippedRecipients ?? [] - const totalAmount = String(Number(data.amount) * recipients.length) - - async function confirm() { - setError(null) - setTransactionHash(null) - setStatus('confirming') - const connector = connection.connector ?? connectors[0] - let hadWalletAccount = false - let phase: 'wallet' | 'submit' = 'wallet' - try { - if (!connector) throw new Error('Tempo Wallet is unavailable.') - // Reset stale local provider state only after the wallet invalidated it. - // Reconnection is a separate user attempt, never an automatic payment retry. - if (reconnectRequired) await connector.disconnect() - const initialProvider = (await connector.getProvider()) as { - request: (parameters: { method: string }) => Promise - } - const initialAccounts = await initialProvider - .request({ method: 'eth_accounts' }) - .catch(() => undefined) - hadWalletAccount = Array.isArray(initialAccounts) && initialAccounts.length > 0 - const account = await (async () => { - if ( - !reconnectRequired && - data.kind === 'reusable_access_key' && - connection.status === 'connected' - ) { - const result = await authorizeAccessKey(connector, data) - return { - address: result.rootAddress, - capabilities: { keyAuthorization: result.keyAuthorization }, - } - } - if ( - data.kind === 'onetime_payment' && - !reconnectRequired && - connection.status === 'connected' && - connection.address - ) - return { address: connection.address, capabilities: {} } - - const result = (await connect.connectAsync({ - capabilities: - data.kind === 'reusable_access_key' - ? { - authorizeAccessKey: getAuthorizeAccessKey(data), - showDeposit: { amount: '1', token: 'USDC.e' }, - } - : { showDeposit: { amount: '1', token: 'USDC.e' } }, - chainId: data.chainId, - connector, - withCapabilities: true, - } as never)) as unknown as { - accounts: readonly [ - { address: string; capabilities: Record }, - ...{ address: string; capabilities: Record }[], - ] - } - return result.accounts[0] - })() - setReconnectRequired(false) - if ( - data.kind === 'onetime_payment' && - account.address.toLowerCase() !== data.transactionRequest?.from.toLowerCase() - ) - throw new Error('Reconnect Tipbot and try again.') - const signedTransaction = await (async () => { - if (data.kind !== 'onetime_payment') return undefined - if (!data.transactionRequest) throw new Error('Payment request is unavailable.') - const provider = (await connector.getProvider()) as { - request: (parameters: { method: string; params: unknown[] }) => Promise - } - return (await provider.request({ - method: 'eth_signTransaction', - params: [ - { - ...data.transactionRequest, - chainId: toHex(data.chainId), - } as never, - ], - })) as string - })() - const keyAuthorization = account.capabilities.keyAuthorization - if (data.kind === 'reusable_access_key' && !keyAuthorization) - throw new Error('Tempo Wallet did not approve this payment.') - phase = 'submit' - const response = await rpc.api.confirm[':token'].$post({ - json: { - address: account.address, - ...(data.kind === 'reusable_access_key' ? { keyAuthorization } : { signedTransaction }), - }, - param: { token: props.token }, - }) - if (!response.ok) { - const json = await response.json().catch(() => null) - throw new Error(json && 'message' in json ? json.message : 'Payment failed.') - } - const json = await response.json() - setTransactionHash(json.transactionHash) - setStatus('sent') - } catch (error) { - setStatus('idle') - if (phase === 'wallet' && hadWalletAccount && connector) { - const provider = await connector.getProvider().catch(() => undefined) - if ( - provider && - (await isLostWalletSession( - error, - provider as { request: (parameters: { method: string }) => Promise }, - )) - ) { - setReconnectRequired(true) - setError( - 'Your wallet session expired. Select Confirm payment to reconnect and try again.', - ) - return - } - } - setError(getErrorMessage(error, 'Payment failed.')) - } - } + const search = Route.useSearch() return (
@@ -213,201 +19,58 @@ function ConfirmPanel(props: { src={tipbotImagePath} width={160} /> - {status === 'sent' ? ( -
-

Payment sent

-

You can close this tab and return to Slack.

- {transactionHash ? ( - - View receipt - - ) : null} -
- ) : ( -
-
-

Confirm payment

-

- Approve this payment in Tempo Wallet. -

-
-
-
-

- Tipbot wants to send a payment -

-

- Review the payment details below before continuing. -

-
-
-

Payment details

-
- {data.groupLabel ? ( -
- Group - - @{data.groupLabel} - -
- ) : null} -
- Amount - - {formatCurrencyAmount(data.amount, data.tokenCurrency)} {data.tokenSymbol} - {recipients.length > 1 ? ' each' : ''} - -
- {recipients.length > 1 ? ( -
- Total - - {formatCurrencyAmount(totalAmount, data.tokenCurrency)} {data.tokenSymbol} - -
- ) : null} -
- To -
- {recipients.map((recipient) => ( -
- {recipient.recipientAvatarUrl ? ( - {`${formatProviderLabel(recipient)} - ) : null} - {formatProviderLabel(recipient)} -
- ))} -
-
- {data.memo ? ( -
- For - - {data.memo} - -
- ) : null} - {skippedRecipients.length ? ( -
- Skipped -
- {skippedRecipients.map((recipient) => ( -
- {formatProviderLabel(recipient)}{' '} - - ({recipient.reason === 'you' ? 'you' : 'not connected yet'}) - -
- ))} -
-
- ) : null} -
-
-
-
- + {(() => { + if (search.status === 'sent') + return ( +
+

Payment sent

+

You can close this tab and return to Slack.

+ {search.tx && search.chain ? ( - Cancel + View receipt -
-

- {data.kind === 'reusable_access_key' - ? `Tipbot can send future ${data.tokenSymbol} tips from ${data.providerLabel}, up to ${formatCurrencyAmount(data.accessKeyLimit, data.tokenCurrency)} per day. You can disconnect anytime.` - : 'Tipbot will use this approval once and won’t save a new access key.'} + ) : null} +

+ ) + if (search.error) + return ( +
+

Payment not sent

+

+ {search.error}

- {error ?

{error}

: null} + + Try again +
+ ) + return ( +
+

Confirmation link expired

+

Run `{slackCommand}` again in Slack.

-
- )} + ) + })()}
) } -const getConfirmData = createServerFn({ method: 'GET' }) - .validator(z.string().check(z.minLength(1))) - .handler(async ({ data }) => { - const endpoint = rpc.api.confirm[':token'] - const response = await api.fetch(new Request(endpoint.$url({ param: { token: data } })), env) - if (!response.ok) { - const json = (await response.json().catch(() => null)) as InferResponseType< - typeof endpoint.$get, - 404 - > | null - return { - message: json && 'message' in json ? json.message : 'Confirmation link expired.', - ok: false as const, - } - } - - return (await response.json()) as InferResponseType - }) - -async function authorizeAccessKey( - connector: ReturnType[number], - data: Extract, { ok: true }>, -) { - const provider = (await connector.getProvider()) as { - request: (parameters: { method: string; params: unknown[] }) => Promise - } - return (await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [getAuthorizeAccessKey(data)], - })) as { keyAuthorization: unknown; rootAddress: string } -} - -function getAuthorizeAccessKey( - data: Extract, { ok: true }>, -) { - return { - chainId: BigInt(data.chainId), - expiry: Math.floor(new Date(data.accessKeyExpiry).getTime() / 1000), - keyType: 'secp256k1' as const, - limits: [ - { - limit: parseUnits(data.accessKeyLimit, 6), - period: data.accessKeyLimitPeriodSeconds, - token: data.tokenAddress, - }, - ], - publicKey: data.accessKeyPublicKey, - scopes: [ - { address: data.tokenAddress, selector: 'transfer(address,uint256)' }, - { address: data.tokenAddress, selector: 'transferWithMemo(address,uint256,bytes32)' }, - ], - } -} - -function formatProviderLabel(input: { - recipientProviderLabel?: string | null - recipientProviderUserId: string -}) { - if (!input.recipientProviderLabel) return input.recipientProviderUserId - return `@${input.recipientProviderLabel.replace(/^@+/, '')}` -} +export const Route = createFileRoute('/confirm/$token')({ + component: Component, + head: () => ({ meta: [{ title: 'Confirm payment - Tipbot' }] }), + validateSearch: z.object({ + chain: z.optional(z.coerce.number()), + error: z.optional(z.string()), + status: z.optional(z.literal('sent')), + tx: z.optional(z.string()), + }), +}) diff --git a/src/routes/connect/$token.tsx b/src/routes/connect/$token.tsx index ee803bd..6259ac3 100644 --- a/src/routes/connect/$token.tsx +++ b/src/routes/connect/$token.tsx @@ -1,152 +1,12 @@ import { createFileRoute } from '@tanstack/react-router' -import { createServerFn } from '@tanstack/react-start' -import { env } from 'cloudflare:workers' -import type { InferResponseType } from 'hono/client' -import * as React from 'react' -import { parseUnits } from 'viem' -import { useConnect, useConnection, useConnectors } from 'wagmi' import * as z from 'zod/mini' -import { api } from '#/api.ts' -import { WalletProviders } from '#/components/WalletProviders.tsx' import { slackCommand, tipbotImagePath } from '#/lib/app.ts' -import { getErrorMessage } from '#/lib/error.ts' -import { formatCurrencyAmount, formatPeriod } from '#/lib/format.ts' -import { rpc } from '#/lib/rpc.ts' -import * as Tapimo from '#/lib/tapimo.ts' -import * as Tempo from '#/lib/tempo.ts' - -export const Route = createFileRoute('/connect/$token')({ - component: Component, - async loader(options) { - return await getConnectData({ data: options.params.token }) - }, -}) +// Slack links to the Tempo ID sign-in, whose approval page names the access key's limits. This page +// shows only where the sign-in ends: connected, a conflict to resolve, an error, or a used link. function Component() { - const data = Route.useLoaderData() const params = Route.useParams() - - if (!data.ok) - return ( -
-
- Tipbot -
-

Connection link unavailable

-

{data.message}

-

- Run `{slackCommand} connect` in Slack to get a new link. -

-
-
-
- ) - - return ( - - - - ) -} - -function ConnectPanel(props: { - data: Extract, { ok: true }> - token: string -}) { - const connect = useConnect() - const connection = useConnection() - const connectors = useConnectors() - const data = props.data - const [error, setError] = React.useState(null) - const [errorCode, setErrorCode] = React.useState(null) - const [pendingConnection, setPendingConnection] = React.useState<{ - address: string - keyAuthorization: unknown - } | null>(null) - const [status, setStatus] = React.useState<'idle' | 'connecting' | 'connected'>('idle') - - async function connectTipbot() { - setError(null) - setErrorCode(null) - setPendingConnection(null) - setStatus('connecting') - try { - const connector = connection.connector ?? connectors[0] - if (!connector) throw new Error('Tempo Wallet is unavailable.') - if (connection.status === 'connected' && connection.address) { - const result = await authorizeAccessKey(connector, data) - await linkAccount({ - address: result.rootAddress, - keyAuthorization: result.keyAuthorization, - }) - setStatus('connected') - return - } - - const result = (await connect.connectAsync({ - capabilities: { - authorizeAccessKey: getAuthorizeAccessKey(data), - showDeposit: { amount: '1', token: 'USDC.e' }, - }, - chainId: data.chainId, - connector, - withCapabilities: true, - } as never)) as unknown as { - accounts: readonly [ - { address: string; capabilities: Record }, - ...{ address: string; capabilities: Record }[], - ] - } - const account = result.accounts[0] - const keyAuthorization = account.capabilities.keyAuthorization - if (!keyAuthorization) throw new Error('Tempo Wallet did not authorize Tipbot.') - await linkAccount({ address: account.address, keyAuthorization }) - setStatus('connected') - } catch (error) { - setStatus('idle') - setError(getErrorMessage(error, 'Could not connect to Tipbot.')) - } - } - - async function disconnectExistingAccount() { - if (!pendingConnection) return - setError(null) - setErrorCode(null) - setStatus('connecting') - try { - await linkAccount({ ...pendingConnection, disconnectExistingAccount: true }) - setPendingConnection(null) - setStatus('connected') - } catch (error) { - setStatus('idle') - setError(getErrorMessage(error, 'Could not connect to Tipbot.')) - } - } - - async function linkAccount(connection: { - address: string - keyAuthorization: unknown - disconnectExistingAccount?: boolean - }) { - const response = await rpc.api.account.link[':token'].$post({ - json: connection, - param: { token: props.token }, - }) - if (response.ok) return - - const json = await response.json().catch(() => null) - if (json && 'code' in json && json.code === 'account_already_connected') { - setErrorCode(json.code) - setPendingConnection(connection) - } - throw new Error(json && 'message' in json ? json.message : 'Could not connect to Tipbot.') - } + const search = Route.useSearch() return (
@@ -158,182 +18,68 @@ function ConnectPanel(props: { src={tipbotImagePath} width={160} /> - {status === 'connected' ? ( -
-

Connected to Tipbot

-

You can close this tab and return to Slack.

-
- ) : ( -
-
-

- Connect Tipbot -

-

- Authorize Tipbot to connect your Tempo Wallet for Slack tips. -

-
-
-
-

- Tipbot wants to connect to your Tempo Wallet -

-

- Review the permissions below before continuing. -

-
-
-

With this connection, Tipbot can:

-
-
-
-
+ {(() => { + if (search.status === 'connected') + return ( +
+

Connected to Tipbot

+

You can close this tab and return to Slack.

-
-
- - - Cancel - -
-

- Next: you’ll be asked to approve this connection in Tempo Wallet. + ) + if (search.status === 'conflict') + return ( +

+

Wallet already connected

+

+ This wallet is already connected to another Slack account in this workspace.

- {error ? ( -
-

{error}

- {errorCode === 'account_already_connected' && pendingConnection ? ( - - ) : null} -
- ) : null} + +
+ ) + if (search.error) + return ( +
+

Could not connect Tipbot

+

+ {search.error} +

+ + Try again +
+ ) + return ( +
+

Connection link unavailable

+

This connection link is invalid or expired.

+

+ Run `{slackCommand} connect` in Slack to get a new link. +

-
- )} + ) + })()}
) } -const getConnectData = createServerFn({ method: 'GET' }) - .validator(z.string().check(z.minLength(1))) - .handler(async ({ data }) => { - const endpoint = rpc.api.account.link[':token'] - const response = await api.fetch(new Request(endpoint.$url({ param: { token: data } })), env) - if (!response.ok) { - const json = (await response.json().catch(() => null)) as InferResponseType< - typeof endpoint.$get, - 404 - > | null - return { - message: - json && 'message' in json ? json.message : 'This connection link is invalid or expired.', - ok: false as const, - } - } - - const json = (await response.json()) as InferResponseType - try { - const metadata = await Tapimo.getTokenMetadata(env, json.chainId, json.tokenAddress) - return { - ...json, - tokenCurrency: metadata.currency, - tokenSymbol: metadata.symbol, - } - } catch { - return { - ...json, - tokenCurrency: 'USD', - tokenSymbol: - json.tokenAddress.toLowerCase() === Tempo.addressLookup.pathUsd.toLowerCase() - ? 'USD' - : `${json.tokenAddress.slice(0, 6)}…${json.tokenAddress.slice(-4)}`, - } - } - }) - -async function authorizeAccessKey( - connector: ReturnType[number], - data: Extract, { ok: true }>, -) { - const provider = (await connector.getProvider()) as { - request: (parameters: { method: string; params: unknown[] }) => Promise - } - return (await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [getAuthorizeAccessKey(data)], - })) as { keyAuthorization: unknown; rootAddress: string } -} - -function getAuthorizeAccessKey( - data: Extract, { ok: true }>, -) { - return { - chainId: BigInt(data.chainId), - expiry: Math.floor(new Date(data.accessKeyExpiry).getTime() / 1000), - keyType: 'secp256k1' as const, - limits: [ - { - limit: parseUnits(data.accessKeyLimit, 6), - period: data.accessKeyLimitPeriodSeconds, - token: data.tokenAddress, - }, - ], - publicKey: data.accessKeyPublicKey, - scopes: [ - { address: data.tokenAddress, selector: 'transfer(address,uint256)' }, - { address: data.tokenAddress, selector: 'transferWithMemo(address,uint256,bytes32)' }, - ], - } -} +export const Route = createFileRoute('/connect/$token')({ + component: Component, + head: () => ({ meta: [{ title: 'Connect - Tipbot' }] }), + validateSearch: z.object({ + error: z.optional(z.string()), + status: z.optional(z.enum(['conflict', 'connected'])), + }), +}) diff --git a/src/routes/dash.tsx b/src/routes/dash.tsx index 93e2e23..131ff3e 100644 --- a/src/routes/dash.tsx +++ b/src/routes/dash.tsx @@ -2,8 +2,6 @@ import { Popover } from '@base-ui/react/popover' import { useMutation } from '@tanstack/react-query' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import * as React from 'react' -import { useDisconnect } from 'wagmi' -import { WalletProviders } from '#/components/WalletProviders.tsx' import { disconnectDashboardAccount, getDashboardData, @@ -15,27 +13,7 @@ import IconLogosSlackIcon from '~icons/logos/slack-icon.jsx' import IconSimpleIconsX from '~icons/simple-icons/x.jsx' function Component() { - return ( - - - - ) -} - -export const Route = createFileRoute('/dash')({ - component: Component, - head: () => ({ meta: [{ title: 'Dashboard - Tipbot' }] }), - async loader() { - const dashboard = await getDashboardData() - if (!dashboard.ok) throw redirect({ to: '/' }) - return dashboard - }, -}) - -function DashboardPanel() { const loaderDashboard = Route.useLoaderData() - const disconnectWallet = useDisconnect() - const navigate = Route.useNavigate() const [dashboard, setDashboard] = React.useState(loaderDashboard) const [statusMessage, setStatusMessage] = React.useState(null) const disconnectAccount = useMutation({ @@ -60,9 +38,7 @@ function DashboardPanel() { }) const error = disconnectAccount.error ? getErrorMessage(disconnectAccount.error, 'Could not disconnect account.') - : disconnectWallet.error - ? getErrorMessage(disconnectWallet.error, 'Could not sign out.') - : null + : null return (
@@ -93,17 +69,15 @@ function DashboardPanel() { {dashboard.walletAddress} - + {/* Sign-out revokes the Tempo ID session on the server, then returns home. */} +
+ +
) : null}
@@ -163,6 +137,16 @@ function DashboardPanel() { ) } +export const Route = createFileRoute('/dash')({ + component: Component, + head: () => ({ meta: [{ title: 'Dashboard - Tipbot' }] }), + async loader() { + const dashboard = await getDashboardData() + if (!dashboard.ok) throw redirect({ to: '/' }) + return dashboard + }, +}) + function AccountSection(props: { accounts: DashboardAccount[] disconnectAccount: ReturnType> diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 012c67a..083768a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,41 +1,14 @@ -import { useMutation } from '@tanstack/react-query' import { Link, createFileRoute } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' -import { useConnect, useConnection, useConnectors } from 'wagmi' import * as z from 'zod/mini' -import { WalletProviders } from '#/components/WalletProviders.tsx' import { slackBotDisplayName, tipbotImagePath } from '#/lib/app.ts' import IconLogosSlackIcon from '~icons/logos/slack-icon.jsx' import IconSimpleIconsX from '~icons/simple-icons/x.jsx' function Component() { - return ( - - - - ) -} - -function HomePanel() { - const connect = useConnect() - const connection = useConnection() - const connectors = useConnectors() const home = Route.useLoaderData() - const navigate = Route.useNavigate() const search = Route.useSearch() - const openDashboard = useMutation({ - mutationFn: async () => { - const connector = connection.connector ?? connectors[0] - if (!connector) throw new Error('Tempo Wallet is unavailable.') - if (connection.status === 'connected') { - await authenticateConnectedWallet(connector) - return - } - await connect.mutateAsync({ capabilities: { auth: '/api/auth' }, connector } as never) - }, - onSuccess: () => void navigate({ to: '/dash' }), - }) return (
{home.dashboardAuthed ? ( @@ -46,14 +19,13 @@ function HomePanel() { Dashboard ) : ( - + Dashboard + )}
@@ -307,16 +279,7 @@ export const Route = createFileRoute('/')({ }) const getHomeData = createServerFn({ method: 'GET' }).handler(async () => { - const { auth } = await import('#/lib/auth.ts') - return { dashboardAuthed: Boolean(await auth.getSession(getRequest())) } + const { env } = await import('cloudflare:workers') + const TempoId = await import('#/lib/tempoId.ts') + return { dashboardAuthed: Boolean(await TempoId.getAddress(env, getRequest())) } }) - -async function authenticateConnectedWallet(connector: ReturnType[number]) { - const provider = (await connector.getProvider()) as { - request: (parameters: { method: string; params: unknown[] }) => Promise - } - await provider.request({ - method: 'wallet_connect', - params: [{ capabilities: { auth: '/api/auth' } }], - }) -} diff --git a/src/routes/link/x.tsx b/src/routes/link/x.tsx index da39d2e..c9f2727 100644 --- a/src/routes/link/x.tsx +++ b/src/routes/link/x.tsx @@ -1,278 +1,30 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' import * as React from 'react' -import { parseUnits } from 'viem' -import { useConnect, useConnection, useConnectors, useDisconnect } from 'wagmi' import * as z from 'zod/mini' -import { WalletProviders } from '#/components/WalletProviders.tsx' import { tipbotImagePath } from '#/lib/app.ts' import { getErrorMessage } from '#/lib/error.ts' import { rpc } from '#/lib/rpc.ts' -export const Route = createFileRoute('/link/x')({ - component: Component, - head: () => ({ meta: [{ title: 'Connect X - Tipbot' }] }), - validateSearch: z.object({ - error: z.optional(z.literal('oauth_failed')), - status: z.optional(z.literal('connected')), - }), -}) - function Component() { - return ( - - - - ) -} - -function LinkPanel() { - const connect = useConnect() - const connection = useConnection() - const connectors = useConnectors() - const disconnectWallet = useDisconnect() - const navigate = Route.useNavigate() + const proof = Route.useLoaderData() const search = Route.useSearch() - const [challenge, setChallenge] = React.useState(null) - const [copiedCurrentLink, setCopiedCurrentLink] = React.useState(false) - const [connectedWalletAddress, setConnectedWalletAddress] = React.useState(null) - const [pendingConnection, setPendingConnection] = React.useState(null) - const [status, setStatus] = React.useState< - 'idle' | 'connecting' | 'signing' | 'oauthing' | 'tweeting' | 'checking' | 'connected' - >(search.status === 'connected' ? 'connected' : 'idle') + const [connected, setConnected] = React.useState(search.status === 'connected') + const [showProofForm, setShowProofForm] = React.useState(search.error === 'link_failed') const [showManualVerification, setShowManualVerification] = React.useState(false) - const [showTweetFallback, setShowTweetFallback] = React.useState(false) - const [copiedExampleTweet, setCopiedExampleTweet] = React.useState(false) + const [checking, setChecking] = React.useState(false) const [tweetUrl, setTweetUrl] = React.useState('') - const [xUsername, setXUsername] = React.useState('') + const [copiedExampleTweet, setCopiedExampleTweet] = React.useState(false) const exampleTweet = '@tipbotgg @awkweb $0.01 for building tipbot' - const twitterInAppBrowser = React.useSyncExternalStore( - () => () => {}, - () => - typeof navigator !== 'undefined' && - /Twitter/i.test(navigator.userAgent) && - !/Twitterbot/i.test(navigator.userAgent), - () => false, - ) - const walletAddress = - connection.status === 'connected' && connection.address - ? connection.address - : (pendingConnection?.address ?? connectedWalletAddress) - const step = challenge ? 3 : pendingConnection ? 2 : 1 - const stepDescription = challenge - ? 'Post the prepared verification tweet from this X account. Tipbot will finish the connection automatically.' - : pendingConnection - ? showTweetFallback - ? 'Prepare the proof tweet for this X account.' - : 'Sign in with X to verify your account privately.' - : showTweetFallback - ? 'Enter the X username to connect, then approve your Tempo Wallet and a limited access key together.' - : 'Approve your Tempo Wallet and a limited access key together.' - const stepTitle = challenge - ? 'Post verification tweet' - : pendingConnection - ? showTweetFallback - ? 'Prepare proof tweet' - : 'Connect X' - : walletAddress - ? 'Create access key' - : 'Connect wallet' - const connectWalletMutation = useMutation({ - mutationFn: async () => { - const username = xUsername.trim().replace(/^@+/, '') - if (showTweetFallback && !username) throw new Error('Enter your X username first.') - const challengeResponse = showTweetFallback - ? await rpc.api.link.twitter.challenge.$post({ - json: walletAddress ? { address: walletAddress, username } : { username }, - }) - : await rpc.api.link.twitter.oauth.challenge.$post({ - json: walletAddress ? { address: walletAddress } : {}, - }) - if (challengeResponse.status !== 200) { - const challengeJson = await challengeResponse.json() - throw new Error( - 'message' in challengeJson - ? challengeJson.message - : 'Could not start Twitter connection.', - ) - } - const challengeJson = await challengeResponse.json() - const keyAuthorization = await authorizeAccessKeyOrConnect(challengeJson) - return { - address: keyAuthorization.rootAddress, - challengeJson, - keyAuthorization: keyAuthorization.keyAuthorization, - } - }, - onError: () => setStatus('idle'), - onMutate: () => setStatus('connecting'), - onSuccess: (value) => { - setConnectedWalletAddress(value.address) - setPendingConnection(value) - setStatus('idle') - }, - }) - const oauthMutation = useMutation({ - mutationFn: async () => { - if (!pendingConnection) throw new Error('Connect your wallet first.') - const startResponse = await rpc.api.link.twitter.oauth.start.$post({ - json: { - address: pendingConnection.address, - challengeId: pendingConnection.challengeJson.challengeId, - keyAuthorization: pendingConnection.keyAuthorization, - }, - }) - if (startResponse.status !== 200) { - const startJson = await startResponse.json() - throw new Error('message' in startJson ? startJson.message : 'Could not connect Twitter.') - } - const startJson = await startResponse.json() - window.location.assign(startJson.authorizationUrl) - }, - onError: () => setStatus('idle'), - onMutate: () => setStatus('oauthing'), - }) - const signMutation = useMutation({ - mutationFn: async () => { - if (!pendingConnection) throw new Error('Connect your wallet first.') - const proofResponse = await rpc.api.link.twitter.proof.$post({ - json: { - address: pendingConnection.address, - challengeId: pendingConnection.challengeJson.challengeId, - keyAuthorization: pendingConnection.keyAuthorization, - ...(!pendingConnection.challengeJson.username - ? { username: xUsername.trim().replace(/^@+/, '') } - : {}), - }, - }) - if (proofResponse.status !== 200) { - const proofJson = await proofResponse.json() - throw new Error( - 'message' in proofJson ? proofJson.message : 'Could not prepare Twitter proof.', - ) - } - const proofJson = await proofResponse.json() - return { - avatarUrl: pendingConnection.challengeJson.avatarUrl ?? proofJson.avatarUrl, - challengeId: pendingConnection.challengeJson.challengeId, - intentUrl: proofJson.intentUrl, - name: pendingConnection.challengeJson.name ?? proofJson.name, - proof: proofJson.proof, - tweetText: proofJson.tweetText, - username: pendingConnection.challengeJson.username ?? proofJson.username, - } - }, - onError: () => setStatus('idle'), - onMutate: () => setStatus('signing'), - onSuccess: (value) => { - setChallenge(value) - setShowManualVerification(false) - setStatus('tweeting') - }, - }) - const verifyMutation = useMutation({ - mutationFn: async () => await verifyTweet(), - onError: () => setStatus('tweeting'), - onMutate: () => setStatus('checking'), - onSuccess: (value) => { - if (value === 'connected') setStatus('connected') - }, - }) - const error = disconnectWallet.error - ? getErrorMessage(disconnectWallet.error, 'Could not disconnect wallet.') - : connectWalletMutation.error - ? getErrorMessage(connectWalletMutation.error, 'Could not connect wallet.') - : oauthMutation.error - ? getErrorMessage(oauthMutation.error, 'Could not connect Twitter.') - : signMutation.error - ? getErrorMessage(signMutation.error, 'Could not connect Twitter.') - : verifyMutation.error - ? getErrorMessage(verifyMutation.error, 'Could not verify the proof tweet.') - : search.error === 'oauth_failed' - ? 'Could not connect Twitter. Try again or use proof tweet verification.' - : null - - useQuery({ - enabled: Boolean(challenge && status === 'checking'), - queryFn: async () => { - try { - const result = await verifyTweet() - if (result === 'connected') setStatus('connected') - return result - } catch { - setStatus('tweeting') - return null - } - }, - queryKey: ['twitter-link-verify', challenge?.challengeId, challenge?.proof, tweetUrl.trim()], - refetchInterval: 5 * 1000, // 5 seconds - retry: false, - }) - - async function authorizeAccessKeyOrConnect(challengeJson: AccessKeyChallenge) { - if (walletAddress) return await authorizeAccessKey(challengeJson) - - const connector = connection.connector ?? connectors[0] - if (!connector) throw new Error('Tempo Wallet is unavailable.') - const result = (await connect.mutateAsync({ - capabilities: { authorizeAccessKey: getAuthorizeAccessKey(challengeJson) }, - chainId: challengeJson.chainId, - connector, - withCapabilities: true, - } as never)) as unknown as { - accounts: readonly [ - { address: string; capabilities: Record }, - ...{ address: string; capabilities: Record }[], - ] - } - const account = result.accounts[0] - const keyAuthorization = account.capabilities.keyAuthorization - if (!keyAuthorization) throw new Error('Tempo Wallet did not authorize Tipbot.') - setConnectedWalletAddress(account.address) - return { keyAuthorization, rootAddress: account.address } - } - - async function authorizeAccessKey(challengeJson: AccessKeyChallenge) { - const connector = connection.connector ?? connectors[0] - if (!connector) throw new Error('Tempo Wallet is unavailable.') - const provider = (await connector.getProvider()) as { - request: (parameters: { method: string; params: unknown[] }) => Promise - } - return (await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [getAuthorizeAccessKey(challengeJson)], - })) as { keyAuthorization: unknown; rootAddress: string } - } - - function getAuthorizeAccessKey(challengeJson: AccessKeyChallenge) { - return { - chainId: BigInt(challengeJson.chainId), - expiry: Math.floor(new Date(challengeJson.accessKeyExpiry).getTime() / 1000), - keyType: 'secp256k1' as const, - limits: [ - { - limit: parseUnits(challengeJson.accessKeyLimit, 6), - period: challengeJson.accessKeyLimitPeriodSeconds, - token: challengeJson.tokenAddress, - }, - ], - publicKey: challengeJson.accessKeyPublicKey, - scopes: [ - { address: challengeJson.tokenAddress, selector: 'transfer(address,uint256)' }, - { - address: challengeJson.tokenAddress, - selector: 'transferWithMemo(address,uint256,bytes32)', - }, - ], - } - } + const step = connected ? 3 : proof ? 2 : 1 async function verifyTweet() { - if (!challenge) throw new Error('Could not verify the proof tweet yet.') + if (!proof) throw new Error('Could not verify the proof tweet yet.') const response = await rpc.api.link.twitter.verify.$post({ json: { - challengeId: challenge.challengeId, - proof: challenge.proof, + challengeId: proof.challengeId, + proof: proof.proof, ...(tweetUrl.trim() ? { tweetUrl: tweetUrl.trim() } : {}), }, }) @@ -283,18 +35,41 @@ function LinkPanel() { throw new Error('Could not verify the proof tweet yet.') } + const verifyMutation = useMutation({ + mutationFn: verifyTweet, + onSuccess: (value) => { + if (value === 'connected') setConnected(true) + }, + }) + + useQuery({ + enabled: Boolean(proof && checking && !connected), + queryFn: async () => { + const result = await verifyTweet().catch(() => 'pending' as const) + if (result === 'connected') setConnected(true) + return result + }, + queryKey: ['twitter-link-verify', proof?.challengeId, proof?.proof, tweetUrl.trim()], + refetchInterval: 5 * 1000, // 5 seconds + retry: false, + }) + + const error = verifyMutation.error + ? getErrorMessage(verifyMutation.error, 'Could not verify the proof tweet.') + : search.error === 'oauth_failed' + ? 'Could not connect X. Try again, or verify with a proof tweet.' + : search.error === 'link_failed' + ? 'Could not prepare the proof tweet. Check the username and try again.' + : search.challenge && !proof + ? 'This proof tweet expired. Start again.' + : null + async function copyExampleTweet() { await navigator.clipboard.writeText(`.${exampleTweet}`) setCopiedExampleTweet(true) window.setTimeout(() => setCopiedExampleTweet(false), 2_000) // 2 seconds } - async function copyCurrentLink() { - await navigator.clipboard.writeText(window.location.href) - setCopiedCurrentLink(true) - window.setTimeout(() => setCopiedCurrentLink(false), 2_000) // 2 seconds - } - function composeExampleTweet() { const intentUrl = new URL('/intent/tweet', 'https://twitter.com') intentUrl.searchParams.set('text', `.${exampleTweet}`) @@ -311,7 +86,7 @@ function LinkPanel() { src={tipbotImagePath} width={160} /> - {status === 'connected' ? ( + {connected ? (

Connected to Tipbot

@@ -350,60 +125,41 @@ function LinkPanel() { Connect X to Tipbot

- Authorize Tipbot to connect your Tempo Wallet for X tips. + Sign in with Tempo ID, then verify your X account.

- Step {step}/3 + Step {step}/2 -

{stepTitle}

+

+ {proof ? 'Post verification tweet' : 'Connect X'} +

-

{stepDescription}

+

+ {proof + ? 'Post the prepared verification tweet from this X account. Tipbot finishes the connection automatically.' + : 'Approve a limited access key with Tempo ID, then sign in with X to verify your account.'} +

- {challenge ? ( + {proof ? (
-
-

Post your verification tweet

-

- Tipbot prepared this tweet for @{challenge.username}. It must be posted from - that X account, then Tipbot will finish the connection automatically. -

-
-
- {challenge.avatarUrl ? ( - {`${challenge.name} - ) : ( -
- )} -
-
- {challenge.name} - @{challenge.username} - · - now -
-
-                          {challenge.tweetText}
-                        
-
+
+

@{proof.username}

+
+                        {proof.tweetText}
+                      
- {status === 'checking' ? ( -

+ {checking ? ( +

Checking for your proof tweet automatically. This can take a few seconds.

) : null} @@ -437,11 +193,7 @@ function LinkPanel() {
) : (
- {pendingConnection ? ( -
- {showTweetFallback ? ( - <> - -
- - setXUsername(event.currentTarget.value)} - placeholder="yourhandle" - value={xUsername} - /> -
-

- The proof tweet must come from this X account. -

- - ) : ( - <> -

- With X OAuth, Tipbot can: -

-
-
-

- With X OAuth, Tipbot cannot: -

-
-
-
-
- - )} +

+ With this connection, Tipbot can: +

+
+
+
+
+
+
+ {showProofForm ? ( + // A proof tweet verifies the account without X sign-in. +
+ + + +
) : null}
)}
- {!challenge ? ( + {!proof ? (
+ {!showProofForm ? ( + + Connect X + + ) : null} - {pendingConnection ? ( - - ) : null}
) : null} {error ? ( @@ -645,35 +291,6 @@ function LinkPanel() { ) : null}
- {walletAddress ? ( -
-
-

Wallet connected

-

{formatAddress(walletAddress)}

-
- -
- ) : null}
)}
@@ -681,35 +298,31 @@ function LinkPanel() { ) } -type AccessKeyChallenge = { - accessKeyAddress: string - accessKeyExpiry: string - accessKeyLimit: string - accessKeyLimitPeriodSeconds: number - accessKeyPublicKey: `0x${string}` - avatarUrl?: string | undefined - chainId: number - challengeId: string - name?: string | undefined - tokenAddress: `0x${string}` - username?: string | undefined -} - -type PendingConnection = { - address: string - challengeJson: AccessKeyChallenge - keyAuthorization: unknown -} +export const Route = createFileRoute('/link/x')({ + component: Component, + head: () => ({ meta: [{ title: 'Connect X - Tipbot' }] }), + loaderDeps: (options) => ({ challenge: options.search.challenge, proof: options.search.proof }), + async loader(options) { + const deps = options.deps + if (!deps.challenge || !deps.proof) return null + return await getProofData({ data: { challengeId: deps.challenge, proof: deps.proof } }) + }, + validateSearch: z.object({ + challenge: z.optional(z.string()), + error: z.optional(z.enum(['link_failed', 'oauth_failed'])), + proof: z.optional(z.string()), + status: z.optional(z.literal('connected')), + }), +}) -type Challenge = { - avatarUrl?: string | undefined - challengeId: string - intentUrl: string - name: string - proof: string - tweetText: string - username: string -} +const getProofData = createServerFn({ method: 'GET' }) + .validator(z.object({ challengeId: z.string(), proof: z.string() })) + .handler(async ({ data }) => { + const { env } = await import('cloudflare:workers') + const Twitter = await import('#/lib/twitter.ts') + const proof = await Twitter.getProof(env, data) + return proof ? { ...proof, challengeId: data.challengeId, proof: data.proof } : null + }) function openTweetComposer(intentUrl: string) { const width = 560 @@ -724,7 +337,3 @@ function openTweetComposer(intentUrl: string) { popup?.focus() } - -function formatAddress(address: string) { - return `${address.slice(0, 6)}…${address.slice(-4)}` -} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b39d10d..9027544 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,4 +1,3 @@ -declare const __PLAYWRIGHT_ACCOUNT_PRIVATE_KEY__: `0x${string}` | undefined declare const __ENV__: 'development' | 'preview' | 'production' declare const __HOST__: string declare const __ORIGIN__: string diff --git a/src/worker-configuration.d.ts b/src/worker-configuration.d.ts index b5f34b2..8f6b39c 100644 --- a/src/worker-configuration.d.ts +++ b/src/worker-configuration.d.ts @@ -1,10 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types src/worker-configuration.d.ts` (hash: 8a894d1cdc6ecbdbdab717f77330ae9f) +// Generated by Wrangler by running `wrangler types src/worker-configuration.d.ts` (hash: 1202609e02cf99b9e269639fdea027e1) // Runtime types generated with workerd@1.20260511.1 2026-05-07 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./entry-server"); - durableNamespaces: "TipbotChatStateDO" | "NonceStorage"; + durableNamespaces: "TipbotChatStateDO" | "TempoIdSessions"; } interface PreviewEnv { DB: D1Database; @@ -13,6 +13,8 @@ declare namespace Cloudflare { CF_VERSION_METADATA: WorkerVersionMetadata; HOST: "__PREVIEW_APEX__.tip.bot"; SLACK_API_URL: "https://slack.com/api"; + TEMPO_ID_CLIENT_ID: "tipbot"; + TEMPO_ID_URL: "https://id.tempo.xyz"; TWITTER_API_URL: "https://api.twitter.com"; TWITTER_BOT_HANDLE: "__PREVIEW_TWITTER_BOT_HANDLE__"; FEE_PAYER_PRIVATE_KEY_MAINNET: string; @@ -22,8 +24,9 @@ declare namespace Cloudflare { SLACK_CLIENT_SECRET: string; SLACK_SIGNING_SECRET: string; TEMPO_API_KEY: string; + TEMPO_ID_CLIENT_SECRET: string; CHAT_STATE: DurableObjectNamespace; - AUTH_NONCE: DurableObjectNamespace; + TEMPO_ID_SESSIONS: DurableObjectNamespace; } interface ProductionEnv { DB: D1Database; @@ -32,6 +35,8 @@ declare namespace Cloudflare { CF_VERSION_METADATA: WorkerVersionMetadata; HOST: "tip.bot"; SLACK_API_URL: "https://slack.com/api"; + TEMPO_ID_CLIENT_ID: "tipbot"; + TEMPO_ID_URL: "https://id.tempo.xyz"; TWITTER_API_URL: "https://api.twitter.com"; TWITTER_BOT_HANDLE: "tipbotgg"; FEE_PAYER_PRIVATE_KEY_MAINNET: string; @@ -41,6 +46,7 @@ declare namespace Cloudflare { SLACK_CLIENT_SECRET: string; SLACK_SIGNING_SECRET: string; TEMPO_API_KEY: string; + TEMPO_ID_CLIENT_SECRET: string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_BEARER_TOKEN: string; @@ -49,7 +55,7 @@ declare namespace Cloudflare { TWITTER_OAUTH_CLIENT_ID: string; TWITTER_OAUTH_CLIENT_SECRET: string; CHAT_STATE: DurableObjectNamespace; - AUTH_NONCE: DurableObjectNamespace; + TEMPO_ID_SESSIONS: DurableObjectNamespace; } interface Env { DB: D1Database; @@ -58,6 +64,8 @@ declare namespace Cloudflare { CF_VERSION_METADATA: WorkerVersionMetadata; HOST: "__PREVIEW_APEX__.tip.bot" | "tip.bot" | "tipbot.localhost"; SLACK_API_URL: "https://slack.com/api"; + TEMPO_ID_CLIENT_ID: "tipbot"; + TEMPO_ID_URL: "https://id.tempo.xyz"; TWITTER_API_URL: "https://api.twitter.com"; TWITTER_BOT_HANDLE: "__PREVIEW_TWITTER_BOT_HANDLE__" | "tipbotgg"; FEE_PAYER_PRIVATE_KEY_MAINNET: string; @@ -67,8 +75,9 @@ declare namespace Cloudflare { SLACK_CLIENT_SECRET: string; SLACK_SIGNING_SECRET: string; TEMPO_API_KEY: string; + TEMPO_ID_CLIENT_SECRET: string; CHAT_STATE: DurableObjectNamespace; - AUTH_NONCE: DurableObjectNamespace; + TEMPO_ID_SESSIONS: DurableObjectNamespace; TWITTER_ACCESS_TOKEN?: string; TWITTER_ACCESS_TOKEN_SECRET?: string; TWITTER_BEARER_TOKEN?: string; @@ -83,7 +92,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/test/e2e.global.setup.ts b/test/e2e.global.setup.ts index 093c899..f5ccaca 100644 --- a/test/e2e.global.setup.ts +++ b/test/e2e.global.setup.ts @@ -3,10 +3,12 @@ import { createEmulator } from 'emulate' import { hc } from 'hono/client' import * as fs from 'node:fs/promises' import { join } from 'node:path' +import { GenericContainer, Wait } from 'testcontainers' import type { api } from '#/api.ts' import * as Constants from './constants.ts' import { startDevServer } from './devServer.ts' import { Env } from './env.ts' +import * as TempoNode from './tempo.ts' import { getAvailablePort } from './utils.ts' export default async function globalSetup() { @@ -24,17 +26,49 @@ export default async function globalSetup() { }) console.log('e2e: started slack emulator') + // Tempo ID's sign-in and approval pages, on the id-dev image. The browser and the dev server + // reach it at one origin, so the container publishes its port on the same port of the host. + const tempoIdPort = await getAvailablePort() + const tempoIdUrl = `http://localhost:${tempoIdPort}` + const defaults = Env.get() + console.log('e2e: starting tempo id') + const tempoId = await new GenericContainer( + process.env.ID_DEV_IMAGE ?? 'ghcr.io/tempoxyz/id-dev:latest', + ) + .withEnvironment({ + ID_DEV_APPS: JSON.stringify([ + { + clientId: defaults.TEMPO_ID_CLIENT_ID, + clientSecret: defaults.TEMPO_ID_CLIENT_SECRET, + name: 'Tipbot', + redirectUris: [`http://${host}/api/auth/callback`], + }, + ]), + PORT: String(tempoIdPort), + URL: tempoIdUrl, + }) + .withExposedPorts({ container: tempoIdPort, host: tempoIdPort }) + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(5 * 60 * 1000) // 5 minutes, for a first pull + .start() + console.log('e2e: started tempo id') + + // The local chain that payments settle on. The Tempo ID accounts of the specs are funded on it. + console.log('e2e: starting tempo') + const tempo = await TempoNode.start() + console.log('e2e: started tempo') + const env = Env.get({ HOST: host, + RPC_URL_TESTNET: tempo.rpcUrl, SLACK_APP_ID: 'A000000001', SLACK_API_URL: `${slack.url}/api`, + TEMPO_ID_URL: tempoIdUrl, }) console.log('e2e: starting dev server') const server = await startDevServer({ CLOUDFLARE_PERSIST_STATE_PATH: statePath, - PLAYWRIGHT_ACCOUNT_PRIVATE_KEY: - '0x0000000000000000000000000000000000000000000000000000000000000001', ...env, PORT: String(port), }) @@ -50,6 +84,8 @@ export default async function globalSetup() { return async () => { server.stop() await slack.close() + await tempoId.stop() + await tempo.stop() } } diff --git a/test/e2e/confirm.test.ts b/test/e2e/confirm.test.ts index f441a07..b3bcf75 100644 --- a/test/e2e/confirm.test.ts +++ b/test/e2e/confirm.test.ts @@ -1,568 +1,105 @@ -import type { Kysely } from 'kysely' +import { Address, Secp256k1 } from 'ox' +import * as AccessKey from '#/lib/accessKey.ts' +import * as AccountLink from '#/lib/accountLink.ts' import * as Confirmation from '#/lib/confirmation.ts' import * as Tempo from '#/lib/tempo.ts' -import type { DB } from '#db/types.gen.ts' -import { Account } from 'viem/tempo' -import type * as Factory from '../factory.ts' +import * as TempoNode from '../tempo.ts' import { expect, test } from './fixture.ts' +import { insertMember } from './members.ts' test('visitor opens an expired confirmation link', async ({ app, page }) => { - await page.goto(app.url({ params: { token: 'missing' }, to: '/confirm/$token' })) + await page.goto(app.url('/api/confirm/missing/start')) + await expect(page).toHaveURL(app.url({ params: { token: 'missing' }, to: '/confirm/$token' })) await expect(page.getByRole('heading', { name: 'Confirmation link expired' })).toBeVisible() await expect(page.getByText('Run `/tip` again in Slack.')).toBeVisible() - await expect(page.getByRole('button', { name: 'Confirm payment' })).toBeHidden() }) -test('slack member opens valid confirmation link', async ({ app, page }) => { - const token = await Confirmation.encrypt(app.env, { - amount: 5_000_000, - chainId: Tempo.chainLookup.localnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - idempotencyKey: `confirm:${crypto.randomUUID()}`, - kind: 'reusable_access_key', - memo: 'lunch', - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: 'C000000001', - providerId: 'T000000001', - recipientProviderLabel: 'member', - recipientProviderUserId: 'U000000002', - senderProviderUserId: 'U000000001', - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: crypto.randomUUID(), - }) - - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - - await expect(page.getByRole('heading', { name: 'Confirm payment' })).toBeVisible() - await expect(page.getByText('Approve this payment in Tempo Wallet.')).toBeVisible() - await expect(page.getByText('$5.00 PathUSD')).toBeVisible() - await expect(page.getByText('@member')).toBeVisible() - await expect(page.getByText('lunch')).toBeVisible() - await expect(page.getByText('Tipbot can send future PathUSD tips from Slack')).toBeVisible() - await expect(page.getByRole('button', { name: 'Confirm payment' })).toBeVisible() - await expect(page.getByRole('link', { name: 'Cancel' })).toBeVisible() -}) - -test('slack member opens valid multi-recipient confirmation link', async ({ app, page }) => { - const token = await Confirmation.encrypt(app.env, { - accessKeyExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days - accessKeyLimit: '3000000', - amount: 1_500_000, - chainId: Tempo.chainLookup.localnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - idempotencyKey: `confirm:${crypto.randomUUID()}`, - kind: 'reusable_access_key', - memo: 'team lunch', - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: 'C000000001', - providerId: 'T000000001', - recipientProviderLabel: 'foo', - recipientProviderUserId: 'U000000002', - recipients: [ - { recipientProviderLabel: 'foo', recipientProviderUserId: 'U000000002' }, - { recipientProviderLabel: 'bar', recipientProviderUserId: 'U000000003' }, - ], - senderProviderUserId: 'U000000001', - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: crypto.randomUUID(), - }) - - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - - await expect(page.getByRole('heading', { name: 'Confirm payment' })).toBeVisible() - await expect(page.getByText('$1.50 PathUSD each')).toBeVisible() - await expect(page.getByText('Total')).toBeVisible() - await expect(page.getByText('$3.00 PathUSD')).toBeVisible() - await expect(page.getByText('@foo')).toBeVisible() - await expect(page.getByText('@bar')).toBeVisible() - await expect(page.getByText('team lunch')).toBeVisible() -}) - -test('slack member opens group confirmation link with skipped recipients', async ({ - app, - page, -}) => { - const token = await Confirmation.encrypt(app.env, { - accessKeyExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days - accessKeyLimit: '3000000', - amount: 1_000, - chainId: Tempo.chainLookup.localnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - groupLabel: 'engineering', - idempotencyKey: `confirm:${crypto.randomUUID()}`, - kind: 'reusable_access_key', - memo: 'coffee', - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: 'C000000001', - providerId: 'T000000001', - recipientProviderLabel: 'foo', - recipientProviderUserId: 'U000000002', - recipients: [ - { recipientProviderLabel: 'foo', recipientProviderUserId: 'U000000002' }, - { recipientProviderLabel: 'bar', recipientProviderUserId: 'U000000003' }, - ], - senderProviderUserId: 'U000000001', - skippedRecipients: [ - { reason: 'not_connected', recipientProviderLabel: 'boo', recipientProviderUserId: 'UBOO' }, - { reason: 'you', recipientProviderLabel: 'joshie', recipientProviderUserId: 'UJOSHIE' }, - ], - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: crypto.randomUUID(), - }) - - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - - await expect(page.getByText('Group')).toBeVisible() - await expect(page.getByText('@engineering')).toBeVisible() - await expect(page.getByText('Skipped')).toBeVisible() - await expect(page.getByText('@boo (not connected yet)')).toBeVisible() - await expect(page.getByText('@joshie (you)')).toBeVisible() - await expect(page.getByText('$0.001 PathUSD each')).toBeVisible() - await expect(page.getByText('$0.002 PathUSD')).toBeVisible() - await expect(page.getByText('coffee')).toBeVisible() -}) - -test('slack member confirms payment with wallet approval', async ({ app, page }) => { - const root = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - const token = await Confirmation.encrypt(app.env, { - amount: 5_000_000, - chainId: Tempo.chainLookup.localnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - idempotencyKey: `confirm:${crypto.randomUUID()}`, - kind: 'reusable_access_key', - memo: 'lunch', - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: 'C000000001', - providerId: 'T000000001', - recipientProviderLabel: 'member', - recipientProviderUserId: 'U000000002', - senderProviderUserId: 'U000000001', - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: crypto.randomUUID(), - }) - await page.route('**/api/confirm/*', async (route) => { - if (route.request().method() !== 'POST') return await route.continue() - - const json = route.request().postDataJSON() as { - address?: string - keyAuthorization?: unknown - } - expect(json.address).toBe(root.address) - expect(json.keyAuthorization).toEqual(expect.any(Object)) - await route.fulfill({ - body: JSON.stringify({ ok: true, transactionHash: `0x${'1'.repeat(64)}` }), - contentType: 'application/json', - status: 200, +for (const kind of ['onetime_payment', 'reusable_access_key'] as const) + test(`slack member confirms a ${kind === 'onetime_payment' ? 'one-time payment' : 'payment with a reusable access key'} with Tempo ID`, async ({ + app, + db, + factory, + page, + }) => { + const workspace = await factory.workspace.insert({ + chain_id: Tempo.chainLookup.localnet, + provider_id: `T${crypto.randomUUID()}`, }) - }) - - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - await page.waitForLoadState('networkidle') - await expect(page.getByRole('button', { name: 'Confirm payment' })).toBeVisible() - await page.getByRole('button', { name: 'Confirm payment' }).click() - - await expect(page.getByRole('heading', { name: 'Payment sent' })).toBeVisible() - await expect(page.getByRole('link', { name: 'View receipt' })).toHaveAttribute( - 'href', - Tempo.formatTxLink(Tempo.chainLookup.localnet, `0x${'1'.repeat(64)}`), - ) - await expect(page.getByText('You can close this tab and return to Slack.')).toBeVisible() -}) - -test('slack member confirms one-time payment with wallet signature', async ({ - app, - db, - factory, - page, -}) => { - const senderRoot = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - const recipientRoot = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000002', - ) - const workspace = await factory.workspace.insert({ - chain_id: Tempo.chainLookup.testnet, - provider_id: `T${crypto.randomUUID().replaceAll('-', '')}`, - }) - const senderAccount = await (async () => { - const existing = await db - .selectFrom('account') - .selectAll() - .where('address', '=', senderRoot.address) - .executeTakeFirst() - if (existing) return existing - try { - return await factory.account.insert({ address: senderRoot.address }) - } catch (error) { - if (!(error instanceof Error && /unique constraint/i.test(error.message))) throw error - return await db - .selectFrom('account') - .selectAll() - .where('address', '=', senderRoot.address) - .executeTakeFirstOrThrow() - } - })() - const recipientAccount = await (async () => { - const existing = await db - .selectFrom('account') - .selectAll() - .where('address', '=', recipientRoot.address) - .executeTakeFirst() - if (existing) return existing - try { - return await factory.account.insert({ address: recipientRoot.address }) - } catch (error) { - if (!(error instanceof Error && /unique constraint/i.test(error.message))) throw error - return await db - .selectFrom('account') - .selectAll() - .where('address', '=', recipientRoot.address) - .executeTakeFirstOrThrow() - } - })() - await insertMember(db, factory, { - account_id: senderAccount.id, - provider_user_id: 'U000000001', - workspace_id: workspace.id, - }) - await insertMember(db, factory, { - account_id: recipientAccount.id, - provider_user_id: 'U000000002', - workspace_id: workspace.id, - }) - const token = await Confirmation.encrypt(app.env, { - amount: 11_000_000, - chainId: Tempo.chainLookup.testnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - idempotencyKey: `confirm:${crypto.randomUUID()}`, - kind: 'onetime_payment', - memo: 'lunch', - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: 'C000000001', - providerId: workspace.provider_id, - recipientProviderLabel: 'member', - recipientProviderUserId: 'U000000002', - senderProviderUserId: 'U000000001', - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: workspace.id, - }) - await page.route('**/api/confirm/*', async (route) => { - if (route.request().method() !== 'POST') return await route.continue() - - const json = route.request().postDataJSON() as { - address?: string - keyAuthorization?: unknown - signedTransaction?: string - } - expect(json.address).toBe(senderRoot.address) - expect(json.keyAuthorization).toBeUndefined() - expect(json.signedTransaction).toMatch(/^0x[0-9a-f]+$/) - await route.fulfill({ - body: JSON.stringify({ ok: true, transactionHash: `0x${'2'.repeat(64)}` }), - contentType: 'application/json', - status: 200, + const recipient = await insertMember(db, factory, { + account_id: ( + await factory.account.insert({ + address: Address.fromPublicKey( + Secp256k1.getPublicKey({ privateKey: Secp256k1.randomPrivateKey() }), + ), + }) + ).id, + provider_user_id: `U${crypto.randomUUID()}`, + workspace_id: workspace.id, }) - }) - - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - await page.waitForLoadState('networkidle') - await expect(page.getByText('Tipbot will use this approval once')).toBeVisible() - await page.getByRole('button', { name: 'Confirm payment' }).click() - - await expect(page.getByRole('heading', { name: 'Payment sent' })).toBeVisible() - await expect(page.getByRole('link', { name: 'View receipt' })).toHaveAttribute( - 'href', - Tempo.formatTxLink(Tempo.chainLookup.testnet, `0x${'2'.repeat(64)}`), - ) -}) - -test('slack member confirms receipt boost with wallet signature', async ({ - app, - db, - factory, - page, -}) => { - const senderRoot = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - const recipientRoot = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000002', - ) - const workspace = await factory.workspace.insert({ - chain_id: Tempo.chainLookup.testnet, - provider_id: `T${crypto.randomUUID().replaceAll('-', '')}`, - }) - const senderAccount = await (async () => { - const existing = await db - .selectFrom('account') - .selectAll() - .where('address', '=', senderRoot.address) - .executeTakeFirst() - if (existing) return existing - try { - return await factory.account.insert({ address: senderRoot.address }) - } catch (error) { - if (!(error instanceof Error && /unique constraint/i.test(error.message))) throw error - return await db - .selectFrom('account') - .selectAll() - .where('address', '=', senderRoot.address) - .executeTakeFirstOrThrow() - } - })() - const recipientAccount = await (async () => { - const existing = await db - .selectFrom('account') - .selectAll() - .where('address', '=', recipientRoot.address) - .executeTakeFirst() - if (existing) return existing - try { - return await factory.account.insert({ address: recipientRoot.address }) - } catch (error) { - if (!(error instanceof Error && /unique constraint/i.test(error.message))) throw error - return await db - .selectFrom('account') - .selectAll() - .where('address', '=', recipientRoot.address) - .executeTakeFirstOrThrow() - } - })() - const senderMember = await insertMember(db, factory, { - account_id: senderAccount.id, - provider_user_id: 'U000000001', - workspace_id: workspace.id, - }) - const recipientMember = await insertMember(db, factory, { - account_id: recipientAccount.id, - provider_user_id: 'U000000002', - workspace_id: workspace.id, - }) - const receiptTs = '1700000000.000001' - const channelId = 'C000000001' - const originalBatch = await factory.tip_batch.insert({ - amount_each: 1000, - idempotency_key: `command:${crypto.randomUUID()}`, - provider: 'slack', - provider_channel_id: channelId, - provider_id: workspace.provider_id, - recipient_count: 1, - sender_member_id: senderMember.id, - source: 'command', - status: 'confirmed', - token_address: Tempo.addressLookup.pathUsd, - total_amount: 1000, - transaction_hash: `0x${'1'.repeat(64)}`, - workspace_id: workspace.id, - }) - await factory.tip.insert({ - amount: 1000, - batch_id: originalBatch.id, - chain_id: workspace.chain_id, - confirmed_at: new Date().toISOString(), - idempotency_key: originalBatch.idempotency_key, - recipient_id: recipientAccount.id, - recipient_member_id: recipientMember.id, - sender_id: senderAccount.id, - sender_member_id: senderMember.id, - token_address: Tempo.addressLookup.pathUsd, - workspace_id: workspace.id, - }) - await factory.tip_receipt_message.insert({ - channel_id: channelId, - message_ts: receiptTs, - thread_ts: receiptTs, - tip_batch_id: originalBatch.id, - workspace_id: workspace.id, - }) - const token = await Confirmation.encrypt(app.env, { - amount: 1000, - chainId: Tempo.chainLookup.testnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - idempotencyKey: `boost:${workspace.id}:${channelId}:${receiptTs}:${senderMember.id}`, - kind: 'onetime_payment', - memo: null, - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: channelId, - providerId: workspace.provider_id, - providerThreadId: receiptTs, - recipientProviderLabel: 'member', - recipientProviderUserId: 'U000000002', - senderProviderUserId: 'U000000001', - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: workspace.id, - }) - await page.route('**/api/confirm/*', async (route) => { - if (route.request().method() !== 'POST') return await route.continue() - - const json = route.request().postDataJSON() as { - address?: string - keyAuthorization?: unknown - signedTransaction?: string - } - expect(json.address).toBe(senderRoot.address) - expect(json.keyAuthorization).toBeUndefined() - expect(json.signedTransaction).toMatch(/^0x[0-9a-f]+$/) - await route.fulfill({ - body: JSON.stringify({ ok: true, transactionHash: `0x${'4'.repeat(64)}` }), - contentType: 'application/json', - status: 200, + const sender = await insertMember(db, factory, { + provider_user_id: `U${crypto.randomUUID()}`, + workspace_id: workspace.id, }) - }) - - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - await page.waitForLoadState('networkidle') - await expect(page.getByText('$0.001 PathUSD')).toBeVisible() - await expect(page.getByText('@member')).toBeVisible() - await expect(page.getByText('Tipbot will use this approval once')).toBeVisible() - await page.getByRole('button', { name: 'Confirm payment' }).click() - - await expect(page.getByRole('heading', { name: 'Payment sent' })).toBeVisible() - await expect(page.getByRole('link', { name: 'View receipt' })).toHaveAttribute( - 'href', - Tempo.formatTxLink(Tempo.chainLookup.testnet, `0x${'4'.repeat(64)}`), - ) -}) - -test('slack member confirms multi-recipient one-time payment with wallet signature', async ({ - app, - db, - factory, - page, -}) => { - const senderRoot = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - const workspace = await factory.workspace.insert({ - chain_id: Tempo.chainLookup.testnet, - provider_id: `T${crypto.randomUUID().replaceAll('-', '')}`, - }) - const senderAccount = await (async () => { - const existing = await db - .selectFrom('account') - .selectAll() - .where('address', '=', senderRoot.address) - .executeTakeFirst() - if (existing) return existing - try { - return await factory.account.insert({ address: senderRoot.address }) - } catch (error) { - if (!(error instanceof Error && /unique constraint/i.test(error.message))) throw error - return await db - .selectFrom('account') - .selectAll() - .where('address', '=', senderRoot.address) - .executeTakeFirstOrThrow() - } - })() - const firstRecipientAccount = await factory.account.insert({}) - const secondRecipientAccount = await factory.account.insert({}) - await insertMember(db, factory, { - account_id: senderAccount.id, - provider_user_id: 'U000000001', - workspace_id: workspace.id, - }) - await insertMember(db, factory, { - account_id: firstRecipientAccount.id, - provider_user_id: 'U000000002', - workspace_id: workspace.id, - }) - await insertMember(db, factory, { - account_id: secondRecipientAccount.id, - provider_user_id: 'U000000003', - workspace_id: workspace.id, - }) - const token = await Confirmation.encrypt(app.env, { - amount: 1_500_000, - chainId: Tempo.chainLookup.testnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - idempotencyKey: `confirm:${crypto.randomUUID()}`, - kind: 'onetime_payment', - memo: 'team lunch', - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: 'C000000001', - providerId: workspace.provider_id, - recipientProviderLabel: 'foo', - recipientProviderUserId: 'U000000002', - recipients: [ - { recipientProviderLabel: 'foo', recipientProviderUserId: 'U000000002' }, - { recipientProviderLabel: 'bar', recipientProviderUserId: 'U000000003' }, - ], - senderProviderUserId: 'U000000001', - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: workspace.id, - }) - await page.route('**/api/confirm/*', async (route) => { - if (route.request().method() !== 'POST') return await route.continue() - - const json = route.request().postDataJSON() as { - address?: string - keyAuthorization?: unknown - signedTransaction?: string - } - expect(json.address).toBe(senderRoot.address) - expect(json.keyAuthorization).toBeUndefined() - expect(json.signedTransaction).toMatch(/^0x[0-9a-f]+$/) - await route.fulfill({ - body: JSON.stringify({ ok: true, transactionHash: `0x${'3'.repeat(64)}` }), - contentType: 'application/json', - status: 200, + const linkToken = crypto.randomUUID() + const accessKey = AccessKey.generate() + await factory.account_link_token.insert({ + access_key_address: accessKey.address, + access_key_ciphertext: await AccessKey.encrypt(app.env, accessKey.privateKey), + access_key_expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days + access_key_public_key: accessKey.publicKey, + member_id: sender.id, + token_hash: await AccountLink.hashToken(app.env, linkToken), }) - }) - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - await page.waitForLoadState('networkidle') - await expect(page.getByText('$1.50 PathUSD each')).toBeVisible() - await expect(page.getByText('$3.00 PathUSD')).toBeVisible() - await expect(page.getByText('@foo')).toBeVisible() - await expect(page.getByText('@bar')).toBeVisible() - await expect(page.getByText('Tipbot will use this approval once')).toBeVisible() - await page.getByRole('button', { name: 'Confirm payment' }).click() - - await expect(page.getByRole('heading', { name: 'Payment sent' })).toBeVisible() - await expect(page.getByRole('link', { name: 'View receipt' })).toHaveAttribute( - 'href', - Tempo.formatTxLink(Tempo.chainLookup.testnet, `0x${'3'.repeat(64)}`), - ) -}) - -async function insertMember( - db: Kysely, - factory: ReturnType, - attrs: Partial & - Pick & { account_id?: string | null }, -) { - const member = factory.member.attrs(attrs as never) - const { account_id: accountId, ...memberValues } = member as typeof member & { - account_id?: string | null - } - if (member.provider_identity_id) return await factory.member.insert(memberValues) + // The sender connects a Tempo Account, which then holds funds on the local chain. + await app.tempoId.signIn(page) + await page.goto(app.url(`/api/account/link/${linkToken}/start`)) + await app.tempoId.approve(page) + await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible() + const senderAccount = await db + .selectFrom('member') + .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') + .innerJoin('account', 'account.id', 'provider_identity.account_id') + .select('account.address') + .where('member.id', '=', sender.id) + .executeTakeFirstOrThrow() + await TempoNode.mint({ + amount: '10', + feePayer: app.env.FEE_PAYER_PRIVATE_KEY_TESTNET, + rpcUrl: app.env.RPC_URL_TESTNET!, + to: senderAccount.address, + }) + const idempotencyKey = `confirm:${crypto.randomUUID()}` + const token = await Confirmation.encrypt(app.env, { + amount: 1_000_000, + chainId: Tempo.chainLookup.localnet, + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes + idempotencyKey, + kind, + memo: 'lunch', + nonce: crypto.randomUUID(), + provider: 'slack', + providerChannelId: 'C000000001', + providerId: workspace.provider_id, + recipientProviderUserId: recipient.provider_user_id, + senderProviderUserId: sender.provider_user_id, + tokenAddress: Tempo.addressLookup.pathUsd, + workspaceId: workspace.id, + }) - const workspace = await db - .selectFrom('workspace') - .select(['provider', 'provider_id']) - .where('id', '=', member.workspace_id) - .executeTakeFirstOrThrow() - const identity = await factory.provider_identity.insert({ - account_id: accountId ?? null, - created_at: member.created_at, - display_name: member.login, - provider: workspace.provider, - provider_user_id: member.provider_user_id, - provider_workspace_id: workspace.provider_id, - real_name: member.name, - updated_at: member.updated_at, + await page.goto(app.url(`/api/confirm/${token}/start`)) + await expect( + page.getByText(kind === 'onetime_payment' ? 'Spend $1' : 'Spend $10 per day'), + ).toBeVisible() + await app.tempoId.approve(page) + + await expect(page.getByRole('heading', { name: 'Payment sent' })).toBeVisible() + await expect(page.getByRole('link', { name: 'View receipt' })).toBeVisible() + const tip = await db + .selectFrom('tip') + .select('confirmed_at') + .where('idempotency_key', '=', idempotencyKey) + .executeTakeFirstOrThrow() + expect(tip.confirmed_at).toEqual(expect.any(String)) }) - return await factory.member.insert({ ...memberValues, provider_identity_id: identity.id }) -} diff --git a/test/e2e/connect.test.ts b/test/e2e/connect.test.ts index ef560c9..1fcf561 100644 --- a/test/e2e/connect.test.ts +++ b/test/e2e/connect.test.ts @@ -1,25 +1,28 @@ import type { APIRequestContext, APIResponse } from '@playwright/test' -import type { Kysely } from 'kysely' import { WebClient } from '@slack/web-api' +import { Address, Secp256k1 } from 'ox' import * as AccessKey from '#/lib/accessKey.ts' import * as AccountLink from '#/lib/accountLink.ts' import { createSlackHeaders } from '#/lib/slack.ts' import * as Tempo from '#/lib/tempo.ts' -import type { DB } from '#db/types.gen.ts' -import { Account } from 'viem/tempo' import * as Constants from '../constants.ts' -import type * as Factory from '../factory.ts' import { expect, test } from './fixture.ts' +import { insertMember } from './members.ts' test('visitor opens an expired connection link', async ({ app, page }) => { - await page.goto(app.url({ params: { token: 'missing' }, to: '/connect/$token' })) + await page.goto(app.url('/api/account/link/missing/start')) + await expect(page).toHaveURL(app.url({ params: { token: 'missing' }, to: '/connect/$token' })) await expect(page.getByText('This connection link is invalid or expired.')).toBeVisible() await expect(page.getByText('Run `/tip connect` in Slack to get a new link.')).toBeVisible() - await expect(page.getByRole('button', { name: 'Connect' })).toBeHidden() }) -test('slack member opens valid connection link', async ({ app, db, factory, page }) => { +test('slack member opens a connection link, which starts the Tempo ID sign-in', async ({ + app, + db, + factory, + page, +}) => { const token = crypto.randomUUID() const workspace = await factory.workspace.insert({ provider_id: `T${crypto.randomUUID()}` }) const member = await insertMember(db, factory, { @@ -31,31 +34,23 @@ test('slack member opens valid connection link', async ({ app, db, factory, page token_hash: await AccountLink.hashToken(app.env, token), }) - await page.goto(app.url({ params: { token }, to: '/connect/$token' })) + await page.goto(app.url(`/api/account/link/${token}/start`)) - await expect(page.getByRole('button', { name: 'Connect' })).toBeVisible() - await expect(page.getByRole('link', { name: 'Cancel' })).toBeVisible() - await expect(page.getByText('Authorize Tipbot to connect your Tempo Wallet')).toBeVisible() + await expect(page).toHaveURL((url) => url.origin === new URL(app.tempoId.url).origin) }) -test('slack member connects wallet from slack', async ({ app, db, page, request }) => { +test('slack member connects a Tempo Account from slack', async ({ app, db, page, request }) => { await installSlack(app, request) await postSlashCommand(app, 'disconnect') await postSlashCommand(app, 'connect') const token = await getConnectToken(app) - const root = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) + await app.tempoId.signIn(page) - await page.goto(app.url({ params: { token }, to: '/connect/$token' })) - await page.waitForLoadState('networkidle') + await page.goto(app.url(`/api/account/link/${token}/start`)) + await expect(page.getByText('Spend $10 per day')).toBeVisible() + await app.tempoId.approve(page) - const walletConnectTimeoutMs = 15_000 // 15 seconds - await expect(page.getByRole('button', { name: 'Connect' })).toBeVisible() - await page.getByRole('button', { name: 'Connect' }).click() - await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible({ - timeout: walletConnectTimeoutMs, - }) + await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible() await expect(page.getByText('You can close this tab and return to Slack.')).toBeVisible() const link = await db @@ -64,7 +59,6 @@ test('slack member connects wallet from slack', async ({ app, db, page, request .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') .innerJoin('account', 'account.id', 'provider_identity.account_id') .select([ - 'account.address as account_address', 'account.id as account_id', 'account_link_token.access_key_address', 'account_link_token.access_key_authorization', @@ -74,30 +68,41 @@ test('slack member connects wallet from slack', async ({ app, db, page, request ]) .where('account_link_token.token_hash', '=', await AccountLink.hashToken(app.env, token)) .executeTakeFirstOrThrow() + const accessKey = await db + .selectFrom('access_key') + .select(['address', 'ciphertext']) + .where('account_id', '=', link.account_id) + .executeTakeFirstOrThrow() - expect(link.account_address).toBe(root.address) expect(link.access_key_authorization).toEqual(expect.any(String)) expect(link.identity_account_id).toBe(link.account_id) expect(link.link_account_id).toBe(link.account_id) expect(link.used_at).toEqual(expect.any(String)) + // The access key that Tempo ID authorized is the one that Tipbot stores and signs with. + expect(accessKey.address).toBe(link.access_key_address) + expect( + Address.isEqual( + Address.fromPublicKey( + Secp256k1.getPublicKey({ + privateKey: await AccessKey.decrypt(app.env, accessKey.ciphertext), + }), + ), + accessKey.address as Address.Address, + ), + ).toBe(true) await page.goto(app.url({ params: { token }, to: '/connect/$token' })) await expect(page.getByText('This connection link is invalid or expired.')).toBeVisible() }) -test('slack member connects another link while wallet is already connected', async ({ +test('slack member connects another link with the same Tempo Account', async ({ app, db, factory, page, }) => { - const firstAccessKey = AccessKey.generate() const firstToken = crypto.randomUUID() const firstTokenHash = await AccountLink.hashToken(app.env, firstToken) - const root = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - const secondAccessKey = AccessKey.generate() const secondToken = crypto.randomUUID() const secondTokenHash = await AccountLink.hashToken(app.env, secondToken) const workspace = await factory.workspace.insert({ @@ -109,138 +114,103 @@ test('slack member connects another link while wallet is already connected', asy workspace_id: workspace.id, }) await factory.account_link_token.insert( - { - access_key_address: firstAccessKey.address, - access_key_ciphertext: await AccessKey.encrypt(app.env, firstAccessKey.privateKey), - access_key_expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days - access_key_public_key: firstAccessKey.publicKey, - member_id: member.id, - token_hash: firstTokenHash, - }, - { - access_key_address: secondAccessKey.address, - access_key_ciphertext: await AccessKey.encrypt(app.env, secondAccessKey.privateKey), - access_key_expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days - access_key_public_key: secondAccessKey.publicKey, - member_id: member.id, - token_hash: secondTokenHash, - }, + await pendingLink(app, { memberId: member.id, tokenHash: firstTokenHash }), + await pendingLink(app, { memberId: member.id, tokenHash: secondTokenHash }), ) + await app.tempoId.signIn(page) - const walletConnectTimeoutMs = 15_000 // 15 seconds - await page.goto(app.url({ params: { token: firstToken }, to: '/connect/$token' })) - await page.waitForLoadState('networkidle') - await page.getByRole('button', { name: 'Connect' }).click() - await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible({ - timeout: walletConnectTimeoutMs, - }) - - await page.goto(app.url({ params: { token: secondToken }, to: '/connect/$token' })) - await page.waitForLoadState('networkidle') - await page.getByRole('button', { name: 'Connect' }).click() - await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible({ - timeout: walletConnectTimeoutMs, - }) + for (const token of [firstToken, secondToken]) { + await page.goto(app.url(`/api/account/link/${token}/start`)) + await app.tempoId.approve(page) + await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible() + } const links = await db .selectFrom('account_link_token') .select(['access_key_authorization', 'account_id', 'token_hash', 'used_at']) .where('token_hash', 'in', [firstTokenHash, secondTokenHash]) .execute() - const firstLink = links.find((link) => link.token_hash === firstTokenHash) - const secondLink = links.find((link) => link.token_hash === secondTokenHash) - const account = await db - .selectFrom('account') - .selectAll() - .where('address', '=', root.address) - .executeTakeFirstOrThrow() expect(links).toHaveLength(2) - expect(firstLink?.account_id).toBe(account.id) - expect(firstLink?.access_key_authorization).toEqual(expect.any(String)) - expect(firstLink?.used_at).toEqual(expect.any(String)) - expect(secondLink?.account_id).toBe(account.id) - expect(secondLink?.access_key_authorization).toEqual(expect.any(String)) - expect(secondLink?.used_at).toEqual(expect.any(String)) + expect(links[0]?.account_id).toEqual(expect.any(String)) + expect(links[1]?.account_id).toBe(links[0]?.account_id) + for (const link of links) { + expect(link.access_key_authorization).toEqual(expect.any(String)) + expect(link.used_at).toEqual(expect.any(String)) + } }) -test('slack member can disconnect an existing member and connect wallet', async ({ +test('slack member disconnects another member of the same Tempo Account, then connects', async ({ app, db, factory, page, }) => { - const token = crypto.randomUUID() - const accessKey = AccessKey.generate() - const root = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) + const firstToken = crypto.randomUUID() + const secondToken = crypto.randomUUID() const workspace = await factory.workspace.insert({ chain_id: Tempo.chainLookup.localnet, provider_id: `T${crypto.randomUUID()}`, }) - const currentMember = await insertMember(db, factory, { + const firstMember = await insertMember(db, factory, { provider_user_id: `U${crypto.randomUUID()}`, workspace_id: workspace.id, }) - const existingAccount = await db - .selectFrom('account') - .selectAll() - .where('address', '=', root.address) - .executeTakeFirst() - const account = existingAccount ?? (await factory.account.insert({ address: root.address })) - const duplicateMember = await insertMember(db, factory, { - account_id: account.id, + const secondMember = await insertMember(db, factory, { provider_user_id: `U${crypto.randomUUID()}`, workspace_id: workspace.id, }) - await factory.account_link_token.insert({ - access_key_address: accessKey.address, - access_key_ciphertext: await AccessKey.encrypt(app.env, accessKey.privateKey), - access_key_expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days - access_key_public_key: accessKey.publicKey, - member_id: currentMember.id, - token_hash: await AccountLink.hashToken(app.env, token), - }) + await factory.account_link_token.insert( + await pendingLink(app, { + memberId: firstMember.id, + tokenHash: await AccountLink.hashToken(app.env, firstToken), + }), + await pendingLink(app, { + memberId: secondMember.id, + tokenHash: await AccountLink.hashToken(app.env, secondToken), + }), + ) + await app.tempoId.signIn(page) + await page.goto(app.url(`/api/account/link/${firstToken}/start`)) + await app.tempoId.approve(page) + await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible() - await page.goto(app.url({ params: { token }, to: '/connect/$token' })) - await page.waitForLoadState('networkidle') - await page.getByRole('button', { name: 'Connect' }).click() + await page.goto(app.url(`/api/account/link/${secondToken}/start`)) + await app.tempoId.approve(page) - const walletConnectTimeoutMs = 15_000 // 15 seconds await expect( page.getByText('This wallet is already connected to another Slack account in this workspace.'), - ).toBeVisible({ timeout: walletConnectTimeoutMs }) + ).toBeVisible() await page.getByRole('button', { name: 'Disconnect existing account and connect' }).click() - await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible({ - timeout: walletConnectTimeoutMs, - }) + await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible() - const updatedCurrentMember = await db - .selectFrom('member') - .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') - .select('provider_identity.account_id') - .where('member.id', '=', currentMember.id) - .executeTakeFirstOrThrow() - const updatedDuplicateMember = await db + const accounts = await db .selectFrom('member') .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') - .select('provider_identity.account_id') - .where('member.id', '=', duplicateMember.id) - .executeTakeFirstOrThrow() - const link = await db - .selectFrom('account_link_token') - .select(['access_key_authorization', 'account_id', 'used_at']) - .where('token_hash', '=', await AccountLink.hashToken(app.env, token)) - .executeTakeFirstOrThrow() + .select(['member.id', 'provider_identity.account_id']) + .where('member.id', 'in', [firstMember.id, secondMember.id]) + .execute() - expect(updatedCurrentMember.account_id).toBe(account.id) - expect(updatedDuplicateMember.account_id).toBe(null) - expect(link.account_id).toBe(account.id) - expect(link.access_key_authorization).toEqual(expect.any(String)) - expect(link.used_at).toEqual(expect.any(String)) + expect(accounts.find((row) => row.id === firstMember.id)?.account_id).toBe(null) + expect(accounts.find((row) => row.id === secondMember.id)?.account_id).toEqual(expect.any(String)) }) +/** Returns a pending link row, with an access key that the Tempo ID callback replaces. */ +async function pendingLink( + app: { env: Pick }, + options: { memberId: string; tokenHash: string }, +) { + const accessKey = AccessKey.generate() + return { + access_key_address: accessKey.address, + access_key_ciphertext: await AccessKey.encrypt(app.env, accessKey.privateKey), + access_key_expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days + access_key_public_key: accessKey.publicKey, + member_id: options.memberId, + token_hash: options.tokenHash, + } +} + async function getConnectToken(app: { slackUrl: string }) { const slack = new WebClient(Constants.slack.botToken, { slackApiUrl: `${app.slackUrl}/api`, @@ -251,7 +221,7 @@ async function getConnectToken(app: { slackUrl: string }) { const history = await slack.conversations.history({ channel: Constants.slack.channelId }) messages = (history.messages ?? []).flatMap((message) => (message.text ? [message.text] : [])) const text = JSON.stringify(history.messages ?? []) - const token = text.match(/\/connect\/([^\s"\\)]+)/)?.[1] + const token = text.match(/\/api\/account\/link\/([^\s"\\)/]+)\/start/)?.[1] if (token) return token await new Promise((resolve) => setTimeout(resolve, 100)) } @@ -325,33 +295,3 @@ async function postSlashCommand(app: { url: (path: `/api/${string}`) => string } const responseText = await response.text() expect(response.status, responseText).toBe(200) } - -async function insertMember( - db: Kysely, - factory: ReturnType, - attrs: Partial & - Pick & { account_id?: string | null }, -) { - const member = factory.member.attrs(attrs as never) - const { account_id: accountId, ...memberValues } = member as typeof member & { - account_id?: string | null - } - if (member.provider_identity_id) return await factory.member.insert(memberValues) - - const workspace = await db - .selectFrom('workspace') - .select(['provider', 'provider_id']) - .where('id', '=', member.workspace_id) - .executeTakeFirstOrThrow() - const identity = await factory.provider_identity.insert({ - account_id: accountId ?? null, - created_at: member.created_at, - display_name: member.login, - provider: workspace.provider, - provider_user_id: member.provider_user_id, - provider_workspace_id: workspace.provider_id, - real_name: member.name, - updated_at: member.updated_at, - }) - return await factory.member.insert({ ...memberValues, provider_identity_id: identity.id }) -} diff --git a/test/e2e/fixture.ts b/test/e2e/fixture.ts index af1f213..93fb3c2 100644 --- a/test/e2e/fixture.ts +++ b/test/e2e/fixture.ts @@ -1,4 +1,4 @@ -import { expect, test as base } from '@playwright/test' +import { expect, type Page, test as base } from '@playwright/test' import Database from 'better-sqlite3' import { Kysely, SqliteDialect } from 'kysely' import type { DB } from '#db/types.gen.ts' @@ -18,6 +18,14 @@ export const test = base.extend< env: ReturnType slackAppId: string slackUrl: string + tempoId: { + /** Approves the access key on Tempo ID's approval page, and skips its deposit page. */ + approve: (page: Page) => Promise + /** Signs a new user in to Tempo ID in the page's context. Returns the email address. */ + signIn: (page: Page, options?: { email?: string | undefined }) => Promise + /** Tempo ID's origin. */ + url: string + } url: { (options: RouteUrlOptionsUnion): string (path: `/api/${string}`): string @@ -36,6 +44,35 @@ export const test = base.extend< env, slackAppId: env.SLACK_APP_ID, slackUrl: new URL(env.SLACK_API_URL).origin, + tempoId: { + async approve(page) { + const origin = new URL(env.TEMPO_ID_URL).origin + await page.getByRole('button', { name: 'Approve' }).click() + await page.waitForURL( + (url) => url.origin !== origin || url.pathname.startsWith('/deposit'), + ) + // A funding hint opens the deposit page for an account that holds less. + if (new URL(page.url()).origin === origin) + await page.getByRole('link', { name: 'Skip for now' }).click() + }, + async signIn(page, options = {}) { + const email = options.email ?? `tipbot-e2e+${crypto.randomUUID()}@example.org` + const sent = await page.request.post(`${env.TEMPO_ID_URL}/v1/auth/code`, { + data: { email }, + }) + expect(sent.status()).toBe(200) + const code = await page.request.get( + `${env.TEMPO_ID_URL}/__id-dev/code?email=${encodeURIComponent(email)}`, + ) + expect(code.status()).toBe(200) + const verified = await page.request.post(`${env.TEMPO_ID_URL}/v1/auth/code/verify`, { + data: { code: ((await code.json()) as { code: string }).code, email }, + }) + expect(verified.status()).toBe(200) + return email + }, + url: env.TEMPO_ID_URL, + }, url: (input: `/api/${string}` | RouteUrlOptionsUnion) => { if (typeof input === 'string') return new URL(input, baseUrl).toString() return new URL(formatRouteUrl(input), baseUrl).toString() diff --git a/test/e2e/home.test.ts b/test/e2e/home.test.ts index 3024b3a..56829db 100644 --- a/test/e2e/home.test.ts +++ b/test/e2e/home.test.ts @@ -9,7 +9,10 @@ test('visitor starts slack install from the home page', async ({ app, page }) => const addToSlack = page.getByRole('link', { name: 'Add to Slack' }) await expect(addToSlack).toHaveAttribute('href', '/install/slack') - await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Dashboard' })).toHaveAttribute( + 'href', + '/api/auth/start', + ) await expect(page.getByRole('link', { name: 'Connect X' })).toHaveAttribute('href', '/link/x') await addToSlack.click() @@ -30,5 +33,23 @@ test('visitor is redirected from dashboard without auth', async ({ app, page }) await page.goto(app.url({ to: '/dash' })) await expect(page).toHaveURL(app.url({ to: '/' })) - await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible() +}) + +test('visitor signs in to the dashboard with Tempo ID, then signs out', async ({ app, page }) => { + await app.tempoId.signIn(page) + await page.goto(app.url({ to: '/' })) + + await page.getByRole('link', { name: 'Dashboard' }).click() + await app.tempoId.approve(page) + + await expect(page).toHaveURL(app.url({ to: '/dash' })) + await expect(page.getByText('Wallet', { exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: /^0x[0-9a-fA-F]{40}$/ })).toBeVisible() + + await page.getByRole('button', { name: 'Sign out' }).click() + + await expect(page).toHaveURL(app.url({ to: '/' })) + await page.goto(app.url({ to: '/dash' })) + await expect(page).toHaveURL(app.url({ to: '/' })) }) diff --git a/test/e2e/link_x.test.ts b/test/e2e/link_x.test.ts index 9aa49f8..23d0dae 100644 --- a/test/e2e/link_x.test.ts +++ b/test/e2e/link_x.test.ts @@ -1,330 +1,115 @@ import * as AccessKey from '#/lib/accessKey.ts' -import * as Tempo from '#/lib/tempo.ts' -import { Account } from 'viem/tempo' +import * as AccountLink from '#/lib/accountLink.ts' import { expect, test } from './fixture.ts' -test('visitor connects X account with OAuth', async ({ app, page }) => { - const accessKey = AccessKey.generate() - const challengeId = crypto.randomUUID() - const root = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - - await page.route('**/api/link/twitter/oauth/challenge', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - address?: string - json?: { address?: string } - } - expect(json.address ?? json.json?.address).toBeUndefined() - await route.fulfill({ - body: JSON.stringify({ - accessKeyExpiry: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - accessKeyLimit: '3000000', - accessKeyLimitPeriodSeconds: 30 * 24 * 60 * 60, // 30 days - accessKeyPublicKey: accessKey.publicKey, - chainId: Tempo.chainLookup.mainnet, - challengeId, - ok: true, - tokenAddress: Tempo.addressLookup.usdcE, - }), - contentType: 'application/json', - status: 200, - }) - }) - await page.route('**/api/link/twitter/oauth/start', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - address?: string - challengeId?: string - json?: { address?: string; challengeId?: string; keyAuthorization?: unknown } - keyAuthorization?: unknown - } - expect(json.address ?? json.json?.address).toBe(root.address) - expect(json.challengeId ?? json.json?.challengeId).toBe(challengeId) - expect(json.keyAuthorization ?? json.json?.keyAuthorization).toEqual(expect.any(Object)) - await route.fulfill({ - body: JSON.stringify({ - authorizationUrl: `${app.url({ to: '/link/x' })}?status=connected`, - ok: true, - }), - contentType: 'application/json', - status: 200, - }) - }) +test('visitor connects X with OAuth after Tempo ID approves the access key', async ({ + app, + db, + page, +}) => { + await app.tempoId.signIn(page) await page.goto(app.url({ to: '/link/x' })) - await page.waitForLoadState('networkidle') - - await expect(page.getByText('Step 1/3')).toBeVisible() - await page.getByRole('button', { name: 'Connect wallet' }).click() - await expect(page.getByText('Step 2/3')).toBeVisible() - await expect(page.getByRole('button', { name: 'Connect X' })).toBeVisible() - await expect(page.getByText('Read your X profile')).toBeVisible() - await page.getByRole('button', { name: 'Connect X' }).click() - - await expect(page.getByText('You can now receive and send tips on X.')).toBeVisible() -}) - -test('visitor cannot create passkey in X in-app browser', async ({ app, page }) => { - let challengeRequested = false + await page.getByRole('link', { name: 'Connect X' }).click() + await expect(page.getByText('Spend $10 per day')).toBeVisible() + await app.tempoId.approve(page) - await page.addInitScript(() => { - Object.defineProperty(navigator, 'userAgent', { - get: () => - 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Twitter for iPhone', - }) - }) - await page.route('**/api/link/twitter/oauth/challenge', async (route) => { - challengeRequested = true - await route.abort() + // The callback redirects to X's authorization page, which the suite does not drive. + await page.waitForURL((url) => url.href.startsWith('https://x.com/i/oauth2/authorize'), { + waitUntil: 'commit', }) - - await page.goto(app.url({ to: '/link/x' })) - await page.waitForLoadState('networkidle') - - await expect(page.getByRole('alert')).toContainText("X's in-app browser cannot create passkeys.") - await expect(page.getByRole('button', { name: 'Open in phone browser first' })).toBeDisabled() - expect(challengeRequested).toBe(false) + const authorizeUrl = new URL(page.url()) + expect(authorizeUrl.searchParams.get('client_id')).toBe(app.env.TWITTER_OAUTH_CLIENT_ID) + expect(authorizeUrl.searchParams.get('redirect_uri')).toBe( + app.url('/api/link/twitter/oauth/callback'), + ) + const challenge = await db + .selectFrom('provider_link_challenge') + .select(['access_key_authorization', 'wallet_address']) + .orderBy('created_at', 'desc') + .executeTakeFirstOrThrow() + expect(challenge.access_key_authorization).toEqual(expect.any(String)) + expect(challenge.wallet_address).not.toBe('0x0000000000000000000000000000000000000000') }) -test('visitor connects X account with proof tweet', async ({ app, page }) => { - const accessKey = AccessKey.generate() +test('visitor posts the proof tweet, and the page connects once X shows it', async ({ + app, + db, + page, +}) => { const challengeId = crypto.randomUUID() - const proof = `tb1_${crypto.randomUUID().replaceAll('-', '')}` - const root = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - const tweetText = ['Verifying my identity for @tipbotgg', '', proof, '', 'https://tip.bot'].join( - '\n', - ) - const intentUrl = new URL('/intent/tweet', 'https://twitter.com') - intentUrl.searchParams.set('text', tweetText) - - await page.addInitScript( - `window.open = (url) => { window.sessionStorage.setItem('openedUrl', String(url)); return null }`, - ) - await page.route('**/api/link/twitter/oauth/challenge', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - address?: string - json?: { address?: string } - } - expect(json.address ?? json.json?.address).toBeUndefined() - await route.fulfill({ - body: JSON.stringify({ - accessKeyExpiry: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - accessKeyLimit: '3000000', - accessKeyLimitPeriodSeconds: 30 * 24 * 60 * 60, // 30 days - accessKeyPublicKey: accessKey.publicKey, - chainId: Tempo.chainLookup.mainnet, - challengeId, - ok: true, - tokenAddress: Tempo.addressLookup.usdcE, - }), - contentType: 'application/json', - status: 200, - }) - }) - await page.route('**/api/link/twitter/proof', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - address?: string - challengeId?: string - json?: { - address?: string - challengeId?: string - keyAuthorization?: unknown - username?: string - } - keyAuthorization?: unknown - username?: string - } - expect(json.address ?? json.json?.address).toBe(root.address) - expect(json.challengeId ?? json.json?.challengeId).toBe(challengeId) - expect(json.keyAuthorization ?? json.json?.keyAuthorization).toEqual(expect.any(Object)) - expect(json.username ?? json.json?.username).toBe('tipbotgg') - await route.fulfill({ - body: JSON.stringify({ - avatarUrl: 'https://example.com/avatar.jpg', - intentUrl: intentUrl.toString(), - name: 'Tipbot', - ok: true, - proof, - tweetText, - username: 'tipbotgg', - }), - contentType: 'application/json', - status: 200, + const proof = `tb1_${crypto.randomUUID()}` + const accessKey = AccessKey.generate() + const now = new Date() + await db + .insertInto('provider_link_challenge') + .values({ + access_key_address: accessKey.address, + access_key_authorization: '{}', + access_key_ciphertext: await AccessKey.encrypt(app.env, accessKey.privateKey), + access_key_expires_at: new Date(now.getTime() + 60 * 60 * 1000).toISOString(), // 1 hour + access_key_public_key: accessKey.publicKey, + account_id: null, + created_at: now.toISOString(), + expected_provider_handle: '@tipbotuser', + expected_provider_user_id: '123', + expires_at: new Date(now.getTime() + 10 * 60 * 1000).toISOString(), // 10 minutes + id: challengeId, + proof_hash: await AccountLink.hashToken(app.env, proof), + provider: 'twitter', + provider_handle: null, + provider_user_id: null, + tweet_id: null, + updated_at: now.toISOString(), + used_at: null, + wallet_address: '0x0000000000000000000000000000000000000001', }) - }) + .execute() + // X's search is outside the suite, so the first check finds no tweet, and the next one finds it. + let checks = 0 await page.route('**/api/link/twitter/verify', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - challengeId?: string - json?: { challengeId?: string; proof?: string; tweetUrl?: string } - proof?: string - tweetUrl?: string - } - expect(json.challengeId ?? json.json?.challengeId).toBe(challengeId) - expect(json.proof ?? json.json?.proof).toBe(proof) - if (!(json.tweetUrl ?? json.json?.tweetUrl)) { - await route.fulfill({ - body: JSON.stringify({ code: 'pending', ok: false }), - contentType: 'application/json', - status: 200, - }) - return - } - expect(json.tweetUrl ?? json.json?.tweetUrl).toBe('https://x.com/tipbotgg/status/123') + checks += 1 await route.fulfill({ - body: JSON.stringify({ handle: 'tipbotgg', ok: true }), + body: JSON.stringify(checks < 2 ? { code: 'pending', ok: false } : { ok: true }), contentType: 'application/json', status: 200, }) }) + await page.addInitScript(() => { + // The composer opens X in a popup, which the suite does not load. + Object.assign(globalThis, { open: () => null }) + }) - await page.goto(app.url({ to: '/link/x' })) + await page.goto(app.url({ search: { challenge: challengeId, proof }, to: '/link/x' })) await page.waitForLoadState('networkidle') - await expect(page.getByRole('heading', { name: 'Connect X to Tipbot' })).toBeVisible() - await expect(page.getByText('Step 1/3')).toBeVisible() - await expect(page.getByLabel('X username')).toBeHidden() - await page.getByRole('button', { name: 'Connect wallet' }).click() - await expect(page.getByText('Step 2/3')).toBeVisible() - await page.getByRole('button', { name: 'Verify with proof tweet' }).click() - await expect(page.getByLabel('X username')).toBeVisible() - await page.getByLabel('X username').fill('@tipbotgg') - await expect(page.getByRole('button', { name: 'Disconnect wallet' })).toBeVisible() - await page.getByRole('button', { name: 'Prepare proof tweet' }).click() - await expect(page.getByText('Step 3/3')).toBeVisible() - await expect(page.getByText(tweetText)).toBeVisible() - await expect(page.getByRole('img', { name: 'Tipbot avatar' })).toBeVisible() - await expect( - page.getByRole('img', { name: 'Tipbot avatar' }).locator('..').getByText('Tipbot', { - exact: true, - }), - ).toBeVisible() - await expect(page.getByText('Tipbot prepared this tweet for @tipbotgg.')).toBeVisible() - await expect.poll(() => page.evaluate("window.sessionStorage.getItem('openedUrl')")).toBe(null) + await expect(page.getByText('@tipbotuser')).toBeVisible() + await expect(page.getByText(proof)).toBeVisible() await page.getByRole('button', { name: 'Post connection tweet' }).click() - await expect - .poll(() => page.evaluate("window.sessionStorage.getItem('openedUrl')")) - .toBe(intentUrl.toString()) - - await page.getByRole('button', { name: 'Manual verification' }).click() - await page.getByLabel('Paste your tweet URL').fill('https://x.com/tipbotgg/status/123') - - await expect(page.getByText('You can now receive and send tips on X.')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Connected to Tipbot' })).toBeVisible({ + timeout: 15_000, // 15 seconds, for the second check + }) }) -test('visitor connects X account after automatic proof tweet polling', async ({ app, page }) => { - const accessKey = AccessKey.generate() - const challengeId = crypto.randomUUID() - const proof = `tb1_${crypto.randomUUID().replaceAll('-', '')}` - const root = Account.fromSecp256k1( - '0x0000000000000000000000000000000000000000000000000000000000000001', - ) - const tweetText = ['Verifying my identity for @tipbotgg', '', proof, '', 'https://tip.bot'].join( - '\n', +test('visitor sees an expired proof tweet', async ({ app, page }) => { + await page.goto( + app.url({ search: { challenge: crypto.randomUUID(), proof: 'tb1_missing' }, to: '/link/x' }), ) - const intentUrl = new URL('/intent/tweet', 'https://twitter.com') - let automaticVerifyCount = 0 - intentUrl.searchParams.set('text', tweetText) - await page.addInitScript( - `window.open = (url) => { window.sessionStorage.setItem('openedUrl', String(url)); return null }`, + await expect(page.getByText('This proof tweet expired. Start again.')).toBeVisible() + await expect(page.getByRole('link', { name: 'Connect X' })).toHaveAttribute( + 'href', + '/api/link/twitter/oauth/begin', ) - await page.route('**/api/link/twitter/oauth/challenge', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - address?: string - json?: { address?: string } - } - expect(json.address ?? json.json?.address).toBeUndefined() - await route.fulfill({ - body: JSON.stringify({ - accessKeyExpiry: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - accessKeyLimit: '3000000', - accessKeyLimitPeriodSeconds: 30 * 24 * 60 * 60, // 30 days - accessKeyPublicKey: accessKey.publicKey, - chainId: Tempo.chainLookup.mainnet, - challengeId, - ok: true, - tokenAddress: Tempo.addressLookup.usdcE, - }), - contentType: 'application/json', - status: 200, - }) - }) - await page.route('**/api/link/twitter/proof', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - address?: string - challengeId?: string - json?: { - address?: string - challengeId?: string - keyAuthorization?: unknown - username?: string - } - keyAuthorization?: unknown - username?: string - } - expect(json.address ?? json.json?.address).toBe(root.address) - expect(json.challengeId ?? json.json?.challengeId).toBe(challengeId) - expect(json.keyAuthorization ?? json.json?.keyAuthorization).toEqual(expect.any(Object)) - expect(json.username ?? json.json?.username).toBe('tipbotgg') - await route.fulfill({ - body: JSON.stringify({ - avatarUrl: 'https://example.com/avatar.jpg', - intentUrl: intentUrl.toString(), - name: 'Tipbot', - ok: true, - proof, - tweetText, - username: 'tipbotgg', - }), - contentType: 'application/json', - status: 200, - }) - }) - await page.route('**/api/link/twitter/verify', async (route) => { - expect(route.request().method()).toBe('POST') - const json = route.request().postDataJSON() as { - challengeId?: string - json?: { challengeId?: string; proof?: string; tweetUrl?: string } - proof?: string - tweetUrl?: string - } - expect(json.challengeId ?? json.json?.challengeId).toBe(challengeId) - expect(json.proof ?? json.json?.proof).toBe(proof) - expect(json.tweetUrl ?? json.json?.tweetUrl).toBeUndefined() - automaticVerifyCount += 1 - await route.fulfill({ - body: JSON.stringify( - automaticVerifyCount === 1 - ? { code: 'pending', ok: false } - : { handle: 'tipbotgg', ok: true }, - ), - contentType: 'application/json', - status: 200, - }) - }) +}) +test('visitor prepares a proof tweet instead of X sign-in', async ({ app, page }) => { await page.goto(app.url({ to: '/link/x' })) await page.waitForLoadState('networkidle') - await page.getByRole('button', { name: 'Connect wallet' }).click() - await expect(page.getByText('Step 2/3')).toBeVisible() + await page.getByRole('button', { name: 'Verify with proof tweet' }).click() - await expect(page.getByLabel('X username')).toBeVisible() - await page.getByLabel('X username').fill('tipbotgg') - await page.getByRole('button', { name: 'Prepare proof tweet' }).click() - await expect(page.getByText(tweetText)).toBeVisible() - await page.getByRole('button', { name: 'Post connection tweet' }).click() - await expect(page.getByText('You can now receive and send tips on X.')).toBeVisible({ - timeout: 10_000, - }) - expect(automaticVerifyCount).toBeGreaterThan(1) + await expect(page.getByLabel('X username')).toBeVisible() + await expect(page.getByRole('button', { name: 'Prepare proof tweet' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Connect X' })).toBeHidden() }) diff --git a/test/e2e/members.ts b/test/e2e/members.ts new file mode 100644 index 0000000..7b0eaa8 --- /dev/null +++ b/test/e2e/members.ts @@ -0,0 +1,34 @@ +import type { Kysely } from 'kysely' +import type { DB } from '#db/types.gen.ts' +import type * as Factory from '../factory.ts' + +/** Inserts a member with a provider identity, connected to `account_id` when it names one. */ +export async function insertMember( + db: Kysely, + factory: ReturnType, + attrs: Partial & + Pick & { account_id?: string | null }, +) { + const member = factory.member.attrs(attrs as never) + const { account_id: accountId, ...memberValues } = member as typeof member & { + account_id?: string | null + } + if (member.provider_identity_id) return await factory.member.insert(memberValues) + + const workspace = await db + .selectFrom('workspace') + .select(['provider', 'provider_id']) + .where('id', '=', member.workspace_id) + .executeTakeFirstOrThrow() + const identity = await factory.provider_identity.insert({ + account_id: accountId ?? null, + created_at: member.created_at, + display_name: member.login, + provider: workspace.provider, + provider_user_id: member.provider_user_id, + provider_workspace_id: workspace.provider_id, + real_name: member.name, + updated_at: member.updated_at, + }) + return await factory.member.insert({ ...memberValues, provider_identity_id: identity.id }) +} diff --git a/test/e2e/wallet_session.test.ts b/test/e2e/wallet_session.test.ts deleted file mode 100644 index d7243b8..0000000 --- a/test/e2e/wallet_session.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import * as Confirmation from '#/lib/confirmation.ts' -import * as Tempo from '#/lib/tempo.ts' -import { expect, test } from './fixture.ts' - -for (const lostSession of [true, false]) - test(`confirmation ${lostSession ? 'recovers a lost wallet session' : 'preserves a genuine wallet decline'}`, async ({ - app, - page, - }) => { - const token = await Confirmation.encrypt(app.env, { - amount: 1_000, - chainId: Tempo.chainLookup.localnet, - expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 minutes - idempotencyKey: `confirm:${crypto.randomUUID()}`, - kind: 'reusable_access_key', - memo: null, - nonce: crypto.randomUUID(), - provider: 'slack', - providerChannelId: 'C000000001', - providerId: 'T000000001', - recipientProviderUserId: 'U000000002', - senderProviderUserId: 'U000000001', - tokenAddress: Tempo.addressLookup.pathUsd, - workspaceId: crypto.randomUUID(), - }) - // Replace only the test connector at the browser module boundary. The real - // confirmation component, Wagmi state, and recovery logic remain unchanged. - await page.route('**/src/components/WalletProviders.tsx*', async (route) => { - const response = await route.fetch() - const body = await response.text() - expect(body).toContain('dangerous_secp256k1({') - await route.fulfill({ - body: body.replace('dangerous_secp256k1({', 'window.__confirmationTestConnector({'), - response, - }) - }) - await page.addInitScript((loseAccount) => { - const address = '0x1111111111111111111111111111111111111111' - const state = { connects: 0, disconnects: 0, hasAccount: true } - Object.assign(globalThis, { - __confirmationTestState: state, - __confirmationTestConnector: () => () => ({ - id: 'confirmation-test', - name: 'Confirmation test wallet', - type: 'mock', - async connect() { - state.connects++ - if (state.connects === 1) { - if (loseAccount) state.hasAccount = false - throw Object.assign(new Error('The user rejected the request.'), { code: 4001 }) - } - state.hasAccount = true - return { - accounts: [{ address, capabilities: { keyAuthorization: { test: true } } }], - chainId: 1337, - } - }, - async disconnect() { - state.disconnects++ - state.hasAccount = false - }, - async getAccounts() { - return state.hasAccount ? [address] : [] - }, - async getChainId() { - return 1337 - }, - async getProvider() { - return { - async request() { - return state.hasAccount ? [address] : [] - }, - } - }, - async isAuthorized() { - return false - }, - onAccountsChanged() {}, - onChainChanged() {}, - onDisconnect() {}, - }), - }) - }, lostSession) - let submissions = 0 - await page.route('**/api/confirm/*', async (route) => { - if (route.request().method() !== 'POST') return route.continue() - submissions++ - await route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ ok: true, transactionHash: `0x${'1'.repeat(64)}` }), - }) - }) - await page.goto(app.url({ params: { token }, to: '/confirm/$token' })) - await page.waitForLoadState('networkidle') - await page.getByRole('button', { name: 'Confirm payment' }).click() - await expect( - page.getByText( - lostSession - ? 'Your wallet session expired. Select Confirm payment to reconnect and try again.' - : 'Request rejected.', - { exact: true }, - ), - ).toBeVisible() - expect(submissions).toBe(0) - expect( - await page.evaluate(() => Reflect.get(globalThis, '__confirmationTestState').connects), - ).toBe(1) - if (!lostSession) return - await page.getByRole('button', { name: 'Confirm payment' }).click() - await expect(page.getByRole('heading', { name: 'Payment sent' })).toBeVisible() - expect(submissions).toBe(1) - expect( - await page.evaluate(() => Reflect.get(globalThis, '__confirmationTestState').disconnects), - ).toBe(1) - }) diff --git a/test/env.ts b/test/env.ts index 1cfa12a..abbefe6 100644 --- a/test/env.ts +++ b/test/env.ts @@ -28,6 +28,9 @@ const schema = z.object({ SLACK_CLIENT_SECRET: z.string(), SLACK_SIGNING_SECRET: z.string(), TEMPO_API_KEY: z.string(), + TEMPO_ID_CLIENT_ID: z.string(), + TEMPO_ID_CLIENT_SECRET: z.string(), + TEMPO_ID_URL: z.string(), TWITTER_ACCESS_TOKEN: z.string(), TWITTER_ACCESS_TOKEN_SECRET: z.string(), TWITTER_API_URL: z.string(), @@ -56,6 +59,10 @@ export const Env = { SLACK_CLIENT_SECRET: 'test-client-secret', SLACK_SIGNING_SECRET: 'test-signing-secret', TEMPO_API_KEY: 'test-tempo-api-key', + TEMPO_ID_CLIENT_ID: 'tipbot-e2e', + // Not a real secret. The e2e suite registers it with the id-dev image. + TEMPO_ID_CLIENT_SECRET: 'tipbot-e2e-client-secret-00000000000', + TEMPO_ID_URL: 'http://localhost:8787', TWITTER_ACCESS_TOKEN: 'twitter-access-token', TWITTER_ACCESS_TOKEN_SECRET: 'twitter-access-token-secret', TWITTER_API_URL: 'https://api.twitter.com', diff --git a/test/tempo.ts b/test/tempo.ts new file mode 100644 index 0000000..4d400cc --- /dev/null +++ b/test/tempo.ts @@ -0,0 +1,67 @@ +import { Server } from 'prool' +import * as TestContainers from 'prool/testcontainers' +import { createClient, http, parseUnits } from 'viem' +import { Account, Actions } from 'viem/tempo' +import * as Tempo from '#/lib/tempo.ts' +import { getAvailablePort } from './utils.ts' + +/** Starts a local Tempo node through prool. Returns its RPC URL and `stop`. */ +export async function start() { + const port = await getAvailablePort() + const server = Server.create({ + instance: TestContainers.Instance.tempo({ + blockTime: '2ms', + image: getImage(), + port, + } satisfies Pick), + port, + }) + await server.start() + return { port, rpcUrl: `http://127.0.0.1:${port}/1`, stop: () => server.stop() } +} + +/** Mints PathUSD to `to` with the fee payer's account, which the dev genesis lets mint. */ +export async function mint(input: { + amount: string + feePayer: `0x${string}` + rpcUrl: string + to: string +}) { + const client = createClient({ + chain: Tempo.getChain(Tempo.chainLookup.localnet), + transport: http(input.rpcUrl), + }) + return await retry( + () => + Actions.token.mintSync(client, { + account: Account.fromSecp256k1(input.feePayer), + amount: parseUnits(input.amount, 6), + to: input.to as `0x${string}`, + token: Tempo.addressLookup.pathUsd, + }), + 'mint PathUSD', + ) +} + +function getImage() { + if (process.env.VITE_TEMPO_IMAGE?.startsWith('sha256:')) + return `ghcr.io/tempoxyz/tempo@${process.env.VITE_TEMPO_IMAGE}` + return `ghcr.io/tempoxyz/tempo:${process.env.VITE_TEMPO_IMAGE || 'latest'}` +} + +// The node accepts transactions a moment after its container starts. +async function retry(fn: () => Promise, label: string) { + const retryTimeoutMs = 30_000 // 30 seconds + const retryDelayMs = 500 // 500 milliseconds + const deadline = Date.now() + retryTimeoutMs + let lastError: unknown + while (Date.now() < deadline) { + try { + return await fn() + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) + } + } + throw new Error(`Timed out trying to ${label}.`, { cause: lastError }) +} diff --git a/test/workers.global.setup.ts b/test/workers.global.setup.ts index 8a2838e..a6e2514 100644 --- a/test/workers.global.setup.ts +++ b/test/workers.global.setup.ts @@ -1,12 +1,9 @@ import { createEmulator } from 'emulate' -import { Server } from 'prool' -import * as TestContainers from 'prool/testcontainers' import type { TestProject } from 'vitest/node' -import { createClient, http, parseUnits } from 'viem' -import { Account, Actions } from 'viem/tempo' -import * as Tempo from '#/lib/tempo.ts' +import { Account } from 'viem/tempo' import * as Constants from './constants.ts' import { Env } from './env.ts' +import * as TempoNode from './tempo.ts' import { getAvailablePort } from './utils.ts' export default async function (project: TestProject) { @@ -18,39 +15,23 @@ export default async function (project: TestProject) { }) console.log('workers: started slack emulator') - const rpcPort = await getAvailablePort() - const tempo = Server.create({ - instance: TestContainers.Instance.tempo({ - blockTime: '2ms', - image: getTempoImage(), - port: rpcPort, - } satisfies Pick), - port: rpcPort, - }) console.log('workers: starting tempo') - await tempo.start() + const tempo = await TempoNode.start() + const rpcPort = tempo.port console.log('workers: started tempo') const env = Env.get({ - RPC_URL_TESTNET: `http://127.0.0.1:${rpcPort}/1`, + RPC_URL_TESTNET: tempo.rpcUrl, SLACK_API_URL: `${slack.url}/api`, }) - const client = createClient({ - chain: Tempo.getChain(Tempo.chainLookup.localnet), - transport: http(env.RPC_URL_TESTNET), - }) console.log('workers: minting fee payer') - await retryTempoSetup( - () => - Actions.token.mintSync(client, { - account: Account.fromSecp256k1(env.FEE_PAYER_PRIVATE_KEY_TESTNET), - amount: parseUnits('1000', 6), - to: Account.fromSecp256k1(Constants.tip.senderRootPrivateKey).address, - token: Tempo.addressLookup.pathUsd, - }), - 'mint fee payer', - ) + await TempoNode.mint({ + amount: '1000', + feePayer: env.FEE_PAYER_PRIVATE_KEY_TESTNET, + rpcUrl: tempo.rpcUrl, + to: Account.fromSecp256k1(Constants.tip.senderRootPrivateKey).address, + }) console.log('workers: minted fee payer') process.env.FEE_PAYER_PRIVATE_KEY_MAINNET = env.FEE_PAYER_PRIVATE_KEY_MAINNET @@ -73,25 +54,3 @@ export default async function (project: TestProject) { await slack.close() } } - -function getTempoImage() { - if (process.env.VITE_TEMPO_IMAGE?.startsWith('sha256:')) - return `ghcr.io/tempoxyz/tempo@${process.env.VITE_TEMPO_IMAGE}` - return `ghcr.io/tempoxyz/tempo:${process.env.VITE_TEMPO_IMAGE || 'latest'}` -} - -async function retryTempoSetup(fn: () => Promise, label: string) { - const retryTimeoutMs = 30_000 // 30 seconds - const retryDelayMs = 500 // 500 milliseconds - const deadline = Date.now() + retryTimeoutMs - let lastError: unknown - while (Date.now() < deadline) { - try { - return await fn() - } catch (error) { - lastError = error - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) - } - } - throw new Error(`Timed out trying to ${label}.`, { cause: lastError }) -} diff --git a/vite.config.ts b/vite.config.ts index 0d86e49..a7e16c0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -46,9 +46,6 @@ export default defineConfig({ return 'development' })() return { - __PLAYWRIGHT_ACCOUNT_PRIVATE_KEY__: JSON.stringify( - process.env.PLAYWRIGHT ? process.env.PLAYWRIGHT_ACCOUNT_PRIVATE_KEY : undefined, - ), __ENV__: JSON.stringify(environment), __HOST__: JSON.stringify(host), __ORIGIN__: process.env.PLAYWRIGHT @@ -98,11 +95,18 @@ export default defineConfig({ })() ?? config.vars?.HOST), ...(process.env.PLAYWRIGHT ? { + FEE_PAYER_PRIVATE_KEY_TESTNET: process.env.FEE_PAYER_PRIVATE_KEY_TESTNET ?? '', + RPC_URL_TESTNET: process.env.RPC_URL_TESTNET ?? '', SECRET_KEY: process.env.SECRET_KEY ?? '', SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID ?? '', SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET ?? '', SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET ?? '', TEMPO_API_KEY: process.env.TEMPO_API_KEY ?? '', + TEMPO_ID_CLIENT_ID: process.env.TEMPO_ID_CLIENT_ID ?? '', + TEMPO_ID_CLIENT_SECRET: process.env.TEMPO_ID_CLIENT_SECRET ?? '', + TEMPO_ID_URL: process.env.TEMPO_ID_URL ?? '', + TWITTER_OAUTH_CLIENT_ID: process.env.TWITTER_OAUTH_CLIENT_ID ?? '', + TWITTER_OAUTH_CLIENT_SECRET: process.env.TWITTER_OAUTH_CLIENT_SECRET ?? '', } : {}), SLACK_API_URL: process.env.SLACK_API_URL ?? config.vars?.SLACK_API_URL, @@ -167,7 +171,6 @@ export default defineConfig({ __ENV__: JSON.stringify('development'), __HOST__: JSON.stringify('tip.bot'), __ORIGIN__: JSON.stringify('https://tip.bot'), - __PLAYWRIGHT_ACCOUNT_PRIVATE_KEY__: 'undefined', __SLACK_APP_ID__: JSON.stringify(''), }, plugins: lazyPlugins(async () => { diff --git a/wrangler.jsonc b/wrangler.jsonc index 6cc32cf..cc0e617 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -27,8 +27,8 @@ "class_name": "TipbotChatStateDO", }, { - "name": "AUTH_NONCE", - "class_name": "NonceStorage", + "name": "TEMPO_ID_SESSIONS", + "class_name": "TempoIdSessions", }, ], }, @@ -60,6 +60,12 @@ "tag": "auth_nonce_v1", "new_classes": ["NonceStorage"], }, + { + // Tempo ID sessions replace the sign-in nonces of the earlier wallet sign-in. + "tag": "tempo_id_sessions_v1", + "deleted_classes": ["NonceStorage"], + "new_sqlite_classes": ["TempoIdSessions"], + }, ], "secrets": { "required": [ @@ -70,6 +76,7 @@ "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", "TEMPO_API_KEY", + "TEMPO_ID_CLIENT_SECRET", "TWITTER_ACCESS_TOKEN", "TWITTER_ACCESS_TOKEN_SECRET", "TWITTER_BEARER_TOKEN", @@ -82,6 +89,8 @@ "vars": { "HOST": "tipbot.localhost", "SLACK_API_URL": "https://slack.com/api", + "TEMPO_ID_CLIENT_ID": "tipbot", + "TEMPO_ID_URL": "https://id.tempo.xyz", "TWITTER_API_URL": "https://api.twitter.com", "TWITTER_BOT_HANDLE": "tipbotgg", }, @@ -103,8 +112,8 @@ "class_name": "TipbotChatStateDO", }, { - "name": "AUTH_NONCE", - "class_name": "NonceStorage", + "name": "TEMPO_ID_SESSIONS", + "class_name": "TempoIdSessions", }, ], }, @@ -153,11 +162,14 @@ "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", "TEMPO_API_KEY", + "TEMPO_ID_CLIENT_SECRET", ], }, "vars": { "HOST": "__PREVIEW_APEX__.tip.bot", "SLACK_API_URL": "https://slack.com/api", + "TEMPO_ID_CLIENT_ID": "tipbot", + "TEMPO_ID_URL": "https://id.tempo.xyz", "TWITTER_API_URL": "https://api.twitter.com", "TWITTER_BOT_HANDLE": "__PREVIEW_TWITTER_BOT_HANDLE__", }, @@ -183,8 +195,8 @@ "class_name": "TipbotChatStateDO", }, { - "name": "AUTH_NONCE", - "class_name": "NonceStorage", + "name": "TEMPO_ID_SESSIONS", + "class_name": "TempoIdSessions", }, ], }, @@ -231,6 +243,7 @@ "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", "TEMPO_API_KEY", + "TEMPO_ID_CLIENT_SECRET", "TWITTER_ACCESS_TOKEN", "TWITTER_ACCESS_TOKEN_SECRET", "TWITTER_BEARER_TOKEN", @@ -243,6 +256,8 @@ "vars": { "HOST": "tip.bot", "SLACK_API_URL": "https://slack.com/api", + "TEMPO_ID_CLIENT_ID": "tipbot", + "TEMPO_ID_URL": "https://id.tempo.xyz", "TWITTER_API_URL": "https://api.twitter.com", "TWITTER_BOT_HANDLE": "tipbotgg", },