From 3bd614e26e7298685744caa4d1972fe10ad4f120 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 20:58:10 +0000 Subject: [PATCH 1/4] wip: sign in to the dashboard with Tempo ID through the backend plugin of `accounts` v1 Adds `src/lib/tempoId.ts` and `GET /api/auth/start`, `GET /api/auth/callback`, and `POST /api/auth/logout`, and reads the dashboard's address from the Tempo ID session. Installs `accounts@1.0.0-alpha.1` as `tempo-accounts`. Wrangler bindings, the home page button, and tests are not updated yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D3nmWin1mVpioUPdvvsHgw --- package.json | 1 + pnpm-lock.yaml | 73 ++++++++++++++++++++++++++++++++++++++++++ src/api.ts | 56 ++++++-------------------------- src/function/dash.ts | 13 ++++---- src/lib/tempoId.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++ src/routes/index.tsx | 5 +-- 6 files changed, 169 insertions(+), 55 deletions(-) create mode 100644 src/lib/tempoId.ts diff --git a/package.json b/package.json index 138bb95..91757db 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react": "19.2.5", "react-dom": "19.2.5", "tapimo": "0.5.2", + "tempo-accounts": "npm:accounts@1.0.0-alpha.1", "viem": "^2.55.10", "wagmi": "^3.6.14", "zod": "4.6.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a795be..bad99b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: tapimo: specifier: 0.5.2 version: 0.5.2(c1458eeeaf225851985db699c3faa73c) + tempo-accounts: + specifier: npm:accounts@1.0.0-alpha.1 + version: accounts@1.0.0-alpha.1(@wagmi/core@3.4.11)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(zod@4.6.6) viem: specifier: ^2.55.10 version: 2.55.10(typescript@6.0.3)(zod@4.6.6) @@ -3034,6 +3037,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 +4162,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==} @@ -4946,6 +4976,14 @@ packages: typescript: optional: true + ox@0.14.59: + resolution: {integrity: sha512-Y+DPEQScTEGKteQATjtytE1JRQ5cg1R2+DvYQ/lbhUCm/xS9n0Jnkkf2AqQyIntefQxs8xY6dZ+kyNZ7zf2FzA==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + oxfmt@0.73.0: resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6251,6 +6289,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'} @@ -9098,6 +9139,19 @@ snapshots: - typescript - use-sync-external-store + accounts@1.0.0-alpha.1(@wagmi/core@3.4.11)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(zod@4.6.6): + dependencies: + jose: 6.2.12 + ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) + viem: 2.55.10(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 + transitivePeerDependencies: + - typescript + - zod + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -10291,6 +10345,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + jose@6.2.3: {} js-base64@3.9.1: {} @@ -11217,6 +11273,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.14.59(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.3.0(typescript@6.0.3)(zod@4.6.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + oxfmt@0.73.0(vite-plus@1.3.0(@types/node@22.19.17)(@typescript/native-preview@7.0.0-dev.20260505.1)(@vitest/ui@5.0.5(vitest@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)): dependencies: tinypool: 2.1.2 @@ -13017,6 +13088,8 @@ snapshots: '@oozcitak/util': 10.0.0 js-yaml: 4.3.2 + xstate@5.33.3: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/api.ts b/src/api.ts index ff9eb06..2ca1bb7 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,3 @@ -import { env } from 'cloudflare:workers' import { Hono } from 'hono' import { Address, Base64, Hex } from 'ox' import * as chat from 'chat' @@ -11,9 +10,9 @@ 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<{ @@ -26,50 +25,15 @@ export const api = new Hono<{ 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 - - 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', - ) - 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 }) - - 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, - }) + .get('/api/auth/start', async (c) => c.redirect(await TempoId.start(c), 302)) + .get('/api/auth/callback', async (c) => { + // A callback without the session cookie, such as after it expired, starts the sign-in again. + if (!(await TempoId.complete(c))) return c.redirect('/api/auth/start', 302) + return c.redirect('/dash', 302) + }) + .post('/api/auth/logout', async (c) => { + await TempoId.end(c) + return c.redirect('/', 303) }) .get('/api/account/link/:token', async (c) => { const link = await c.var.db diff --git a/src/function/dash.ts b/src/function/dash.ts index c7cd3c3..54eced9 100644 --- a/src/function/dash.ts +++ b/src/function/dash.ts @@ -6,9 +6,9 @@ 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 +19,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 +55,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/tempoId.ts b/src/lib/tempoId.ts new file mode 100644 index 0000000..f14fa92 --- /dev/null +++ b/src/lib/tempoId.ts @@ -0,0 +1,76 @@ +import type { Context } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { Address } from 'ox' +import { Accounts } from 'tempo-accounts' +import { DurableObject } from 'tempo-accounts/cloudflare' +import { backend } from 'tempo-accounts/plugins' + +// Holds each Tempo ID session in its own Durable Object, so concurrent requests of a session share +// one refresh, and the refresh token never reaches the browser. +export const Sessions = DurableObject.sessions((env: Env) => ({ + clientSecret: env.TEMPO_ID_CLIENT_SECRET, + url: env.TEMPO_ID_URL, +})) + +export const cookieName = 'tempo_id' + +export const cookieOptions = { + httpOnly: true, + path: '/', + prefix: 'host', + sameSite: 'Lax', + secure: true, +} as const + +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, + }) +} + +export async function start(c: Context<{ Bindings: Env }>) { + // The dashboard only needs the account's address, so the sign-in requests no access key. + const started = await create(c.env).backend.start({ + accessKey: false, + redirectUri: new URL('/api/auth/callback', c.req.url).toString(), + }) + setCookie(c, cookieName, started.id, cookieOptions) + return started.url +} + +export async function complete(c: Context<{ Bindings: Env }>) { + const id = getCookie(c, cookieName, 'host') + if (!id) return false + await create(c.env).backend.complete({ id, url: c.req.url }) + return true +} + +export async function end(c: Context<{ Bindings: Env }>) { + const id = getCookie(c, cookieName, 'host') + if (id) await create(c.env).backend.end({ id }) + deleteCookie(c, cookieName, cookieOptions) +} + +/** 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) + if (!id) return undefined + const accounts = create(env) + const session = await accounts.backend.get({ id }) + if (!session) return undefined + // A sign-in through a redirect 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 readCookie(request: Request) { + const name = `__Host-${cookieName}=` + for (const part of (request.headers.get('cookie') ?? '').split(';')) { + const value = part.trim() + if (value.startsWith(name)) return decodeURIComponent(value.slice(name.length)) + } + return undefined +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 012c67a..32d24c8 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -307,8 +307,9 @@ 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]) { From 6a1efe89b1d60cb43947f46286a94650ee168536 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 21:30:09 +0000 Subject: [PATCH 2/4] wip: move connect, confirm, X link, and the dashboard to Tempo ID redirects with `accounts` v1 Replaces `accounts@0.15` and wagmi with `accounts@1.0.0-alpha.1` and its `backend` plugin, with sessions in the `TempoIdSessions` Durable Object. `GET /api/auth/callback` adopts the session's access key into the link, the confirmation, or the X challenge, then runs the existing link and confirm logic. One-time payments authorize a recipient-scoped access key in place of a signed transaction. Removes the wallet sign-in proxy, `WalletProviders`, and `confirmationWallet`, and starts `ghcr.io/tempoxyz/id-dev` in the end-to-end suite. The confirm and X link specs are not updated yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D3nmWin1mVpioUPdvvsHgw --- .env.example | 1 + package.json | 8 +- pnpm-lock.yaml | 289 +----- src/api.ts | 1415 ++++++++++++++++------------ src/api.workers.test.ts | 102 +- src/chat.workers.test.ts | 75 +- src/components/WalletProviders.tsx | 44 - src/entry-server.ts | 4 +- src/function/dash.ts | 1 - src/lib/auth.ts | 8 - src/lib/confirmationWallet.test.ts | 29 - src/lib/confirmationWallet.ts | 22 - src/lib/tempoId.ts | 166 +++- src/lib/tip.ts | 477 +--------- src/lib/twitter.ts | 40 +- src/router.tsx | 6 + src/routes/confirm/$token.tsx | 250 +---- src/routes/connect/$token.tsx | 220 ++--- src/routes/dash.tsx | 38 +- src/routes/index.tsx | 48 +- src/routes/link/x.tsx | 693 +++----------- src/vite-env.d.ts | 1 - src/worker-configuration.d.ts | 21 +- test/e2e.global.setup.ts | 32 +- test/e2e/connect.test.ts | 210 ++--- test/e2e/fixture.ts | 39 +- test/e2e/home.test.ts | 25 +- test/e2e/wallet_session.test.ts | 115 --- test/env.ts | 7 + vite.config.ts | 7 +- wrangler.jsonc | 27 +- 31 files changed, 1608 insertions(+), 2812 deletions(-) delete mode 100644 src/components/WalletProviders.tsx delete mode 100644 src/lib/auth.ts delete mode 100644 src/lib/confirmationWallet.test.ts delete mode 100644 src/lib/confirmationWallet.ts delete mode 100644 test/e2e/wallet_session.test.ts 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/package.json b/package.json index 91757db..6c3c3c7 100644 --- a/package.json +++ b/package.json @@ -35,18 +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", - "tempo-accounts": "npm:accounts@1.0.0-alpha.1", - "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 bad99b3..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,16 +121,10 @@ importers: version: 19.2.5(react@19.2.5) tapimo: specifier: 0.5.2 - version: 0.5.2(c1458eeeaf225851985db699c3faa73c) - tempo-accounts: - specifier: npm:accounts@1.0.0-alpha.1 - version: accounts@1.0.0-alpha.1(@wagmi/core@3.4.11)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(zod@4.6.6) + 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 @@ -2851,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==} @@ -4952,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: @@ -4968,14 +4905,6 @@ packages: typescript: optional: true - ox@0.14.33: - resolution: {integrity: sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==} - peerDependencies: - typescript: '>=5.4.0' - peerDependenciesMeta: - typescript: - optional: true - ox@0.14.59: resolution: {integrity: sha512-Y+DPEQScTEGKteQATjtytE1JRQ5cg1R2+DvYQ/lbhUCm/xS9n0Jnkkf2AqQyIntefQxs8xY6dZ+kyNZ7zf2FzA==} peerDependencies: @@ -6020,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: @@ -6063,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 @@ -6192,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: @@ -6350,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'} @@ -7274,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 @@ -9002,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': @@ -9085,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' @@ -9112,41 +8984,13 @@ 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): - 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)) - 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 - - accounts@1.0.0-alpha.1(@wagmi/core@3.4.11)(react@19.2.5)(typescript@6.0.3)(viem@2.55.10(typescript@6.0.3)(zod@4.6.6))(zod@4.6.6): + 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: jose: 6.2.12 ox: 0.14.59(typescript@6.0.3)(zod@4.6.6) - viem: 2.55.10(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 transitivePeerDependencies: - typescript @@ -11106,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: {} @@ -11228,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 @@ -11258,21 +11072,6 @@ snapshots: transitivePeerDependencies: - zod - ox@0.14.33(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.59(typescript@6.0.3)(zod@4.6.6): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -12196,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 @@ -12380,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 @@ -12660,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 @@ -12710,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 @@ -12718,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 @@ -12966,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 @@ -13008,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 @@ -13161,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: {} @@ -13194,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 2ca1bb7..80867ee 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -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' @@ -15,26 +15,235 @@ import * as Tip from '#/lib/tip.ts' import * as Twitter from '#/lib/twitter.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() }) - .get('/api/auth/start', async (c) => c.redirect(await TempoId.start(c), 302)) + .get('/api/auth/start', async (c) => + c.redirect(await TempoId.start(c, { intent: { kind: 'dash' } }), 302), + ) .get('/api/auth/callback', async (c) => { - // A callback without the session cookie, such as after it expired, starts the sign-in again. - if (!(await TempoId.complete(c))) return c.redirect('/api/auth/start', 302) - return c.redirect('/dash', 302) + 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, + }) + return c.redirect(await getResultUrl(`/confirm/${intent.token}`, response), 302) + } + + // 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', 'link_failed') + 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 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) => { + try { + const data = await Tip.getConfirmationData(c.env, c.req.param('token')) + 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') @@ -92,254 +301,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', @@ -645,10 +607,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( @@ -667,331 +625,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') @@ -1476,3 +1113,617 @@ 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.workers.test.ts b/src/chat.workers.test.ts index 60431a4..7b10e4e 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() 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 54eced9..2cf9da9 100644 --- a/src/function/dash.ts +++ b/src/function/dash.ts @@ -2,7 +2,6 @@ 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' 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 index f14fa92..a6ade33 100644 --- a/src/lib/tempoId.ts +++ b/src/lib/tempoId.ts @@ -1,20 +1,21 @@ +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 { Address } from 'ox' -import { Accounts } from 'tempo-accounts' -import { DurableObject } from 'tempo-accounts/cloudflare' -import { backend } from 'tempo-accounts/plugins' +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 never reaches the browser. +// 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, })) -export const cookieName = 'tempo_id' - -export const cookieOptions = { +const cookieOptions = { httpOnly: true, path: '/', prefix: 'host', @@ -22,6 +23,20 @@ export const cookieOptions = { 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, @@ -31,46 +46,143 @@ export function create(env: Env) { }) } -export async function start(c: Context<{ Bindings: Env }>) { - // The dashboard only needs the account's address, so the sign-in requests no access key. +/** + * 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: false, + accessKey: input.accessKey ?? false, + ...(input.funding ? { funding: input.funding } : {}), redirectUri: new URL('/api/auth/callback', c.req.url).toString(), }) - setCookie(c, cookieName, started.id, cookieOptions) + setCookie(c, sessionCookie, started.id, cookieOptions) + setCookie( + c, + intentCookie, + Base64.fromString(JSON.stringify(input.intent), { url: true }), + cookieOptions, + ) return started.url } -export async function complete(c: Context<{ Bindings: Env }>) { - const id = getCookie(c, cookieName, 'host') - if (!id) return false - await create(c.env).backend.complete({ id, url: c.req.url }) - return true +/** + * 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 } } -export async function end(c: Context<{ Bindings: Env }>) { - const id = getCookie(c, cookieName, 'host') - if (id) await create(c.env).backend.end({ id }) - deleteCookie(c, cookieName, cookieOptions) +/** 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) + const id = readCookie(request, sessionCookie) if (!id) return undefined const accounts = create(env) - const session = await accounts.backend.get({ id }) + const session = await accounts.backend.get({ id }).catch(() => undefined) if (!session) return undefined - // A sign-in through a redirect reads no account, so the session's access token reads it. + 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 readCookie(request: Request) { - const name = `__Host-${cookieName}=` +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(name)) return decodeURIComponent(value.slice(name.length)) + 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..5ff6fa5 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 @@ -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..ffd640b 100644 --- a/src/routes/confirm/$token.tsx +++ b/src/routes/confirm/$token.tsx @@ -2,15 +2,9 @@ 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' @@ -20,12 +14,47 @@ export const Route = createFileRoute('/confirm/$token')({ async loader(options) { return await getConfirmData({ data: options.params.token }) }, + validateSearch: z.object({ + error: z.optional(z.string()), + status: z.optional(z.literal('sent')), + tx: z.optional(z.string()), + }), }) function Component() { const data = Route.useLoaderData() const params = Route.useParams() + const search = Route.useSearch() + // A sent payment's link can expire, so the callback's result shows first. + if (search.status === 'sent') + return ( +
+
+ Tipbot +
+

Payment sent

+

You can close this tab and return to Slack.

+ {search.tx && data.ok ? ( + + View receipt + + ) : null} +
+
+
+ ) if (!data.ok) return (
@@ -46,25 +75,16 @@ function Component() {
) - return ( - - - - ) + return } function ConfirmPanel(props: { data: Extract, { ok: true }> + error?: string | undefined 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 error = props.error const recipients: Array<{ recipientAvatarUrl?: string | null recipientProviderLabel?: string | null @@ -83,126 +103,6 @@ function ConfirmPanel(props: { }> = 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.')) - } - } - return (
@@ -213,27 +113,11 @@ function ConfirmPanel(props: { src={tipbotImagePath} width={160} /> - {status === 'sent' ? ( -
-

Payment sent

-

You can close this tab and return to Slack.

- {transactionHash ? ( - - View receipt - - ) : null} -
- ) : ( - )}
) @@ -369,41 +254,6 @@ const getConfirmData = createServerFn({ method: 'GET' }) 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 diff --git a/src/routes/connect/$token.tsx b/src/routes/connect/$token.tsx index ee803bd..9522432 100644 --- a/src/routes/connect/$token.tsx +++ b/src/routes/connect/$token.tsx @@ -2,14 +2,9 @@ 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' @@ -20,12 +15,19 @@ export const Route = createFileRoute('/connect/$token')({ async loader(options) { return await getConnectData({ data: options.params.token }) }, + validateSearch: z.object({ + error: z.optional(z.string()), + status: z.optional(z.enum(['conflict', 'connected'])), + }), }) function Component() { const data = Route.useLoaderData() const params = Route.useParams() + const search = Route.useSearch() + // A used link reads as unavailable, so the callback's result shows first. + if (search.status === 'connected') return if (!data.ok) return (
@@ -48,105 +50,36 @@ function Component() {
) + return +} + +function Connected() { return ( - - - +
+
+ Tipbot +
+

Connected to Tipbot

+

You can close this tab and return to Slack.

+
+
+
) } function ConnectPanel(props: { data: Extract, { ok: true }> + search: ReturnType 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 = props.search return (
@@ -158,25 +91,19 @@ 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. + Authorize Tipbot to connect your Tempo Account for Slack tips.

- Tipbot wants to connect to your Tempo Wallet + Tipbot wants to connect to your Tempo Account

Review the permissions below before continuing. @@ -222,14 +149,12 @@ function ConnectPanel(props: {

- )}
) @@ -302,38 +231,3 @@ const getConnectData = createServerFn({ method: 'GET' }) } } }) - -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)' }, - ], - } -} diff --git a/src/routes/dash.tsx b/src/routes/dash.tsx index 93e2e23..82c6c1f 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, @@ -14,16 +12,8 @@ import { getErrorMessage } from '#/lib/error.ts' 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, + component: DashboardPanel, head: () => ({ meta: [{ title: 'Dashboard - Tipbot' }] }), async loader() { const dashboard = await getDashboardData() @@ -34,8 +24,6 @@ export const Route = createFileRoute('/dash')({ 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 +48,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 +79,15 @@ function DashboardPanel() { {dashboard.walletAddress} - + {/* Sign-out revokes the Tempo ID session on the server, then returns home. */} +
+ +
) : null}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 32d24c8..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 + )}
@@ -311,13 +283,3 @@ const getHomeData = createServerFn({ method: 'GET' }).handler(async () => { 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..c09b11c 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 +283,6 @@ function LinkPanel() { ) : null}
- {walletAddress ? ( -
-
-

Wallet connected

-

{formatAddress(walletAddress)}

-
- -
- ) : null}
)}
@@ -681,35 +290,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 +329,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..e90d80c 100644 --- a/test/e2e.global.setup.ts +++ b/test/e2e.global.setup.ts @@ -3,6 +3,7 @@ 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' @@ -24,17 +25,43 @@ 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') + const env = Env.get({ HOST: host, 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 +77,7 @@ export default async function globalSetup() { return async () => { server.stop() await slack.close() + await tempoId.stop() } } diff --git a/test/e2e/connect.test.ts b/test/e2e/connect.test.ts index ef560c9..1f9836a 100644 --- a/test/e2e/connect.test.ts +++ b/test/e2e/connect.test.ts @@ -1,12 +1,12 @@ 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' @@ -16,7 +16,7 @@ test('visitor opens an expired connection link', async ({ app, page }) => { 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() + await expect(page.getByRole('link', { name: 'Connect' })).toBeHidden() }) test('slack member opens valid connection link', async ({ app, db, factory, page }) => { @@ -33,29 +33,27 @@ test('slack member opens valid connection link', async ({ app, db, factory, page await page.goto(app.url({ params: { token }, to: '/connect/$token' })) - await expect(page.getByRole('button', { name: 'Connect' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Connect' })).toHaveAttribute( + 'href', + `/api/account/link/${token}/start`, + ) await expect(page.getByRole('link', { name: 'Cancel' })).toBeVisible() - await expect(page.getByText('Authorize Tipbot to connect your Tempo Wallet')).toBeVisible() + await expect(page.getByText('Authorize Tipbot to connect your Tempo Account')).toBeVisible() }) -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.getByRole('link', { name: 'Connect' }).click() + 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 +62,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 +71,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 +117,106 @@ 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({ params: { token }, to: '/connect/$token' })) + await page.getByRole('link', { name: 'Connect' }).click() + 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({ params: { token: firstToken }, to: '/connect/$token' })) + await page.getByRole('link', { name: 'Connect' }).click() + 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({ params: { token: secondToken }, to: '/connect/$token' })) + await page.getByRole('link', { name: 'Connect' }).click() + 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`, 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/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/vite.config.ts b/vite.config.ts index 0d86e49..817143d 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 @@ -103,6 +100,9 @@ export default defineConfig({ 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 ?? '', } : {}), SLACK_API_URL: process.env.SLACK_API_URL ?? config.vars?.SLACK_API_URL, @@ -167,7 +167,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", }, From b5dc68db9a1d755e3566c4dc5e21ccb43f7d4881 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 21:42:26 +0000 Subject: [PATCH 3/4] test: drive the confirm and X link specs through Tempo ID, and pay on a local Tempo node Starts a local Tempo node in the end-to-end setup through `test/tempo.ts`, which the workers setup shares. The confirm spec connects a Tempo Account, funds it, and confirms one-time and reusable payments through Tempo ID's approval page. The X link spec follows the OAuth redirect to X and the proof tweet page. CI logs in to GHCR for the pinned `ID_DEV_IMAGE`. Also passes the X OAuth client to the end-to-end dev server, and puts the dashboard's component before its route. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D3nmWin1mVpioUPdvvsHgw --- .github/workflows/check.yml | 9 + src/api.ts | 1004 ++++++++++++++++----------------- src/lib/tempoId.ts | 5 +- src/routes/confirm/$token.tsx | 202 ++++--- src/routes/connect/$token.tsx | 169 +++--- src/routes/dash.tsx | 22 +- src/routes/link/x.tsx | 14 +- test/e2e.global.setup.ts | 8 + test/e2e/confirm.test.ts | 531 +++-------------- test/e2e/connect.test.ts | 34 +- test/e2e/link_x.test.ts | 381 +++---------- test/e2e/members.ts | 34 ++ test/tempo.ts | 67 +++ test/workers.global.setup.ts | 63 +-- vite.config.ts | 4 + 15 files changed, 1009 insertions(+), 1538 deletions(-) create mode 100644 test/e2e/members.ts create mode 100644 test/tempo.ts 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/src/api.ts b/src/api.ts index 80867ee..c4257f6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -101,7 +101,10 @@ export const api = new Hono() return c.redirect(redirectUrl.toString(), 302) } catch (error) { console.error('Twitter link after Tempo ID failed:', error) - redirectUrl.searchParams.set('error', 'link_failed') + redirectUrl.searchParams.set( + 'error', + intent.kind === 'x_oauth' ? 'oauth_failed' : 'link_failed', + ) return c.redirect(redirectUrl.toString(), 302) } }) @@ -171,10 +174,7 @@ export const api = new Hono() }) // 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'), - ) + const recipients = await Tip.getConfirmationRecipientAddresses(c.env, c.req.param('token')) return TempoId.permissions({ chainId: payload.chainId, expiresAt: payload.expiresAt, @@ -1123,251 +1123,244 @@ async function linkAccount( 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), - ) + 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() - 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, - ) + const account = await (async () => { + if (existingAccount) { + await c.var.db + .updateTable('account') + .set({ updated_at: now }) + .where('id', '=', existingAccount.id) + .execute() + return existingAccount + } - 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 + 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('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() - })() + .where('id', '=', id) + .executeTakeFirstOrThrow() + })() - const duplicate = await c.var.db + 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.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) - .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() + const duplicateIdentityIds = duplicateIdentities.map((row) => row.provider_identity_id) await c.var.db - .deleteFrom('access_key') - .where('account_id', '=', account.id) - .where('chain_id', '=', link.chain_id) - .where('token_address', 'is', null) + .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 - .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, - }) + .updateTable('provider_identity') + .set({ account_id: account.id, updated_at: now }) + .where('id', '=', link.member_provider_identity_id) .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) + 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() - 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() + 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 () => { - 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(', ') + await Chat.getChat().initialize() + const installation = await Chat.getSlack().getInstallation( + link.channel_provider_id ?? link.provider_id, + ) + if (!installation) return - const channelRef = Chat.getChat().channel( - link.provider_channel_id!.startsWith('slack:') - ? link.provider_channel_id! - : `slack:${link.provider_channel_id!}`, + 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)})`, ) - const truncatedAddress = `${account.address.slice(0, 6)}…${account.address.slice(-4)}` - const explorerUrl = Tempo.explorerLink(link.chain_id, account.address) + .join(', ') - 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) - }), - ) + 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) - 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, + 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( @@ -1379,174 +1372,101 @@ async function confirmPayment( 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) + 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, ) - 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) + 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) + }), ) - c.executionCtx.waitUntil( - (async () => { - await Chat.getChat().initialize() - const installation = await Chat.getSlack().getInstallation(data.payload.providerId) - if (!installation) return + 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 - 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 + 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 - 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 [, 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 - 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, { + if (receipt.thread_ts !== receipt.message_ts) { + await Chat.updateReceiptBoostAggregate(data.payload.providerId, { channelId, - messageTs: json.ts, threadTs: receipt.thread_ts, - transactionHash: result.transactionHash, + workspaceId, }) if (data.payload.skippedRecipients?.length) { const skippedBody = new URLSearchParams() @@ -1567,136 +1487,200 @@ async function confirmPayment( }), ) } - })().catch((error) => { - console.error('Failed to post Slack boost receipt after confirmation:', error) - }), - ) - else if ( - result.status === 'sent' && - Chat.isTipAskIdempotencyKey(data.payload.idempotencyKey) + 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) + }), ) - 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 + 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}>`, + 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 ?? []) - .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', + ...((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', + }), ) - 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. - }) - } + 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, + } + })().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: { diff --git a/src/lib/tempoId.ts b/src/lib/tempoId.ts index a6ade33..30d4421 100644 --- a/src/lib/tempoId.ts +++ b/src/lib/tempoId.ts @@ -132,7 +132,10 @@ export async function get(c: Context) { /** 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) + if (id) + await create(c.env) + .backend.end({ id }) + .catch(() => undefined) deleteCookie(c, sessionCookie, cookieOptions) } diff --git a/src/routes/confirm/$token.tsx b/src/routes/confirm/$token.tsx index ffd640b..9b381d4 100644 --- a/src/routes/confirm/$token.tsx +++ b/src/routes/confirm/$token.tsx @@ -113,123 +113,121 @@ function ConfirmPanel(props: { src={tipbotImagePath} width={160} /> -
-
-

Confirm payment

-

- Approve this payment with Tempo ID. +

+
+

Confirm payment

+

Approve this payment with Tempo ID.

+
+
+
+

+ Tipbot wants to send a payment +

+

+ Review the payment details below before continuing.

-
-
-

- Tipbot wants to send a payment -

-

- Review the payment details below before continuing. -

-
-
-

Payment details

-
- {data.groupLabel ? ( -
- Group - - @{data.groupLabel} - -
- ) : null} +
+

Payment details

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

- {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 uses this approval for this payment only, and keeps no access key.'} -

- {error ? ( -

- {error} -

) : null}
+
+ +

+ {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 uses this approval for this payment only, and keeps no access key.'} +

+ {error ? ( +

+ {error} +

+ ) : null} +
+
) diff --git a/src/routes/connect/$token.tsx b/src/routes/connect/$token.tsx index 9522432..a5bf0db 100644 --- a/src/routes/connect/$token.tsx +++ b/src/routes/connect/$token.tsx @@ -91,105 +91,98 @@ function ConnectPanel(props: { src={tipbotImagePath} width={160} /> -
-
-

- Connect Tipbot -

-

- Authorize Tipbot to connect your Tempo Account for Slack tips. +

+
+

+ Connect Tipbot +

+

+ Authorize Tipbot to connect your Tempo Account for Slack tips. +

+
+
+
+

+ Tipbot wants to connect to your Tempo Account +

+

+ Review the permissions below before continuing.

-
-
-

- Tipbot wants to connect to your Tempo Account -

-

- Review the permissions below before continuing. -

-
-
-

With this connection, Tipbot can:

-
-
-
-
) diff --git a/src/routes/dash.tsx b/src/routes/dash.tsx index 82c6c1f..131ff3e 100644 --- a/src/routes/dash.tsx +++ b/src/routes/dash.tsx @@ -12,17 +12,7 @@ import { getErrorMessage } from '#/lib/error.ts' import IconLogosSlackIcon from '~icons/logos/slack-icon.jsx' import IconSimpleIconsX from '~icons/simple-icons/x.jsx' -export const Route = createFileRoute('/dash')({ - component: DashboardPanel, - head: () => ({ meta: [{ title: 'Dashboard - Tipbot' }] }), - async loader() { - const dashboard = await getDashboardData() - if (!dashboard.ok) throw redirect({ to: '/' }) - return dashboard - }, -}) - -function DashboardPanel() { +function Component() { const loaderDashboard = Route.useLoaderData() const [dashboard, setDashboard] = React.useState(loaderDashboard) const [statusMessage, setStatusMessage] = React.useState(null) @@ -147,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/link/x.tsx b/src/routes/link/x.tsx index c09b11c..c9f2727 100644 --- a/src/routes/link/x.tsx +++ b/src/routes/link/x.tsx @@ -203,9 +203,14 @@ function Component() {
) : (
-

With this connection, Tipbot can:

+

+ With this connection, Tipbot can: +

-
-