diff --git a/.changeset/eight-jokes-refuse.md b/.changeset/eight-jokes-refuse.md new file mode 100644 index 00000000..bb293f57 --- /dev/null +++ b/.changeset/eight-jokes-refuse.md @@ -0,0 +1,18 @@ +--- +'accounts': minor +--- + +Rewrote `accounts/cli` onto the Wata device-code transport and deprecated `CliAuth` and `Handler.codeAuth` in favor of `Handler.deviceCode`. + +```diff +- Provider.create({ +- host: 'https://wallet.example.com/api/auth/cli', +- pollIntervalMs: 1000, +- timeoutMs: 60_000, +- }) ++ Provider.create({ ++ host: 'https://wallet.example.com/auth/device', ++ pollingInterval: 1000, ++ timeout: 60_000, ++ }) +``` diff --git a/.changeset/plenty-donkeys-search.md b/.changeset/plenty-donkeys-search.md new file mode 100644 index 00000000..af9f15d4 --- /dev/null +++ b/.changeset/plenty-donkeys-search.md @@ -0,0 +1,16 @@ +--- +'accounts': minor +--- + +Added a `deviceCode` adapter (`accounts/deviceCode`) that forwards wallet RPC through the Wata device-code transport (RFC 8628 with PKCE), with a `tempoWallet` preset. + +```ts +import { deviceCode } from 'accounts/deviceCode' + +const adapter = deviceCode({ + name: 'Example CLI', + onPrompt: ({ userCode, verificationUriFull }) => console.log(userCode, verificationUriFull), + rdns: 'com.example.cli', + url: 'https://wallet.tempo.xyz/auth/device', +}) +``` diff --git a/README.md b/README.md index e47d248a..c7bdae95 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,9 @@ Set `VITE_BASE_URL` to the production docs origin when using a custom domain. The `ref-impls/` directory contains reference implementations for building with Tempo Accounts SDK: -| Directory | Description | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref-impls/dialog/` | Minimal, unstyled embed dialog app demonstrating how to build a custom embed using the `Remote` API. Select `dialogRefImpl` in the web playground's adapter dropdown to test against it. | -| `ref-impls/cli-auth/` | Minimal React + Cloudflare/Vite host reference for device-code CLI auth: built-in code-auth endpoints plus a single unstyled approval screen and local smoke client. | +| Directory | Description | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ref-impls/dialog/` | Minimal, unstyled embed dialog app demonstrating how to build a custom embed using the `Remote` API. Select `dialogRefImpl` in the web playground's adapter dropdown to test against it. | ## License diff --git a/package.json b/package.json index c4692657..e95efba5 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,12 @@ "src": "./src/wagmi/index.ts", "default": "./dist/wagmi/index.js" }, + "./deviceCode": { + "types": "./dist/core/adapters/deviceCode/index.d.ts", + "src": "./src/core/adapters/deviceCode/index.ts", + "react-native": "./dist/core/adapters/deviceCode/index.js", + "default": "./dist/core/adapters/deviceCode/index.js" + }, "./mobileWebAuth": { "types": "./dist/core/adapters/mobileWebAuth/index.d.ts", "src": "./src/core/adapters/mobileWebAuth/index.ts", diff --git a/playgrounds/web/wrangler.jsonc b/playgrounds/web/wrangler.jsonc index a2d4e41a..c2def7d9 100644 --- a/playgrounds/web/wrangler.jsonc +++ b/playgrounds/web/wrangler.jsonc @@ -11,7 +11,6 @@ "/auth", "/auth/*", "/me", - "/cli-auth/*", "/webauthn/*", "/relay", "/fortune", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093955b5..a86eb3f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,9 +42,6 @@ catalogs: tsx: specifier: ^4.21.0 version: 4.21.0 - typed-query-selector: - specifier: ^2.12.1 - version: 2.12.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1002,52 +999,6 @@ importers: specifier: ^4.98.0 version: 4.98.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) - ref-impls/cli-auth: - dependencies: - accounts: - specifier: workspace:* - version: link:../.. - incur: - specifier: ^0.3.25 - version: 0.3.25 - react: - specifier: ^19.2.8 - version: 19.2.8 - react-dom: - specifier: ^19.2.8 - version: 19.2.8(react@19.2.8) - devDependencies: - '@cloudflare/vite-plugin': - specifier: 'catalog:' - version: 1.33.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.16(@types/node@25.9.3)(@vitejs/devtools@0.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0))(workerd@1.20260603.1)(wrangler@4.98.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@types/node': - specifier: 'catalog:' - version: 25.9.3 - '@types/react': - specifier: catalog:default - version: 19.2.14 - '@types/react-dom': - specifier: catalog:default - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: catalog:default - version: 5.2.0(vite@8.0.16) - tsx: - specifier: 'catalog:' - version: 4.21.0 - typed-query-selector: - specifier: 'catalog:' - version: 2.12.1 - typescript: - specifier: 'catalog:' - version: 5.9.3 - vp: - specifier: npm:vite-plus@~0.1.24 - version: vite-plus@0.1.24(@types/node@25.9.3)(@vitejs/devtools@0.2.0)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@8.0.16)(yaml@2.9.0) - wrangler: - specifier: ^4.98.0 - version: 4.98.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) - ref-impls/dialog: dependencies: '@tanstack/react-query': @@ -7945,11 +7896,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - incur@0.3.25: - resolution: {integrity: sha512-jrSkzauM42ilbQJ6THVkAY6dTulkyVW0sZpVHdA8gfiBwrLrLnLUf8U3bAOegAKBIMSOFgk1idchgu9xm9HMng==} - engines: {node: '>=22'} - hasBin: true - incur@0.4.11: resolution: {integrity: sha512-3mAuuiaA1YaSFm5Ydz76kYZpJAkICPjN/Ep4AyldwTm4x9IsBvKhrOPH8FkzU7T/XSGu+qSN/Av4zc2n5bwYjQ==} engines: {node: '>=22'} @@ -10675,9 +10621,6 @@ packages: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} - typed-query-selector@2.12.1: - resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -21961,15 +21904,6 @@ snapshots: imurmurhash@0.1.4: {} - incur@0.3.25: - dependencies: - '@cfworker/json-schema': 4.1.1 - '@modelcontextprotocol/server': 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) - '@toon-format/toon': 2.1.0 - tokenx: 1.3.0 - yaml: 2.8.3 - zod: 4.3.6 - incur@0.4.11: dependencies: '@cfworker/json-schema': 4.1.1 @@ -25752,8 +25686,6 @@ snapshots: es-errors: 1.3.0 is-typed-array: 1.1.15 - typed-query-selector@2.12.1: {} - typescript@5.9.3: {} ua-parser-js@1.0.41: {} diff --git a/ref-impls/cli-auth/.env.example b/ref-impls/cli-auth/.env.example deleted file mode 100644 index e308e60e..00000000 --- a/ref-impls/cli-auth/.env.example +++ /dev/null @@ -1 +0,0 @@ -PRIVATE_KEY="" diff --git a/ref-impls/cli-auth/.gitignore b/ref-impls/cli-auth/.gitignore deleted file mode 100644 index 6953af32..00000000 --- a/ref-impls/cli-auth/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -dist-ssr -.wrangler -.env -tmp -worker-configuration.d.ts diff --git a/ref-impls/cli-auth/cli.ts b/ref-impls/cli-auth/cli.ts deleted file mode 100644 index 57cbaf1e..00000000 --- a/ref-impls/cli-auth/cli.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Expiry, Store } from 'accounts' -import { Provider } from 'accounts/cli' -import { Cli, z } from 'incur' -import { pathToFileURL } from 'node:url' -import { Hex } from 'ox' -import { connect } from 'viem/experimental/erc7846' -import { tempoMainnet, tempoTestnet, tempoDevnet } from 'viem/tempo/chains' - -const defaultHost = 'https://wallet.tempo.xyz/api/auth/cli' as const -const defaultToken = '0x20c0000000000000000000000000000000000000' as const - -const sharedOptions = z.object({ - host: z.string().default(defaultHost).describe('CLI auth host URL'), - chain: z - .string() - .check((ctx) => { - if (['mainnet', 'testnet', 'devnet'].includes(ctx.value)) return - ctx.issues.push({ code: 'custom', input: ctx.value }) - }) - .default('mainnet') - .describe('Tempo Chain: mainnet, testnet, devnet'), - limit: z - .number() - .int() - .refine((value) => value > 0, '`--limit` must be a positive integer.') - .default(1_000) - .describe('Token spend limit'), - token: z - .string() - .regex(/^0x[0-9a-fA-F]+$/, '`--token` must be a hex string.') - .default(defaultToken) - .describe('TIP-20 token address for the spend limit'), -}) - -const options = sharedOptions.extend({ - expiry: z - .number() - .int() - .refine((value) => value > 0, '`--expiry` must be a positive number of seconds.') - .default(3_600) - .describe('Access key expiry in seconds'), -}) - -const updateOptions = z.object({ - host: z.string().default(defaultHost).describe('CLI auth host URL'), - limit: z - .number() - .int() - .refine((value) => value > 0, '`--limit` must be a positive integer.') - .default(1_000) - .describe('Token spend limit'), - token: z - .string() - .regex(/^0x[0-9a-fA-F]+$/, '`--token` must be a hex string.') - .default(defaultToken) - .describe('TIP-20 token address for the spend limit'), -}) - -/** Tempo accounts/cli auth reference implementation CLI. */ -const cli = Cli.create('accounts-cli') - .command('connect', { - description: 'Connect to the CLI auth provider', - options, - async run({ options }) { - configureLocalTls(options.host) - - const chain = - options.chain === 'mainnet' - ? tempoMainnet - : options.chain === 'testnet' - ? tempoTestnet - : tempoDevnet - - const provider = Provider.create({ - host: options.host, - testnet: chain.testnet, - }) - const authorizeAccessKey = { - expiry: Expiry.seconds(options.expiry), - limits: [ - { - limit: Hex.fromNumber(options.limit), - token: options.token as Hex.Hex, - }, - ], - } - - const client = provider.getClient() - return connect(client, { - capabilities: { - authorizeAccessKey, - }, - }) - }, - }) - .command('request', { - description: 'Request a new access key from the CLI auth provider', - options, - async run({ options }) { - configureLocalTls(options.host) - const chain = - options.chain === 'mainnet' - ? tempoMainnet - : options.chain === 'testnet' - ? tempoTestnet - : tempoDevnet - - const provider = Provider.create({ - host: options.host, - testnet: chain.testnet, - }) - const authorizeAccessKey = { - expiry: Expiry.seconds(options.expiry), - limits: [ - { - limit: Hex.fromNumber(options.limit), - token: options.token as Hex.Hex, - }, - ], - } - - return provider.request({ - method: 'wallet_authorizeAccessKey', - params: [authorizeAccessKey], - }) - }, - }) - .command('update-access-key', { - description: 'Update an access key spend limit through the CLI auth provider', - options: updateOptions, - async run({ options }) { - configureLocalTls(options.host) - const provider = Provider.create({ host: options.host }) - await Store.waitForHydration(provider.store) - const account = provider.getAccount() - const accessKey = await provider.store.accessKeys.select({ - account: account.address, - chainId: provider.getClient().chain.id, - }) - if (!accessKey) throw new Error('No connected access key found.') - - await provider.request({ - method: 'wallet_updateAccessKey', - params: [ - { - address: account.address, - accessKeyAddress: accessKey.accessKeyAddress, - limits: [ - { - limit: Hex.fromNumber(options.limit), - token: options.token as Hex.Hex, - }, - ], - }, - ], - }) - return { status: 'updated' } - }, - }) -export default cli - -function configureLocalTls(value: string) { - const url = new URL(value) - if (url.protocol !== 'https:') return - if ( - url.hostname !== 'localhost' && - url.hostname !== '127.0.0.1' && - !url.hostname.endsWith('.local') - ) - return - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' -} - -function isMain() { - const [, entry] = process.argv - if (!entry) return false - return import.meta.url === pathToFileURL(entry).href -} - -if (isMain()) void cli.serve() diff --git a/ref-impls/cli-auth/index.html b/ref-impls/cli-auth/index.html deleted file mode 100644 index 0012ebc7..00000000 --- a/ref-impls/cli-auth/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - CLI Auth Reference - - -
- - - diff --git a/ref-impls/cli-auth/package.json b/ref-impls/cli-auth/package.json deleted file mode 100644 index 3c40c54d..00000000 --- a/ref-impls/cli-auth/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "cli-auth-ref", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "connect": "tsx ./cli.ts connect", - "dev": "test -f .env || cp .env.example .env && vp dev", - "build": "tsc --noEmit && vp build", - "check:types": "tsc --noEmit", - "postinstall": "wrangler types --env-interface='CloudflareBindings' --env-file .env.example", - "preview": "vp preview", - "request": "tsx ./cli.ts request", - "update-access-key": "tsx ./cli.ts update-access-key" - }, - "dependencies": { - "accounts": "workspace:*", - "incur": "^0.3.25", - "react": "^19.2.5", - "react-dom": "^19.2.5" - }, - "devDependencies": { - "@cloudflare/vite-plugin": "catalog:", - "@types/node": "catalog:", - "@types/react": "catalog:default", - "@types/react-dom": "catalog:default", - "@vitejs/plugin-react": "catalog:default", - "tsx": "catalog:", - "typed-query-selector": "catalog:", - "typescript": "catalog:", - "vp": "catalog:", - "wrangler": "^4.82.2" - } -} diff --git a/ref-impls/cli-auth/src/App.tsx b/ref-impls/cli-auth/src/App.tsx deleted file mode 100644 index 10d771b5..00000000 --- a/ref-impls/cli-auth/src/App.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import * as React from 'react' - -type Pending = - | { - action?: 'authorizeAccessKey' | undefined - account?: string | undefined - accessKeyAddress: string - chainId: string - code: string - expiry: number - keyType: string - limits?: readonly { limit: string; token: string }[] | undefined - } - | { - action: 'updateAccessKey' - account: string - accessKeyAddress: string - chainId: string - code: string - limits: readonly { limit: string; token: string }[] - } - -type Approved = { - accountAddress: string - status: 'authorized' -} - -type PendingState = - | { status: 'idle' } - | { status: 'loading' } - | { pending: Pending; status: 'ready' } - | { error: string; status: 'error' } - -type ApproveState = - | { status: 'idle' } - | { approved: Approved; code: string; status: 'approved' } - | { error: string; status: 'error' } - -const assets = { - '0x20c0000000000000000000000000000000000000': { decimals: 6, symbol: 'pathUSD' }, - '0x20c0000000000000000000000000000000000001': { decimals: 6, symbol: 'alphaUSD' }, - '0x20c0000000000000000000000000000000000002': { decimals: 6, symbol: 'betaUSD' }, - '0x20c0000000000000000000000000000000000003': { decimals: 6, symbol: 'thetaUSD' }, - '0x20c0000000000000000000009e8d7eb59b783726': { decimals: 6, symbol: 'USDC.e' }, - '0x20c000000000000000000000b9537d11c60e8b50': { decimals: 6, symbol: 'USDC.e' }, -} as const - -function formatAmount(value: bigint, decimals: number) { - if (decimals === 0) return new Intl.NumberFormat().format(value) - - const negative = value < 0n - const base = 10n ** BigInt(decimals) - const whole = negative ? -value / base : value / base - const fraction = negative ? (-value % base).toString() : (value % base).toString() - const trimmed = fraction.padStart(decimals, '0').replace(/0+$/, '') - const wholeText = new Intl.NumberFormat().format(whole) - - if (!trimmed) return negative ? `-${wholeText}` : wholeText - return `${negative ? '-' : ''}${wholeText}.${trimmed}` -} - -function formatLimit(limit: { limit: string; token: string }) { - const token = limit.token.toLowerCase() - const asset = assets[token as keyof typeof assets] - const amount = BigInt(limit.limit) - - if (!asset) return `${new Intl.NumberFormat().format(amount)} on asset ${limit.token}` - - return `${formatAmount(amount, asset.decimals)} ${asset.symbol} on asset ${limit.token}` -} - -function formatExpiry(expiry: number) { - return new Date(expiry * 1000).toISOString().replace('T', ' ').replace('.000Z', ' UTC') -} - -function formatTimeLeft(expiry: number, now: number) { - const total = Math.max(0, expiry - Math.floor(now / 1000)) - const days = Math.floor(total / 86_400) - const hours = Math.floor((total % 86_400) / 3_600) - const minutes = Math.floor((total % 3_600) / 60) - const seconds = total % 60 - const parts = [ - ...(days ? [`${days}d`] : []), - ...(days || hours ? [`${hours}h`] : []), - ...(days || hours || minutes ? [`${minutes}m`] : []), - `${seconds}s`, - ] - - return parts.join(' ') -} - -async function loadPending(code: string): Promise { - try { - return { - pending: await fetch(`/cli-auth/pending/${code}`).then((response) => response.json()), - status: 'ready', - } - } catch (error) { - return { - error: error instanceof Error ? error.message : 'Request failed.', - status: 'error', - } - } -} - -/** Minimal approval page for the CLI auth reference implementation. */ -export function App() { - const current = new URLSearchParams(window.location.search).get('code') - const code = current ? current : '' - const [now, setNow] = React.useState(Date.now()) - const [pending, setPending] = React.useState( - code ? { status: 'loading' } : { status: 'idle' }, - ) - const [approveState, approve, approving] = React.useActionState( - async (_: ApproveState, formData: FormData): Promise => { - try { - return { - approved: await fetch('/cli-auth/approve', { - body: JSON.stringify({ code: String(formData.get('code') ?? '') }), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }).then((response) => response.json()), - code: String(formData.get('code') ?? ''), - status: 'approved', - } - } catch (error) { - return { - error: error instanceof Error ? error.message : 'Request failed.', - status: 'error', - } - } - }, - { status: 'idle' }, - ) - - React.useEffect(() => { - if (!code) { - setPending({ status: 'idle' }) - return - } - - let cancelled = false - setPending({ status: 'loading' }) - - void loadPending(code).then((result) => { - if (!cancelled) setPending(result) - }) - - return () => { - cancelled = true - } - }, [code]) - - React.useEffect(() => { - if (pending.status !== 'ready') return - - const interval = window.setInterval(() => { - setNow(Date.now()) - }, 1_000) - - return () => { - window.clearInterval(interval) - } - }, [pending.status]) - - return ( -
-

CLI auth

-

Approve the device code generated by your CLI.

- -
- - - -
- - {pending.status === 'idle' &&

Paste a device code to load the pending request.

} - {pending.status === 'loading' &&

Loading request...

} - {pending.status === 'error' &&

{pending.error}

} - {pending.status === 'ready' && ( - <> - {approveState.status === 'approved' ? ( - <> -

Approved

-

- Device code {approveState.code} was authorized by{' '} - {approveState.approved.accountAddress}. -

-

You can return to the CLI.

- - ) : ( -

Pending request

- )} -
-
Device code
-
{pending.pending.code}
-
Access key
-
{pending.pending.accessKeyAddress}
- {pending.pending.account && ( - <> -
Requested account
-
{pending.pending.account}
- - )} -
Chain ID
-
{pending.pending.chainId}
- {pending.pending.action !== 'updateAccessKey' && ( - <> -
Key type
-
{pending.pending.keyType}
-
Expires
-
{formatExpiry(pending.pending.expiry)}
-
Time left
-
{formatTimeLeft(pending.pending.expiry, now)}
- - )} - {pending.pending.limits && pending.pending.limits.length > 0 && ( - <> -
- {pending.pending.action === 'updateAccessKey' - ? 'New remaining limit' - : 'Max spend'} -
-
-
    - {pending.pending.limits.map((limit) => ( -
  • {formatLimit(limit)}
  • - ))} -
-
- - )} -
- {approveState.status !== 'approved' && ( - <> - {approveState.status === 'error' &&

{approveState.error}

} -
- - -
- - )} - - )} -
- ) -} diff --git a/ref-impls/cli-auth/src/main.tsx b/ref-impls/cli-auth/src/main.tsx deleted file mode 100644 index e1ec0c1d..00000000 --- a/ref-impls/cli-auth/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' - -import { App } from './App.js' - -const root = document.querySelector('div#root') -if (!root) throw new Error('Root element not found') - -createRoot(root).render( - - - , -) diff --git a/ref-impls/cli-auth/tsconfig.json b/ref-impls/cli-auth/tsconfig.json deleted file mode 100644 index 3d051ea4..00000000 --- a/ref-impls/cli-auth/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "lib": ["DOM", "DOM.Iterable", "ES2024"], - "types": ["vp/client", "node", "typed-query-selector/strict"], - "paths": { - "accounts": ["../src/index.ts"], - "accounts/*": ["../src/*"] - } - }, - "files": ["./cli.ts"], - "include": ["src", "worker", "worker-configuration.d.ts"] -} diff --git a/ref-impls/cli-auth/vite.config.ts b/ref-impls/cli-auth/vite.config.ts deleted file mode 100644 index 02cd992f..00000000 --- a/ref-impls/cli-auth/vite.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { cloudflare } from '@cloudflare/vite-plugin' -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vp' - -export default defineConfig({ - server: { - host: 'localhost', - port: 5173, - strictPort: true, - }, - plugins: [react(), cloudflare()], -}) diff --git a/ref-impls/cli-auth/worker/approve.ts b/ref-impls/cli-auth/worker/approve.ts deleted file mode 100644 index 08ee5199..00000000 --- a/ref-impls/cli-auth/worker/approve.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { CliAuth } from 'accounts/server' -import { Address, type Hex, PublicKey } from 'ox' -import { KeyAuthorization } from 'ox/tempo' -import { sendTransactionSync } from 'viem/actions' -import { Account, Actions } from 'viem/tempo' -import * as z from 'zod/mini' - -import { client, store } from './deps.js' - -function parseCode(value: string) { - const code = value.replaceAll('-', '').toUpperCase() - if (/^[A-Z0-9]{8}$/.test(code)) return code - - throw new Error('Expected an 8-character device code.') -} - -function getRoot(env: { PRIVATE_KEY: Hex.Hex }) { - if (!env.PRIVATE_KEY) - throw new Error('Missing PRIVATE_KEY. Copy .env.example to .env and set a root wallet key.') - - return Account.fromSecp256k1(env.PRIVATE_KEY) -} - -/** - * Completes a pending key authorization or limit update with the root wallet. - */ -export async function approve(request: Request, env: { PRIVATE_KEY: Hex.Hex }) { - const body = z.decode(z.object({ code: z.string() }), await request.json()) - const code = parseCode(body.code) - const pending = await CliAuth.pending({ - code, - store, - }) - const root = getRoot(env) - if (pending.action === 'updateAccessKey') { - const replacement = pending.keyAuthorization - ? await root.signKeyAuthorization( - { - accessKeyAddress: pending.accessKeyAddress, - keyType: pending.keyAuthorization.keyType, - }, - { - chainId: pending.chainId, - expiry: pending.keyAuthorization.expiry, - limits: pending.limits, - }, - ) - : undefined - if (!replacement) - await sendTransactionSync(client, { - account: root, - calls: pending.limits.map((limit) => - Actions.accessKey.updateLimit.call({ - accessKey: pending.accessKeyAddress, - limit: limit.limit, - token: limit.token, - }), - ), - }) - const replacementRpc = replacement ? KeyAuthorization.toRpc(replacement) : undefined - const result = await CliAuth.authorize({ - chainId: pending.chainId, - client, - request: { - action: 'updateAccessKey', - accountAddress: root.address, - chainId: pending.chainId, - code, - ...(replacementRpc - ? { - keyAuthorization: z.decode(CliAuth.keyAuthorization, { - ...replacementRpc, - address: replacementRpc.keyId, - }), - } - : {}), - }, - store, - }) - return Response.json({ accountAddress: root.address, status: result.status }) - } - - const signed = await root.signKeyAuthorization( - { - accessKeyAddress: Address.fromPublicKey(PublicKey.from(pending.pubKey)), - keyType: pending.keyType, - }, - { - chainId: pending.chainId, - expiry: pending.expiry, - ...(pending.limits ? { limits: pending.limits } : {}), - }, - ) - const keyAuthorization = KeyAuthorization.toRpc(signed) - const result = await CliAuth.authorize({ - chainId: pending.chainId, - client, - request: { - accountAddress: root.address, - code, - keyAuthorization: z.decode(CliAuth.keyAuthorization, { - ...keyAuthorization, - address: keyAuthorization.keyId, - }), - }, - store, - }) - - return Response.json({ - accountAddress: root.address, - status: result.status, - }) -} diff --git a/ref-impls/cli-auth/worker/deps.ts b/ref-impls/cli-auth/worker/deps.ts deleted file mode 100644 index cbfbb213..00000000 --- a/ref-impls/cli-auth/worker/deps.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CliAuth } from 'accounts/server' -import { createClient, http } from 'viem' -import { tempoModerato } from 'viem/tempo/chains' - -/** Viem client for Tempo Moderato RPC used by `approve`. */ -export const client = createClient({ - chain: tempoModerato, - transport: http(tempoModerato.rpcUrls.default.http[0]), -}) - -/** In-memory pending device code store shared by the worker handler and approve route. */ -export const store = CliAuth.Store.memory() diff --git a/ref-impls/cli-auth/worker/index.ts b/ref-impls/cli-auth/worker/index.ts deleted file mode 100644 index 4d61d140..00000000 --- a/ref-impls/cli-auth/worker/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { CliAuth, Handler } from 'accounts/server' -import type { Hex } from 'ox' -import { tempoModerato } from 'viem/tempo/chains' - -import { approve } from './approve.js' -import { store } from './deps.js' - -const path = '/cli-auth' - -const PRIVATE_KEY = process.env.PRIVATE_KEY -if (!PRIVATE_KEY) throw new Error('PRIVATE_KEY is not set') - -type Bindings = CloudflareBindings & { - ASSETS?: - | { - fetch: typeof fetch - } - | undefined - PRIVATE_KEY: Hex.Hex -} - -const cliAuth = Handler.codeAuth({ - chains: [tempoModerato], - path, - store, -}) - -/** Minimal Cloudflare Worker reference for CLI device code approval. */ -export default { - async fetch(request: Request, env: Bindings) { - const url = new URL(request.url) - - if (request.method === 'POST' && url.pathname === `${path}/approve`) { - try { - return await approve(request, env) - } catch (error) { - const status = error instanceof CliAuth.PendingError ? error.status : 400 - return Response.json( - { - error: error instanceof Error ? error.message : 'Request failed.', - }, - { status }, - ) - } - } - - if (request.method === 'GET' && url.pathname === path) { - if (env.ASSETS) { - const assetUrl = new URL('/', request.url) - assetUrl.search = url.search - return env.ASSETS.fetch(new Request(assetUrl, request)) - } - - const redirect = new URL('/', request.url) - redirect.search = url.search - return Response.redirect(redirect, 302) - } - - const response = await cliAuth.fetch(request) - if (response.status === 404 && env.ASSETS) return env.ASSETS.fetch(request) - return response - }, -} satisfies ExportedHandler diff --git a/ref-impls/cli-auth/wrangler.jsonc b/ref-impls/cli-auth/wrangler.jsonc deleted file mode 100644 index 39b5d052..00000000 --- a/ref-impls/cli-auth/wrangler.jsonc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "cli-auth-ref", - "compatibility_date": "2026-03-09", - "compatibility_flags": ["nodejs_compat"], - "main": "./worker/index.ts", - "assets": { - "not_found_handling": "single-page-application", - "run_worker_first": ["/cli-auth", "/cli-auth/*"], - }, -} diff --git a/site/src/components/ServerTransfer.tsx b/site/src/components/ServerTransfer.tsx index 9d35d785..31231a00 100644 --- a/site/src/components/ServerTransfer.tsx +++ b/site/src/components/ServerTransfer.tsx @@ -34,8 +34,7 @@ export function ServerTransfer() { try { const res = await fetch('/api/transfer') const body = await res.json() - if (!res.ok) - throw new Error(`${res.status}: ${JSON.stringify(body)}`) + if (!res.ok) throw new Error(`${res.status}: ${JSON.stringify(body)}`) setData(body) } catch (e) { setError(e as Error) @@ -52,9 +51,7 @@ export function ServerTransfer() {
Settled. - {data.message ? ( - {data.message} - ) : null} + {data.message ? {data.message} : null}
) : null} {error ? ( diff --git a/site/src/components/SponsoredPayment.tsx b/site/src/components/SponsoredPayment.tsx index bff143c3..b0f75398 100644 --- a/site/src/components/SponsoredPayment.tsx +++ b/site/src/components/SponsoredPayment.tsx @@ -35,7 +35,8 @@ function toFeeAmount(receipt: { }) { if (receipt.effectiveGasPrice === undefined || receipt.gasUsed === undefined) return undefined const value = - (toBigInt(receipt.effectiveGasPrice) * toBigInt(receipt.gasUsed)) / 10n ** BigInt(18 - pathUsdDecimals) + (toBigInt(receipt.effectiveGasPrice) * toBigInt(receipt.gasUsed)) / + 10n ** BigInt(18 - pathUsdDecimals) return toPathUsdAmount(value) } @@ -92,7 +93,12 @@ export function SponsoredPayment(props: SponsoredPayment.Props) { Balance
{balanceAmount ? ( - + ) : ( {balance.isLoading ? 'Loading...' : '-'} )} @@ -114,12 +120,22 @@ export function SponsoredPayment(props: SponsoredPayment.Props) {
Spent - +
{feeAmount ? (
Sponsor paid - +
) : null} {transfer.data.receipt.feePayer ? ( diff --git a/site/src/components/Steps.tsx b/site/src/components/Steps.tsx index 8be4e912..bbce4b21 100644 --- a/site/src/components/Steps.tsx +++ b/site/src/components/Steps.tsx @@ -1,13 +1,6 @@ 'use client' -import { - createContext, - type ReactNode, - useCallback, - useContext, - useId, - useState, -} from 'react' +import { createContext, type ReactNode, useCallback, useContext, useId, useState } from 'react' import { Button } from 'regen-ui' import LucideRotateCcw from '~icons/lucide/rotate-ccw' diff --git a/site/src/components/SubscriptionPayment.tsx b/site/src/components/SubscriptionPayment.tsx index 03faf730..6b3a02da 100644 --- a/site/src/components/SubscriptionPayment.tsx +++ b/site/src/components/SubscriptionPayment.tsx @@ -21,10 +21,7 @@ function getNextRenewalAt(receipt: Receipt.Receipt) { return timestamp + subscriptionPeriodMs } -export function SubscriptionStepLabel(props: { - badge: 'Server' | 'User' - children: ReactNode -}) { +export function SubscriptionStepLabel(props: { badge: 'Server' | 'User'; children: ReactNode }) { const badgeClass = props.badge === 'User' ? '[background-color:color-mix(in_srgb,var(--color-info)_16%,transparent)] [color:light-dark(oklch(0.58_0.26_251.8),oklch(0.84_0.26_251.8))]' @@ -93,12 +90,12 @@ function SubscribeStep(props: { }) const balanceAmount = balance.data !== undefined - ? { + ? ({ amount: toHex(balance.data.amount), decimals: balance.data.decimals, formatted: balance.data.formatted, symbol: pathUsdSymbol, - } satisfies Amount.Amount + } satisfies Amount.Amount) : undefined return ( @@ -125,7 +122,12 @@ function SubscribeStep(props: { Balance
{balanceAmount ? ( - + ) : ( {balance.isLoading ? 'Loading...' : '-'} )} @@ -250,7 +252,9 @@ function CollectStep(props: {
) : null} {collect.data ? ( -
{stringify({ collection: collect.data }, null, 2)}
+
+            {stringify({ collection: collect.data }, null, 2)}
+          
) : null} {collect.error ? (
{`${collect.error.name}: ${collect.error.message}`}
diff --git a/site/src/landing/animation.ts b/site/src/landing/animation.ts index d75d6687..46d8b4d1 100644 --- a/site/src/landing/animation.ts +++ b/site/src/landing/animation.ts @@ -1,4 +1,4 @@ -import { spring } from "animejs"; +import { spring } from 'animejs' /** Shared Anime.js spring presets for landing page WAAPI motion. */ export const springs = { @@ -8,4 +8,4 @@ export const springs = { navEntrance: spring({ stiffness: 440, damping: 28, mass: 1 }), progress: spring({ stiffness: 220, damping: 24, mass: 1 }), scroll: spring({ stiffness: 10, damping: 200, mass: 1 }), -} as const; +} as const diff --git a/site/src/landing/ascii-bg.tsx b/site/src/landing/ascii-bg.tsx index 333a9a9a..7609fe41 100644 --- a/site/src/landing/ascii-bg.tsx +++ b/site/src/landing/ascii-bg.tsx @@ -1,8 +1,9 @@ -"use client"; +'use client' -import { useEffect, useRef } from "react"; -import * as THREE from "three"; -import { readCssVar, useTheme } from "./useTheme"; +import { useEffect, useRef } from 'react' +import * as THREE from 'three' + +import { readCssVar, useTheme } from './useTheme' /* ─── Tweakables ──────────────────────────────────────────────────────── * * @@ -14,43 +15,43 @@ import { readCssVar, useTheme } from "./useTheme"; * ───────────────────────────────────────────────────────────────────── */ /** Size of each rendered pixel in CSS pixels. */ -const PIXEL_SIZE = 2.5; +const PIXEL_SIZE = 2.5 /** Spatial scale of the FBM noise (higher = smaller wisps). */ -const PATTERN_SCALE = 4; +const PATTERN_SCALE = 4 /** Density bias: 0.5 ≈ balanced, lower = sparser, higher = denser. */ -const PATTERN_DENSITY = 0.5; +const PATTERN_DENSITY = 0.5 /** Per-pixel size jitter on the dust grid. */ -const PIXEL_JITTER = 0; +const PIXEL_JITTER = 0 /** Flow speed multiplier for the noise field. */ -const FLOW_SPEED = 2; +const FLOW_SPEED = 2 /** Soft fade toward the edges (0 = no fade, larger = wider fade). */ -const EDGE_FADE = 0.25; +const EDGE_FADE = 0.25 /** Multiplier applied on top of `--canvas-dot-alpha-base` so the binary * dither has enough contrast to read. */ -const ALPHA_GAIN = 8; +const ALPHA_GAIN = 8 /** Radius around the cursor (in CSS px) where the dust lifts. */ -const CURSOR_RADIUS = 180; +const CURSOR_RADIUS = 180 /** Strength of the cursor brightness boost (added to the dither feed). */ -const CURSOR_STRENGTH = 0.5; +const CURSOR_STRENGTH = 0.5 /** Cursor follow lag (ms). Higher = laggier / smoother. */ -const CURSOR_RESPONSE_MS = 90; +const CURSOR_RESPONSE_MS = 90 /** Max simultaneous click ripples kept in the shader. */ -const MAX_CLICKS = 8; +const MAX_CLICKS = 8 /** Ripple speed in fragCoord units / second. */ -const RIPPLE_SPEED = 90; +const RIPPLE_SPEED = 90 /** Ripple ring thickness (smaller = thinner / crisper). */ -const RIPPLE_THICKNESS = 90; +const RIPPLE_THICKNESS = 90 /** Strength of a fresh ripple. Fades over time. */ -const RIPPLE_STRENGTH = 1.1; +const RIPPLE_STRENGTH = 1.1 /** Lifetime of one ripple in seconds (visual decay). */ -const RIPPLE_LIFETIME = 1.3; +const RIPPLE_LIFETIME = 1.3 const VERTEX_SRC = /* glsl */ ` void main() { gl_Position = vec4(position, 1.0); } -`; +` const FRAGMENT_SRC = /* glsl */ ` precision highp float; @@ -182,11 +183,11 @@ void main(){ fragColor = vec4(uColor, coverage * fade * uAlpha); } -`; +` function parseRgbVar(value: string): [number, number, number] { - const parts = value.split(",").map((s) => Number.parseFloat(s.trim())); - const [r, g, b] = parts; + const parts = value.split(',').map((s) => Number.parseFloat(s.trim())) + const [r, g, b] = parts if ( r === undefined || g === undefined || @@ -195,51 +196,43 @@ function parseRgbVar(value: string): [number, number, number] { Number.isNaN(g) || Number.isNaN(b) ) - return [0.49, 0.49, 0.49]; - return [r / 255, g / 255, b / 255]; + return [0.49, 0.49, 0.49] + return [r / 255, g / 255, b / 255] } type Props = { - className?: string; -}; + className?: string +} export default function AsciiBackground({ className }: Props) { - const containerRef = useRef(null); - const { resolved } = useTheme(); + const containerRef = useRef(null) + const { resolved } = useTheme() useEffect(() => { - const container = containerRef.current; - if (!container) return; + const container = containerRef.current + if (!container) return - const reduced = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches - const canvas = document.createElement("canvas"); + const canvas = document.createElement('canvas') const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, - powerPreference: "low-power", - }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); - renderer.setClearAlpha(0); - canvas.style.width = "100%"; - canvas.style.height = "100%"; - canvas.style.display = "block"; - container.appendChild(canvas); - - const [r, g, b] = parseRgbVar( - readCssVar(container, "--canvas-dot-rgb") || "125, 125, 125", - ); - const baseAlpha = - Number(readCssVar(container, "--canvas-dot-alpha-base")) || 0.07; - - const clickPositions = Array.from( - { length: MAX_CLICKS }, - () => new THREE.Vector2(-1, -1), - ); - const clickTimes = new Float32Array(MAX_CLICKS); + powerPreference: 'low-power', + }) + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) + renderer.setClearAlpha(0) + canvas.style.width = '100%' + canvas.style.height = '100%' + canvas.style.display = 'block' + container.appendChild(canvas) + + const [r, g, b] = parseRgbVar(readCssVar(container, '--canvas-dot-rgb') || '125, 125, 125') + const baseAlpha = Number(readCssVar(container, '--canvas-dot-alpha-base')) || 0.07 + + const clickPositions = Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1)) + const clickTimes = new Float32Array(MAX_CLICKS) const uniforms = { uResolution: { value: new THREE.Vector2(0, 0) }, @@ -264,10 +257,10 @@ export default function AsciiBackground({ className }: Props) { }, uRippleStrength: { value: reduced ? 0 : RIPPLE_STRENGTH }, uRippleLifetime: { value: RIPPLE_LIFETIME }, - }; + } - const scene = new THREE.Scene(); - const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const scene = new THREE.Scene() + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) const material = new THREE.ShaderMaterial({ vertexShader: VERTEX_SRC, fragmentShader: FRAGMENT_SRC, @@ -276,142 +269,136 @@ export default function AsciiBackground({ className }: Props) { depthTest: false, depthWrite: false, glslVersion: THREE.GLSL3, - }); - const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); - scene.add(quad); + }) + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material) + scene.add(quad) - const clock = new THREE.Clock(); - const timeOffset = Math.random() * 1000; + const clock = new THREE.Clock() + const timeOffset = Math.random() * 1000 const setSize = () => { - const w = container.clientWidth || 1; - const h = container.clientHeight || 1; - renderer.setSize(w, h, false); - uniforms.uResolution.value.set(canvas.width, canvas.height); - uniforms.uPixelSize.value = PIXEL_SIZE * renderer.getPixelRatio(); - uniforms.uCursorRadius.value = - CURSOR_RADIUS * renderer.getPixelRatio(); - uniforms.uRippleSpeed.value = RIPPLE_SPEED * renderer.getPixelRatio(); - uniforms.uRippleThickness.value = - RIPPLE_THICKNESS * renderer.getPixelRatio(); - }; - setSize(); - const ro = new ResizeObserver(setSize); - ro.observe(container); + const w = container.clientWidth || 1 + const h = container.clientHeight || 1 + renderer.setSize(w, h, false) + uniforms.uResolution.value.set(canvas.width, canvas.height) + uniforms.uPixelSize.value = PIXEL_SIZE * renderer.getPixelRatio() + uniforms.uCursorRadius.value = CURSOR_RADIUS * renderer.getPixelRatio() + uniforms.uRippleSpeed.value = RIPPLE_SPEED * renderer.getPixelRatio() + uniforms.uRippleThickness.value = RIPPLE_THICKNESS * renderer.getPixelRatio() + } + setSize() + const ro = new ResizeObserver(setSize) + ro.observe(container) // Pause work when the field is offscreen — avoids painting an idle // landing tab forever. - let visible = true; + let visible = true const io = new IntersectionObserver( ([entry]) => { - visible = entry?.isIntersecting ?? true; + visible = entry?.isIntersecting ?? true }, - { rootMargin: "200px" }, - ); - io.observe(container); + { rootMargin: '200px' }, + ) + io.observe(container) // Cursor follow + click ripples — listen on window so pointer events // reach us even though the container is pointer-events:none. - const target = { x: -9999, y: -9999, active: false }; - const smooth = { x: -9999, y: -9999 }; - let clickIx = 0; + const target = { x: -9999, y: -9999, active: false } + const smooth = { x: -9999, y: -9999 } + let clickIx = 0 const toCanvasPx = (clientX: number, clientY: number) => { - const rect = container.getBoundingClientRect(); + const rect = container.getBoundingClientRect() const inside = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && - clientY <= rect.bottom; - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - const fx = (clientX - rect.left) * scaleX; + clientY <= rect.bottom + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + const fx = (clientX - rect.left) * scaleX // WebGL fragCoord origin is bottom-left, so flip Y. - const fy = (rect.height - (clientY - rect.top)) * scaleY; - return { fx, fy, inside }; - }; + const fy = (rect.height - (clientY - rect.top)) * scaleY + return { fx, fy, inside } + } const onMove = (e: MouseEvent) => { - const { fx, fy, inside } = toCanvasPx(e.clientX, e.clientY); + const { fx, fy, inside } = toCanvasPx(e.clientX, e.clientY) if (inside) { - target.x = fx; - target.y = fy; + target.x = fx + target.y = fy if (!target.active) { - smooth.x = fx; - smooth.y = fy; + smooth.x = fx + smooth.y = fy } - target.active = true; + target.active = true } else { - target.active = false; + target.active = false } - }; + } const onLeave = () => { - target.active = false; - }; + target.active = false + } const onPointerDown = (e: PointerEvent) => { - if (reduced) return; - const { fx, fy, inside } = toCanvasPx(e.clientX, e.clientY); - if (!inside) return; - const slot = clickPositions[clickIx]; - if (slot) slot.set(fx, fy); - clickTimes[clickIx] = uniforms.uTime.value; - clickIx = (clickIx + 1) % MAX_CLICKS; - }; - - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseleave", onLeave); - window.addEventListener("blur", onLeave); - window.addEventListener("pointerdown", onPointerDown); - - let raf = 0; - let lastFrame = 0; + if (reduced) return + const { fx, fy, inside } = toCanvasPx(e.clientX, e.clientY) + if (!inside) return + const slot = clickPositions[clickIx] + if (slot) slot.set(fx, fy) + clickTimes[clickIx] = uniforms.uTime.value + clickIx = (clickIx + 1) % MAX_CLICKS + } + + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseleave', onLeave) + window.addEventListener('blur', onLeave) + window.addEventListener('pointerdown', onPointerDown) + + let raf = 0 + let lastFrame = 0 const animate = (now: number) => { if (!visible) { - lastFrame = now; - raf = requestAnimationFrame(animate); - return; + lastFrame = now + raf = requestAnimationFrame(animate) + return } - const dt = lastFrame ? Math.min(now - lastFrame, 100) : 16; - lastFrame = now; + const dt = lastFrame ? Math.min(now - lastFrame, 100) : 16 + lastFrame = now // Exponential smoothing toward the target so the lift trails the // cursor naturally instead of snapping. - const damping = 1 - Math.exp(-dt / CURSOR_RESPONSE_MS); - smooth.x += (target.x - smooth.x) * damping; - smooth.y += (target.y - smooth.y) * damping; - uniforms.uCursor.value.set( - target.active ? smooth.x : -9999, - target.active ? smooth.y : -9999, - ); - - uniforms.uTime.value = - timeOffset + clock.getElapsedTime() * (reduced ? 0 : FLOW_SPEED); - renderer.render(scene, camera); - raf = requestAnimationFrame(animate); - }; - raf = requestAnimationFrame(animate); + const damping = 1 - Math.exp(-dt / CURSOR_RESPONSE_MS) + smooth.x += (target.x - smooth.x) * damping + smooth.y += (target.y - smooth.y) * damping + uniforms.uCursor.value.set(target.active ? smooth.x : -9999, target.active ? smooth.y : -9999) + + uniforms.uTime.value = timeOffset + clock.getElapsedTime() * (reduced ? 0 : FLOW_SPEED) + renderer.render(scene, camera) + raf = requestAnimationFrame(animate) + } + raf = requestAnimationFrame(animate) return () => { - cancelAnimationFrame(raf); - ro.disconnect(); - io.disconnect(); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseleave", onLeave); - window.removeEventListener("blur", onLeave); - window.removeEventListener("pointerdown", onPointerDown); - quad.geometry.dispose(); - material.dispose(); - renderer.dispose(); - renderer.forceContextLoss(); - if (canvas.parentElement === container) container.removeChild(canvas); - }; - }, [resolved]); + cancelAnimationFrame(raf) + ro.disconnect() + io.disconnect() + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseleave', onLeave) + window.removeEventListener('blur', onLeave) + window.removeEventListener('pointerdown', onPointerDown) + quad.geometry.dispose() + material.dispose() + renderer.dispose() + renderer.forceContextLoss() + if (canvas.parentElement === container) container.removeChild(canvas) + } + }, [resolved]) return (
- ); + ) } diff --git a/site/src/landing/customize.tsx b/site/src/landing/customize.tsx index bdd7bdb9..46cd6511 100644 --- a/site/src/landing/customize.tsx +++ b/site/src/landing/customize.tsx @@ -1,64 +1,65 @@ -"use client"; +'use client' -import { waapi, type WAAPIAnimation } from "animejs"; -import { useEffect, useRef, useState } from "react"; -import { springs } from "./animation"; -import { useTheme } from "./useTheme"; +import { waapi, type WAAPIAnimation } from 'animejs' +import { useEffect, useRef, useState } from 'react' -const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"; +import { springs } from './animation' +import { useTheme } from './useTheme' -type ThemePreset = "Default" | "Minimal" | "Marketplace" | "Custom"; +const easeOut = 'cubic-bezier(0.23, 1, 0.32, 1)' + +type ThemePreset = 'Default' | 'Minimal' | 'Marketplace' | 'Custom' type Palette = { - bg: string; - border?: string; - rounded?: string; - headerColor: string; - metaColor: string; - skeleton: string; - buttonBg: string; - buttonText: string; - buttonRounded?: string; + bg: string + border?: string + rounded?: string + headerColor: string + metaColor: string + skeleton: string + buttonBg: string + buttonText: string + buttonRounded?: string /** Used by OrderPizzaCard's accent block; falls back to skeleton. */ - accent?: string; -}; + accent?: string +} /** Maps `Dialog.Theme` from the SDK 1:1 — the three settings the wallet dialog supports. */ type CustomTheme = { /** Hex/CSS color for the primary action (button). */ - accent: string; - radius: "none" | "small" | "medium" | "large" | "full"; - scheme: "light" | "dark"; -}; + accent: string + radius: 'none' | 'small' | 'medium' | 'large' | 'full' + scheme: 'light' | 'dark' +} // Note: the accent presets are passed as `Dialog.Theme["accent"]` to the // SDK and recorded as the literal hex value. To keep that contract clear // these stay as hex strings (also mirrored into styles.css as // `--swatch-*` tokens for visual reference). const ACCENT_PRESETS = [ - { id: "neutral", label: "Neutral", color: "#71717a" }, - { id: "blue", label: "Blue", color: "#3b82f6" }, - { id: "red", label: "Red", color: "#eb0000" }, - { id: "amber", label: "Amber", color: "#f59e0b" }, - { id: "green", label: "Green", color: "#22c55e" }, - { id: "purple", label: "Purple", color: "#a855f7" }, -] as const; + { id: 'neutral', label: 'Neutral', color: '#71717a' }, + { id: 'blue', label: 'Blue', color: '#3b82f6' }, + { id: 'red', label: 'Red', color: '#eb0000' }, + { id: 'amber', label: 'Amber', color: '#f59e0b' }, + { id: 'green', label: 'Green', color: '#22c55e' }, + { id: 'purple', label: 'Purple', color: '#a855f7' }, +] as const const RADIUS_OPTIONS = [ - { id: "none", label: "None", px: "0px" }, - { id: "small", label: "Sm", px: "4px" }, - { id: "medium", label: "Md", px: "12px" }, - { id: "large", label: "Lg", px: "24px" }, - { id: "full", label: "Full", px: "9999px" }, -] as const; - -const RADIUS_BY_ID: Record = { - none: "0px", - small: "4px", - medium: "12px", - large: "24px", - full: "9999px", -}; + { id: 'none', label: 'None', px: '0px' }, + { id: 'small', label: 'Sm', px: '4px' }, + { id: 'medium', label: 'Md', px: '12px' }, + { id: 'large', label: 'Lg', px: '24px' }, + { id: 'full', label: 'Full', px: '9999px' }, +] as const + +const RADIUS_BY_ID: Record = { + none: '0px', + small: '4px', + medium: '12px', + large: '24px', + full: '9999px', +} // The preview palettes describe what the *SDK dialog* looks like, so // they're intentionally theme-invariant (the page's light/dark toggle @@ -66,131 +67,121 @@ const RADIUS_BY_ID: Record = { // kept in styles.css as `--preview-*` tokens so the catalogue stays // single-sourced. function customPalette(theme: CustomTheme): Palette { - const radius = RADIUS_BY_ID[theme.radius]; + const radius = RADIUS_BY_ID[theme.radius] // The SDK's `full` preset is designed for small elements (buttons, // pills). Applying it to a 358×384 card renders a giant stadium — // not representative of the SDK's intent. Cap card rounded at a // sensible value, while the small button stays fully round. - const cardRadius = theme.radius === "full" ? "32px" : radius; - if (theme.scheme === "dark") { + const cardRadius = theme.radius === 'full' ? '32px' : radius + if (theme.scheme === 'dark') { return { - bg: "var(--preview-dark-bg)", - border: "var(--preview-dark-border)", + bg: 'var(--preview-dark-bg)', + border: 'var(--preview-dark-border)', rounded: cardRadius, - headerColor: "var(--preview-dark-header)", - metaColor: "var(--preview-dark-meta)", - skeleton: "var(--preview-dark-skeleton)", + headerColor: 'var(--preview-dark-header)', + metaColor: 'var(--preview-dark-meta)', + skeleton: 'var(--preview-dark-skeleton)', buttonBg: theme.accent, - buttonText: "var(--preview-button-text)", + buttonText: 'var(--preview-button-text)', buttonRounded: radius, - }; + } } return { - bg: "var(--preview-light-bg)", - border: "var(--preview-light-border)", + bg: 'var(--preview-light-bg)', + border: 'var(--preview-light-border)', rounded: cardRadius, - headerColor: "var(--preview-light-header)", - metaColor: "var(--preview-light-meta)", - skeleton: "var(--preview-light-skeleton)", + headerColor: 'var(--preview-light-header)', + metaColor: 'var(--preview-light-meta)', + skeleton: 'var(--preview-light-skeleton)', buttonBg: theme.accent, - buttonText: "var(--preview-button-text)", + buttonText: 'var(--preview-button-text)', buttonRounded: radius, - }; + } } -const NATIVE: Record< - "orderPizza" | "balancesDark" | "clearInvoice" | "reload", - Palette -> = { +const NATIVE: Record<'orderPizza' | 'balancesDark' | 'clearInvoice' | 'reload', Palette> = { orderPizza: { - bg: "var(--preview-pizza-bg)", - border: "var(--preview-pizza-border)", - rounded: "24px", - headerColor: "var(--preview-pizza-header)", - metaColor: "var(--preview-pizza-meta)", - skeleton: "var(--preview-pizza-skeleton)", - buttonBg: "var(--preview-pizza-button-bg)", - buttonText: "var(--preview-button-text)", - buttonRounded: "9999px", + bg: 'var(--preview-pizza-bg)', + border: 'var(--preview-pizza-border)', + rounded: '24px', + headerColor: 'var(--preview-pizza-header)', + metaColor: 'var(--preview-pizza-meta)', + skeleton: 'var(--preview-pizza-skeleton)', + buttonBg: 'var(--preview-pizza-button-bg)', + buttonText: 'var(--preview-button-text)', + buttonRounded: '9999px', }, balancesDark: { - bg: "var(--preview-dark-bg)", - border: "var(--preview-dark-border)", - headerColor: "var(--preview-dark-header)", - metaColor: "var(--preview-dark-meta)", - skeleton: "var(--preview-dark-skeleton)", - buttonBg: "var(--preview-dark-button-bg)", - buttonText: "var(--preview-button-text)", + bg: 'var(--preview-dark-bg)', + border: 'var(--preview-dark-border)', + headerColor: 'var(--preview-dark-header)', + metaColor: 'var(--preview-dark-meta)', + skeleton: 'var(--preview-dark-skeleton)', + buttonBg: 'var(--preview-dark-button-bg)', + buttonText: 'var(--preview-button-text)', }, clearInvoice: { - bg: "var(--preview-invoice-bg)", - headerColor: "var(--preview-invoice-header)", - metaColor: "var(--preview-invoice-meta)", - skeleton: "var(--preview-invoice-skeleton)", - buttonBg: "var(--preview-invoice-button-bg)", - buttonText: "var(--preview-button-text)", + bg: 'var(--preview-invoice-bg)', + headerColor: 'var(--preview-invoice-header)', + metaColor: 'var(--preview-invoice-meta)', + skeleton: 'var(--preview-invoice-skeleton)', + buttonBg: 'var(--preview-invoice-button-bg)', + buttonText: 'var(--preview-button-text)', }, reload: { - bg: "var(--preview-reload-bg)", - border: "var(--preview-reload-border)", - headerColor: "var(--preview-reload-header)", - metaColor: "var(--preview-reload-meta)", - skeleton: "var(--preview-reload-skeleton)", - buttonBg: "var(--preview-reload-button-bg)", - buttonText: "var(--preview-button-text)", + bg: 'var(--preview-reload-bg)', + border: 'var(--preview-reload-border)', + headerColor: 'var(--preview-reload-header)', + metaColor: 'var(--preview-reload-meta)', + skeleton: 'var(--preview-reload-skeleton)', + buttonBg: 'var(--preview-reload-button-bg)', + buttonText: 'var(--preview-button-text)', }, -}; +} const MINIMAL: Palette = { - bg: "var(--preview-invoice-bg)", - headerColor: "var(--preview-invoice-header)", - metaColor: "var(--preview-invoice-meta)", - skeleton: "var(--preview-invoice-skeleton)", - buttonBg: "var(--preview-invoice-button-bg)", - buttonText: "var(--preview-button-text)", -}; + bg: 'var(--preview-invoice-bg)', + headerColor: 'var(--preview-invoice-header)', + metaColor: 'var(--preview-invoice-meta)', + skeleton: 'var(--preview-invoice-skeleton)', + buttonBg: 'var(--preview-invoice-button-bg)', + buttonText: 'var(--preview-button-text)', +} const MARKETPLACE: Palette = { - bg: "var(--preview-pizza-bg)", - border: "var(--preview-pizza-border)", - rounded: "24px", - headerColor: "var(--preview-pizza-header)", - metaColor: "var(--preview-marketplace-meta)", - skeleton: "var(--preview-pizza-skeleton)", - buttonBg: "var(--preview-pizza-button-bg)", - buttonText: "var(--preview-button-text)", - buttonRounded: "9999px", -}; + bg: 'var(--preview-pizza-bg)', + border: 'var(--preview-pizza-border)', + rounded: '24px', + headerColor: 'var(--preview-pizza-header)', + metaColor: 'var(--preview-marketplace-meta)', + skeleton: 'var(--preview-pizza-skeleton)', + buttonBg: 'var(--preview-pizza-button-bg)', + buttonText: 'var(--preview-button-text)', + buttonRounded: '9999px', +} function paletteFor( preset: ThemePreset, cardKey: keyof typeof NATIVE, custom: CustomTheme, ): Palette { - if (preset === "Minimal") return MINIMAL; - if (preset === "Marketplace") return MARKETPLACE; - if (preset === "Custom") return customPalette(custom); - return NATIVE[cardKey]; + if (preset === 'Minimal') return MINIMAL + if (preset === 'Marketplace') return MARKETPLACE + if (preset === 'Custom') return customPalette(custom) + return NATIVE[cardKey] } -function Skeleton({ - className, - bg, -}: { - className?: string; - bg: string; - delay?: number; -}) { +function Skeleton({ className, bg }: { className?: string; bg: string; delay?: number }) { return ( - ); + ) } function CardShell({ @@ -198,9 +189,9 @@ function CardShell({ children, width = 358, }: { - palette: Palette; - children: React.ReactNode; - width?: number; + palette: Palette + children: React.ReactNode + width?: number }) { return (
{children}
- ); + ) } function Cta({ @@ -223,14 +214,14 @@ function Cta({ palette, full = false, }: { - label: string; - palette: Palette; - full?: boolean; + label: string + palette: Palette + full?: boolean }) { return ( - ); + ) } function OrderPizzaCard({ palette }: { palette: Palette }) { - const accent = palette.accent ?? palette.skeleton; + const accent = palette.accent ?? palette.skeleton return (
-

+

Order Pizza

-

+

View order

@@ -275,17 +260,9 @@ function OrderPizzaCard({ palette }: { palette: Palette }) { bg={palette.skeleton} delay={i * 80} /> - +
- +
))} @@ -293,7 +270,7 @@ function OrderPizzaCard({ palette }: { palette: Palette }) { - ); + ) } function BalancesCard({ palette }: { palette: Palette }) { @@ -301,16 +278,10 @@ function BalancesCard({ palette }: { palette: Palette }) {
-

+

Balances

-

+

View all

@@ -323,24 +294,16 @@ function BalancesCard({ palette }: { palette: Palette }) { bg={palette.skeleton} delay={i * 120} /> - +
- + ))}
- ); + ) } function ClearInvoiceCard({ palette }: { palette: Palette }) { @@ -348,27 +311,17 @@ function ClearInvoiceCard({ palette }: { palette: Palette }) {
-

+

Clear Invoice

-

+

Paytrie Inc

- +
{[0, 1, 2].map((i) => (
@@ -378,24 +331,16 @@ function ClearInvoiceCard({ palette }: { palette: Palette }) { bg={palette.skeleton} delay={i * 120} /> - +
- +
))}
- ); + ) } function ReloadCard({ palette }: { palette: Palette }) { @@ -403,16 +348,10 @@ function ReloadCard({ palette }: { palette: Palette }) {
-

+

Reload Card

-

+

View all

@@ -425,56 +364,43 @@ function ReloadCard({ palette }: { palette: Palette }) { bg={palette.skeleton} delay={i * 120} /> - +
- + ))}
- ); + ) } function ThemeSwitcher({ value, onChange, }: { - value: ThemePreset; - onChange: (t: ThemePreset) => void; + value: ThemePreset + onChange: (t: ThemePreset) => void }) { - const presets: ThemePreset[] = [ - "Default", - "Minimal", - "Marketplace", - "Custom", - ]; + const presets: ThemePreset[] = ['Default', 'Minimal', 'Marketplace', 'Custom'] return (
{presets.map((p) => { - const active = p === value; + const active = p === value return ( - ); + ) })}
- ); + ) } /** @@ -486,8 +412,8 @@ function CustomThemeToolbar({ theme, onChange, }: { - theme: CustomTheme; - onChange: (next: CustomTheme) => void; + theme: CustomTheme + onChange: (next: CustomTheme) => void }) { return (
@@ -498,7 +424,7 @@ function CustomThemeToolbar({
{ACCENT_PRESETS.map((a) => { - const active = theme.accent.toLowerCase() === a.color.toLowerCase(); + const active = theme.accent.toLowerCase() === a.color.toLowerCase() return (
@@ -545,18 +471,18 @@ function CustomThemeToolbar({
{RADIUS_OPTIONS.map((r) => { - const active = theme.radius === r.id; + const active = theme.radius === r.id return ( - ); + ) })}
@@ -569,113 +495,110 @@ function CustomThemeToolbar({ Scheme
- {(["light", "dark"] as const).map((s) => { - const active = theme.scheme === s; + {(['light', 'dark'] as const).map((s) => { + const active = theme.scheme === s return ( - ); + ) })}
- ); + ) } export default function Customize() { - const [theme, setTheme] = useState("Custom"); - const sectionRef = useRef(null); - const marqueeRef = useRef(null); - const marqueeAnimationRef = useRef(null); - const cardAnimationRef = useRef(null); + const [theme, setTheme] = useState('Custom') + const sectionRef = useRef(null) + const marqueeRef = useRef(null) + const marqueeAnimationRef = useRef(null) + const cardAnimationRef = useRef(null) // The Custom preset's `scheme` tracks the global site theme: it seeds // from the resolved theme on first paint and re-syncs whenever the user // flips the page's light/dark switch, so the preview always matches the // surrounding page. The toolbar's own Light/Dark toggle still lets the // user override it until the next global flip. - const { resolved } = useTheme(); + const { resolved } = useTheme() const [custom, setCustom] = useState(() => ({ - accent: "#3b82f6", - radius: "medium", + accent: '#3b82f6', + radius: 'medium', scheme: resolved, - })); + })) useEffect(() => { - setCustom((prev) => - prev.scheme === resolved ? prev : { ...prev, scheme: resolved }, - ); - }, [resolved]); + setCustom((prev) => (prev.scheme === resolved ? prev : { ...prev, scheme: resolved })) + }, [resolved]) useEffect(() => { - const el = marqueeRef.current; - if (!el) return; + const el = marqueeRef.current + if (!el) return - const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const media = window.matchMedia('(prefers-reduced-motion: reduce)') const start = () => { - marqueeAnimationRef.current?.cancel(); - marqueeAnimationRef.current = null; + marqueeAnimationRef.current?.cancel() + marqueeAnimationRef.current = null if (media.matches) { - el.style.transform = "translate3d(0, 0, 0)"; - return; + el.style.transform = 'translate3d(0, 0, 0)' + return } marqueeAnimationRef.current = waapi.animate(el, { - transform: ["translate3d(0, 0, 0)", "translate3d(-50%, 0, 0)"], + transform: ['translate3d(0, 0, 0)', 'translate3d(-50%, 0, 0)'], duration: 50_000, - ease: "linear", + ease: 'linear', loop: true, - }); - }; + }) + } - start(); - media.addEventListener("change", start); + start() + media.addEventListener('change', start) return () => { - media.removeEventListener("change", start); - marqueeAnimationRef.current?.cancel(); - marqueeAnimationRef.current = null; - }; - }, []); + media.removeEventListener('change', start) + marqueeAnimationRef.current?.cancel() + marqueeAnimationRef.current = null + } + }, []) useEffect(() => { - const section = sectionRef.current; - if (!section || window.matchMedia("(prefers-reduced-motion: reduce)").matches) - return; + const section = sectionRef.current + if (!section || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return - cardAnimationRef.current?.cancel(); + cardAnimationRef.current?.cancel() cardAnimationRef.current = waapi.animate( - [...section.querySelectorAll("[data-customize-card]")], + [...section.querySelectorAll('[data-customize-card]')], { opacity: [0.72, 1], translateY: [10, 0], delay: (_target, index) => (index % 5) * 22, ease: springs.gentle, }, - ); + ) return () => { - cardAnimationRef.current?.cancel(); - cardAnimationRef.current = null; - }; - }, [theme]); + cardAnimationRef.current?.cancel() + cardAnimationRef.current = null + } + }, [theme]) const cards = ( <> - - - - - + + + + + - ); + ) return (
match your app

- Tempo Accounts SDK ships with full control on customizability to allow - you to design embed like your native styles. + Tempo Accounts SDK ships with full control on customizability to allow you to design embed + like your native styles.

@@ -715,19 +637,19 @@ export default function Customize() { className="group mt-14 -mx-6 overflow-hidden" style={{ maskImage: - "linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%)", + 'linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%)', WebkitMaskImage: - "linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%)", + 'linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%)', }} >
{ - marqueeAnimationRef.current?.pause(); + marqueeAnimationRef.current?.pause() }} onMouseLeave={() => { - marqueeAnimationRef.current?.resume(); + marqueeAnimationRef.current?.resume() }} > {[0, 1].map((copy) => ( @@ -742,5 +664,5 @@ export default function Customize() {
- ); + ) } diff --git a/site/src/landing/demo/Demo.tsx b/site/src/landing/demo/Demo.tsx index f0f38908..861a94c4 100644 --- a/site/src/landing/demo/Demo.tsx +++ b/site/src/landing/demo/Demo.tsx @@ -1,10 +1,11 @@ -"use client"; +'use client' -import { animate, onScroll } from "animejs"; -import { useEffect, useRef, useState } from "react"; -import { springs } from "../animation"; -import { BrowserMockup } from "./components/BrowserMockup"; -import { activeSpendPermissionResult, DEMOS } from "./config"; +import { animate, onScroll } from 'animejs' +import { useEffect, useRef, useState } from 'react' + +import { springs } from '../animation' +import { BrowserMockup } from './components/BrowserMockup' +import { activeSpendPermissionResult, DEMOS } from './config' import { connectWallet, getDemoProvider, @@ -12,7 +13,7 @@ import { shorten, TEMPO_MAINNET_CHAIN_ID, TEMPO_MODERATO_CHAIN_ID, -} from "./sdk"; +} from './sdk' import type { AccountStatus, AccountsProvider, @@ -20,7 +21,7 @@ import type { DemoKind, DemoResult, Status, -} from "./types"; +} from './types' // Privy is intentionally NOT wired in V1. // React 19's dev-mode component logging (logComponentRender) walks all props, @@ -32,79 +33,77 @@ import type { // cross-origin window refs on a React-walkable path. type Connected = { - address: `0x${string}`; - balanceDisplay: string; - balance: bigint; -}; + address: `0x${string}` + balanceDisplay: string + balance: bigint +} -const ACTION_TIMEOUT_MS = 20_000; -const DEPOSIT_BALANCE_ATTEMPTS = 40; -const DEPOSIT_BALANCE_INTERVAL_MS = 1500; +const ACTION_TIMEOUT_MS = 20_000 +const DEPOSIT_BALANCE_ATTEMPTS = 40 +const DEPOSIT_BALANCE_INTERVAL_MS = 1500 /** Scale when the demo box first enters from the bottom of the viewport. */ -const SCROLL_START_SCALE = 0.92; +const SCROLL_START_SCALE = 0.92 /** Max translateY (px, upward) applied at full progress. */ -const SCROLL_LIFT_PX = 60; +const SCROLL_LIFT_PX = 60 -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const balanceChainIdForDemo = (demo: DemoKind) => - demo === "Add Funds" ? TEMPO_MAINNET_CHAIN_ID : TEMPO_MODERATO_CHAIN_ID; + demo === 'Add Funds' ? TEMPO_MAINNET_CHAIN_ID : TEMPO_MODERATO_CHAIN_ID const cancelPendingRequests = () => { - clearWalletSurfaces(); -}; + clearWalletSurfaces() +} const clearWalletSurfaces = () => { - if (typeof document === "undefined") return; + if (typeof document === 'undefined') return for (const dialog of document.querySelectorAll( 'dialog[data-tempo-wallet-postmessage], dialog[data-tempo-wallet]', )) { - dialog.remove(); + dialog.remove() } - document.body.style.overflow = ""; + document.body.style.overflow = '' for (const sibling of Array.from(document.body.children)) { - if (sibling.hasAttribute("inert")) sibling.removeAttribute("inert"); + if (sibling.hasAttribute('inert')) sibling.removeAttribute('inert') } -}; +} const timeout = (ms: number) => new Promise<{ __timeout: true }>((resolve) => { - setTimeout(() => resolve({ __timeout: true }), ms); - }); + setTimeout(() => resolve({ __timeout: true }), ms) + }) const formatUsd = (balance: bigint) => - new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(Number(balance) / 1_000_000); + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(Number(balance) / 1_000_000) const parseBalance = (balance: unknown) => { - if (typeof balance === "bigint") return balance; - if (typeof balance === "string") { + if (typeof balance === 'bigint') return balance + if (typeof balance === 'string') { try { - return BigInt(balance); + return BigInt(balance) } catch { - return 0n; + return 0n } } - return 0n; -}; + return 0n +} export default function Demo() { - const adapter: Adapter = "tempoAuth"; - const boxRef = useRef(null); + const adapter: Adapter = 'tempoAuth' + const boxRef = useRef(null) useEffect(() => { - const el = boxRef.current; - if (!el) return; - const reduced = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; - if (reduced) return; - - el.style.transformOrigin = "center"; - el.style.willChange = "transform"; + const el = boxRef.current + if (!el) return + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches + if (reduced) return + + el.style.transformOrigin = 'center' + el.style.willChange = 'transform' const animation = animate(el, { scale: [SCROLL_START_SCALE, 1], y: [0, -SCROLL_LIFT_PX], @@ -113,44 +112,43 @@ export default function Demo() { target: el, // 0 when the box enters from the viewport bottom; 1 after it has // crossed 80% of the viewport, then clamp at full size. - enter: "end start", - leave: "20% start", + enter: 'end start', + leave: '20% start', sync: true, }), - }); + }) return () => { - animation.revert(); - el.style.willChange = ""; - el.style.transformOrigin = ""; - }; - }, []); - - const [demo, setDemo] = useState("Log In"); - const [status, setStatus] = useState("idle"); - const [signInStatus, setSignInStatus] = useState("idle"); - const [result, setResult] = useState(null); - const [lastVariant, setLastVariant] = useState(null); - const [connected, setConnected] = useState(null); - const [accountStatus, setAccountStatus] = - useState("checking"); - const providerRef = useRef(null); - const activeDemoRef = useRef("Log In"); - const statusRef = useRef("idle"); - const depositWatchRef = useRef(0); - - activeDemoRef.current = demo; - statusRef.current = status; + animation.revert() + el.style.willChange = '' + el.style.transformOrigin = '' + } + }, []) + + const [demo, setDemo] = useState('Log In') + const [status, setStatus] = useState('idle') + const [signInStatus, setSignInStatus] = useState('idle') + const [result, setResult] = useState(null) + const [lastVariant, setLastVariant] = useState(null) + const [connected, setConnected] = useState(null) + const [accountStatus, setAccountStatus] = useState('checking') + const providerRef = useRef(null) + const activeDemoRef = useRef('Log In') + const statusRef = useRef('idle') + const depositWatchRef = useRef(0) + + activeDemoRef.current = demo + statusRef.current = status const selectDemo = (next: DemoKind) => { - cancelPendingRequests(); - depositWatchRef.current += 1; - setDemo(next); - setStatus("idle"); - setSignInStatus("idle"); - setResult(null); - setLastVariant(null); - }; + cancelPendingRequests() + depositWatchRef.current += 1 + setDemo(next) + setStatus('idle') + setSignInStatus('idle') + setResult(null) + setLastVariant(null) + } const refreshBalance = async ( provider: AccountsProvider, @@ -162,183 +160,177 @@ export default function Demo() { account: address, tokens: [PATH_USD], ...(options.chainId === undefined ? {} : { chainId: options.chainId }), - }; + } const balances = (await provider.request({ - method: "wallet_getBalances", + method: 'wallet_getBalances', params: [params], } as Parameters[0])) as ReadonlyArray<{ - balance?: `0x${string}` | bigint | undefined; - display?: string | undefined; - }>; - const native = balances?.[0]; - const balance = parseBalance(native?.balance); + balance?: `0x${string}` | bigint | undefined + display?: string | undefined + }> + const native = balances?.[0] + const balance = parseBalance(native?.balance) const next = { address, balanceDisplay: native?.display ?? formatUsd(balance), balance, - }; - setConnected(next); - return next; + } + setConnected(next) + return next } catch { - const next = { address, balanceDisplay: "$0.00", balance: 0n }; - setConnected(next); - return next; + const next = { address, balanceDisplay: '$0.00', balance: 0n } + setConnected(next) + return next } - }; + } const onDisconnect = async () => { - cancelPendingRequests(); - depositWatchRef.current += 1; + cancelPendingRequests() + depositWatchRef.current += 1 try { - const provider = providerRef.current; + const provider = providerRef.current if (provider) { - await provider.request({ method: "wallet_disconnect" } as Parameters< + await provider.request({ method: 'wallet_disconnect' } as Parameters< typeof provider.request - >[0]); + >[0]) } } catch { // ignore — clear local state regardless so the UI doesn't lock up. } - setConnected(null); - setAccountStatus("disconnected"); - setStatus("idle"); - setSignInStatus("idle"); - setResult(null); - setLastVariant(null); - }; + setConnected(null) + setAccountStatus('disconnected') + setStatus('idle') + setSignInStatus('idle') + setResult(null) + setLastVariant(null) + } const getProvider = async () => { - providerRef.current ??= await getDemoProvider(); - return providerRef.current; - }; + providerRef.current ??= await getDemoProvider() + return providerRef.current + } // Hydrate the connector's persisted session. Only the Log In demo gets a // "Signed in" result line; other demos stay idle and show account state in // the browser chrome. useEffect(() => { - let cancelled = false; - const currentDemo = demo; + let cancelled = false + const currentDemo = demo const hydrate = async () => { try { - const provider = await getProvider(); + const provider = await getProvider() // Small delay so zustand persist middleware finishes hydrating. - await new Promise((r) => setTimeout(r, 150)); + await new Promise((r) => setTimeout(r, 150)) const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - if (cancelled || activeDemoRef.current !== currentDemo) return; - const addr = accounts?.[0]; + method: 'eth_accounts', + })) as readonly `0x${string}`[] + if (cancelled || activeDemoRef.current !== currentDemo) return + const addr = accounts?.[0] if (addr) { await refreshBalance(provider, addr, { chainId: balanceChainIdForDemo(currentDemo), - }); - if (cancelled || activeDemoRef.current !== currentDemo) return; - setAccountStatus("connected"); - if (currentDemo === "Log In" && statusRef.current !== "running") { - setResult({ summary: `Signed in · ${shorten(addr)}` }); - setStatus("done"); - setLastVariant(null); + }) + if (cancelled || activeDemoRef.current !== currentDemo) return + setAccountStatus('connected') + if (currentDemo === 'Log In' && statusRef.current !== 'running') { + setResult({ summary: `Signed in · ${shorten(addr)}` }) + setStatus('done') + setLastVariant(null) } - if ( - currentDemo === "Spend Permissions" && - statusRef.current !== "running" - ) { - const permissionResult = activeSpendPermissionResult( - provider, - addr, - ); + if (currentDemo === 'Spend Permissions' && statusRef.current !== 'running') { + const permissionResult = activeSpendPermissionResult(provider, addr) if (permissionResult) { - setResult(permissionResult); - setStatus("done"); - setLastVariant(null); + setResult(permissionResult) + setStatus('done') + setLastVariant(null) } } } else { - setConnected(null); - setAccountStatus("disconnected"); + setConnected(null) + setAccountStatus('disconnected') } } catch { if (!cancelled) { - setConnected(null); - setAccountStatus("disconnected"); + setConnected(null) + setAccountStatus('disconnected') } } - }; - void hydrate(); + } + void hydrate() return () => { - cancelled = true; - }; - }, [demo]); + cancelled = true + } + }, [demo]) // Suppress the React-19 dev-overlay SecurityError that fires when // `logComponentRender` walks props that touch the wallet iframe's // cross-origin contentWindow. It's a dev-only artifact; functionality // is unaffected. Strips noise so the user can keep clicking through demos. useEffect(() => { - if (!import.meta.env.DEV) return; + if (!import.meta.env.DEV) return const onError = (e: ErrorEvent) => { - const msg = e.message ?? e.error?.message ?? ""; + const msg = e.message ?? e.error?.message ?? '' if ( - typeof msg === "string" && - msg.includes("Blocked a frame") && - msg.includes("cross-origin") + typeof msg === 'string' && + msg.includes('Blocked a frame') && + msg.includes('cross-origin') ) { - e.stopImmediatePropagation(); - e.preventDefault(); + e.stopImmediatePropagation() + e.preventDefault() } - }; - window.addEventListener("error", onError, true); - return () => window.removeEventListener("error", onError, true); - }, []); + } + window.addEventListener('error', onError, true) + return () => window.removeEventListener('error', onError, true) + }, []) const handleDemo = (next: DemoKind) => { - if (next === demo) return; - selectDemo(next); - }; + if (next === demo) return + selectDemo(next) + } const onSignIn = async () => { - if (signInStatus === "running") return; - setSignInStatus("running"); + if (signInStatus === 'running') return + setSignInStatus('running') try { - const provider = await getProvider(); - const address = await connectWallet(provider); - if (!address) return; + const provider = await getProvider() + const address = await connectWallet(provider) + if (!address) return await refreshBalance(provider, address, { chainId: balanceChainIdForDemo(activeDemoRef.current), - }); - setAccountStatus("connected"); + }) + setAccountStatus('connected') } catch { - cancelPendingRequests(); - if (!connected) setAccountStatus("disconnected"); + cancelPendingRequests() + if (!connected) setAccountStatus('disconnected') } finally { - setSignInStatus("idle"); + setSignInStatus('idle') } - }; + } const onAction = async (variant?: string) => { - if (status === "running") return; - const nonBlockingDeposit = demo === "Add Funds"; - setStatus(nonBlockingDeposit ? "idle" : "running"); - setLastVariant(variant ?? null); - const def = DEMOS[demo]; - const provider = await getProvider(); - if (nonBlockingDeposit) cancelPendingRequests(); - let depositBaseline = connected?.balance ?? 0n; - let depositBaselineAddress = connected?.address ?? null; + if (status === 'running') return + const nonBlockingDeposit = demo === 'Add Funds' + setStatus(nonBlockingDeposit ? 'idle' : 'running') + setLastVariant(variant ?? null) + const def = DEMOS[demo] + const provider = await getProvider() + if (nonBlockingDeposit) cancelPendingRequests() + let depositBaseline = connected?.balance ?? 0n + let depositBaselineAddress = connected?.address ?? null if (nonBlockingDeposit) { try { const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const addr = accounts?.[0]; + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const addr = accounts?.[0] if (addr) { const current = await refreshBalance(provider, addr, { chainId: TEMPO_MAINNET_CHAIN_ID, - }); - depositBaseline = current.balance; - depositBaselineAddress = addr; - setAccountStatus("connected"); + }) + depositBaseline = current.balance + depositBaselineAddress = addr + setAccountStatus('connected') } } catch { // The deposit dialog can still connect an account; the balance @@ -350,53 +342,50 @@ export default function Demo() { adapter, previousResult: result, // privy hooks intentionally omitted — see header comment. - }; - const runPromise = def.run( - provider, - variant === undefined ? ctx : { ...ctx, variant }, - ); + } + const runPromise = def.run(provider, variant === undefined ? ctx : { ...ctx, variant }) if (nonBlockingDeposit) { try { - await runPromise; - if (activeDemoRef.current !== demo) return; - setResult(null); - const watch = depositWatchRef.current + 1; - depositWatchRef.current = watch; + await runPromise + if (activeDemoRef.current !== demo) return + setResult(null) + const watch = depositWatchRef.current + 1 + depositWatchRef.current = watch void (async () => { for (let i = 0; i < DEPOSIT_BALANCE_ATTEMPTS; i += 1) { - await sleep(DEPOSIT_BALANCE_INTERVAL_MS); - if (depositWatchRef.current !== watch) return; - if (activeDemoRef.current !== "Add Funds") return; + await sleep(DEPOSIT_BALANCE_INTERVAL_MS) + if (depositWatchRef.current !== watch) return + if (activeDemoRef.current !== 'Add Funds') return try { const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const addr = accounts?.[0] ?? depositBaselineAddress; - if (!addr) continue; + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const addr = accounts?.[0] ?? depositBaselineAddress + if (!addr) continue const next = await refreshBalance(provider, addr, { chainId: TEMPO_MAINNET_CHAIN_ID, - }); - if (activeDemoRef.current !== "Add Funds") return; - setAccountStatus("connected"); + }) + if (activeDemoRef.current !== 'Add Funds') return + setAccountStatus('connected') if (next.balance > depositBaseline) { setResult({ summary: `Balance updated · ${next.balanceDisplay}`, - }); - setStatus("done"); - return; + }) + setStatus('done') + return } } catch { // Balance may lag the deposit path; keep polling. } } - })(); + })() } catch { - if (activeDemoRef.current !== demo) return; - setStatus("idle"); - setResult(null); + if (activeDemoRef.current !== demo) return + setStatus('idle') + setResult(null) } - return; + return } // Active poll: only for Log In. The SDK's wallet_connect promise @@ -404,85 +393,83 @@ export default function Demo() { // short-circuit as soon as the wallet persists the address. Other demos // must let their SDK request win, otherwise an existing account can make // a deposit/payment action incorrectly render "Signed in". - const POLL_INTERVAL_MS = 1500; - const POLL_TIMEOUT_MS = 60_000; - const shouldPollAccounts = demo === "Log In"; - let pollHandle: ReturnType | undefined; + const POLL_INTERVAL_MS = 1500 + const POLL_TIMEOUT_MS = 60_000 + const shouldPollAccounts = demo === 'Log In' + let pollHandle: ReturnType | undefined const pollPromise = shouldPollAccounts - ? new Promise<{ __polled: `0x${string}` } | { __pollTimeout: true }>( - (resolve) => { - const startedAt = Date.now(); - pollHandle = setInterval(async () => { - try { - const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const next = accounts?.[0]; - if (next) { - resolve({ __polled: next }); - } else if (Date.now() - startedAt > POLL_TIMEOUT_MS) { - resolve({ __pollTimeout: true }); - } - } catch { - // transient — keep polling + ? new Promise<{ __polled: `0x${string}` } | { __pollTimeout: true }>((resolve) => { + const startedAt = Date.now() + pollHandle = setInterval(async () => { + try { + const accounts = (await provider.request({ + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const next = accounts?.[0] + if (next) { + resolve({ __polled: next }) + } else if (Date.now() - startedAt > POLL_TIMEOUT_MS) { + resolve({ __pollTimeout: true }) } - }, POLL_INTERVAL_MS); - }, - ) - : null; + } catch { + // transient — keep polling + } + }, POLL_INTERVAL_MS) + }) + : null try { - const sdkPromise = runPromise.then((v) => ({ __sdk: v }) as const); + const sdkPromise = runPromise.then((v) => ({ __sdk: v }) as const) const winner = await Promise.race( pollPromise ? [sdkPromise, pollPromise, timeout(ACTION_TIMEOUT_MS)] : [sdkPromise, timeout(ACTION_TIMEOUT_MS)], - ); - if (pollHandle) clearInterval(pollHandle); - if (activeDemoRef.current !== demo) return; - - let nextResult: DemoResult; - if ("__sdk" in winner) { - nextResult = winner.__sdk; - } else if ("__polled" in winner) { - nextResult = { summary: `Signed in · ${shorten(winner.__polled)}` }; + ) + if (pollHandle) clearInterval(pollHandle) + if (activeDemoRef.current !== demo) return + + let nextResult: DemoResult + if ('__sdk' in winner) { + nextResult = winner.__sdk + } else if ('__polled' in winner) { + nextResult = { summary: `Signed in · ${shorten(winner.__polled)}` } } else { - if ("__timeout" in winner) cancelPendingRequests(); - setStatus("idle"); - setResult(null); - return; + if ('__timeout' in winner) cancelPendingRequests() + setStatus('idle') + setResult(null) + return } // Refresh wallet status (address + balance) after success. try { const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const addr = accounts?.[0]; + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const addr = accounts?.[0] if (addr) { await refreshBalance(provider, addr, { chainId: balanceChainIdForDemo(activeDemoRef.current), - }); - if (activeDemoRef.current === demo) setAccountStatus("connected"); + }) + if (activeDemoRef.current === demo) setAccountStatus('connected') } else { - setAccountStatus("disconnected"); + setAccountStatus('disconnected') } } catch { // ignore } - if (activeDemoRef.current !== demo) return; - setResult(nextResult); - setStatus("done"); + if (activeDemoRef.current !== demo) return + setResult(nextResult) + setStatus('done') } catch { - if (pollHandle) clearInterval(pollHandle); - cancelPendingRequests(); - if (activeDemoRef.current !== demo) return; - setStatus("idle"); - setResult(null); + if (pollHandle) clearInterval(pollHandle) + cancelPendingRequests() + if (activeDemoRef.current !== demo) return + setStatus('idle') + setResult(null) } - }; + } - const def = DEMOS[demo]; + const def = DEMOS[demo] return (
@@ -504,5 +491,5 @@ export default function Demo() { />
- ); + ) } diff --git a/site/src/landing/demo/bodies/FeeSponsorship.tsx b/site/src/landing/demo/bodies/FeeSponsorship.tsx index 228ec215..d5f76ead 100644 --- a/site/src/landing/demo/bodies/FeeSponsorship.tsx +++ b/site/src/landing/demo/bodies/FeeSponsorship.tsx @@ -1,19 +1,15 @@ -"use client"; +'use client' -import { shorten } from "../sdk"; -import type { DemoBodyProps } from "../types"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import { shorten } from '../sdk' +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' export function FeeSponsorshipBody(props: DemoBodyProps) { - const { delay, onAction, result, status } = props; - const body = useBodyAnimation(delay); - const done = status === "done"; + const { delay, onAction, result, status } = props + const body = useBodyAnimation(delay) + const done = status === 'done' const buttonLabel = - status === "running" - ? "Sending..." - : done - ? "Sponsored transfer sent" - : "Send without fees"; + status === 'running' ? 'Sending...' : done ? 'Sponsored transfer sent' : 'Send without fees' return (

No-fee transfer

-

- $1.00 -

+

$1.00

- {done ? "sponsored" : "approved"} + {done ? 'sponsored' : 'approved'}
@@ -41,16 +35,13 @@ export function FeeSponsorshipBody(props: DemoBodyProps) {
Wisselbank covers - {result?.sponsoredFee ?? "network fee"} + {result?.sponsoredFee ?? 'network fee'}
{result?.feePayer ? (
Fee payer - + {shorten(result.feePayer)}
@@ -58,7 +49,7 @@ export function FeeSponsorshipBody(props: DemoBodyProps) { - {result.summary}{" "} + {result.summary}{' '}

) : ( -

- {result.summary} -

+

{result.summary}

) ) : null} - ); + ) } diff --git a/site/src/landing/demo/bodies/LocalPayments.tsx b/site/src/landing/demo/bodies/LocalPayments.tsx index 4645877a..7dcb0bcb 100644 --- a/site/src/landing/demo/bodies/LocalPayments.tsx +++ b/site/src/landing/demo/bodies/LocalPayments.tsx @@ -1,17 +1,17 @@ -"use client"; +'use client' -import type { DemoBodyProps } from "../types"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' /** Preset deposit amounts (USD), surfaced as selectable chips in the body. */ export const DEPOSIT_AMOUNTS = [ - { id: "10", label: "$10" }, - { id: "50", label: "$50" }, - { id: "100", label: "$100" }, - { id: "500", label: "$500" }, -] as const; + { id: '10', label: '$10' }, + { id: '50', label: '$50' }, + { id: '100', label: '$100' }, + { id: '500', label: '$500' }, +] as const -export type DepositAmountId = (typeof DEPOSIT_AMOUNTS)[number]["id"]; +export type DepositAmountId = (typeof DEPOSIT_AMOUNTS)[number]['id'] export function LocalPaymentsBody({ status, @@ -23,16 +23,14 @@ export function LocalPaymentsBody({ onSelectAmount, methodLabel, }: DemoBodyProps & { - selectedAmountId: DepositAmountId; - onSelectAmount: (id: DepositAmountId) => void; - methodLabel: string; + selectedAmountId: DepositAmountId + onSelectAmount: (id: DepositAmountId) => void + methodLabel: string }) { - const body = useBodyAnimation(delay); - const selected = - DEPOSIT_AMOUNTS.find((a) => a.id === selectedAmountId) ?? - DEPOSIT_AMOUNTS[0]; + const body = useBodyAnimation(delay) + const selected = DEPOSIT_AMOUNTS.find((a) => a.id === selectedAmountId) ?? DEPOSIT_AMOUNTS[0] - const buttonLabel = `Add ${selected.label} with ${methodLabel}`; + const buttonLabel = `Add ${selected.label} with ${methodLabel}` return (

Available balance

- {connectedBalance ?? "$0.00"} + {connectedBalance ?? '$0.00'}

@@ -51,21 +49,21 @@ export function LocalPaymentsBody({

Amount

{DEPOSIT_AMOUNTS.map((a) => { - const active = a.id === selectedAmountId; + const active = a.id === selectedAmountId return ( - ); + ) })}
@@ -80,5 +78,5 @@ export function LocalPaymentsBody({

{result.summary}

) : null} - ); + ) } diff --git a/site/src/landing/demo/bodies/LogIn.tsx b/site/src/landing/demo/bodies/LogIn.tsx index f7847776..70cda92a 100644 --- a/site/src/landing/demo/bodies/LogIn.tsx +++ b/site/src/landing/demo/bodies/LogIn.tsx @@ -1,7 +1,7 @@ -"use client"; +'use client' -import type { DemoBodyProps } from "../types"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' export function LogInBody({ status, @@ -11,22 +11,22 @@ export function LogInBody({ delay, adapter, }: DemoBodyProps) { - const body = useBodyAnimation(delay); + const body = useBodyAnimation(delay) const idleCta = - adapter === "webAuth" - ? "Continue with passkey" - : adapter === "privy" - ? "Continue with Privy" - : "Continue with Tempo"; + adapter === 'webAuth' + ? 'Continue with passkey' + : adapter === 'privy' + ? 'Continue with Privy' + : 'Continue with Tempo' const description = - adapter === "webAuth" - ? "Sign in with an on-device passkey — no popup, no third-party host." - : adapter === "privy" - ? "Sign in via Privy. The SDK manages access keys after authentication." - : "Use a passkey to create or sign in to a stablecoin account."; + adapter === 'webAuth' + ? 'Sign in with an on-device passkey — no popup, no third-party host.' + : adapter === 'privy' + ? 'Sign in via Privy. The SDK manages access keys after authentication.' + : 'Use a passkey to create or sign in to a stablecoin account.' - const connected = status === "done" && Boolean(result?.summary); - const accountLabel = connected ? result?.summary : "Not connected"; + const connected = status === 'done' && Boolean(result?.summary) + const accountLabel = connected ? result?.summary : 'Not connected' return (
-

- {accountLabel} -

+

{accountLabel}

{connected ? ( @@ -59,13 +57,8 @@ export function LogInBody({ Log out ) : ( - + )} - ); + ) } diff --git a/site/src/landing/demo/bodies/OnRamp.tsx b/site/src/landing/demo/bodies/OnRamp.tsx index 39b550a4..055c7165 100644 --- a/site/src/landing/demo/bodies/OnRamp.tsx +++ b/site/src/landing/demo/bodies/OnRamp.tsx @@ -1,16 +1,10 @@ -"use client"; +'use client' -import type { DemoBodyProps } from "../types"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' -export function OnRampBody({ - status, - result, - onAction, - delay, - connectedBalance, -}: DemoBodyProps) { - const body = useBodyAnimation(delay); +export function OnRampBody({ status, result, onAction, delay, connectedBalance }: DemoBodyProps) { + const body = useBodyAnimation(delay) return (

Available balance

- {connectedBalance ?? "$0.00"} + {connectedBalance ?? '$0.00'}

- Open the wallet deposit flow with cards, Apple Pay, crypto, and X - verification. + Open the wallet deposit flow with cards, Apple Pay, crypto, and X verification.

- + {result?.summary ? (

{result.summary}

) : null} - ); + ) } diff --git a/site/src/landing/demo/bodies/PayOnce.tsx b/site/src/landing/demo/bodies/PayOnce.tsx index d1705ab0..fdb89279 100644 --- a/site/src/landing/demo/bodies/PayOnce.tsx +++ b/site/src/landing/demo/bodies/PayOnce.tsx @@ -1,13 +1,13 @@ -"use client"; +'use client' -import type { DemoBodyProps } from "../types"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' export function PayOnceBody(props: DemoBodyProps) { - const { status, result, onAction, delay } = props; - const body = useBodyAnimation(delay); - const done = status === "done"; - const buttonLabel = done ? "Transfer sent" : "Send transfer"; + const { status, result, onAction, delay } = props + const body = useBodyAnimation(delay) + const done = status === 'done' + const buttonLabel = done ? 'Transfer sent' : 'Send transfer' return (

Transfer

-

- $240 -

+

$240

Recipient - - Main account - + Main account
Memo - - Monthly transfer - + Monthly transfer
- {result.summary}{" "} + {result.summary}{' '}
- ); + ) } diff --git a/site/src/landing/demo/bodies/Receive.tsx b/site/src/landing/demo/bodies/Receive.tsx index b151bcb5..67f52755 100644 --- a/site/src/landing/demo/bodies/Receive.tsx +++ b/site/src/landing/demo/bodies/Receive.tsx @@ -1,9 +1,10 @@ -"use client"; +'use client' -import { useEffect, useRef, useState } from "react"; -import { InteractiveQr } from "../../sections/InteractiveQr"; -import { shorten } from "../sdk"; -import { useBodyAnimation } from "./shared"; +import { useEffect, useRef, useState } from 'react' + +import { InteractiveQr } from '../../sections/InteractiveQr' +import { shorten } from '../sdk' +import { useBodyAnimation } from './shared' /** * Pre-connect placeholder address. The Tempo wallet exposes a single @@ -12,41 +13,34 @@ import { useBodyAnimation } from "./shared"; * placeholder so the section still renders end-to-end (QR, copy * button) without a connected user. */ -export const RECEIVE_FALLBACK_ADDRESS = - "0x16214C64fa1230b8DDc4F8e29D7AdAfee8b0B171" as const; +export const RECEIVE_FALLBACK_ADDRESS = '0x16214C64fa1230b8DDc4F8e29D7AdAfee8b0B171' as const -export function ReceiveBody({ - address, - delay, -}: { - address: `0x${string}`; - delay: number; -}) { - const body = useBodyAnimation(delay); - const [copied, setCopied] = useState(false); - const copyTimer = useRef | null>(null); +export function ReceiveBody({ address, delay }: { address: `0x${string}`; delay: number }) { + const body = useBodyAnimation(delay) + const [copied, setCopied] = useState(false) + const copyTimer = useRef | null>(null) useEffect( () => () => { - if (copyTimer.current) clearTimeout(copyTimer.current); + if (copyTimer.current) clearTimeout(copyTimer.current) }, [], - ); + ) const onCopy = async () => { try { - await navigator.clipboard.writeText(address); - setCopied(true); - if (copyTimer.current) clearTimeout(copyTimer.current); - copyTimer.current = setTimeout(() => setCopied(false), 1200); + await navigator.clipboard.writeText(address) + setCopied(true) + if (copyTimer.current) clearTimeout(copyTimer.current) + copyTimer.current = setTimeout(() => setCopied(false), 1200) } catch { // ignore } - }; + } // EIP-681 payment URI so crypto-aware scanners recognise the payload. // Generic scanners fall back to the raw URI as text. - const qrValue = `ethereum:${address}`; + const qrValue = `ethereum:${address}` return (
Account - - {shorten(address)} - + {shorten(address)}
@@ -75,8 +67,8 @@ export function ReceiveBody({ onClick={onCopy} className="flex h-11 w-full items-center justify-center bg-accent px-4 text-[14px] text-on-accent outline-none hover:bg-accent-hover active:bg-accent-active focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-info focus-visible:outline-offset-2" > - {copied ? "Copied" : "Copy address"} + {copied ? 'Copied' : 'Copy address'} - ); + ) } diff --git a/site/src/landing/demo/bodies/Send.tsx b/site/src/landing/demo/bodies/Send.tsx index ae14d97e..5c203b09 100644 --- a/site/src/landing/demo/bodies/Send.tsx +++ b/site/src/landing/demo/bodies/Send.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import type { DemoBodyProps } from "../types"; -import { shorten } from "../sdk"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import { shorten } from '../sdk' +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' /** * Curated, display-only destinations. The actual on-chain `wallet_send` @@ -12,26 +12,26 @@ import { PrimaryButton, useBodyAnimation } from "./shared"; */ export const DESTINATIONS = [ { - id: "coffee", - label: "Coffee Cart", - address: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEbb", - memo: "Latte", + id: 'coffee', + label: 'Coffee Cart', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEbb', + memo: 'Latte', }, { - id: "alex", - label: "Gavin Belson", - address: "0x9c12Cf3F40d8b07816e7dDA3b18BcDbF6E0B6271", - memo: "Hooli sub", + id: 'alex', + label: 'Gavin Belson', + address: '0x9c12Cf3F40d8b07816e7dDA3b18BcDbF6E0B6271', + memo: 'Hooli sub', }, { - id: "invoice", - label: "Pearson Spectre", - address: "0x4dCe5DD53d65d12C09D6f7c1Dc9B0d7C2b15A7B0", - memo: "Invoice #482", + id: 'invoice', + label: 'Pearson Spectre', + address: '0x4dCe5DD53d65d12C09D6f7c1Dc9B0d7C2b15A7B0', + memo: 'Invoice #482', }, -] as const; +] as const -export type DestinationId = (typeof DESTINATIONS)[number]["id"]; +export type DestinationId = (typeof DESTINATIONS)[number]['id'] export function SendBody({ status, @@ -42,18 +42,14 @@ export function SendBody({ selectedId, onSelect, }: DemoBodyProps & { - selectedId: DestinationId; - onSelect: (id: DestinationId) => void; + selectedId: DestinationId + onSelect: (id: DestinationId) => void }) { - const body = useBodyAnimation(delay); - const dest = DESTINATIONS.find((d) => d.id === selectedId) ?? DESTINATIONS[0]; + const body = useBodyAnimation(delay) + const dest = DESTINATIONS.find((d) => d.id === selectedId) ?? DESTINATIONS[0] const buttonLabel = - status === "running" - ? "Sending…" - : status === "done" - ? "Sent" - : `Send $0.01 to ${dest.label}`; + status === 'running' ? 'Sending…' : status === 'done' ? 'Sent' : `Send $0.01 to ${dest.label}` return (

Available balance

- {connectedBalance ?? "$0.00"} + {connectedBalance ?? '$0.00'}

@@ -72,13 +68,13 @@ export function SendBody({

Saved recipients

{DESTINATIONS.map((d) => { - const active = d.id === selectedId; + const active = d.id === selectedId return ( - ); + ) })}
@@ -105,5 +101,5 @@ export function SendBody({

{result.summary}

) : null} - ); + ) } diff --git a/site/src/landing/demo/bodies/SpendPermissions.tsx b/site/src/landing/demo/bodies/SpendPermissions.tsx index d210a77a..4da06e91 100644 --- a/site/src/landing/demo/bodies/SpendPermissions.tsx +++ b/site/src/landing/demo/bodies/SpendPermissions.tsx @@ -1,69 +1,60 @@ -"use client"; +'use client' -import { waapi, type WAAPIAnimation } from "animejs"; -import { useLayoutEffect, useRef } from "react"; -import { springs } from "../../animation"; -import type { DemoBodyProps } from "../types"; -import { - PrimaryButton, - SecondaryButton, - useBodyAnimation, -} from "./shared"; +import { waapi, type WAAPIAnimation } from 'animejs' +import { useLayoutEffect, useRef } from 'react' + +import { springs } from '../../animation' +import type { DemoBodyProps } from '../types' +import { PrimaryButton, SecondaryButton, useBodyAnimation } from './shared' export function SpendPermissionsBody(props: DemoBodyProps) { - const { status, result, lastVariant, onAction, delay } = props; - const used = result?.progressValue ?? 0; - const cap = result?.progressMax ?? 5; - const pct = Math.min(100, (used / cap) * 100); - const body = useBodyAnimation(delay); - const fillRef = useRef(null); - const pctRef = useRef(0); - const activePermission = result?.permissionState === "active"; - const removedPermission = result?.permissionState === "removed"; - const canSend = - activePermission && - status === "done" && - result?.complete === false && - used < cap; - const done = status === "done" && result?.complete !== false; - const nextPayment = Math.min(used + 1, cap); - const expiresAt = result?.permissionExpiresAt - ? new Date(result.permissionExpiresAt * 1000) - : null; - const transactions = result?.transactions ?? []; + const { status, result, lastVariant, onAction, delay } = props + const used = result?.progressValue ?? 0 + const cap = result?.progressMax ?? 5 + const pct = Math.min(100, (used / cap) * 100) + const body = useBodyAnimation(delay) + const fillRef = useRef(null) + const pctRef = useRef(0) + const activePermission = result?.permissionState === 'active' + const removedPermission = result?.permissionState === 'removed' + const canSend = activePermission && status === 'done' && result?.complete === false && used < cap + const done = status === 'done' && result?.complete !== false + const nextPayment = Math.min(used + 1, cap) + const expiresAt = result?.permissionExpiresAt ? new Date(result.permissionExpiresAt * 1000) : null + const transactions = result?.transactions ?? [] useLayoutEffect(() => { - const el = fillRef.current; - if (!el) return; + const el = fillRef.current + if (!el) return - const previousPct = pctRef.current; - pctRef.current = pct; + const previousPct = pctRef.current + pctRef.current = pct - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - el.style.transform = `scaleX(${pct / 100})`; - return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + el.style.transform = `scaleX(${pct / 100})` + return } const animation: WAAPIAnimation = waapi.animate(el, { scaleX: [previousPct / 100, pct / 100], ease: springs.progress, - }); + }) return () => { - animation.cancel(); - }; - }, [pct]); + animation.cancel() + } + }, [pct]) const buttonLabel = (() => { - if (status === "running") - return lastVariant?.startsWith("spend") || lastVariant === "again" - ? "Paying..." - : "Approving..."; - if (canSend) return "Pay $0.01"; - if (done) return "Limit reached"; - if (removedPermission) return "Approve rule again"; - return "Approve spending rule"; - })(); + if (status === 'running') + return lastVariant?.startsWith('spend') || lastVariant === 'again' + ? 'Paying...' + : 'Approving...' + if (canSend) return 'Pay $0.01' + if (done) return 'Limit reached' + if (removedPermission) return 'Approve rule again' + return 'Approve spending rule' + })() return (
-

- Micro-payments -

+

Micro-payments

{used.toLocaleString()} - {" / "} + {' / '} {cap.toLocaleString()}

@@ -91,65 +80,55 @@ export function SpendPermissionsBody(props: DemoBodyProps) {

- Set a spending limit once. Matching payments can run in the background - until the limit or expiry is reached. + Set a spending limit once. Matching payments can run in the background until the limit or + expiry is reached.

{activePermission ? (

Budget

-

- Remaining -

+

Remaining

- {result?.permissionLimit ?? "$1.00"} + {result?.permissionLimit ?? '$1.00'}

- {result?.permissionRemaining ?? "$1.00"} + {result?.permissionRemaining ?? '$1.00'}

- {result?.permissionSpent - ? `${result.permissionSpent} used` - : "$0.00 used"} - {" · "} + {result?.permissionSpent ? `${result.permissionSpent} used` : '$0.00 used'} + {' · '} {expiresAt ? `valid until ${expiresAt.toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", + hour: 'numeric', + minute: '2-digit', })}` - : "valid for 24h"} + : 'valid for 24h'}

) : null} onAction(canSend ? `spend:${nextPayment}` : "authorize")} + onClick={() => onAction(canSend ? `spend:${nextPayment}` : 'authorize')} className="h-11 w-full" /> {activePermission ? ( onAction("revoke")} + label={status === 'running' && lastVariant === 'revoke' ? 'Removing...' : 'Revoke rule'} + status={status === 'running' ? 'running' : 'idle'} + onClick={() => onAction('revoke')} className="h-9 w-full" /> ) : null} {result?.summary ? ( -

- {result.summary} -

+

{result.summary}

) : null} {transactions.length > 0 ? (
@@ -170,5 +149,5 @@ export function SpendPermissionsBody(props: DemoBodyProps) {
) : null}
- ); + ) } diff --git a/site/src/landing/demo/bodies/Subscribe.tsx b/site/src/landing/demo/bodies/Subscribe.tsx index 3a420b3e..aebdb569 100644 --- a/site/src/landing/demo/bodies/Subscribe.tsx +++ b/site/src/landing/demo/bodies/Subscribe.tsx @@ -1,57 +1,55 @@ -"use client"; +'use client' -import { useEffect, useRef, useState } from "react"; -import { shorten } from "../sdk"; -import type { DemoBodyProps } from "../types"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import { useEffect, useRef, useState } from 'react' + +import { shorten } from '../sdk' +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' export function SubscribeBody(props: DemoBodyProps) { - const { status, result, onAction, lastVariant, delay } = props; - const body = useBodyAnimation(delay); - const [now, setNow] = useState(() => Date.now()); - const autoCollectAtRef = useRef(undefined); - const subscriptionId = result?.subscriptionId; - const receipts = result?.subscriptionReceipts ?? []; - const cancelled = result?.subscriptionState === "cancelled"; - const charging = status === "running" && lastVariant === "collect"; - const nextCollectAt = result?.subscriptionNextCollectAt; + const { status, result, onAction, lastVariant, delay } = props + const body = useBodyAnimation(delay) + const [now, setNow] = useState(() => Date.now()) + const autoCollectAtRef = useRef(undefined) + const subscriptionId = result?.subscriptionId + const receipts = result?.subscriptionReceipts ?? [] + const cancelled = result?.subscriptionState === 'cancelled' + const charging = status === 'running' && lastVariant === 'collect' + const nextCollectAt = result?.subscriptionNextCollectAt const secondsUntilCollect = - nextCollectAt === undefined - ? undefined - : Math.max(0, Math.ceil((nextCollectAt - now) / 1000)); + nextCollectAt === undefined ? undefined : Math.max(0, Math.ceil((nextCollectAt - now) / 1000)) const waiting = Boolean(subscriptionId) && !cancelled && secondsUntilCollect !== undefined && - secondsUntilCollect > 0; - const canCollect = Boolean(subscriptionId) && !cancelled && !waiting; + secondsUntilCollect > 0 + const canCollect = Boolean(subscriptionId) && !cancelled && !waiting const buttonLabel = (() => { - if (status === "running" && lastVariant === "cancel") return "Cancelling..."; - if (status === "running" && !subscriptionId) return "Subscribing..."; - if (cancelled) return "Subscription cancelled"; - if (subscriptionId) return "Cancel subscription"; - return "Start subscription"; - })(); + if (status === 'running' && lastVariant === 'cancel') return 'Cancelling...' + if (status === 'running' && !subscriptionId) return 'Subscribing...' + if (cancelled) return 'Subscription cancelled' + if (subscriptionId) return 'Cancel subscription' + return 'Start subscription' + })() useEffect(() => { - if (!subscriptionId) return; - const interval = window.setInterval(() => setNow(Date.now()), 500); - return () => window.clearInterval(interval); - }, [subscriptionId]); + if (!subscriptionId) return + const interval = window.setInterval(() => setNow(Date.now()), 500) + return () => window.clearInterval(interval) + }, [subscriptionId]) useEffect(() => { - autoCollectAtRef.current = undefined; - }, [subscriptionId]); + autoCollectAtRef.current = undefined + }, [subscriptionId]) useEffect(() => { - if (!subscriptionId || cancelled || !canCollect || status === "running") - return; - if (nextCollectAt === undefined) return; - if (autoCollectAtRef.current === nextCollectAt) return; - autoCollectAtRef.current = nextCollectAt; - const timeout = window.setTimeout(() => onAction("collect"), 250); - return () => window.clearTimeout(timeout); - }, [cancelled, canCollect, nextCollectAt, onAction, status, subscriptionId]); + if (!subscriptionId || cancelled || !canCollect || status === 'running') return + if (nextCollectAt === undefined) return + if (autoCollectAtRef.current === nextCollectAt) return + autoCollectAtRef.current = nextCollectAt + const timeout = window.setTimeout(() => onAction('collect'), 250) + return () => window.clearTimeout(timeout) + }, [cancelled, canCollect, nextCollectAt, onAction, status, subscriptionId]) return (

- Approve once. Wisselbank can collect each period automatically while - the subscription stays active. + Approve once. Wisselbank can collect each period automatically while the subscription stays + active.

{subscriptionId ? (

Subscription

-

- Next collection -

+

Next collection

{shorten(subscriptionId)}

{cancelled - ? "Cancelled" + ? 'Cancelled' : charging - ? "Charging..." + ? 'Charging...' : secondsUntilCollect === undefined - ? "Pending" - : secondsUntilCollect > 0 - ? `${secondsUntilCollect}s` - : "Due"} + ? 'Pending' + : secondsUntilCollect > 0 + ? `${secondsUntilCollect}s` + : 'Due'}

) : null} - onAction( - subscriptionId ? (waiting ? "cancel" : "collect") : "subscribe", - ) - } + onClick={() => onAction(subscriptionId ? (waiting ? 'cancel' : 'collect') : 'subscribe')} className="h-11 w-full" /> {result?.summary ? ( -

- {result.summary} -

+

{result.summary}

) : null} {receipts.length > 0 ? (
@@ -138,5 +128,5 @@ export function SubscribeBody(props: DemoBodyProps) {
) : null}
- ); + ) } diff --git a/site/src/landing/demo/bodies/Themes.tsx b/site/src/landing/demo/bodies/Themes.tsx index e54c9d44..cc39d48f 100644 --- a/site/src/landing/demo/bodies/Themes.tsx +++ b/site/src/landing/demo/bodies/Themes.tsx @@ -1,53 +1,51 @@ -"use client"; +'use client' -import { useState, type CSSProperties, type KeyboardEvent } from "react"; -import type { DemoBodyProps } from "../types"; -import { useBodyAnimation } from "./shared"; +import { useState, type CSSProperties, type KeyboardEvent } from 'react' + +import type { DemoBodyProps } from '../types' +import { useBodyAnimation } from './shared' const ACCENTS = [ - { id: "neutral", label: "Neutral", color: "var(--swatch-neutral)" }, - { id: "blue", label: "Blue", color: "var(--swatch-blue)" }, - { id: "red", label: "Red", color: "var(--swatch-red)" }, - { id: "amber", label: "Amber", color: "var(--swatch-amber)" }, - { id: "green", label: "Green", color: "var(--swatch-green)" }, - { id: "purple", label: "Purple", color: "var(--swatch-purple)" }, -] as const; + { id: 'neutral', label: 'Neutral', color: 'var(--swatch-neutral)' }, + { id: 'blue', label: 'Blue', color: 'var(--swatch-blue)' }, + { id: 'red', label: 'Red', color: 'var(--swatch-red)' }, + { id: 'amber', label: 'Amber', color: 'var(--swatch-amber)' }, + { id: 'green', label: 'Green', color: 'var(--swatch-green)' }, + { id: 'purple', label: 'Purple', color: 'var(--swatch-purple)' }, +] as const const RADII = [ - { id: "none", label: "None", value: "0px" }, - { id: "small", label: "Small", value: "4px" }, - { id: "medium", label: "Medium", value: "6px" }, - { id: "large", label: "Large", value: "12px" }, -] as const; + { id: 'none', label: 'None', value: '0px' }, + { id: 'small', label: 'Small', value: '4px' }, + { id: 'medium', label: 'Medium', value: '6px' }, + { id: 'large', label: 'Large', value: '12px' }, +] as const -const SCHEMES = ["light", "dark"] as const; +const SCHEMES = ['light', 'dark'] as const -function activateOnKey( - event: KeyboardEvent, - activate: () => void, -) { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activate(); +function activateOnKey(event: KeyboardEvent, activate: () => void) { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + activate() } export function ThemesBody({ delay }: DemoBodyProps) { - const body = useBodyAnimation(delay); - const [accent, setAccent] = useState<(typeof ACCENTS)[number]>(ACCENTS[0]); - const [radius, setRadius] = useState<(typeof RADII)[number]>(RADII[1]); - const [scheme, setScheme] = useState<(typeof SCHEMES)[number]>("light"); + const body = useBodyAnimation(delay) + const [accent, setAccent] = useState<(typeof ACCENTS)[number]>(ACCENTS[0]) + const [radius, setRadius] = useState<(typeof RADII)[number]>(RADII[1]) + const [scheme, setScheme] = useState<(typeof SCHEMES)[number]>('light') const preview = - scheme === "light" + scheme === 'light' ? { - bg: "var(--preview-light-bg)", - text: "var(--preview-light-header)", - muted: "var(--preview-light-meta)", + bg: 'var(--preview-light-bg)', + text: 'var(--preview-light-header)', + muted: 'var(--preview-light-meta)', } : { - bg: "var(--preview-dark-bg)", - text: "var(--preview-dark-header)", - muted: "var(--preview-dark-meta)", - }; + bg: 'var(--preview-dark-bg)', + text: 'var(--preview-dark-header)', + muted: 'var(--preview-dark-meta)', + } return (

Embedded prompt

-

- Choose your favorite style -

+

Choose your favorite style

Tempo Wallet @@ -101,7 +98,7 @@ export function ThemesBody({ delay }: DemoBodyProps) { className="h-11 w-full bg-[var(--theme-accent)] px-5 text-[14px] text-white outline-none hover:bg-[var(--theme-accent-hover)] active:bg-[var(--theme-accent-active)]" style={{ borderRadius: radius.value, - color: "var(--theme-on-accent)", + color: 'var(--theme-on-accent)', }} > Continue with Tempo @@ -116,7 +113,7 @@ export function ThemesBody({ delay }: DemoBodyProps) {

{SCHEMES.map((item) => { - const active = item === scheme; + const active = item === scheme return ( - ); + ) })}
@@ -136,12 +133,9 @@ export function ThemesBody({ delay }: DemoBodyProps) {

Accent

-
+
{ACCENTS.map((item) => { - const active = item.id === accent.id; + const active = item.id === accent.id return ( - ); + ) })}
@@ -169,7 +163,7 @@ export function ThemesBody({ delay }: DemoBodyProps) {

{RADII.map((item) => { - const active = item.id === radius.id; + const active = item.id === radius.id return ( - ); + ) })}
- ); + ) } diff --git a/site/src/landing/demo/bodies/Trade.tsx b/site/src/landing/demo/bodies/Trade.tsx index 824af03e..99efceb0 100644 --- a/site/src/landing/demo/bodies/Trade.tsx +++ b/site/src/landing/demo/bodies/Trade.tsx @@ -1,17 +1,11 @@ -"use client"; +'use client' -import type { DemoBodyProps } from "../types"; -import { PrimaryButton, useBodyAnimation } from "./shared"; +import type { DemoBodyProps } from '../types' +import { PrimaryButton, useBodyAnimation } from './shared' function SwapArrow() { return ( - + - ); + ) } -function TokenRow({ - label, - amount, - token, -}: { - label: string; - amount: string; - token: string; -}) { +function TokenRow({ label, amount, token }: { label: string; amount: string; token: string }) { return (

{label}

-

- {amount} -

+

{amount}

{token}
- ); + ) } export function TradeBody(props: DemoBodyProps) { - const { status, result, onAction, delay } = props; - const body = useBodyAnimation(delay); - const done = status === "done"; + const { status, result, onAction, delay } = props + const body = useBodyAnimation(delay) + const done = status === 'done' const buttonLabel = - status === "running" - ? "Reviewing..." - : done - ? "Exchange submitted" - : "Review exchange"; + status === 'running' ? 'Reviewing...' : done ? 'Exchange submitted' : 'Review exchange' return (
Route - - Stablecoin DEX - + Stablecoin DEX
- ); + ) } diff --git a/site/src/landing/demo/bodies/shared.tsx b/site/src/landing/demo/bodies/shared.tsx index 07f3380d..8e36ebd7 100644 --- a/site/src/landing/demo/bodies/shared.tsx +++ b/site/src/landing/demo/bodies/shared.tsx @@ -1,117 +1,113 @@ -"use client"; +'use client' -import { waapi, type WAAPIAnimation } from "animejs"; -import { - useLayoutEffect, - useRef, - useState, - type CSSProperties, -} from "react"; -import { springs } from "../../animation"; -import type { Status } from "../types"; +import { waapi, type WAAPIAnimation } from 'animejs' +import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react' + +import { springs } from '../../animation' +import type { Status } from '../types' const bodyInitialStyle = { opacity: 0, - translate: "0 12px", - transformOrigin: "top left", - willChange: "opacity, translate", -} satisfies CSSProperties; + translate: '0 12px', + transformOrigin: 'top left', + willChange: 'opacity, translate', +} satisfies CSSProperties export function useBodyAnimation(delay: number) { - const ref = useRef(null); - const [ready, setReady] = useState(false); + const ref = useRef(null) + const [ready, setReady] = useState(false) useLayoutEffect(() => { - const el = ref.current; - setReady(false); - if (!el) return; + const el = ref.current + setReady(false) + if (!el) return - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setReady(true); - return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setReady(true) + return } - let disposed = false; + let disposed = false const animation: WAAPIAnimation = waapi.animate(el, { opacity: [0, 1], - translate: ["0 12px", "0 0"], + translate: ['0 12px', '0 0'], delay, ease: springs.entrance, - }); + }) void animation.then(() => { - if (disposed) return; - setReady(true); - }); + if (disposed) return + setReady(true) + }) return () => { - disposed = true; - animation.cancel(); - }; - }, [delay]); + disposed = true + animation.cancel() + } + }, [delay]) return { ref, style: ready ? undefined : bodyInitialStyle, - }; + } } export function PrimaryButton({ label, status, onClick, - className = "", + className = '', disabled = false, }: { - label: string; - status: Status; - onClick: () => void; - className?: string; - disabled?: boolean | undefined; + label: string + status: Status + onClick: () => void + className?: string + disabled?: boolean | undefined }) { return ( - ); + ) } export function SecondaryButton({ label, onClick, - className = "", + className = '', status, prefix, }: { - label: string; - onClick?: () => void; - className?: string; - status?: Status; - prefix?: React.ReactNode; + label: string + onClick?: () => void + className?: string + status?: Status + prefix?: React.ReactNode }) { return ( - ); + ) } export function StatusLabel({ @@ -120,12 +116,12 @@ export function StatusLabel({ runningLabel, doneLabel, }: { - status: Status; - defaultLabel: string; - runningLabel: string; - doneLabel: string; + status: Status + defaultLabel: string + runningLabel: string + doneLabel: string }) { - if (status === "running") return <>{runningLabel}; - if (status === "done") return <>{doneLabel}; - return <>{defaultLabel}; + if (status === 'running') return <>{runningLabel} + if (status === 'done') return <>{doneLabel} + return <>{defaultLabel} } diff --git a/site/src/landing/demo/components/BrowserMockup.tsx b/site/src/landing/demo/components/BrowserMockup.tsx index eeef5db0..a96ec90b 100644 --- a/site/src/landing/demo/components/BrowserMockup.tsx +++ b/site/src/landing/demo/components/BrowserMockup.tsx @@ -1,18 +1,13 @@ -"use client"; +'use client' -import { stagger, waapi, type WAAPIAnimation } from "animejs"; -import { - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type CSSProperties, -} from "react"; -import { springs } from "../../animation"; -import { LockIcon, TempoLogo } from "../../icons"; -import { PrimaryButton, useBodyAnimation } from "../bodies/shared"; -import { DEMOS, DEMO_STEPS } from "../config"; +import { stagger, waapi, type WAAPIAnimation } from 'animejs' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react' + +import { springs } from '../../animation' +import { LockIcon, TempoLogo } from '../../icons' +import { PrimaryButton, useBodyAnimation } from '../bodies/shared' +import { DEMOS, DEMO_STEPS } from '../config' +import { shorten } from '../sdk' import type { AccountStatus, Adapter, @@ -21,29 +16,21 @@ import type { DemoKind, DemoResult, Status, -} from "../types"; -import { shorten } from "../sdk"; -import { ChatBubble } from "./ChatBubble"; +} from '../types' +import { ChatBubble } from './ChatBubble' -const MESSAGE_STAGGER_MS = 70; -const MESSAGE_BODY_GAP_MS = 180; -const INITIAL_LOG_IN_DELAY_MS = 260; +const MESSAGE_STAGGER_MS = 70 +const MESSAGE_BODY_GAP_MS = 180 +const INITIAL_LOG_IN_DELAY_MS = 260 const messageInitialStyle: CSSProperties = { opacity: 0, - translate: "0 12px", - willChange: "opacity, translate", -}; + translate: '0 12px', + willChange: 'opacity, translate', +} function ChevronRight() { return ( - + - ); + ) } -function DemoGuideCallout({ - guide, - delay, -}: { - guide: DemoGuide; - delay: number; -}) { - const [copied, setCopied] = useState(false); - const [ready, setReady] = useState(false); - const copyTimer = useRef | null>(null); - const rootRef = useRef(null); +function DemoGuideCallout({ guide, delay }: { guide: DemoGuide; delay: number }) { + const [copied, setCopied] = useState(false) + const [ready, setReady] = useState(false) + const copyTimer = useRef | null>(null) + const rootRef = useRef(null) useEffect(() => { - setCopied(false); - if (!copyTimer.current) return; - clearTimeout(copyTimer.current); - copyTimer.current = null; - }, [guide.prompt]); + setCopied(false) + if (!copyTimer.current) return + clearTimeout(copyTimer.current) + copyTimer.current = null + }, [guide.prompt]) useEffect( () => () => { - if (copyTimer.current) clearTimeout(copyTimer.current); + if (copyTimer.current) clearTimeout(copyTimer.current) }, [], - ); + ) const copy = async () => { try { - await navigator.clipboard.writeText(guide.prompt); - setCopied(true); - if (copyTimer.current) clearTimeout(copyTimer.current); + await navigator.clipboard.writeText(guide.prompt) + setCopied(true) + if (copyTimer.current) clearTimeout(copyTimer.current) copyTimer.current = setTimeout(() => { - setCopied(false); - copyTimer.current = null; - }, 1400); + setCopied(false) + copyTimer.current = null + }, 1400) } catch { - setCopied(false); + setCopied(false) } - }; + } // Entrance animation runs once on mount — switching demos shouldn't // re-fade the callout, so we intentionally omit `delay`/`guide.prompt` @@ -101,32 +82,32 @@ function DemoGuideCallout({ // render, which is fine for a one-shot fade-in. // biome-ignore lint/correctness/useExhaustiveDependencies: see above useLayoutEffect(() => { - const root = rootRef.current; - if (!root) return; + const root = rootRef.current + if (!root) return - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setReady(true); - return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setReady(true) + return } - let disposed = false; + let disposed = false const animation: WAAPIAnimation = waapi.animate(root, { opacity: [0, 1], - translate: ["0 12px", "0 0"], + translate: ['0 12px', '0 0'], delay, ease: springs.entrance, - }); + }) void animation.then(() => { - if (disposed) return; - setReady(true); - }); + if (disposed) return + setReady(true) + }) return () => { - disposed = true; - animation.cancel(); - }; - }, []); + disposed = true + animation.cancel() + } + }, []) return (
Add {guide.label.toLowerCase()} to your app - + - {copied ? "Copied" : "Copy prompt"} + {copied ? 'Copied' : 'Copy prompt'}
- ); + ) } -function NextDemoMessage({ - label, - onClick, -}: { - label: string; - onClick: () => void; -}) { - const body = useBodyAnimation(0); +function NextDemoMessage({ label, onClick }: { label: string; onClick: () => void }) { + const body = useBodyAnimation(0) return (
- ); + ) } function SignInGate({ @@ -206,20 +175,20 @@ function SignInGate({ onSignIn, signInStatus, }: { - accountStatus: AccountStatus; - delay: number; - onSignIn: () => void; - signInStatus: Status; + accountStatus: AccountStatus + delay: number + onSignIn: () => void + signInStatus: Status }) { - const body = useBodyAnimation(delay); - const checking = accountStatus === "checking"; - const status = checking ? "running" : signInStatus; + const body = useBodyAnimation(delay) + const checking = accountStatus === 'checking' + const status = checking ? 'running' : signInStatus const label = - accountStatus === "checking" - ? "Checking..." - : signInStatus === "running" - ? "Signing in..." - : "Sign in"; + accountStatus === 'checking' + ? 'Checking...' + : signInStatus === 'running' + ? 'Signing in...' + : 'Sign in' return (
- ); + ) } export type ConnectedSession = { - address: `0x${string}`; - balanceDisplay: string; - balance: bigint; -}; + address: `0x${string}` + balanceDisplay: string + balance: bigint +} export function BrowserMockup({ demo, @@ -265,122 +234,116 @@ export function BrowserMockup({ onChangeDemo, onDisconnect, }: { - demo: DemoKind; - def: DemoDef; - status: Status; - signInStatus: Status; - result: DemoResult | null; - adapter: Adapter; - lastVariant: string | null; - connected: ConnectedSession | null; - accountStatus: AccountStatus; - onAction: (variant?: string) => void; - onSignIn: () => void; - onChangeDemo: (d: DemoKind) => void; - onDisconnect: () => void; + demo: DemoKind + def: DemoDef + status: Status + signInStatus: Status + result: DemoResult | null + adapter: Adapter + lastVariant: string | null + connected: ConnectedSession | null + accountStatus: AccountStatus + onAction: (variant?: string) => void + onSignIn: () => void + onChangeDemo: (d: DemoKind) => void + onDisconnect: () => void }) { - const Body = def.Body; - const preludeCount = def.prelude?.length ?? 0; + const Body = def.Body + const preludeCount = def.prelude?.length ?? 0 const demoDelayRef = useRef({ demo, - delay: demo === "Log In" ? INITIAL_LOG_IN_DELAY_MS : 0, - }); + delay: demo === 'Log In' ? INITIAL_LOG_IN_DELAY_MS : 0, + }) if (demoDelayRef.current.demo !== demo) { - demoDelayRef.current = { demo, delay: 0 }; + demoDelayRef.current = { demo, delay: 0 } } - const initialDelay = demoDelayRef.current.delay; + const initialDelay = demoDelayRef.current.delay const bodyDelay = useMemo( () => preludeCount === 0 ? initialDelay + 120 - : initialDelay + - (preludeCount - 1) * MESSAGE_STAGGER_MS + - MESSAGE_BODY_GAP_MS, + : initialDelay + (preludeCount - 1) * MESSAGE_STAGGER_MS + MESSAGE_BODY_GAP_MS, [initialDelay, preludeCount], - ); - const guideDelay = bodyDelay + MESSAGE_BODY_GAP_MS; - const rootRef = useRef(null); - const messagesRef = useRef(null); - const pressedDemoRef = useRef(null); - const [animatedMessagesDemo, setAnimatedMessagesDemo] = - useState(null); - const activeIndex = DEMO_STEPS.indexOf(demo); - const previousDemo = - DEMO_STEPS[(activeIndex - 1 + DEMO_STEPS.length) % DEMO_STEPS.length]; - const nextDemo = DEMO_STEPS[(activeIndex + 1) % DEMO_STEPS.length]; - const previousIndex = previousDemo ? DEMO_STEPS.indexOf(previousDemo) : -1; - const nextIndex = nextDemo ? DEMO_STEPS.indexOf(nextDemo) : -1; - const previousStep = - previousIndex >= 0 ? String(previousIndex + 1).padStart(2, "0") : ""; - const nextStep = nextIndex >= 0 ? String(nextIndex + 1).padStart(2, "0") : ""; - const previousLabel = previousDemo ? DEMOS[previousDemo].guide.label : ""; - const nextLabel = nextDemo ? DEMOS[nextDemo].guide.label : ""; + ) + const guideDelay = bodyDelay + MESSAGE_BODY_GAP_MS + const rootRef = useRef(null) + const messagesRef = useRef(null) + const pressedDemoRef = useRef(null) + const [animatedMessagesDemo, setAnimatedMessagesDemo] = useState(null) + const activeIndex = DEMO_STEPS.indexOf(demo) + const previousDemo = DEMO_STEPS[(activeIndex - 1 + DEMO_STEPS.length) % DEMO_STEPS.length] + const nextDemo = DEMO_STEPS[(activeIndex + 1) % DEMO_STEPS.length] + const previousIndex = previousDemo ? DEMO_STEPS.indexOf(previousDemo) : -1 + const nextIndex = nextDemo ? DEMO_STEPS.indexOf(nextDemo) : -1 + const previousStep = previousIndex >= 0 ? String(previousIndex + 1).padStart(2, '0') : '' + const nextStep = nextIndex >= 0 ? String(nextIndex + 1).padStart(2, '0') : '' + const previousLabel = previousDemo ? DEMOS[previousDemo].guide.label : '' + const nextLabel = nextDemo ? DEMOS[nextDemo].guide.label : '' const nextCtaLabel = activeIndex === DEMO_STEPS.length - 1 - ? "Restart examples" + ? 'Restart examples' : nextLabel ? `Go to ${nextLabel}` - : "Next example"; - const messageStyle = - animatedMessagesDemo === demo ? undefined : messageInitialStyle; - const needsSignIn = demo !== "Log In" && !connected; + : 'Next example' + const messageStyle = animatedMessagesDemo === demo ? undefined : messageInitialStyle + const needsSignIn = demo !== 'Log In' && !connected const changeDemo = (d: DemoKind) => { - onChangeDemo(d); - if (!window.matchMedia("(min-width: 640px)").matches) - rootRef.current?.scrollIntoView({ block: "start", inline: "nearest" }); - }; + onChangeDemo(d) + if (!window.matchMedia('(min-width: 640px)').matches) + rootRef.current?.scrollIntoView({ block: 'start', inline: 'nearest' }) + } const goNextDemo = () => { - if (nextDemo) changeDemo(nextDemo); - }; + if (nextDemo) changeDemo(nextDemo) + } const pressDemo = (d: DemoKind) => { - if (pressedDemoRef.current === d) return; - pressedDemoRef.current = d; - changeDemo(d); - }; + if (pressedDemoRef.current === d) return + pressedDemoRef.current = d + changeDemo(d) + } const clickDemo = (d: DemoKind) => { if (pressedDemoRef.current === d) { - pressedDemoRef.current = null; - return; + pressedDemoRef.current = null + return } - changeDemo(d); - }; + changeDemo(d) + } useLayoutEffect(() => { - const root = messagesRef.current; + const root = messagesRef.current if (!root) { - setAnimatedMessagesDemo(demo); - return; + setAnimatedMessagesDemo(demo) + return } - const items = root.querySelectorAll("[data-demo-message]"); + const items = root.querySelectorAll('[data-demo-message]') if (items.length === 0) { - setAnimatedMessagesDemo(demo); - return; + setAnimatedMessagesDemo(demo) + return } - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setAnimatedMessagesDemo(demo); - return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setAnimatedMessagesDemo(demo) + return } - let disposed = false; + let disposed = false const animation: WAAPIAnimation = waapi.animate(items, { opacity: [0, 1], - translate: ["0 12px", "0 0"], + translate: ['0 12px', '0 0'], delay: stagger(MESSAGE_STAGGER_MS, { start: initialDelay }), ease: springs.entrance, - }); + }) void animation.then(() => { - if (disposed) return; - setAnimatedMessagesDemo(demo); - }); + if (disposed) return + setAnimatedMessagesDemo(demo) + }) return () => { - disposed = true; - animation.cancel(); - }; - }, [demo, initialDelay, preludeCount]); + disposed = true + animation.cancel() + } + }, [demo, initialDelay, preludeCount]) return (
{connected ? ( <> - - {shorten(connected.address)} - + {shorten(connected.address)} {connected.balanceDisplay ? ( <> · @@ -425,7 +386,7 @@ export function BrowserMockup({ ) : ( - {accountStatus === "checking" ? "Checking…" : "Not connected"} + {accountStatus === 'checking' ? 'Checking…' : 'Not connected'} )}
@@ -443,7 +404,7 @@ export function BrowserMockup({ type="button" aria-label={`Previous demo: ${previousLabel}`} onClick={() => { - if (previousDemo) changeDemo(previousDemo); + if (previousDemo) changeDemo(previousDemo) }} className="flex min-h-14 items-center justify-start gap-3 px-4 text-left text-foreground outline-none hover:bg-surface-hover active:bg-surface-active active:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-info focus-visible:outline-offset-2" > @@ -464,7 +425,7 @@ export function BrowserMockup({ type="button" aria-label={`Next demo: ${nextLabel}`} onClick={() => { - if (nextDemo) changeDemo(nextDemo); + if (nextDemo) changeDemo(nextDemo) }} className="flex min-h-14 items-center justify-end gap-3 border-l border-panel-border px-4 text-right text-foreground outline-none hover:bg-surface-hover active:bg-surface-active active:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-info focus-visible:outline-offset-2" > @@ -472,9 +433,7 @@ export function BrowserMockup({ {nextStep} - - {nextLabel} - + {nextLabel} @@ -483,41 +442,38 @@ export function BrowserMockup({ {/* Desktop: always-visible numbered stepper nav on the left */} @@ -533,11 +489,7 @@ export function BrowserMockup({ {preludeCount > 0 ? (
{def.prelude?.map((m, i) => ( - + ))}
) : null} @@ -564,7 +516,7 @@ export function BrowserMockup({ connectedBalance={connected?.balanceDisplay ?? null} /> )} - {status === "done" && result?.complete !== false ? ( + {status === 'done' && result?.complete !== false ? (
- ); + ) } diff --git a/site/src/landing/demo/components/ChatBubble.tsx b/site/src/landing/demo/components/ChatBubble.tsx index 3496932b..928897f0 100644 --- a/site/src/landing/demo/components/ChatBubble.tsx +++ b/site/src/landing/demo/components/ChatBubble.tsx @@ -1,23 +1,20 @@ -"use client"; +'use client' -import type { CSSProperties } from "react"; -import type { DemoPreludeMessage } from "../types"; +import type { CSSProperties } from 'react' + +import type { DemoPreludeMessage } from '../types' export function ChatBubble({ message, style, }: { - message: DemoPreludeMessage; - style?: CSSProperties | undefined; + message: DemoPreludeMessage + style?: CSSProperties | undefined }) { return ( -
+

- {typeof message === "string" ? ( + {typeof message === 'string' ? ( message ) : ( <> @@ -35,5 +32,5 @@ export function ChatBubble({ )}

- ); + ) } diff --git a/site/src/landing/demo/components/GridBackdrop.tsx b/site/src/landing/demo/components/GridBackdrop.tsx index a8ce0210..70506cb8 100644 --- a/site/src/landing/demo/components/GridBackdrop.tsx +++ b/site/src/landing/demo/components/GridBackdrop.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client' export function GridBackdrop() { // Concentric horizontal dashed lines centered on the browser frame. // Extends past the section's px-6 so lines reach the container edges. - const heights = [588, 487, 381, 265, 145]; + const heights = [588, 487, 381, 265, 145] return (
- ); + ) } diff --git a/site/src/landing/demo/components/PillTabs.tsx b/site/src/landing/demo/components/PillTabs.tsx index c7afe011..f99dfc08 100644 --- a/site/src/landing/demo/components/PillTabs.tsx +++ b/site/src/landing/demo/components/PillTabs.tsx @@ -1,29 +1,29 @@ -"use client"; +'use client' export function PillTabs({ tabs, value, onChange, }: { - tabs: readonly T[]; - value: T; - onChange: (t: T) => void; + tabs: readonly T[] + value: T + onChange: (t: T) => void }) { return (
{tabs.map((t) => { - const active = t === value; + const active = t === value return ( - ); + ) })}
- ); + ) } diff --git a/site/src/landing/demo/components/ScrollableTabs.tsx b/site/src/landing/demo/components/ScrollableTabs.tsx index d2fe422c..9a1cc19d 100644 --- a/site/src/landing/demo/components/ScrollableTabs.tsx +++ b/site/src/landing/demo/components/ScrollableTabs.tsx @@ -1,80 +1,76 @@ -"use client"; +'use client' -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from 'react' export function ScrollableTabs({ tabs, value, onChange, }: { - tabs: readonly T[]; - value: T; - onChange: (t: T) => void; + tabs: readonly T[] + value: T + onChange: (t: T) => void }) { - const scrollRef = useRef(null); - const [edges, setEdges] = useState({ left: false, right: false }); + const scrollRef = useRef(null) + const [edges, setEdges] = useState({ left: false, right: false }) useEffect(() => { - const el = scrollRef.current; - if (!el) return; + const el = scrollRef.current + if (!el) return const update = () => { - const left = el.scrollLeft > 4; - const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4; - setEdges((prev) => - prev.left === left && prev.right === right ? prev : { left, right }, - ); - }; - update(); - el.addEventListener("scroll", update, { passive: true }); - const ro = new ResizeObserver(update); - ro.observe(el); + const left = el.scrollLeft > 4 + const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4 + setEdges((prev) => (prev.left === left && prev.right === right ? prev : { left, right })) + } + update() + el.addEventListener('scroll', update, { passive: true }) + const ro = new ResizeObserver(update) + ro.observe(el) return () => { - el.removeEventListener("scroll", update); - ro.disconnect(); - }; - }, []); + el.removeEventListener('scroll', update) + ro.disconnect() + } + }, []) // Auto-scroll the active tab into view when value changes. useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const target = el.querySelector( - `button[data-tab="${value}"]`, - ); - if (!target) return; - const rect = target.getBoundingClientRect(); - const parentRect = el.getBoundingClientRect(); + const el = scrollRef.current + if (!el) return + const target = el.querySelector(`button[data-tab="${value}"]`) + if (!target) return + const rect = target.getBoundingClientRect() + const parentRect = el.getBoundingClientRect() if (rect.left < parentRect.left || rect.right > parentRect.right) { target.scrollIntoView({ - behavior: "smooth", - block: "nearest", - inline: "center", - }); + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }) } - }, [value]); + }, [value]) return (
{tabs.map((t) => { - const active = t === value; + const active = t === value return ( - ); + ) })}
@@ -89,5 +85,5 @@ export function ScrollableTabs({ style={{ opacity: edges.right ? 1 : 0 }} />
- ); + ) } diff --git a/site/src/landing/demo/config.ts b/site/src/landing/demo/config.ts index 28171470..73197a63 100644 --- a/site/src/landing/demo/config.ts +++ b/site/src/landing/demo/config.ts @@ -1,15 +1,16 @@ -import { Receipt } from "mppx"; -import { Hex } from "ox"; -import { formatUnits, parseUnits } from "viem"; -import { Actions } from "viem/tempo"; -import { tempoModerato } from "viem/tempo/chains"; -import { FeeSponsorshipBody } from "./bodies/FeeSponsorship"; -import { LogInBody } from "./bodies/LogIn"; -import { OnRampBody } from "./bodies/OnRamp"; -import { PayOnceBody } from "./bodies/PayOnce"; -import { SpendPermissionsBody } from "./bodies/SpendPermissions"; -import { SubscribeBody } from "./bodies/Subscribe"; -import { TradeBody } from "./bodies/Trade"; +import { Receipt } from 'mppx' +import { Hex } from 'ox' +import { formatUnits, parseUnits } from 'viem' +import { Actions } from 'viem/tempo' +import { tempoModerato } from 'viem/tempo/chains' + +import { FeeSponsorshipBody } from './bodies/FeeSponsorship' +import { LogInBody } from './bodies/LogIn' +import { OnRampBody } from './bodies/OnRamp' +import { PayOnceBody } from './bodies/PayOnce' +import { SpendPermissionsBody } from './bodies/SpendPermissions' +import { SubscribeBody } from './bodies/Subscribe' +import { TradeBody } from './bodies/Trade' import { connectWallet, connectWalletResult, @@ -23,219 +24,209 @@ import { spendPermissionAuthorizeAccessKey, TEMPO_MAINNET_CHAIN_ID, TEMPO_MODERATO_CHAIN_ID, -} from "./sdk"; -import type { AccountsProvider, DemoDef, DemoKind, DemoResult } from "./types"; - -const PURCHASE_AMOUNT_USD = "240"; -const TRANSFER_SELECTOR = "0xa9059cbb"; -const PATH_USD_DECIMALS = 6; -const DEMO_AMOUNT_UNITS = parseUnits(DEMO_AMOUNT_USD, PATH_USD_DECIMALS); -const SPEND_PERMISSION_LIMIT_UNITS = parseUnits(SPEND_PERMISSION_LIMIT_USD, 6); -const SUBSCRIPTION_PERIOD_SECONDS = 10; -const SUBSCRIPTION_PERIOD_MS = SUBSCRIPTION_PERIOD_SECONDS * 1000; -const FEE_SPONSORSHIP_AMOUNT_USD = "1.00"; -const FEE_SPONSORSHIP_RECIPIENT = - "0x0000000000000000000000000000000000000001" as const; -const SWAP_AMOUNT_USD = "1"; -const ALPHA_USD = "0x20c0000000000000000000000000000000000001" as const; - -async function connectedAddress(provider: Parameters[0]) { +} from './sdk' +import type { AccountsProvider, DemoDef, DemoKind, DemoResult } from './types' + +const PURCHASE_AMOUNT_USD = '240' +const TRANSFER_SELECTOR = '0xa9059cbb' +const PATH_USD_DECIMALS = 6 +const DEMO_AMOUNT_UNITS = parseUnits(DEMO_AMOUNT_USD, PATH_USD_DECIMALS) +const SPEND_PERMISSION_LIMIT_UNITS = parseUnits(SPEND_PERMISSION_LIMIT_USD, 6) +const SUBSCRIPTION_PERIOD_SECONDS = 10 +const SUBSCRIPTION_PERIOD_MS = SUBSCRIPTION_PERIOD_SECONDS * 1000 +const FEE_SPONSORSHIP_AMOUNT_USD = '1.00' +const FEE_SPONSORSHIP_RECIPIENT = '0x0000000000000000000000000000000000000001' as const +const SWAP_AMOUNT_USD = '1' +const ALPHA_USD = '0x20c0000000000000000000000000000000000001' as const + +async function connectedAddress(provider: Parameters[0]) { const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - return accounts?.[0] ?? (await connectWallet(provider)); + method: 'eth_accounts', + })) as readonly `0x${string}`[] + return accounts?.[0] ?? (await connectWallet(provider)) } type SpendPermissionRecord = { - address: `0x${string}`; - access: `0x${string}`; - chainId: number; - expiry?: number | undefined; + address: `0x${string}` + access: `0x${string}` + chainId: number + expiry?: number | undefined limits?: | readonly { - limit: bigint | number | string; - token: `0x${string}`; + limit: bigint | number | string + token: `0x${string}` }[] - | undefined; - scopes?: readonly { - address: `0x${string}`; - recipients?: readonly `0x${string}`[] | undefined; - selector?: string | undefined; - }[] | undefined; -}; + | undefined + scopes?: + | readonly { + address: `0x${string}` + recipients?: readonly `0x${string}`[] | undefined + selector?: string | undefined + }[] + | undefined +} type TransactionReceipt = { - effectiveGasPrice?: bigint | number | string | undefined; - feePayer?: `0x${string}` | string | undefined; - gasUsed?: bigint | number | string | undefined; - transactionHash?: `0x${string}` | undefined; -}; + effectiveGasPrice?: bigint | number | string | undefined + feePayer?: `0x${string}` | string | undefined + gasUsed?: bigint | number | string | undefined + transactionHash?: `0x${string}` | undefined +} type SponsoredTransactionReceipt = TransactionReceipt & { - sponsoredFee?: string | undefined; -}; + sponsoredFee?: string | undefined +} type WalletTransferResult = { - receipt: TransactionReceipt; -}; + receipt: TransactionReceipt +} type WalletSwapResult = { - receipt: TransactionReceipt; -}; + receipt: TransactionReceipt +} type SubscriptionCollectResponse = { - receipt: Receipt.Receipt | null; - renewed: boolean; - subscriptionId: string; -}; + receipt: Receipt.Receipt | null + renewed: boolean + subscriptionId: string +} function readPermissionExpiry(value: unknown) { - if (typeof value === "number") return value; - if (typeof value === "bigint") return Number(value); - if (typeof value === "string" && value.startsWith("0x")) - return Hex.toNumber(value as `0x${string}`); - return Math.floor(Date.now() / 1000) + SPEND_PERMISSION_VALID_SECONDS; + if (typeof value === 'number') return value + if (typeof value === 'bigint') return Number(value) + if (typeof value === 'string' && value.startsWith('0x')) + return Hex.toNumber(value as `0x${string}`) + return Math.floor(Date.now() / 1000) + SPEND_PERMISSION_VALID_SECONDS } function readTokenLimit(value: bigint | number | string) { - if (typeof value === "bigint") return value; - if (typeof value === "number") return BigInt(value); + if (typeof value === 'bigint') return value + if (typeof value === 'number') return BigInt(value) try { - return BigInt(value); + return BigInt(value) } catch { - return 0n; + return 0n } } function readUnits(value: string | undefined) { - if (!value) return 0n; + if (!value) return 0n try { - return BigInt(value); + return BigInt(value) } catch { - return 0n; + return 0n } } function toBigInt(value: bigint | number | string | undefined) { - if (typeof value === "undefined") return undefined; - if (typeof value === "bigint") return value; - return BigInt(value); + if (typeof value === 'undefined') return undefined + if (typeof value === 'bigint') return value + return BigInt(value) } function receiptFeeUnits(receipt: TransactionReceipt) { - const effectiveGasPrice = toBigInt(receipt.effectiveGasPrice); - const gasUsed = toBigInt(receipt.gasUsed); - if (effectiveGasPrice === undefined || gasUsed === undefined) return 0n; - return ( - (effectiveGasPrice * gasUsed) / - 10n ** BigInt(18 - PATH_USD_DECIMALS) - ); + const effectiveGasPrice = toBigInt(receipt.effectiveGasPrice) + const gasUsed = toBigInt(receipt.gasUsed) + if (effectiveGasPrice === undefined || gasUsed === undefined) return 0n + return (effectiveGasPrice * gasUsed) / 10n ** BigInt(18 - PATH_USD_DECIMALS) } function receiptFeeDisplay(receipt: TransactionReceipt) { - const units = receiptFeeUnits(receipt); - if (units <= 0n) return undefined; - return formatFeeUnits(units); + const units = receiptFeeUnits(receipt) + if (units <= 0n) return undefined + return formatFeeUnits(units) } function formatUsdUnits(units: bigint) { - return `$${Number(formatUnits(units, PATH_USD_DECIMALS)).toFixed(2)}`; + return `$${Number(formatUnits(units, PATH_USD_DECIMALS)).toFixed(2)}` } function formatFeeUnits(units: bigint) { - const value = Number(formatUnits(units, PATH_USD_DECIMALS)); - return `$${value.toFixed(units < 10_000n ? 6 : 2)}`; + const value = Number(formatUnits(units, PATH_USD_DECIMALS)) + return `$${value.toFixed(units < 10_000n ? 6 : 2)}` } -function spendPermissionBudget(options: { - limitUnits?: bigint | undefined; - spentUnits?: bigint | undefined; -} = {}) { - const limitUnits = options.limitUnits ?? SPEND_PERMISSION_LIMIT_UNITS; - const spentUnits = options.spentUnits ?? 0n; - const remaining = - spentUnits >= limitUnits - ? 0n - : limitUnits - spentUnits; +function spendPermissionBudget( + options: { + limitUnits?: bigint | undefined + spentUnits?: bigint | undefined + } = {}, +) { + const limitUnits = options.limitUnits ?? SPEND_PERMISSION_LIMIT_UNITS + const spentUnits = options.spentUnits ?? 0n + const remaining = spentUnits >= limitUnits ? 0n : limitUnits - spentUnits return { permissionRemaining: formatUsdUnits(remaining), permissionSpent: formatUsdUnits(spentUnits), permissionSpentUnits: spentUnits.toString(), - }; + } } -function spendPermissionResult(options: { - expiresAt?: number | undefined; - permissionAddress?: `0x${string}` | undefined; - permissionLimitUnits?: bigint | undefined; -} = {}): DemoResult { - const limitUnits = options.permissionLimitUnits ?? SPEND_PERMISSION_LIMIT_UNITS; - const permissionLimit = formatUsdUnits(limitUnits); +function spendPermissionResult( + options: { + expiresAt?: number | undefined + permissionAddress?: `0x${string}` | undefined + permissionLimitUnits?: bigint | undefined + } = {}, +): DemoResult { + const limitUnits = options.permissionLimitUnits ?? SPEND_PERMISSION_LIMIT_UNITS + const permissionLimit = formatUsdUnits(limitUnits) return { summary: `Access key authorized · ${permissionLimit} cap`, complete: false, permissionAddress: options.permissionAddress, permissionExpiresAt: - options.expiresAt ?? - Math.floor(Date.now() / 1000) + SPEND_PERMISSION_VALID_SECONDS, + options.expiresAt ?? Math.floor(Date.now() / 1000) + SPEND_PERMISSION_VALID_SECONDS, permissionLimit, ...spendPermissionBudget({ limitUnits }), - permissionState: "active", + permissionState: 'active', progressMax: SPEND_PERMISSION_PAYMENT_COUNT, progressValue: 0, - }; + } } export async function connectSpendPermission(provider: AccountsProvider) { const result = await connectWalletResult(provider, { authorizeAccessKey: spendPermissionAuthorizeAccessKey(), chainId: TEMPO_MODERATO_CHAIN_ID, - }); - const account = result.accounts?.[0]; - const key = account?.capabilities?.keyAuthorization; - const permission = account - ? findSpendPermission(provider, account.address) - : undefined; + }) + const account = result.accounts?.[0] + const key = account?.capabilities?.keyAuthorization + const permission = account ? findSpendPermission(provider, account.address) : undefined return { address: account?.address ?? null, result: spendPermissionResult({ expiresAt: permission?.expiry ?? readPermissionExpiry(key?.expiry), permissionAddress: permission?.address ?? key?.address ?? key?.keyId, }), - }; + } } /** Returns active access-key payment state for a connected account. */ -export function activeSpendPermissionResult( - provider: AccountsProvider, - account: `0x${string}`, -) { - const permission = findSpendPermission(provider, account); - if (!permission) return null; +export function activeSpendPermissionResult(provider: AccountsProvider, account: `0x${string}`) { + const permission = findSpendPermission(provider, account) + if (!permission) return null return spendPermissionResult({ expiresAt: permission.expiry, permissionAddress: permission.address, permissionLimitUnits: spendPermissionLimitUnits(permission), - }); + }) } function spendPermissionLimitUnits(permission: SpendPermissionRecord) { const limit = permission.limits?.find( (limit) => limit.token.toLowerCase() === PATH_USD.toLowerCase(), - ); - return limit ? readTokenLimit(limit.limit) : SPEND_PERMISSION_LIMIT_UNITS; + ) + return limit ? readTokenLimit(limit.limit) : SPEND_PERMISSION_LIMIT_UNITS } -function findSpendPermission( - provider: AccountsProvider, - account: `0x${string}`, -) { +function findSpendPermission(provider: AccountsProvider, account: `0x${string}`) { const state = provider.store.getState() as unknown as { - accessKeys?: readonly SpendPermissionRecord[] | undefined; - }; + accessKeys?: readonly SpendPermissionRecord[] | undefined + } return state.accessKeys?.find((key) => { - if (key.access.toLowerCase() !== account.toLowerCase()) return false; - if (key.chainId !== TEMPO_MODERATO_CHAIN_ID) return false; + if (key.access.toLowerCase() !== account.toLowerCase()) return false + if (key.chainId !== TEMPO_MODERATO_CHAIN_ID) return false if ( !key.limits?.some( (limit) => @@ -243,34 +234,30 @@ function findSpendPermission( readTokenLimit(limit.limit) >= SPEND_PERMISSION_LIMIT_UNITS, ) ) - return false; + return false return key.scopes?.some((scope) => { - if (scope.address.toLowerCase() !== PATH_USD.toLowerCase()) return false; + if (scope.address.toLowerCase() !== PATH_USD.toLowerCase()) return false if ( - scope.selector !== "transfer(address,uint256)" && + scope.selector !== 'transfer(address,uint256)' && scope.selector?.toLowerCase() !== TRANSFER_SELECTOR ) - return false; - if (!scope.recipients || scope.recipients.length === 0) return true; + return false + if (!scope.recipients || scope.recipients.length === 0) return true return scope.recipients.some( - (recipient) => - recipient.toLowerCase() === SPEND_PERMISSION_RECIPIENT.toLowerCase(), - ); - }); - }); + (recipient) => recipient.toLowerCase() === SPEND_PERMISSION_RECIPIENT.toLowerCase(), + ) + }) + }) } -async function sendApprovedPayment( - provider: AccountsProvider, - account: `0x${string}`, -) { +async function sendApprovedPayment(provider: AccountsProvider, account: `0x${string}`) { const call = Actions.token.transfer.call({ amount: DEMO_AMOUNT_UNITS, to: SPEND_PERMISSION_RECIPIENT, token: PATH_USD, - }); + }) return (await provider.request({ - method: "eth_sendTransactionSync", + method: 'eth_sendTransactionSync', params: [ { calls: [call], @@ -279,83 +266,78 @@ async function sendApprovedPayment( from: account, }, ], - } as Parameters[0])) as TransactionReceipt; + } as Parameters[0])) as TransactionReceipt } function readSpendPaymentCount(variant: string | undefined) { - if (variant === "again") return 2; - if (!variant?.startsWith("spend")) return 1; - const value = Number(variant.slice("spend:".length)); - if (!Number.isInteger(value)) return 1; - return Math.min(Math.max(value, 1), SPEND_PERMISSION_PAYMENT_COUNT); + if (variant === 'again') return 2 + if (!variant?.startsWith('spend')) return 1 + const value = Number(variant.slice('spend:'.length)) + if (!Number.isInteger(value)) return 1 + return Math.min(Math.max(value, 1), SPEND_PERMISSION_PAYMENT_COUNT) } async function requireConnectedAccount(provider: AccountsProvider) { const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const account = accounts?.[0]; - if (!account) throw new Error("No account connected."); - return account; + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const account = accounts?.[0] + if (!account) throw new Error('No account connected.') + return account } function responseBodyText(body: unknown) { - if (typeof body === "string") return body; + if (typeof body === 'string') return body try { - return JSON.stringify(body); + return JSON.stringify(body) } catch { - return String(body); + return String(body) } } function subscriptionNextCollectAt(receipt: Receipt.Receipt) { - const timestamp = Date.parse(receipt.timestamp); - if (!Number.isFinite(timestamp)) return Date.now() + SUBSCRIPTION_PERIOD_MS; - return timestamp + SUBSCRIPTION_PERIOD_MS; + const timestamp = Date.parse(receipt.timestamp) + if (!Number.isFinite(timestamp)) return Date.now() + SUBSCRIPTION_PERIOD_MS + return timestamp + SUBSCRIPTION_PERIOD_MS } function subscriptionReceipt(label: string, receipt: Receipt.Receipt) { - const reference = receipt.reference; + const reference = receipt.reference return { reference, label: `${label} · ${shorten(reference)}`, - ...(reference.startsWith("0x") + ...(reference.startsWith('0x') ? { href: `${tempoModerato.blockExplorers.default.url}/tx/${reference}` } : {}), - }; + } } function readCollectResponse(body: unknown): SubscriptionCollectResponse { - if (!body || typeof body !== "object") - throw new Error("Invalid subscription collection response."); + if (!body || typeof body !== 'object') + throw new Error('Invalid subscription collection response.') const data = body as { - receipt?: unknown; - renewed?: unknown; - subscriptionId?: unknown; - }; - if (typeof data.subscriptionId !== "string") - throw new Error("Missing subscription id."); - if (typeof data.renewed !== "boolean") - throw new Error("Missing renewal status."); + receipt?: unknown + renewed?: unknown + subscriptionId?: unknown + } + if (typeof data.subscriptionId !== 'string') throw new Error('Missing subscription id.') + if (typeof data.renewed !== 'boolean') throw new Error('Missing renewal status.') const receipt = - data.receipt === null || data.receipt === undefined - ? null - : Receipt.Schema.parse(data.receipt); - if (data.renewed && !receipt?.subscriptionId) - throw new Error("Missing renewal receipt."); + data.receipt === null || data.receipt === undefined ? null : Receipt.Schema.parse(data.receipt) + if (data.renewed && !receipt?.subscriptionId) throw new Error('Missing renewal receipt.') return { receipt, renewed: data.renewed, subscriptionId: data.subscriptionId, - }; + } } async function sendSponsoredTransfer( provider: AccountsProvider, ): Promise { - await connectedAddress(provider); + await connectedAddress(provider) const result = (await provider.request({ - method: "wallet_transfer", + method: 'wallet_transfer', params: [ { amount: FEE_SPONSORSHIP_AMOUNT_USD, @@ -364,103 +346,98 @@ async function sendSponsoredTransfer( token: PATH_USD, }, ], - } as Parameters[0])) as WalletTransferResult; - const receipt = result.receipt; - if (!receipt.feePayer) throw new Error("Fee sponsorship was not applied."); - const sponsoredFee = receiptFeeDisplay(receipt); + } as Parameters[0])) as WalletTransferResult + const receipt = result.receipt + if (!receipt.feePayer) throw new Error('Fee sponsorship was not applied.') + const sponsoredFee = receiptFeeDisplay(receipt) return { ...receipt, ...(sponsoredFee ? { sponsoredFee } : {}), - }; + } } /** Ordered list of landing demo steps. */ export const DEMO_STEPS = [ - "Log In", - "Pay Once", - "Spend Permissions", - "Subscribe", - "Fee Sponsorship", - "Swap Currencies", - "Add Funds", -] as const satisfies readonly DemoKind[]; + 'Log In', + 'Pay Once', + 'Spend Permissions', + 'Subscribe', + 'Fee Sponsorship', + 'Swap Currencies', + 'Add Funds', +] as const satisfies readonly DemoKind[] /** * Most on-chain actions sign for $0.01 — larger display copy is storytelling. * Pay Once intentionally prefills $240 so the wallet matches the checkout. */ export const DEMOS: Record = { - "Log In": { - url: "wisselbank.xyz", + 'Log In': { + url: 'wisselbank.xyz', guide: { - label: "Authentication", - href: "/docs/guides/connect-accounts", + label: 'Authentication', + href: '/docs/guides/connect-accounts', prompt: - "Referencing accounts.tempo.xyz/docs/guides/connect-accounts, add account sign-in to my app with the Tempo Accounts SDK.", + 'Referencing accounts.tempo.xyz/docs/guides/connect-accounts, add account sign-in to my app with the Tempo Accounts SDK.', }, prelude: [ - "Welcome to Wisselbank", - "Create a stablecoin account with a passkey", - "No password, seed phrase, or extension needed", + 'Welcome to Wisselbank', + 'Create a stablecoin account with a passkey', + 'No password, seed phrase, or extension needed', ], Body: LogInBody, async run(provider, ctx) { // Privy adapter routes through Privy's own login modal in V1. - if (ctx.adapter === "privy" && ctx.privy) { - if (!ctx.privy.authenticated) await ctx.privy.login(); - const addr = ctx.privy.user?.wallet?.address; - return { summary: addr ? `Signed in · ${shorten(addr)}` : "Signed in" }; + if (ctx.adapter === 'privy' && ctx.privy) { + if (!ctx.privy.authenticated) await ctx.privy.login() + const addr = ctx.privy.user?.wallet?.address + return { summary: addr ? `Signed in · ${shorten(addr)}` : 'Signed in' } } - const address = await connectWallet(provider); + const address = await connectWallet(provider) return { - summary: address ? `Signed in · ${shorten(address)}` : "Signed in", - }; + summary: address ? `Signed in · ${shorten(address)}` : 'Signed in', + } }, }, - "Add Funds": { - url: "wisselbank.xyz", + 'Add Funds': { + url: 'wisselbank.xyz', guide: { - label: "Deposits", - href: "/docs/guides/deposits", + label: 'Deposits', + href: '/docs/guides/deposits', prompt: - "Referencing accounts.tempo.xyz/docs/guides/deposits, add deposits to my app with the Tempo Accounts SDK.", + 'Referencing accounts.tempo.xyz/docs/guides/deposits, add deposits to my app with the Tempo Accounts SDK.', }, - prelude: [ - "Add money to your Tempo account", - "Choose a deposit method in the wallet", - ], + prelude: ['Add money to your Tempo account', 'Choose a deposit method in the wallet'], Body: OnRampBody, async run(provider) { // wallet_deposit opens the wallet's native Deposit dialog // ($20/$50/$100/Other, Apple Pay, Deposit crypto, etc.). // Pre-fill with $0.01 so the demo amount stays consistent. - void provider.request({ - method: "wallet_deposit", - params: [ - { - amount: DEMO_AMOUNT_USD, - chainId: TEMPO_MAINNET_CHAIN_ID, - }, - ], - } as Parameters[0]).catch(() => undefined); - return {}; + void provider + .request({ + method: 'wallet_deposit', + params: [ + { + amount: DEMO_AMOUNT_USD, + chainId: TEMPO_MAINNET_CHAIN_ID, + }, + ], + } as Parameters[0]) + .catch(() => undefined) + return {} }, }, - "Pay Once": { - url: "wisselbank.xyz", + 'Pay Once': { + url: 'wisselbank.xyz', guide: { - label: "Transfer", - href: "/docs/guides/transfers", + label: 'Transfer', + href: '/docs/guides/transfers', prompt: - "Referencing accounts.tempo.xyz/docs/guides/transfers, add transfers to my app with the Tempo Accounts SDK.", + 'Referencing accounts.tempo.xyz/docs/guides/transfers, add transfers to my app with the Tempo Accounts SDK.', }, - prelude: [ - "Start a transfer", - "Checking recipient details", - "Ready to send", - ], + prelude: ['Start a transfer', 'Checking recipient details', 'Ready to send'], Body: PayOnceBody, async run(provider) { // `wallet_transfer` opens the wallet UI so the user can confirm @@ -468,94 +445,85 @@ export const DEMOS: Record = { // own address so the demo signs a real on-chain tx without // sending funds to a third party. const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const self = accounts?.[0]; - if (!self) throw new Error("No account connected."); + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const self = accounts?.[0] + if (!self) throw new Error('No account connected.') const result = (await provider.request({ - method: "wallet_transfer", + method: 'wallet_transfer', params: [ { editable: true, chainId: TEMPO_MODERATO_CHAIN_ID, to: self, amount: PURCHASE_AMOUNT_USD, - token: "pathUsd", + token: 'pathUsd', }, ], } as Parameters[0])) as | { receipt?: { transactionHash?: `0x${string}` } } - | undefined; - const tx = result?.receipt?.transactionHash; + | undefined + const tx = result?.receipt?.transactionHash return { - summary: tx ? "Transfer sent ·" : "Transfer sent", - href: tx - ? `${tempoModerato.blockExplorers.default.url}/tx/${tx}` - : undefined, + summary: tx ? 'Transfer sent ·' : 'Transfer sent', + href: tx ? `${tempoModerato.blockExplorers.default.url}/tx/${tx}` : undefined, hrefLabel: tx ? `tx ${shorten(tx)}` : undefined, - }; + } }, }, - "Spend Permissions": { - url: "wisselbank.xyz", + 'Spend Permissions': { + url: 'wisselbank.xyz', guide: { - label: "Spend Permissions", - href: "/docs/guides/spend-permissions", + label: 'Spend Permissions', + href: '/docs/guides/spend-permissions', prompt: - "Referencing accounts.tempo.xyz/docs/guides/spend-permissions, add spend permissions for per-use payments to my app with the Tempo Accounts SDK.", + 'Referencing accounts.tempo.xyz/docs/guides/spend-permissions, add spend permissions for per-use payments to my app with the Tempo Accounts SDK.', }, - prelude: [ - "Approve a payment rule once", - "Let Wisselbank make matching micro-payments", - ], + prelude: ['Approve a payment rule once', 'Let Wisselbank make matching micro-payments'], Body: SpendPermissionsBody, async run(provider, ctx) { - let account = await connectedAddress(provider); - if (!account) throw new Error("No account connected."); + let account = await connectedAddress(provider) + if (!account) throw new Error('No account connected.') - if (ctx.variant === "revoke") { - const permission = findSpendPermission(provider, account); + if (ctx.variant === 'revoke') { + const permission = findSpendPermission(provider, account) if (permission) await provider.request({ - method: "wallet_revokeAccessKey", + method: 'wallet_revokeAccessKey', params: [ { accessKeyAddress: permission.address, address: account, }, ], - } as Parameters[0]); + } as Parameters[0]) return { - summary: "Access key revoked", + summary: 'Access key revoked', complete: false, - permissionState: "removed", + permissionState: 'removed', progressMax: SPEND_PERMISSION_PAYMENT_COUNT, progressValue: 0, - }; + } } - let permission = findSpendPermission(provider, account); - if ( - !permission && - !ctx.variant?.startsWith("spend") && - ctx.variant !== "again" - ) { - const connected = await connectSpendPermission(provider); - if (!connected.address) throw new Error("No account connected."); - return connected.result; + let permission = findSpendPermission(provider, account) + if (!permission && !ctx.variant?.startsWith('spend') && ctx.variant !== 'again') { + const connected = await connectSpendPermission(provider) + if (!connected.address) throw new Error('No account connected.') + return connected.result } if (!permission) { - const connected = await connectSpendPermission(provider); - if (!connected.address) throw new Error("No account connected."); - account = connected.address; - permission = findSpendPermission(provider, account); + const connected = await connectSpendPermission(provider) + if (!connected.address) throw new Error('No account connected.') + account = connected.address + permission = findSpendPermission(provider, account) } - if (!permission) throw new Error("No access key authorized."); + if (!permission) throw new Error('No access key authorized.') - const receipt = await sendApprovedPayment(provider, account); - const payments = readSpendPaymentCount(ctx.variant); - const transactionHash = receipt.transactionHash; + const receipt = await sendApprovedPayment(provider, account) + const payments = readSpendPaymentCount(ctx.variant) + const transactionHash = receipt.transactionHash const transactions = [ ...(ctx.previousResult?.transactions ?? []), ...(transactionHash @@ -567,162 +535,153 @@ export const DEMOS: Record = { }, ] : []), - ]; + ] const spentUnits = readUnits(ctx.previousResult?.permissionSpentUnits) + DEMO_AMOUNT_UNITS + - receiptFeeUnits(receipt); - const permissionLimitUnits = spendPermissionLimitUnits(permission); + receiptFeeUnits(receipt) + const permissionLimitUnits = spendPermissionLimitUnits(permission) return { - complete: - payments >= SPEND_PERMISSION_PAYMENT_COUNT ? undefined : false, + complete: payments >= SPEND_PERMISSION_PAYMENT_COUNT ? undefined : false, permissionAddress: permission?.address, permissionExpiresAt: - permission?.expiry ?? - Math.floor(Date.now() / 1000) + SPEND_PERMISSION_VALID_SECONDS, + permission?.expiry ?? Math.floor(Date.now() / 1000) + SPEND_PERMISSION_VALID_SECONDS, permissionLimit: formatUsdUnits(permissionLimitUnits), ...spendPermissionBudget({ limitUnits: permissionLimitUnits, spentUnits, }), - permissionState: "active", + permissionState: 'active', progressMax: SPEND_PERMISSION_PAYMENT_COUNT, progressValue: payments, transactions, - }; + } }, }, Subscribe: { - url: "wisselbank.xyz", + url: 'wisselbank.xyz', guide: { - label: "Subscriptions", - href: "/docs/guides/subscriptions", + label: 'Subscriptions', + href: '/docs/guides/subscriptions', prompt: - "Referencing accounts.tempo.xyz/docs/guides/subscriptions, add subscriptions to my app with the Tempo Accounts SDK.", + 'Referencing accounts.tempo.xyz/docs/guides/subscriptions, add subscriptions to my app with the Tempo Accounts SDK.', }, prelude: [ - "Check membership status", + 'Check membership status', { - before: "Approve recurring access with ", - label: "MPP", - href: "https://mpp.dev", + before: 'Approve recurring access with ', + label: 'MPP', + href: 'https://mpp.dev', }, ], Body: SubscribeBody, async run(provider, ctx) { - const account = await requireConnectedAccount(provider); + const account = await requireConnectedAccount(provider) - if (ctx.variant === "cancel") { - const subscriptionId = ctx.previousResult?.subscriptionId; - if (!subscriptionId) throw new Error("Subscribe before cancelling."); + if (ctx.variant === 'cancel') { + const subscriptionId = ctx.previousResult?.subscriptionId + if (!subscriptionId) throw new Error('Subscribe before cancelling.') return { - summary: "Subscription cancelled", + summary: 'Subscription cancelled', complete: false, subscriptionId, subscriptionReceipts: ctx.previousResult?.subscriptionReceipts, - subscriptionState: "cancelled", - }; + subscriptionState: 'cancelled', + } } - if (ctx.variant === "collect") { - const subscriptionId = ctx.previousResult?.subscriptionId; - if (!subscriptionId) throw new Error("Subscribe before collecting."); - const res = await fetch("/__subscriptions/collect", { + if (ctx.variant === 'collect') { + const subscriptionId = ctx.previousResult?.subscriptionId + if (!subscriptionId) throw new Error('Subscribe before collecting.') + const res = await fetch('/__subscriptions/collect', { body: JSON.stringify({ subscriptionId }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const body = (await res.json().catch(() => null)) as unknown; - if (!res.ok) - throw new Error(`${res.status}: ${responseBodyText(body)}`); - const collection = readCollectResponse(body); + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + const body = (await res.json().catch(() => null)) as unknown + if (!res.ok) throw new Error(`${res.status}: ${responseBodyText(body)}`) + const collection = readCollectResponse(body) if (!collection.renewed) return { - summary: "Subscription already current", + summary: 'Subscription already current', complete: false, subscriptionId: collection.subscriptionId, subscriptionNextCollectAt: Date.now() + SUBSCRIPTION_PERIOD_MS, subscriptionReceipts: ctx.previousResult?.subscriptionReceipts, - subscriptionState: "current", - }; + subscriptionState: 'current', + } - const receipt = collection.receipt; - if (!receipt) throw new Error("Missing renewal receipt."); + const receipt = collection.receipt + if (!receipt) throw new Error('Missing renewal receipt.') return { - summary: "Renewal collected", + summary: 'Renewal collected', subscriptionId: collection.subscriptionId, subscriptionNextCollectAt: subscriptionNextCollectAt(receipt), subscriptionReceipts: [ ...(ctx.previousResult?.subscriptionReceipts ?? []), - subscriptionReceipt("Renewal", receipt), + subscriptionReceipt('Renewal', receipt), ], - subscriptionState: "active", - }; + subscriptionState: 'active', + } } - const res = await fetch("/api/articles", { - headers: { "X-Subscriber": account }, - }); - const body = (await res.json().catch(() => null)) as unknown; - if (!res.ok) throw new Error(`${res.status}: ${responseBodyText(body)}`); - const header = res.headers.get("Payment-Receipt"); - const receipt = header ? Receipt.deserialize(header) : undefined; - if (!receipt?.subscriptionId) - throw new Error("Missing subscription receipt."); + const res = await fetch('/api/articles', { + headers: { 'X-Subscriber': account }, + }) + const body = (await res.json().catch(() => null)) as unknown + if (!res.ok) throw new Error(`${res.status}: ${responseBodyText(body)}`) + const header = res.headers.get('Payment-Receipt') + const receipt = header ? Receipt.deserialize(header) : undefined + if (!receipt?.subscriptionId) throw new Error('Missing subscription receipt.') return { - summary: "Subscription active", + summary: 'Subscription active', complete: false, subscriptionId: receipt.subscriptionId, subscriptionNextCollectAt: subscriptionNextCollectAt(receipt), - subscriptionReceipts: [subscriptionReceipt("Activation", receipt)], - subscriptionState: "active", - }; + subscriptionReceipts: [subscriptionReceipt('Activation', receipt)], + subscriptionState: 'active', + } }, }, - "Fee Sponsorship": { - url: "wisselbank.xyz", + 'Fee Sponsorship': { + url: 'wisselbank.xyz', guide: { - label: "Fee Sponsorship", - href: "/docs/guides/fee-sponsorship", + label: 'Fee Sponsorship', + href: '/docs/guides/fee-sponsorship', prompt: - "Referencing accounts.tempo.xyz/docs/guides/fee-sponsorship, add fee sponsorship to my app with the Tempo Accounts SDK.", + 'Referencing accounts.tempo.xyz/docs/guides/fee-sponsorship, add fee sponsorship to my app with the Tempo Accounts SDK.', }, - prelude: [ - "Check fee policy", - "Wisselbank covers the network fee", - ], + prelude: ['Check fee policy', 'Wisselbank covers the network fee'], Body: FeeSponsorshipBody, async run(provider) { - const receipt = await sendSponsoredTransfer(provider); - const tx = receipt.transactionHash; + const receipt = await sendSponsoredTransfer(provider) + const tx = receipt.transactionHash return { - summary: "Sponsored transfer sent", + summary: 'Sponsored transfer sent', feePayer: receipt.feePayer, sponsoredFee: receipt.sponsoredFee, - href: tx - ? `${tempoModerato.blockExplorers.default.url}/tx/${tx}` - : undefined, + href: tx ? `${tempoModerato.blockExplorers.default.url}/tx/${tx}` : undefined, hrefLabel: tx ? `tx ${shorten(tx)}` : undefined, - }; + } }, }, - "Swap Currencies": { - url: "wisselbank.xyz", + 'Swap Currencies': { + url: 'wisselbank.xyz', guide: { - label: "Exchange Currencies", - href: "/docs/guides/swaps", + label: 'Exchange Currencies', + href: '/docs/guides/swaps', prompt: - "Referencing accounts.tempo.xyz/docs/guides/swaps, add currency exchange to my app with the Tempo Accounts SDK.", + 'Referencing accounts.tempo.xyz/docs/guides/swaps, add currency exchange to my app with the Tempo Accounts SDK.', }, - prelude: ["Find the best stablecoin route", "Quote pathUSD to alphaUSD"], + prelude: ['Find the best stablecoin route', 'Quote pathUSD to alphaUSD'], Body: TradeBody, async run(provider) { - await requireConnectedAccount(provider); + await requireConnectedAccount(provider) const result = (await provider.request({ - method: "wallet_swap", + method: 'wallet_swap', params: [ { amount: SWAP_AMOUNT_USD, @@ -730,18 +689,16 @@ export const DEMOS: Record = { pairToken: ALPHA_USD, slippage: 0.005, token: PATH_USD, - type: "sell", + type: 'sell', }, ], - } as Parameters[0])) as WalletSwapResult; - const tx = result.receipt.transactionHash; + } as Parameters[0])) as WalletSwapResult + const tx = result.receipt.transactionHash return { - summary: tx ? "Exchange submitted ·" : "Exchange submitted", - href: tx - ? `${tempoModerato.blockExplorers.default.url}/tx/${tx}` - : undefined, + summary: tx ? 'Exchange submitted ·' : 'Exchange submitted', + href: tx ? `${tempoModerato.blockExplorers.default.url}/tx/${tx}` : undefined, hrefLabel: tx ? `tx ${shorten(tx)}` : undefined, - }; + } }, }, -}; +} diff --git a/site/src/landing/demo/sdk.ts b/site/src/landing/demo/sdk.ts index 38660e3e..e118a2a8 100644 --- a/site/src/landing/demo/sdk.ts +++ b/site/src/landing/demo/sdk.ts @@ -1,37 +1,36 @@ -import { parseUnits } from "viem"; -import { type Config, createConfig, createStorage, http } from "wagmi"; -import { tempo, tempoModerato } from "wagmi/chains"; -import { tempoWallet } from "wagmi/tempo"; -import { Storage } from "accounts"; -import type { AccountsProvider } from "./types"; +import { Storage } from 'accounts' +import { parseUnits } from 'viem' +import { type Config, createConfig, createStorage, http } from 'wagmi' +import { tempo, tempoModerato } from 'wagmi/chains' +import { tempoWallet } from 'wagmi/tempo' + +import type { AccountsProvider } from './types' /** All on-chain demo CTAs sign for $0.01 — the merchant display copy is just storytelling. */ -export const DEMO_AMOUNT_USD = "0.01"; +export const DEMO_AMOUNT_USD = '0.01' /** Storage namespace so all demos register as one app in wallet.tempo.xyz. */ -export const STORAGE_KEY = "tempo-accounts-demo"; +export const STORAGE_KEY = 'tempo-accounts-demo' /** Tempo path-USD aggregate token (TokenId 0). */ -export const PATH_USD = - "0x20c0000000000000000000000000000000000000" as const; +export const PATH_USD = '0x20c0000000000000000000000000000000000000' as const /** Tempo mainnet chain ID, used by demos that intentionally target production funds. */ -export const TEMPO_MAINNET_CHAIN_ID = tempo.id; +export const TEMPO_MAINNET_CHAIN_ID = tempo.id /** Tempo Moderato chain ID, used by demos that rely on the public testnet sponsor. */ -export const TEMPO_MODERATO_CHAIN_ID = tempoModerato.id; +export const TEMPO_MODERATO_CHAIN_ID = tempoModerato.id -const ONE_DAY = 24 * 60 * 60; -const FIVE_USD = 5_000_000n; -const PUBLIC_TESTNET_FEE_PAYER = "https://sponsor.moderato.tempo.xyz"; -export const SPEND_PERMISSION_LIMIT_USD = "1.00"; -export const SPEND_PERMISSION_PAYMENT_COUNT = 5; -export const SPEND_PERMISSION_RECIPIENT = - "0x0000000000000000000000000000000000000001" as const; -export const SPEND_PERMISSION_VALID_SECONDS = ONE_DAY; +const ONE_DAY = 24 * 60 * 60 +const FIVE_USD = 5_000_000n +const PUBLIC_TESTNET_FEE_PAYER = 'https://sponsor.moderato.tempo.xyz' +export const SPEND_PERMISSION_LIMIT_USD = '1.00' +export const SPEND_PERMISSION_PAYMENT_COUNT = 5 +export const SPEND_PERMISSION_RECIPIENT = '0x0000000000000000000000000000000000000001' as const +export const SPEND_PERMISSION_VALID_SECONDS = ONE_DAY const accountsStorage = Storage.combine( Storage.cookie({ key: STORAGE_KEY }), Storage.localStorage({ key: STORAGE_KEY }), -); +) const wagmiStorage = createStorage({ - storage: typeof window === "undefined" ? undefined : window.localStorage, -}); + storage: typeof window === 'undefined' ? undefined : window.localStorage, +}) /** * Default `authorizeAccessKey` payload for `wallet_connect`. Mirrors @@ -58,10 +57,10 @@ export function defaultAuthorizeAccessKey() { scopes: [ { address: PATH_USD, - selector: "transfer(address,uint256)", + selector: 'transfer(address,uint256)', }, ], - } as const; + } as const } export function spendPermissionAuthorizeAccessKey() { @@ -76,31 +75,31 @@ export function spendPermissionAuthorizeAccessKey() { scopes: [ { address: PATH_USD, - selector: "transfer(address,uint256)", + selector: 'transfer(address,uint256)', }, ], - } as const; + } as const } type AuthorizeAccessKey = | ReturnType - | ReturnType; + | ReturnType type ShowDeposit = | boolean | { - amount?: string | undefined; - displayName?: string | undefined; - on?: "login" | "register" | undefined; - token?: `0x${string}` | string | undefined; - }; + amount?: string | undefined + displayName?: string | undefined + on?: 'login' | 'register' | undefined + token?: `0x${string}` | string | undefined + } type ConnectWalletOptions = { - authorizeAccessKey?: AuthorizeAccessKey | undefined; - authorizeDefaultAccessKey?: boolean | undefined; - chainId?: number | undefined; - showDeposit?: ShowDeposit | undefined; -}; + authorizeAccessKey?: AuthorizeAccessKey | undefined + authorizeDefaultAccessKey?: boolean | undefined + chainId?: number | undefined + showDeposit?: ShowDeposit | undefined +} export const landingDemoWagmiConfig: Config = createConfig({ chains: [tempoModerato, tempo], @@ -110,8 +109,8 @@ export const landingDemoWagmiConfig: Config = createConfig({ persistCredentials: true, storage: accountsStorage, testnet: true, - theme: { radius: "none" }, - name: "Tempo Accounts SDK", + theme: { radius: 'none' }, + name: 'Tempo Accounts SDK', }), ], multiInjectedProviderDiscovery: false, @@ -120,43 +119,49 @@ export const landingDemoWagmiConfig: Config = createConfig({ [tempoModerato.id]: http(), [tempo.id]: http(), }, -}); +}) export async function getDemoProvider() { - const connector = landingDemoWagmiConfig.connectors[0]; - if (!connector) throw new Error("Missing Tempo Wallet connector."); - return (await connector.getProvider()) as AccountsProvider; + const connector = landingDemoWagmiConfig.connectors[0] + if (!connector) throw new Error('Missing Tempo Wallet connector.') + return (await connector.getProvider()) as AccountsProvider } -export function connectCapabilities(options: { - authorizeAccessKey?: AuthorizeAccessKey | undefined; - authorizeDefaultAccessKey?: boolean | undefined; - showDeposit?: ShowDeposit | undefined; -} = {}) { - const capabilities: Record = {}; +export function connectCapabilities( + options: { + authorizeAccessKey?: AuthorizeAccessKey | undefined + authorizeDefaultAccessKey?: boolean | undefined + showDeposit?: ShowDeposit | undefined + } = {}, +) { + const capabilities: Record = {} if (options.authorizeAccessKey) { - capabilities.authorizeAccessKey = options.authorizeAccessKey; + capabilities.authorizeAccessKey = options.authorizeAccessKey } else if (options.authorizeDefaultAccessKey !== false) { - capabilities.authorizeAccessKey = defaultAuthorizeAccessKey(); + capabilities.authorizeAccessKey = defaultAuthorizeAccessKey() } if (options.showDeposit !== undefined) { - capabilities.showDeposit = options.showDeposit; + capabilities.showDeposit = options.showDeposit } - return capabilities; + return capabilities } type WalletConnectResult = { accounts?: ReadonlyArray<{ - address: `0x${string}`; - capabilities?: { - keyAuthorization?: { - address?: `0x${string}` | undefined; - expiry?: `0x${string}` | number | bigint | null | undefined; - keyId?: `0x${string}` | undefined; - } | undefined; - } | undefined; - }>; -}; + address: `0x${string}` + capabilities?: + | { + keyAuthorization?: + | { + address?: `0x${string}` | undefined + expiry?: `0x${string}` | number | bigint | null | undefined + keyId?: `0x${string}` | undefined + } + | undefined + } + | undefined + }> +} /** Opens the wallet sign-in flow and returns the raw wallet_connect result. */ export async function connectWalletResult( @@ -166,14 +171,14 @@ export async function connectWalletResult( // Lazy access-key co-signing: every normal connect grants a scoped // session key unless the caller supplies a specialized permission. return (await provider.request({ - method: "wallet_connect", + method: 'wallet_connect', params: [ { capabilities: connectCapabilities(options), chainId: options.chainId ?? TEMPO_MODERATO_CHAIN_ID, } as Record, ], - })) as WalletConnectResult; + })) as WalletConnectResult } /** Opens the wallet sign-in flow and returns the connected account address. */ @@ -181,10 +186,10 @@ export async function connectWallet( provider: AccountsProvider, options: ConnectWalletOptions = {}, ) { - const result = await connectWalletResult(provider, options); - return result?.accounts?.[0]?.address ?? null; + const result = await connectWalletResult(provider, options) + return result?.accounts?.[0]?.address ?? null } export function shorten(addr: string) { - return `${addr.slice(0, 6)}…${addr.slice(-4)}`; + return `${addr.slice(0, 6)}…${addr.slice(-4)}` } diff --git a/site/src/landing/demo/types.ts b/site/src/landing/demo/types.ts index 0751b775..5115c8b0 100644 --- a/site/src/landing/demo/types.ts +++ b/site/src/landing/demo/types.ts @@ -1,154 +1,149 @@ -import type { Provider } from "accounts"; -import type { ReactNode } from "react"; +import type { Provider } from 'accounts' +import type { ReactNode } from 'react' -export type Adapter = "tempoAuth" | "webAuth" | "privy" | "turnkey"; +export type Adapter = 'tempoAuth' | 'webAuth' | 'privy' | 'turnkey' export type DemoKind = - | "Log In" - | "Add Funds" - | "Pay Once" - | "Spend Permissions" - | "Subscribe" - | "Fee Sponsorship" - | "Swap Currencies"; + | 'Log In' + | 'Add Funds' + | 'Pay Once' + | 'Spend Permissions' + | 'Subscribe' + | 'Fee Sponsorship' + | 'Swap Currencies' -export type Status = "idle" | "running" | "done"; +export type Status = 'idle' | 'running' | 'done' /** Current account lookup state for the shared demo session. */ -export type AccountStatus = "checking" | "disconnected" | "connected"; +export type AccountStatus = 'checking' | 'disconnected' | 'connected' -export type AccountsProvider = ReturnType; +export type AccountsProvider = ReturnType export type DemoResult = { /** Short human-readable result line shown in the body's `done` state. */ - summary?: string; + summary?: string /** Whether the demo is complete and should show the next-step CTA. */ - complete?: boolean | undefined; + complete?: boolean | undefined /** Optional destination for the result line, such as an explorer receipt URL. */ - href?: string | undefined; + href?: string | undefined /** Optional text for the linked portion of the result line. */ - hrefLabel?: string | undefined; + hrefLabel?: string | undefined /** Optional progress value for multi-step demo bodies. */ - progressValue?: number | undefined; + progressValue?: number | undefined /** Optional progress maximum for multi-step demo bodies. */ - progressMax?: number | undefined; + progressMax?: number | undefined /** Permission status for demos that authorize a spend permission. */ - permissionState?: "active" | "removed" | undefined; + permissionState?: 'active' | 'removed' | undefined /** Unix timestamp when the authorized permission expires. */ - permissionExpiresAt?: number | undefined; + permissionExpiresAt?: number | undefined /** Optional human-readable permission limit. */ - permissionLimit?: string | undefined; + permissionLimit?: string | undefined /** Human-readable amount used from the permission. */ - permissionSpent?: string | undefined; + permissionSpent?: string | undefined /** Raw pathUSD units used from the permission. */ - permissionSpentUnits?: string | undefined; + permissionSpentUnits?: string | undefined /** Human-readable amount remaining in the permission. */ - permissionRemaining?: string | undefined; + permissionRemaining?: string | undefined /** Optional authorized access key address. */ - permissionAddress?: `0x${string}` | undefined; + permissionAddress?: `0x${string}` | undefined /** Transaction links produced by the demo. */ transactions?: | readonly { - hash: `0x${string}`; - href: string; - label: string; + hash: `0x${string}` + href: string + label: string }[] - | undefined; + | undefined /** Status for demos that activate and renew a subscription. */ - subscriptionState?: - | "active" - | "current" - | "collected" - | "cancelled" - | undefined; + subscriptionState?: 'active' | 'current' | 'collected' | 'cancelled' | undefined /** Server-issued subscription identifier. */ - subscriptionId?: string | undefined; + subscriptionId?: string | undefined /** Millisecond timestamp when the next renewal can be collected. */ - subscriptionNextCollectAt?: number | undefined; + subscriptionNextCollectAt?: number | undefined /** Payment receipts produced by a subscription demo. */ subscriptionReceipts?: | readonly { - reference: string; - href?: string | undefined; - label: string; + reference: string + href?: string | undefined + label: string }[] - | undefined; + | undefined /** Address that paid fees for a sponsored transaction. */ - feePayer?: `0x${string}` | string | undefined; + feePayer?: `0x${string}` | string | undefined /** Human-readable fee paid by the sponsor. */ - sponsoredFee?: string | undefined; -}; + sponsoredFee?: string | undefined +} /** Guide metadata attached to one landing demo step. */ export type DemoGuide = { /** Guide keyword shown in the demo stepper. */ - label: string; + label: string /** Local docs route for the guide. */ - href: string; + href: string /** Prompt copied for agent-assisted implementation. */ - prompt: string; -}; + prompt: string +} export type DemoPreludeMessage = | string | { - before: string; - label: string; - href: string; - after?: string | undefined; - }; + before: string + label: string + href: string + after?: string | undefined + } export type DemoBodyProps = { - status: Status; - result: DemoResult | null; + status: Status + result: DemoResult | null /** Triggers the demo's `run`. Optional `variant` lets bodies with multiple buttons (Read vs Write) signal which one was pressed. */ - onAction: (variant?: string) => void; + onAction: (variant?: string) => void /** Moves to the next landing demo step. */ - onNextDemo: () => void; + onNextDemo: () => void /** Label for the explicit next-step CTA after a demo completes. */ - nextCtaLabel?: string | undefined; + nextCtaLabel?: string | undefined /** Disconnects the current account. */ - onDisconnect?: (() => void) | undefined; + onDisconnect?: (() => void) | undefined /** The variant string passed to the most recent `onAction` call, or null. */ - lastVariant: string | null; + lastVariant: string | null /** Entrance delay (ms) — set so the body fades up after the prelude. */ - delay: number; + delay: number /** Active adapter — bodies may render adapter-specific affordances (Privy). */ - adapter: Adapter; + adapter: Adapter /** USD-denominated balance string from the SDK (e.g., "$4.27"), or null if not connected. */ - connectedBalance: string | null; -}; + connectedBalance: string | null +} export type DemoDef = { - url: string; + url: string /** Guide metadata shown around the active demo step. */ - guide: DemoGuide; - prelude?: readonly DemoPreludeMessage[] | undefined; - Body: React.ComponentType; + guide: DemoGuide + prelude?: readonly DemoPreludeMessage[] | undefined + Body: React.ComponentType /** * Performs the SDK call. Receives a callable provider; resolves with an * optional summary line for the done state. Throw to fall back to idle. */ - run: (provider: AccountsProvider, ctx: RunContext) => Promise; -}; + run: (provider: AccountsProvider, ctx: RunContext) => Promise +} export type RunContext = { - adapter: Adapter; + adapter: Adapter /** Variant string passed by the body's `onAction(...)` call. Used by demos with multiple CTAs (e.g., Read vs Write). */ - variant?: string; + variant?: string /** Previous result for demos that append state across repeated actions. */ - previousResult?: DemoResult | null | undefined; + previousResult?: DemoResult | null | undefined /** Privy hooks routed through (only relevant when adapter === "privy"). */ privy?: { - login: () => Promise; - logout: () => Promise; - user: { wallet?: { address?: string } | null } | null; - authenticated: boolean; - }; -}; + login: () => Promise + logout: () => Promise + user: { wallet?: { address?: string } | null } | null + authenticated: boolean + } +} export type AdapterInfo = { - label: string; -}; + label: string +} -export type ChildrenProps = { children?: ReactNode }; +export type ChildrenProps = { children?: ReactNode } diff --git a/site/src/landing/dino-game.tsx b/site/src/landing/dino-game.tsx index 16a8b69b..57637e9d 100644 --- a/site/src/landing/dino-game.tsx +++ b/site/src/landing/dino-game.tsx @@ -1,93 +1,94 @@ -"use client"; - -import { useEffect, useRef, useState, useCallback } from "react"; -import { readCssVar, useTheme } from "./useTheme"; - -const SCALE = 2; -const CANVAS_W_INITIAL = 560; -const CANVAS_H = 140; -const GROUND_Y = 110; -const GRAVITY = 0.6; -const JUMP_VEL = -10; -const MINER_W = 20; -const MINER_H = 24; -const MINER_DUCK_H = 14; -const INITIAL_SPEED = 3; -const SPEED_INC = 0.001; +'use client' + +import { useEffect, useRef, useState, useCallback } from 'react' + +import { readCssVar, useTheme } from './useTheme' + +const SCALE = 2 +const CANVAS_W_INITIAL = 560 +const CANVAS_H = 140 +const GROUND_Y = 110 +const GRAVITY = 0.6 +const JUMP_VEL = -10 +const MINER_W = 20 +const MINER_H = 24 +const MINER_DUCK_H = 14 +const INITIAL_SPEED = 3 +const SPEED_INC = 0.001 /** All hex values consumed below come from styles.css (`--dino-*`, * `--salt-*`, `--sparkle-*`). We refresh the palette on every theme * change (re-init keys off `resolved`). */ type Palette = { - body: string; - skin: string; - eye: string; - shadow: string; - shade: string; - line: string; - detail: string; - leather: string; - debris: string; - saltFill: string; - saltHighlight: string; - saltEdge: string; - saltStreak: string; - sparkleLight: string; - sparkleWarm: string; - scoreFg: string; - gameOverFg: string; - gameOverSub: string; -}; + body: string + skin: string + eye: string + shadow: string + shade: string + line: string + detail: string + leather: string + debris: string + saltFill: string + saltHighlight: string + saltEdge: string + saltStreak: string + sparkleLight: string + sparkleWarm: string + scoreFg: string + gameOverFg: string + gameOverSub: string +} function readPalette(el: Element): Palette { - const v = (name: string) => readCssVar(el, name); + const v = (name: string) => readCssVar(el, name) return { - body: v("--dino-body") || "#f5a623", - skin: v("--dino-skin") || "#e0c9a6", - eye: v("--dino-eye") || "#0a0a0a", - shadow: v("--dino-shadow") || "#737373", - shade: v("--dino-shade") || "#525252", - line: v("--dino-line") || "#404040", - detail: v("--dino-detail") || "#a3a3a3", - leather: v("--dino-leather") || "#8b5e3c", - debris: v("--dino-debris") || "#2a2a2a", - saltFill: v("--salt-fill") || "#d4d4d4", - saltHighlight: v("--salt-highlight") || "#fafafa", - saltEdge: v("--salt-edge") || "#a3a3a3", - saltStreak: v("--salt-streak") || "#e5e5e5", - sparkleLight: v("--sparkle-light") || "#fafafa", - sparkleWarm: v("--sparkle-warm") || "#f5a623", - scoreFg: v("--score-fg") || "#a3a3a3", - gameOverFg: v("--game-over-fg") || "#fafafa", - gameOverSub: v("--game-over-sub") || "#a3a3a3", - }; + body: v('--dino-body') || '#f5a623', + skin: v('--dino-skin') || '#e0c9a6', + eye: v('--dino-eye') || '#0a0a0a', + shadow: v('--dino-shadow') || '#737373', + shade: v('--dino-shade') || '#525252', + line: v('--dino-line') || '#404040', + detail: v('--dino-detail') || '#a3a3a3', + leather: v('--dino-leather') || '#8b5e3c', + debris: v('--dino-debris') || '#2a2a2a', + saltFill: v('--salt-fill') || '#d4d4d4', + saltHighlight: v('--salt-highlight') || '#fafafa', + saltEdge: v('--salt-edge') || '#a3a3a3', + saltStreak: v('--salt-streak') || '#e5e5e5', + sparkleLight: v('--sparkle-light') || '#fafafa', + sparkleWarm: v('--sparkle-warm') || '#f5a623', + scoreFg: v('--score-fg') || '#a3a3a3', + gameOverFg: v('--game-over-fg') || '#fafafa', + gameOverSub: v('--game-over-sub') || '#a3a3a3', + } } -let audioCtx: AudioContext | null = null; +let audioCtx: AudioContext | null = null function getAudioCtx() { - if (!audioCtx) audioCtx = new AudioContext(); - return audioCtx; + if (!audioCtx) audioCtx = new AudioContext() + return audioCtx } function playMineSfx() { try { - const ctx = getAudioCtx(); + const ctx = getAudioCtx() // short percussive "clink" — pickaxe hitting salt - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "square"; - osc.frequency.setValueAtTime(800, ctx.currentTime); - osc.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08); - gain.gain.setValueAtTime(0.15, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1); - osc.connect(gain).connect(ctx.destination); - osc.start(ctx.currentTime); - osc.stop(ctx.currentTime + 0.1); + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = 'square' + osc.frequency.setValueAtTime(800, ctx.currentTime) + osc.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.08) + gain.gain.setValueAtTime(0.15, ctx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1) + osc.connect(gain).connect(ctx.destination) + osc.start(ctx.currentTime) + osc.stop(ctx.currentTime + 0.1) } catch {} } -type SaltBlock = { x: number; h: number; w: number; mined: boolean; sparkle: number }; -type Bird = { x: number; y: number; frame: number }; +type SaltBlock = { x: number; h: number; w: number; mined: boolean; sparkle: number } +type Bird = { x: number; y: number; frame: number } function drawMiner( ctx: CanvasRenderingContext2D, @@ -98,62 +99,62 @@ function drawMiner( p: Palette, ) { // hardhat - ctx.fillStyle = p.body; - ctx.fillRect(x + 2, y - 4, 16, 4); - ctx.fillRect(x + 4, y - 6, 12, 2); - ctx.fillRect(x + 0, y, 20, 2); + ctx.fillStyle = p.body + ctx.fillRect(x + 2, y - 4, 16, 4) + ctx.fillRect(x + 4, y - 6, 12, 2) + ctx.fillRect(x + 0, y, 20, 2) // head - ctx.fillStyle = p.skin; - ctx.fillRect(x + 4, y + 2, 12, 8); - ctx.fillStyle = p.eye; - ctx.fillRect(x + 12, y + 4, 2, 2); + ctx.fillStyle = p.skin + ctx.fillRect(x + 4, y + 2, 12, 8) + ctx.fillStyle = p.eye + ctx.fillRect(x + 12, y + 4, 2, 2) // body - ctx.fillStyle = p.shadow; - ctx.fillRect(x + 4, y + 10, 12, 10); - ctx.fillStyle = p.shade; - ctx.fillRect(x + 4, y + 16, 12, 2); + ctx.fillStyle = p.shadow + ctx.fillRect(x + 4, y + 10, 12, 10) + ctx.fillStyle = p.shade + ctx.fillRect(x + 4, y + 16, 12, 2) // pickaxe (swings) - const swingUp = !jumping && frame % 20 < 10; - ctx.fillStyle = p.detail; + const swingUp = !jumping && frame % 20 < 10 + ctx.fillStyle = p.detail if (swingUp) { - ctx.fillRect(x + 16, y + 4, 2, 10); - ctx.fillStyle = p.leather; - ctx.fillRect(x + 14, y + 2, 6, 2); - ctx.fillRect(x + 18, y + 2, 2, 4); + ctx.fillRect(x + 16, y + 4, 2, 10) + ctx.fillStyle = p.leather + ctx.fillRect(x + 14, y + 2, 6, 2) + ctx.fillRect(x + 18, y + 2, 2, 4) } else { - ctx.fillRect(x + 16, y + 8, 2, 10); - ctx.fillStyle = p.leather; - ctx.fillRect(x + 14, y + 6, 6, 2); - ctx.fillRect(x + 18, y + 6, 2, 4); + ctx.fillRect(x + 16, y + 8, 2, 10) + ctx.fillStyle = p.leather + ctx.fillRect(x + 14, y + 6, 6, 2) + ctx.fillRect(x + 18, y + 6, 2, 4) } // legs - ctx.fillStyle = p.line; + ctx.fillStyle = p.line if (jumping) { - ctx.fillRect(x + 5, y + 20, 3, 6); - ctx.fillRect(x + 12, y + 20, 3, 6); + ctx.fillRect(x + 5, y + 20, 3, 6) + ctx.fillRect(x + 12, y + 20, 3, 6) } else if (frame % 12 < 6) { - ctx.fillRect(x + 5, y + 20, 3, 6); - ctx.fillRect(x + 12, y + 20, 3, 3); + ctx.fillRect(x + 5, y + 20, 3, 6) + ctx.fillRect(x + 12, y + 20, 3, 3) } else { - ctx.fillRect(x + 5, y + 20, 3, 3); - ctx.fillRect(x + 12, y + 20, 3, 6); + ctx.fillRect(x + 5, y + 20, 3, 3) + ctx.fillRect(x + 12, y + 20, 3, 6) } // boots - ctx.fillStyle = p.shade; + ctx.fillStyle = p.shade if (jumping) { - ctx.fillRect(x + 4, y + 26, 5, 2); - ctx.fillRect(x + 11, y + 26, 5, 2); + ctx.fillRect(x + 4, y + 26, 5, 2) + ctx.fillRect(x + 11, y + 26, 5, 2) } else if (frame % 12 < 6) { - ctx.fillRect(x + 4, y + 26, 5, 2); - ctx.fillRect(x + 11, y + 23, 5, 2); + ctx.fillRect(x + 4, y + 26, 5, 2) + ctx.fillRect(x + 11, y + 23, 5, 2) } else { - ctx.fillRect(x + 4, y + 23, 5, 2); - ctx.fillRect(x + 11, y + 26, 5, 2); + ctx.fillRect(x + 4, y + 23, 5, 2) + ctx.fillRect(x + 11, y + 26, 5, 2) } } @@ -165,36 +166,36 @@ function drawMinerDucking( p: Palette, ) { // hardhat (flat) - ctx.fillStyle = p.body; - ctx.fillRect(x + 0, y, 22, 2); - ctx.fillRect(x + 2, y - 2, 18, 2); + ctx.fillStyle = p.body + ctx.fillRect(x + 0, y, 22, 2) + ctx.fillRect(x + 2, y - 2, 18, 2) // head (leaning forward) - ctx.fillStyle = p.skin; - ctx.fillRect(x + 4, y + 2, 14, 6); - ctx.fillStyle = p.eye; - ctx.fillRect(x + 14, y + 3, 2, 2); + ctx.fillStyle = p.skin + ctx.fillRect(x + 4, y + 2, 14, 6) + ctx.fillStyle = p.eye + ctx.fillRect(x + 14, y + 3, 2, 2) // body (crouched, wider) - ctx.fillStyle = p.shadow; - ctx.fillRect(x + 2, y + 8, 18, 6); - ctx.fillStyle = p.shade; - ctx.fillRect(x + 2, y + 12, 18, 2); + ctx.fillStyle = p.shadow + ctx.fillRect(x + 2, y + 8, 18, 6) + ctx.fillStyle = p.shade + ctx.fillRect(x + 2, y + 12, 18, 2) // pickaxe (held low) - ctx.fillStyle = p.detail; - ctx.fillRect(x + 20, y + 4, 8, 2); - ctx.fillStyle = p.leather; - ctx.fillRect(x + 26, y + 2, 2, 6); + ctx.fillStyle = p.detail + ctx.fillRect(x + 20, y + 4, 8, 2) + ctx.fillStyle = p.leather + ctx.fillRect(x + 26, y + 2, 2, 6) // legs (tucked) - ctx.fillStyle = p.line; + ctx.fillStyle = p.line if (frame % 12 < 6) { - ctx.fillRect(x + 4, y + 14, 4, 3); - ctx.fillRect(x + 12, y + 14, 4, 3); + ctx.fillRect(x + 4, y + 14, 4, 3) + ctx.fillRect(x + 12, y + 14, 4, 3) } else { - ctx.fillRect(x + 3, y + 14, 4, 3); - ctx.fillRect(x + 13, y + 14, 4, 3); + ctx.fillRect(x + 3, y + 14, 4, 3) + ctx.fillRect(x + 13, y + 14, 4, 3) } } @@ -206,76 +207,64 @@ function drawSaltBlock( h: number, p: Palette, ) { - ctx.fillStyle = p.saltFill; - ctx.fillRect(x, y, w, h); - ctx.fillStyle = p.saltHighlight; - ctx.fillRect(x, y, w, 2); - ctx.fillRect(x, y, 2, h); - ctx.fillStyle = p.saltEdge; - ctx.fillRect(x + w - 2, y, 2, h); - ctx.fillRect(x, y + h - 2, w, 2); - ctx.fillStyle = p.saltStreak; - ctx.fillRect(x + 4, y + Math.floor(h * 0.3), Math.floor(w * 0.4), 1); + ctx.fillStyle = p.saltFill + ctx.fillRect(x, y, w, h) + ctx.fillStyle = p.saltHighlight + ctx.fillRect(x, y, w, 2) + ctx.fillRect(x, y, 2, h) + ctx.fillStyle = p.saltEdge + ctx.fillRect(x + w - 2, y, 2, h) + ctx.fillRect(x, y + h - 2, w, 2) + ctx.fillStyle = p.saltStreak + ctx.fillRect(x + 4, y + Math.floor(h * 0.3), Math.floor(w * 0.4), 1) } -function drawSparkle( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - t: number, - p: Palette, -) { - const alpha = Math.max(0, 1 - t / 20); - ctx.globalAlpha = alpha; - ctx.fillStyle = p.sparkleLight; - const spread = t * 1.5; - ctx.fillRect(x - spread, y - spread, 2, 2); - ctx.fillRect(x + spread, y - spread * 0.7, 2, 2); - ctx.fillRect(x - spread * 0.5, y - spread * 1.2, 2, 2); - ctx.fillRect(x + spread * 0.8, y - spread * 1.5, 2, 2); - ctx.fillStyle = p.sparkleWarm; - ctx.fillRect(x, y - spread * 0.5, 2, 2); - ctx.fillRect(x + spread * 0.3, y - spread, 2, 2); - ctx.globalAlpha = 1; +function drawSparkle(ctx: CanvasRenderingContext2D, x: number, y: number, t: number, p: Palette) { + const alpha = Math.max(0, 1 - t / 20) + ctx.globalAlpha = alpha + ctx.fillStyle = p.sparkleLight + const spread = t * 1.5 + ctx.fillRect(x - spread, y - spread, 2, 2) + ctx.fillRect(x + spread, y - spread * 0.7, 2, 2) + ctx.fillRect(x - spread * 0.5, y - spread * 1.2, 2, 2) + ctx.fillRect(x + spread * 0.8, y - spread * 1.5, 2, 2) + ctx.fillStyle = p.sparkleWarm + ctx.fillRect(x, y - spread * 0.5, 2, 2) + ctx.fillRect(x + spread * 0.3, y - spread, 2, 2) + ctx.globalAlpha = 1 } -function drawBird( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - frame: number, - p: Palette, -) { - ctx.fillStyle = p.detail; +function drawBird(ctx: CanvasRenderingContext2D, x: number, y: number, frame: number, p: Palette) { + ctx.fillStyle = p.detail // body - ctx.fillRect(x + 4, y + 4, 10, 6); + ctx.fillRect(x + 4, y + 4, 10, 6) // head - ctx.fillRect(x + 14, y + 4, 6, 4); + ctx.fillRect(x + 14, y + 4, 6, 4) // beak - ctx.fillStyle = p.body; - ctx.fillRect(x + 20, y + 5, 3, 2); + ctx.fillStyle = p.body + ctx.fillRect(x + 20, y + 5, 3, 2) // eye - ctx.fillStyle = p.eye; - ctx.fillRect(x + 17, y + 5, 2, 2); + ctx.fillStyle = p.eye + ctx.fillRect(x + 17, y + 5, 2, 2) // wings (flap) - ctx.fillStyle = p.shadow; + ctx.fillStyle = p.shadow if (frame % 16 < 8) { - ctx.fillRect(x + 6, y, 8, 4); - ctx.fillRect(x + 8, y - 2, 4, 2); + ctx.fillRect(x + 6, y, 8, 4) + ctx.fillRect(x + 8, y - 2, 4, 2) } else { - ctx.fillRect(x + 6, y + 10, 8, 4); - ctx.fillRect(x + 8, y + 14, 4, 2); + ctx.fillRect(x + 6, y + 10, 8, 4) + ctx.fillRect(x + 8, y + 14, 4, 2) } // tail - ctx.fillStyle = p.detail; - ctx.fillRect(x, y + 2, 4, 4); + ctx.fillStyle = p.detail + ctx.fillRect(x, y + 2, 4, 4) } export function DinoGame() { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const widthRef = useRef(CANVAS_W_INITIAL); - const { resolved } = useTheme(); + const canvasRef = useRef(null) + const containerRef = useRef(null) + const widthRef = useRef(CANVAS_W_INITIAL) + const { resolved } = useTheme() const stateRef = useRef({ minerY: GROUND_Y - MINER_H - 4, velY: 0, @@ -289,251 +278,243 @@ export function DinoGame() { frameCount: 0, saltTimer: 30, birdTimer: 100, - }); - const animRef = useRef(0); - const [, setScore] = useState(0); - const [, setGameOver] = useState(false); + }) + const animRef = useRef(0) + const [, setScore] = useState(0) + const [, setGameOver] = useState(false) const reset = useCallback(() => { - const s = stateRef.current; - s.minerY = GROUND_Y - MINER_H - 4; - s.velY = 0; - s.jumping = false; - s.ducking = false; - s.salts = []; - s.birds = []; - s.speed = INITIAL_SPEED; - s.score = 0; - s.gameOver = false; - s.frameCount = 0; - s.saltTimer = 30; - s.birdTimer = 100; - setGameOver(false); - setScore(0); - }, []); + const s = stateRef.current + s.minerY = GROUND_Y - MINER_H - 4 + s.velY = 0 + s.jumping = false + s.ducking = false + s.salts = [] + s.birds = [] + s.speed = INITIAL_SPEED + s.score = 0 + s.gameOver = false + s.frameCount = 0 + s.saltTimer = 30 + s.birdTimer = 100 + setGameOver(false) + setScore(0) + }, []) const jump = useCallback(() => { - const s = stateRef.current; + const s = stateRef.current if (s.gameOver) { - reset(); - return; + reset() + return } if (!s.jumping) { - s.velY = JUMP_VEL; - s.jumping = true; + s.velY = JUMP_VEL + s.jumping = true } - }, [reset]); + }, [reset]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === "Space" || e.code === "ArrowUp") { - e.preventDefault(); - jump(); + if (e.code === 'Space' || e.code === 'ArrowUp') { + e.preventDefault() + jump() } - if (e.code === "ArrowDown") { - e.preventDefault(); - const s = stateRef.current; - if (s.gameOver) return; - s.ducking = true; - if (s.jumping) s.velY = Math.max(s.velY, 8); + if (e.code === 'ArrowDown') { + e.preventDefault() + const s = stateRef.current + if (s.gameOver) return + s.ducking = true + if (s.jumping) s.velY = Math.max(s.velY, 8) } - }; + } const handleKeyUp = (e: KeyboardEvent) => { - if (e.code === "ArrowDown") { - stateRef.current.ducking = false; + if (e.code === 'ArrowDown') { + stateRef.current.ducking = false } - }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); + } + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, [jump]); + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [jump]) useEffect(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - const ctx = canvas.getContext("2d")!; - const palette = readPalette(canvas); + const canvas = canvasRef.current + const container = containerRef.current + if (!canvas || !container) return + const ctx = canvas.getContext('2d')! + const palette = readPalette(canvas) const sync = (dispW: number) => { - canvas.width = dispW; - widthRef.current = Math.floor(dispW / SCALE); - }; + canvas.width = dispW + widthRef.current = Math.floor(dispW / SCALE) + } - const initialDisp = Math.floor(container.getBoundingClientRect().width) || CANVAS_W_INITIAL * SCALE; - sync(initialDisp); + const initialDisp = + Math.floor(container.getBoundingClientRect().width) || CANVAS_W_INITIAL * SCALE + sync(initialDisp) const ro = new ResizeObserver(([entry]) => { - if (!entry) return; - const dispW = Math.floor(entry.contentRect.width); - if (dispW > 0 && dispW !== canvas.width) sync(dispW); - }); - ro.observe(container); + if (!entry) return + const dispW = Math.floor(entry.contentRect.width) + if (dispW > 0 && dispW !== canvas.width) sync(dispW) + }) + ro.observe(container) const loop = () => { - const s = stateRef.current; - const minerBottom = GROUND_Y - MINER_H - 4; - const W = widthRef.current; + const s = stateRef.current + const minerBottom = GROUND_Y - MINER_H - 4 + const W = widthRef.current if (!s.gameOver) { // physics - s.velY += GRAVITY; - s.minerY += s.velY; + s.velY += GRAVITY + s.minerY += s.velY if (s.minerY >= minerBottom) { - s.minerY = minerBottom; - s.velY = 0; - s.jumping = false; + s.minerY = minerBottom + s.velY = 0 + s.jumping = false } // spawn salt blocks (collectibles) - s.saltTimer--; + s.saltTimer-- if (s.saltTimer <= 0) { - const w = 14 + Math.random() * 6; + const w = 14 + Math.random() * 6 s.salts.push({ x: W, h: 10 + Math.random() * 8, w, mined: false, sparkle: 0, - }); - s.saltTimer = 40 + Math.random() * 60; + }) + s.saltTimer = 40 + Math.random() * 60 } // spawn birds (obstacles) - low birds (jump) and high birds (duck) - s.birdTimer--; + s.birdTimer-- if (s.birdTimer <= 0) { - const isHigh = Math.random() < 0.4; + const isHigh = Math.random() < 0.4 s.birds.push({ x: W, y: isHigh ? GROUND_Y - 50 - Math.random() * 15 : GROUND_Y - 20 - Math.random() * 15, frame: 0, - }); - s.birdTimer = 80 + Math.random() * 100; + }) + s.birdTimer = 80 + Math.random() * 100 } // move salt blocks - for (const b of s.salts) b.x -= s.speed; + for (const b of s.salts) b.x -= s.speed // mine salt when miner passes over it - const mLeft = 30; - const mRight = mLeft + MINER_W; + const mLeft = 30 + const mRight = mLeft + MINER_W for (const b of s.salts) { if (!b.mined && mRight > b.x && mLeft < b.x + b.w) { - b.mined = true; - b.sparkle = 1; - s.score += 10; - setScore(s.score); - playMineSfx(); + b.mined = true + b.sparkle = 1 + s.score += 10 + setScore(s.score) + playMineSfx() } - if (b.mined && b.sparkle > 0) b.sparkle++; + if (b.mined && b.sparkle > 0) b.sparkle++ } - s.salts = s.salts.filter((b) => b.x + b.w > 0 && (!b.mined || b.sparkle < 25)); + s.salts = s.salts.filter((b) => b.x + b.w > 0 && (!b.mined || b.sparkle < 25)) // move birds for (const b of s.birds) { - b.x -= s.speed + 1; - b.frame++; + b.x -= s.speed + 1 + b.frame++ } - s.birds = s.birds.filter((b) => b.x + 24 > 0); + s.birds = s.birds.filter((b) => b.x + 24 > 0) // bird collision (ducking reduces height) - const effectiveH = s.ducking ? MINER_DUCK_H : MINER_H + 4; - const mTop = s.ducking ? GROUND_Y - MINER_DUCK_H - 2 : s.minerY - 6; - const mBottom = mTop + effectiveH; + const effectiveH = s.ducking ? MINER_DUCK_H : MINER_H + 4 + const mTop = s.ducking ? GROUND_Y - MINER_DUCK_H - 2 : s.minerY - 6 + const mBottom = mTop + effectiveH for (const b of s.birds) { - if ( - mRight > b.x + 4 && - mLeft < b.x + 18 && - mBottom > b.y + 2 && - mTop < b.y + 12 - ) { - s.gameOver = true; - setGameOver(true); - break; + if (mRight > b.x + 4 && mLeft < b.x + 18 && mBottom > b.y + 2 && mTop < b.y + 12) { + s.gameOver = true + setGameOver(true) + break } } - s.speed += SPEED_INC; - s.frameCount++; + s.speed += SPEED_INC + s.frameCount++ } // draw - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.scale(SCALE, SCALE); + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.save() + ctx.scale(SCALE, SCALE) // ground - ctx.strokeStyle = palette.line; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, GROUND_Y + 0.5); - ctx.lineTo(W, GROUND_Y + 0.5); - ctx.stroke(); + ctx.strokeStyle = palette.line + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(0, GROUND_Y + 0.5) + ctx.lineTo(W, GROUND_Y + 0.5) + ctx.stroke() // ground debris - ctx.fillStyle = palette.debris; + ctx.fillStyle = palette.debris for (let i = 0; i < W; i += 30) { - const offset = (i * 7 + s.frameCount) % 40; - ctx.fillRect(((i + offset * s.speed * 0.3) % W), GROUND_Y + 2, 2, 1); - ctx.fillRect(((i + 15 + offset * s.speed * 0.3) % W), GROUND_Y + 4, 1, 1); + const offset = (i * 7 + s.frameCount) % 40 + ctx.fillRect((i + offset * s.speed * 0.3) % W, GROUND_Y + 2, 2, 1) + ctx.fillRect((i + 15 + offset * s.speed * 0.3) % W, GROUND_Y + 4, 1, 1) } // salt blocks (draw before miner so sparkles show on top) for (const b of s.salts) { if (b.mined) { if (b.sparkle < 25) { - drawSparkle(ctx, b.x + b.w / 2, GROUND_Y - b.h / 2, b.sparkle, palette); + drawSparkle(ctx, b.x + b.w / 2, GROUND_Y - b.h / 2, b.sparkle, palette) } } else { - drawSaltBlock(ctx, b.x, GROUND_Y - b.h, b.w, b.h, palette); + drawSaltBlock(ctx, b.x, GROUND_Y - b.h, b.w, b.h, palette) } } // miner if (s.ducking && !s.jumping) { - drawMinerDucking(ctx, 30, GROUND_Y - MINER_DUCK_H - 2, s.frameCount, palette); + drawMinerDucking(ctx, 30, GROUND_Y - MINER_DUCK_H - 2, s.frameCount, palette) } else { - drawMiner(ctx, 30, s.minerY, s.frameCount, s.jumping, palette); + drawMiner(ctx, 30, s.minerY, s.frameCount, s.jumping, palette) } // birds for (const b of s.birds) { - drawBird(ctx, b.x, b.y, b.frame, palette); + drawBird(ctx, b.x, b.y, b.frame, palette) } // score - ctx.fillStyle = palette.scoreFg; - ctx.font = "11px 'Geist Mono', monospace"; - ctx.textAlign = "right"; - ctx.fillText(`⛏ ${String(s.score).padStart(5, "0")}`, W - 12, 20); + ctx.fillStyle = palette.scoreFg + ctx.font = "11px 'Geist Mono', monospace" + ctx.textAlign = 'right' + ctx.fillText(`⛏ ${String(s.score).padStart(5, '0')}`, W - 12, 20) // game over if (s.gameOver) { - ctx.fillStyle = palette.gameOverFg; - ctx.font = "12px 'Geist Mono', monospace"; - ctx.textAlign = "center"; - ctx.fillText("REKT", W / 2, CANVAS_H / 2 - 4); - ctx.fillStyle = palette.gameOverSub; - ctx.font = "10px 'Geist Mono', monospace"; - ctx.fillText( - "tap or space to restart", - W / 2, - CANVAS_H / 2 + 12 - ); + ctx.fillStyle = palette.gameOverFg + ctx.font = "12px 'Geist Mono', monospace" + ctx.textAlign = 'center' + ctx.fillText('REKT', W / 2, CANVAS_H / 2 - 4) + ctx.fillStyle = palette.gameOverSub + ctx.font = "10px 'Geist Mono', monospace" + ctx.fillText('tap or space to restart', W / 2, CANVAS_H / 2 + 12) } - ctx.restore(); - animRef.current = requestAnimationFrame(loop); - }; + ctx.restore() + animRef.current = requestAnimationFrame(loop) + } - animRef.current = requestAnimationFrame(loop); + animRef.current = requestAnimationFrame(loop) return () => { - cancelAnimationFrame(animRef.current); - ro.disconnect(); - }; - }, [resolved]); + cancelAnimationFrame(animRef.current) + ro.disconnect() + } + }, [resolved]) return (
@@ -545,5 +526,5 @@ export function DinoGame() { style={{ height: CANVAS_H * SCALE }} />
- ); + ) } diff --git a/site/src/landing/footer.tsx b/site/src/landing/footer.tsx index 212a1fc2..b3e36736 100644 --- a/site/src/landing/footer.tsx +++ b/site/src/landing/footer.tsx @@ -1,33 +1,32 @@ -"use client"; +'use client' -import { Suspense, lazy, useEffect, useRef, useState } from "react"; -import { PlayMark } from "./icons"; +import { Suspense, lazy, useEffect, useRef, useState } from 'react' -const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"; +import { PlayMark } from './icons' -const DinoGame = lazy(() => - import("./dino-game").then((module) => ({ default: module.DinoGame })), -); +const easeOut = 'cubic-bezier(0.23, 1, 0.32, 1)' + +const DinoGame = lazy(() => import('./dino-game').then((module) => ({ default: module.DinoGame }))) function GameLoading() { return (
Loading game
- ); + ) } export default function Footer() { - const [revealed, setRevealed] = useState(false); - const footerRef = useRef(null); + const [revealed, setRevealed] = useState(false) + const footerRef = useRef(null) useEffect(() => { - if (!revealed || !footerRef.current) return; + if (!revealed || !footerRef.current) return const id = window.requestAnimationFrame(() => { - footerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - }); - return () => window.cancelAnimationFrame(id); - }, [revealed]); + footerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) + }) + return () => window.cancelAnimationFrame(id) + }, [revealed]) return (
{ - e.stopPropagation(); - setRevealed(false); + e.stopPropagation() + setRevealed(false) }} className="absolute top-3 left-6 z-10 flex h-7 w-7 items-center justify-center rounded-md border border-border bg-foreground/[0.04] font-mono text-[16px] leading-none text-foreground-muted backdrop-blur-sm transition-[background-color,border-color,color] duration-150 hover:border-border-strong hover:bg-foreground/[0.08] hover:text-foreground sm:left-9" > @@ -77,12 +76,12 @@ export default function Footer() { className="relative z-10 px-6 outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-info focus-visible:outline-offset-2 transition-[opacity,transform] hover:opacity-80 active:translate-y-px sm:px-9" style={{ filter: - "drop-shadow(0 0 12px var(--footer-glow)) drop-shadow(0 0 24px var(--footer-glow))", + 'drop-shadow(0 0 12px var(--footer-glow)) drop-shadow(0 0 24px var(--footer-glow))', }} > )}
- ); + ) } diff --git a/site/src/landing/guides.tsx b/site/src/landing/guides.tsx index db67d245..79668a9a 100644 --- a/site/src/landing/guides.tsx +++ b/site/src/landing/guides.tsx @@ -1,4 +1,4 @@ -const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"; +const easeOut = 'cubic-bezier(0.23, 1, 0.32, 1)' // Tailwind v4 wasn't picking up the custom `.dash-tl` / `.dash-frame` // utilities from globals.css, so we inline the dash background-images @@ -8,20 +8,19 @@ const HORIZONTAL_DASH = `repeating-linear-gradient( to right, var(--dash-color) 0 var(--dash-length), transparent var(--dash-length) calc(var(--dash-length) + var(--dash-gap)) -)`; +)` const VERTICAL_DASH = `repeating-linear-gradient( to bottom, var(--dash-color) 0 var(--dash-length), transparent var(--dash-length) calc(var(--dash-length) + var(--dash-gap)) -)`; +)` const cardDashStyle: React.CSSProperties = { backgroundImage: `${HORIZONTAL_DASH}, ${VERTICAL_DASH}`, - backgroundSize: - "100% var(--dash-thickness), var(--dash-thickness) 100%", - backgroundPosition: "top left, top left", - backgroundRepeat: "no-repeat", -}; + backgroundSize: '100% var(--dash-thickness), var(--dash-thickness) 100%', + backgroundPosition: 'top left, top left', + backgroundRepeat: 'no-repeat', +} // Only paint the bottom + right of the outer frame. The cards already // contribute the top and left edges via `cardDashStyle`, so painting @@ -31,64 +30,58 @@ const cardDashStyle: React.CSSProperties = { // noticeably darker stroke than the rest of the page). const frameDashStyle: React.CSSProperties = { backgroundImage: `${HORIZONTAL_DASH}, ${VERTICAL_DASH}`, - backgroundSize: - "100% var(--dash-thickness), var(--dash-thickness) 100%", - backgroundPosition: "bottom left, top right", - backgroundRepeat: "no-repeat", -}; + backgroundSize: '100% var(--dash-thickness), var(--dash-thickness) 100%', + backgroundPosition: 'bottom left, top right', + backgroundRepeat: 'no-repeat', +} type Guide = { - title: string; - href: string; -}; + title: string + href: string +} const GUIDES: readonly Guide[] = [ { - title: "Getting Started", - href: "/docs", + title: 'Getting Started', + href: '/docs', }, { - title: "Authentication", - href: "/docs/guides/connect-accounts", + title: 'Authentication', + href: '/docs/guides/connect-accounts', }, { - title: "Transfers", - href: "/docs/guides/transfers", + title: 'Transfers', + href: '/docs/guides/transfers', }, { - title: "Spend Permissions", - href: "/docs/guides/spend-permissions", + title: 'Spend Permissions', + href: '/docs/guides/spend-permissions', }, { - title: "Subscriptions", - href: "/docs/guides/subscriptions", + title: 'Subscriptions', + href: '/docs/guides/subscriptions', }, { - title: "Fee Sponsorship", - href: "/docs/guides/fee-sponsorship", + title: 'Fee Sponsorship', + href: '/docs/guides/fee-sponsorship', }, { - title: "Exchange Currencies", - href: "/docs/guides/swaps", + title: 'Exchange Currencies', + href: '/docs/guides/swaps', }, { - title: "Deposits", - href: "/docs/guides/deposits", + title: 'Deposits', + href: '/docs/guides/deposits', }, { - title: "Themes", - href: "/docs/guides/theming", + title: 'Themes', + href: '/docs/guides/theming', }, -]; +] function ArrowUpRight() { return ( - + - ); + ) } export default function Guides() { @@ -111,14 +104,11 @@ export default function Guides() { Guides

- Learn the core SDK flows, from authentication and deposits to - transfers, subscriptions, exchange, and theming. + Learn the core SDK flows, from authentication and deposits to transfers, subscriptions, + exchange, and theming.

-
+
{GUIDES.map((g) => ( - - {g.title} - + {g.title} ))}
- ); + ) } diff --git a/site/src/landing/hero.tsx b/site/src/landing/hero.tsx index f62962d8..bf8b54f4 100644 --- a/site/src/landing/hero.tsx +++ b/site/src/landing/hero.tsx @@ -1,122 +1,131 @@ -"use client"; - -import { stagger, waapi, type WAAPIAnimation } from "animejs"; -import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Link } from "vocs"; -import { springs } from "./animation"; -import AsciiBackground from "./ascii-bg"; -import { connectWallet } from "./demo/sdk"; -import { AgentCopyIcon, CopyIcon, DocsIcon, GithubIcon, TempoIcon } from "./icons"; -import { useTempoSession } from "./sections/useTempoSession"; -import { ThemeSwitch } from "./theme-switch"; - -type PackageManager = "npm" | "pnpm" | "bun"; -type Adapter = "tempoAuth" | "webAuth" | "privy" | "turnkey"; - -const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"; -const PM_ROTATE_MS = 1800; -const HERO_ENTRANCE_STAGGER_MS = 28; -const HERO_NAV_DELAY_MS = 120; +'use client' + +import { stagger, waapi, type WAAPIAnimation } from 'animejs' +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { Link } from 'vocs' + +import { springs } from './animation' +import AsciiBackground from './ascii-bg' +import { connectWallet } from './demo/sdk' +import { AgentCopyIcon, CopyIcon, DocsIcon, GithubIcon, TempoIcon } from './icons' +import { useTempoSession } from './sections/useTempoSession' +import { ThemeSwitch } from './theme-switch' + +type PackageManager = 'npm' | 'pnpm' | 'bun' +type Adapter = 'tempoAuth' | 'webAuth' | 'privy' | 'turnkey' + +const easeOut = 'cubic-bezier(0.23, 1, 0.32, 1)' +const PM_ROTATE_MS = 1800 +const HERO_ENTRANCE_STAGGER_MS = 28 +const HERO_NAV_DELAY_MS = 120 const heroEntranceInitialStyle = { opacity: 0, - translate: "0 10px", - willChange: "opacity, translate", -} satisfies CSSProperties; + translate: '0 10px', + willChange: 'opacity, translate', +} satisfies CSSProperties const heroNavInitialStyle = { opacity: 0, - translate: "0 -46px", - willChange: "opacity, translate", -} satisfies CSSProperties; + translate: '0 -46px', + willChange: 'opacity, translate', +} satisfies CSSProperties type HeroNavFall = { - translate: string; - delay: number; -}; + translate: string + delay: number +} const HERO_NAV_DEFAULT_FALL: HeroNavFall = { - translate: "0 -46px", + translate: '0 -46px', delay: 0, -}; +} const HERO_NAV_FALLS: readonly HeroNavFall[] = [ - { translate: "0 -12px", delay: 60 }, - { translate: "0 -12px", delay: 0 }, - { translate: "0 -12px", delay: 120 }, - { translate: "0 -12px", delay: 180 }, -]; + { translate: '0 -12px', delay: 60 }, + { translate: '0 -12px', delay: 0 }, + { translate: '0 -12px', delay: 120 }, + { translate: '0 -12px', delay: 180 }, +] function heroNavStyle(fall: HeroNavFall | undefined) { - if (!fall) fall = HERO_NAV_DEFAULT_FALL; + if (!fall) fall = HERO_NAV_DEFAULT_FALL return { ...heroNavInitialStyle, translate: fall.translate, - } satisfies CSSProperties; + } satisfies CSSProperties } const installCommand: Record = { - npm: { prefix: "npm i", pkg: "accounts" }, - pnpm: { prefix: "pnpm add", pkg: "accounts" }, - bun: { prefix: "bun add", pkg: "accounts" }, -}; + npm: { prefix: 'npm i', pkg: 'accounts' }, + pnpm: { prefix: 'pnpm add', pkg: 'accounts' }, + bun: { prefix: 'bun add', pkg: 'accounts' }, +} -const PACKAGE_MANAGERS: PackageManager[] = ["npm", "pnpm", "bun"]; +const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'bun'] const adapterInfo: Record = { tempoAuth: { - title: "Tempo Wallet Adapter", + title: 'Tempo Wallet Adapter', description: - "Enables universal wallet experiences by delegating signing to an external origin dialog. Also exported as tempoWallet.", + 'Enables universal wallet experiences by delegating signing to an external origin dialog. Also exported as tempoWallet.', }, webAuth: { - title: "WebAuthn Adapter", + title: 'WebAuthn Adapter', description: - "Authenticates users with on-device passkeys via the WebAuthn ceremony — no popup, no third-party host. Best for first-party flows where you control the relying party.", + 'Authenticates users with on-device passkeys via the WebAuthn ceremony — no popup, no third-party host. Best for first-party flows where you control the relying party.', }, privy: { - title: "Privy Adapter", + title: 'Privy Adapter', description: "Bring your own auth: route sign-in through Privy's embedded wallets while keeping the Tempo Accounts SDK's wagmi-compatible surface. Falls back to the Tempo dialog when Privy is unavailable.", }, turnkey: { - title: "Turnkey Adapter", + title: 'Turnkey Adapter', description: - "Bring your own signing infrastructure: delegate key management and approvals to Turnkey while the Tempo Accounts SDK exposes the same wagmi-compatible surface to your app.", + 'Bring your own signing infrastructure: delegate key management and approvals to Turnkey while the Tempo Accounts SDK exposes the same wagmi-compatible surface to your app.', }, -}; +} const agentInstructions = - "Read accounts.tempo.xyz/docs and integrate Tempo accounts into my application"; + 'Read accounts.tempo.xyz/docs and integrate Tempo accounts into my application' function shorten(addr: string) { - return `${addr.slice(0, 6)}…${addr.slice(-4)}`; + return `${addr.slice(0, 6)}…${addr.slice(-4)}` } function useCopy() { - const [copied, setCopied] = useState(false); - const timer = useRef | null>(null); + const [copied, setCopied] = useState(false) + const timer = useRef | null>(null) useEffect( () => () => { - if (timer.current) clearTimeout(timer.current); + if (timer.current) clearTimeout(timer.current) }, [], - ); + ) const copy = async (text: string) => { try { - await navigator.clipboard.writeText(text); - setCopied(true); - if (timer.current) clearTimeout(timer.current); - timer.current = setTimeout(() => setCopied(false), 1200); + await navigator.clipboard.writeText(text) + setCopied(true) + if (timer.current) clearTimeout(timer.current) + timer.current = setTimeout(() => setCopied(false), 1200) } catch { // ignore } - }; - return { copied, copy }; + } + return { copied, copy } } function TopNav({ navStaggerStyles, }: { - navStaggerStyles: readonly (CSSProperties | undefined)[]; + navStaggerStyles: readonly (CSSProperties | undefined)[] }) { return ( - ); + ) } -function HeroIntro({ - staggerStyle, -}: { - staggerStyle: CSSProperties | undefined; -}) { - const [pmIndex, setPmIndex] = useState(0); - const [pmItems, setPmItems] = useState(() => [{ key: 0, index: 0 }]); - const [pmPaused, setPmPaused] = useState(false); - const [pmManual, setPmManual] = useState(false); - const pmTimer = useRef(null); - const pmPausedRef = useRef(false); - const pmManualRef = useRef(false); - const pmIndexRef = useRef(0); - const pmKey = useRef(1); - const activePmKey = useRef(0); - const animatedPmKey = useRef(0); - const prefixRef = useRef(null); - const prefixItemRefs = useRef(new Map()); - const prefixAnimations = useRef(new Map()); - const prefixWidthAnimation = useRef(null); - const exitingPmKeys = useRef(new Set()); - const previousPmIndex = useRef(0); - const { copied: copiedInstall, copy: copyInstall } = useCopy(); - const { copied: copiedAgent, copy: copyAgent } = useCopy(); - const pm = PACKAGE_MANAGERS[pmIndex] ?? "npm"; - const cmd = installCommand[pm]; - const fullCommand = `${cmd.prefix} ${cmd.pkg}`; +function HeroIntro({ staggerStyle }: { staggerStyle: CSSProperties | undefined }) { + const [pmIndex, setPmIndex] = useState(0) + const [pmItems, setPmItems] = useState(() => [{ key: 0, index: 0 }]) + const [pmPaused, setPmPaused] = useState(false) + const [pmManual, setPmManual] = useState(false) + const pmTimer = useRef(null) + const pmPausedRef = useRef(false) + const pmManualRef = useRef(false) + const pmIndexRef = useRef(0) + const pmKey = useRef(1) + const activePmKey = useRef(0) + const animatedPmKey = useRef(0) + const prefixRef = useRef(null) + const prefixItemRefs = useRef(new Map()) + const prefixAnimations = useRef(new Map()) + const prefixWidthAnimation = useRef(null) + const exitingPmKeys = useRef(new Set()) + const previousPmIndex = useRef(0) + const { copied: copiedInstall, copy: copyInstall } = useCopy() + const { copied: copiedAgent, copy: copyAgent } = useCopy() + const pm = PACKAGE_MANAGERS[pmIndex] ?? 'npm' + const cmd = installCommand[pm] + const fullCommand = `${cmd.prefix} ${cmd.pkg}` const clearPmTimer = useCallback(() => { - if (pmTimer.current === null) return; - window.clearTimeout(pmTimer.current); - pmTimer.current = null; - }, []); + if (pmTimer.current === null) return + window.clearTimeout(pmTimer.current) + pmTimer.current = null + }, []) const advancePm = useCallback(() => { - const index = (pmIndexRef.current + 1) % PACKAGE_MANAGERS.length; - const key = pmKey.current++; - pmIndexRef.current = index; - activePmKey.current = key; - setPmIndex(index); - setPmItems((items) => [...items, { key, index }]); - }, []); + const index = (pmIndexRef.current + 1) % PACKAGE_MANAGERS.length + const key = pmKey.current++ + pmIndexRef.current = index + activePmKey.current = key + setPmIndex(index) + setPmItems((items) => [...items, { key, index }]) + }, []) const schedulePmRotation = useCallback(() => { - clearPmTimer(); - if (pmManualRef.current || pmPausedRef.current || document.hidden) return; + clearPmTimer() + if (pmManualRef.current || pmPausedRef.current || document.hidden) return pmTimer.current = window.setTimeout(() => { - pmTimer.current = null; - if (pmManualRef.current || pmPausedRef.current || document.hidden) return; - advancePm(); - schedulePmRotation(); - }, PM_ROTATE_MS); - }, [advancePm, clearPmTimer]); + pmTimer.current = null + if (pmManualRef.current || pmPausedRef.current || document.hidden) return + advancePm() + schedulePmRotation() + }, PM_ROTATE_MS) + }, [advancePm, clearPmTimer]) const setPmPausedValue = (paused: boolean) => { - pmPausedRef.current = paused; - setPmPaused(paused); - if (paused) clearPmTimer(); - }; + pmPausedRef.current = paused + setPmPaused(paused) + if (paused) clearPmTimer() + } const nextPm = () => { - pmManualRef.current = true; - setPmManual(true); - clearPmTimer(); - advancePm(); - }; + pmManualRef.current = true + setPmManual(true) + clearPmTimer() + advancePm() + } useEffect(() => { - pmManualRef.current = pmManual; - pmPausedRef.current = pmPaused; - schedulePmRotation(); - }, [pmManual, pmPaused, schedulePmRotation]); + pmManualRef.current = pmManual + pmPausedRef.current = pmPaused + schedulePmRotation() + }, [pmManual, pmPaused, schedulePmRotation]) useEffect(() => { const onVisibilityChange = () => { - schedulePmRotation(); - }; + schedulePmRotation() + } const onBlur = () => { - clearPmTimer(); - }; + clearPmTimer() + } const onFocus = () => { - schedulePmRotation(); - }; - document.addEventListener("visibilitychange", onVisibilityChange); - window.addEventListener("blur", onBlur); - window.addEventListener("focus", onFocus); - schedulePmRotation(); + schedulePmRotation() + } + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('blur', onBlur) + window.addEventListener('focus', onFocus) + schedulePmRotation() return () => { - document.removeEventListener("visibilitychange", onVisibilityChange); - window.removeEventListener("blur", onBlur); - window.removeEventListener("focus", onFocus); - clearPmTimer(); - }; - }, [clearPmTimer, schedulePmRotation]); + document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('blur', onBlur) + window.removeEventListener('focus', onFocus) + clearPmTimer() + } + }, [clearPmTimer, schedulePmRotation]) useLayoutEffect(() => { - const prefix = prefixRef.current; - if (!prefix) return; + const prefix = prefixRef.current + if (!prefix) return - const previous = previousPmIndex.current; + const previous = previousPmIndex.current if (previous !== pmIndex) { - const prevPm = PACKAGE_MANAGERS[previous] ?? "npm"; - const prevCmd = installCommand[prevPm]; - prefixWidthAnimation.current?.cancel(); + const prevPm = PACKAGE_MANAGERS[previous] ?? 'npm' + const prevCmd = installCommand[prevPm] + prefixWidthAnimation.current?.cancel() prefixWidthAnimation.current = waapi.animate(prefix, { width: [`${prevCmd.prefix.length}ch`, `${cmd.prefix.length}ch`], ease: springs.snappy, - }); - previousPmIndex.current = pmIndex; + }) + previousPmIndex.current = pmIndex } - const active = activePmKey.current; + const active = activePmKey.current for (const item of pmItems) { - const el = prefixItemRefs.current.get(item.key); - if (!el) continue; + const el = prefixItemRefs.current.get(item.key) + if (!el) continue if (item.key === active) { - if (animatedPmKey.current === active) continue; - animatedPmKey.current = active; - prefixAnimations.current.get(item.key)?.cancel(); - exitingPmKeys.current.delete(item.key); + if (animatedPmKey.current === active) continue + animatedPmKey.current = active + prefixAnimations.current.get(item.key)?.cancel() + exitingPmKeys.current.delete(item.key) prefixAnimations.current.set( item.key, waapi.animate(el, { @@ -292,38 +289,38 @@ function HeroIntro({ translateX: [-14, 0], ease: springs.snappy, }), - ); - continue; + ) + continue } - if (exitingPmKeys.current.has(item.key)) continue; + if (exitingPmKeys.current.has(item.key)) continue - prefixAnimations.current.get(item.key)?.cancel(); + prefixAnimations.current.get(item.key)?.cancel() const animation = waapi.animate(el, { opacity: [1, 0], translateX: [0, 14], ease: springs.snappy, - }); - exitingPmKeys.current.add(item.key); - prefixAnimations.current.set(item.key, animation); + }) + exitingPmKeys.current.add(item.key) + prefixAnimations.current.set(item.key, animation) void animation.then(() => { - exitingPmKeys.current.delete(item.key); - prefixAnimations.current.delete(item.key); - setPmItems((items) => items.filter((i) => i.key !== item.key)); - }); + exitingPmKeys.current.delete(item.key) + prefixAnimations.current.delete(item.key) + setPmItems((items) => items.filter((i) => i.key !== item.key)) + }) } - }, [cmd.prefix.length, pmIndex, pmItems]); + }, [cmd.prefix.length, pmIndex, pmItems]) useEffect( () => () => { - prefixWidthAnimation.current?.cancel(); + prefixWidthAnimation.current?.cancel() for (const animation of prefixAnimations.current.values()) { - animation.cancel(); + animation.cancel() } - prefixAnimations.current.clear(); + prefixAnimations.current.clear() }, [], - ); + ) return (
@@ -344,22 +341,18 @@ function HeroIntro({

-
+
setPmPausedValue(true)} onPointerLeave={() => setPmPausedValue(false)} onFocus={() => setPmPausedValue(true)} onBlur={(event) => { - const next = event.relatedTarget; + const next = event.relatedTarget if (next instanceof Node && event.currentTarget.contains(next)) { - return; + return } - setPmPausedValue(false); + setPmPausedValue(false) }} >
@@ -370,7 +363,7 @@ function HeroIntro({ aria-label={`Switch package manager from ${cmd.prefix}`} className="relative inline-block overflow-hidden whitespace-nowrap border-0 bg-transparent p-0 text-left align-bottom outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-info focus-visible:outline-offset-2 transition-opacity hover:opacity-75" style={{ - textAlign: "left", + textAlign: 'left', width: `${cmd.prefix.length}ch`, }} > @@ -378,24 +371,24 @@ function HeroIntro({ {cmd.prefix} {pmItems.map((item) => { - const itemPm = PACKAGE_MANAGERS[item.index] ?? "npm"; - const itemCmd = installCommand[itemPm]; + const itemPm = PACKAGE_MANAGERS[item.index] ?? 'npm' + const itemCmd = installCommand[itemPm] return ( { - if (el) prefixItemRefs.current.set(item.key, el); - else prefixItemRefs.current.delete(item.key); + if (el) prefixItemRefs.current.set(item.key, el) + else prefixItemRefs.current.delete(item.key) }} aria-hidden={item.key === activePmKey.current ? undefined : true} className="absolute top-0 left-0 text-left text-foreground-subtle" style={{ - willChange: "transform, opacity", + willChange: 'transform, opacity', }} > {itemCmd.prefix} - ); + ) })} {cmd.pkg} @@ -403,29 +396,29 @@ function HeroIntro({
@@ -440,13 +433,7 @@ function HeroIntro({ className="flex items-center gap-1.5 text-[12px] text-foreground outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-info focus-visible:outline-offset-2 transition-[opacity,transform] hover:opacity-75 active:translate-y-px active:opacity-90" > View docs - + copied @@ -479,21 +466,21 @@ function HeroIntro({
- ); + ) } const Keyword = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Str = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Fn = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Var = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Hl = ({ children }: { children: React.ReactNode }) => ( ( > {children} -); +) const codeFor: Record = { - tempoAuth: { connector: "tempoWallet", importPath: "wagmi/connectors" }, - webAuth: { connector: "webAuthConnector", importPath: "@tempo/web-auth" }, - privy: { connector: "privyConnector", importPath: "@privy-io/wagmi" }, - turnkey: { connector: "turnkeyConnector", importPath: "@turnkey/wagmi" }, -}; + tempoAuth: { connector: 'tempoWallet', importPath: 'wagmi/connectors' }, + webAuth: { connector: 'webAuthConnector', importPath: '@tempo/web-auth' }, + privy: { connector: 'privyConnector', importPath: '@privy-io/wagmi' }, + turnkey: { connector: 'turnkeyConnector', importPath: '@turnkey/wagmi' }, +} function CodeBlock({ adapter }: { adapter: Adapter }) { - const { connector, importPath } = codeFor[adapter]; + const { connector, importPath } = codeFor[adapter] return (
       
         
- import {"{ createConfig, http } "} + import {'{ createConfig, http } '} from {`'wagmi'`}
- import {"{ tempo } "} + import {'{ tempo } '} from {`'wagmi/chains'`}
- import {"{ "} + import {'{ '} {connector} - {" } "} + {' } '} from {`'${importPath}'`}
-
{" "}
+
{' '}
- export const config ={" "} + export const config ={' '} createConfig - {"({"} + {'({'}
-
{" chains: [tempo],"}
+
{' chains: [tempo],'}
- {" connectors: ["} + {' connectors: ['} {connector} - {"()],"} + {'()],'}
-
{" transports: {"}
+
{' transports: {'}
- {" [tempo.id]: "} + {' [tempo.id]: '} http - {"(),"} + {'(),'}
-
{" },"}
-
{"})"}
+
{' },'}
+
{'})'}
- ); + ) } function AdapterTabs({ adapter, setAdapter, }: { - adapter: Adapter; - setAdapter: (a: Adapter) => void; + adapter: Adapter + setAdapter: (a: Adapter) => void }) { const tabs: { id: Adapter; label: string }[] = [ - { id: "tempoAuth", label: "tempoAuth" }, - { id: "webAuth", label: "webAuth" }, - { id: "privy", label: "privy" }, - { id: "turnkey", label: "turnkey" }, - ]; - const tabsRef = useRef(null); + { id: 'tempoAuth', label: 'tempoAuth' }, + { id: 'webAuth', label: 'webAuth' }, + { id: 'privy', label: 'privy' }, + { id: 'turnkey', label: 'turnkey' }, + ] + const tabsRef = useRef(null) const [highlight, setHighlight] = useState({ left: 0, width: 0, ready: false, - }); + }) useEffect(() => { - const container = tabsRef.current; - if (!container) return; - const button = container.querySelector( - `button[data-adapter="${adapter}"]`, - ); - if (!button) return; + const container = tabsRef.current + if (!container) return + const button = container.querySelector(`button[data-adapter="${adapter}"]`) + if (!button) return setHighlight({ left: button.offsetLeft, width: button.offsetWidth, ready: true, - }); - }, [adapter]); + }) + }, [adapter]) return (
Adapter - { - /* + {/* Inactive pills get the dimmer bg from the row container itself (`bg-panel-0`). The active pill's brighter bg + border come from a single floating that slides between positions — buttons are transparent so the slide reads through them. - */ - } + */}
{tabs.map((t) => { - const active = adapter === t.id; + const active = adapter === t.id return ( - ); + ) })}
- ); + ) } function BalancesCard({ @@ -647,35 +630,32 @@ function BalancesCard({ balanceDisplay, onSignIn, }: { - status: "idle" | "running" | "done"; - address: string | null; - balanceDisplay: string | null; - onSignIn: () => void; + status: 'idle' | 'running' | 'done' + address: string | null + balanceDisplay: string | null + onSignIn: () => void }) { - const connected = !!address; + const connected = !!address const balances = [ - { sym: "USDC", value: "1,234.56" }, - { sym: "USDT", value: "567.89" }, - { sym: "ETH", value: "0.42" }, - ]; + { sym: 'USDC', value: '1,234.56' }, + { sym: 'USDT', value: '567.89' }, + { sym: 'ETH', value: '0.42' }, + ] const ringColors: Record = { - USDC: "var(--brand-usdc)", - USDT: "var(--brand-usdt)", - ETH: "var(--brand-eth)", - }; + USDC: 'var(--brand-usdc)', + USDT: 'var(--brand-usdt)', + ETH: 'var(--brand-eth)', + } - const cta = status === "running" - ? "Opening Tempo…" - : connected && address - ? shorten(address) - : "Sign in"; + const cta = + status === 'running' ? 'Opening Tempo…' : connected && address ? shorten(address) : 'Sign in' return (

Available balance

- {connected ? (balanceDisplay ?? "$0.00") : "$0.00"} + {connected ? (balanceDisplay ?? '$0.00') : '$0.00'}

@@ -686,55 +666,50 @@ function BalancesCard({
{balances.map((b, i) => ( -
+
- {connected - ? ( - - {b.sym.slice(0, 1)} - - ) - : ( - - )} - {connected ? {b.sym} : ( + {connected ? ( + {b.sym.slice(0, 1)} + + ) : ( + )} -
- {connected - ? ( - - {b.value} - - ) - : ( + {connected ? ( + {b.sym} + ) : ( )} +
+ {connected ? ( + + {b.value} + + ) : ( + + )}
))}
@@ -743,22 +718,20 @@ function BalancesCard({
- ); + ) } // The hero's BalancesCard now leans on the shared `useTempoSession`, so @@ -767,22 +740,22 @@ function BalancesCard({ // the shared landing demo Wagmi config handles the actual sign-in flow. function DemoSplit() { - const [adapter, setAdapter] = useState("tempoAuth"); - const { status, address, balanceDisplay, run } = useTempoSession(); + const [adapter, setAdapter] = useState('tempoAuth') + const { status, address, balanceDisplay, run } = useTempoSession() const handleAdapterChange = (next: Adapter) => { - if (next === adapter) return; - setAdapter(next); - }; + if (next === adapter) return + setAdapter(next) + } const signIn = () => { void run(async (provider) => { - const address = await connectWallet(provider); + const address = await connectWallet(provider) return { - summary: address ? `Signed in · ${shorten(address)}` : "Signed in", - }; - }); - }; + summary: address ? `Signed in · ${shorten(address)}` : 'Signed in', + } + }) + } return (
@@ -839,83 +812,72 @@ function DemoSplit() {
- ); + ) } export default function Hero({ children }: { children?: ReactNode }) { - const rootRef = useRef(null); - const [staggerReady, setStaggerReady] = useState(false); + const rootRef = useRef(null) + const [staggerReady, setStaggerReady] = useState(false) useLayoutEffect(() => { - const root = rootRef.current; - if (!root) return; - - const items = root.querySelectorAll("[data-hero-stagger]"); - const navItems = root.querySelectorAll( - "[data-hero-nav-stagger]", - ); - if (items.length === 0 && navItems.length === 0) return; - - const reduced = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; + const root = rootRef.current + if (!root) return + + const items = root.querySelectorAll('[data-hero-stagger]') + const navItems = root.querySelectorAll('[data-hero-nav-stagger]') + if (items.length === 0 && navItems.length === 0) return + + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches if (reduced) { - setStaggerReady(true); - return; + setStaggerReady(true) + return } - const animations: WAAPIAnimation[] = []; + const animations: WAAPIAnimation[] = [] if (items.length > 0) { animations.push( waapi.animate(items, { opacity: [0, 1], - translate: ["0 10px", "0 0"], + translate: ['0 10px', '0 0'], delay: stagger(HERO_ENTRANCE_STAGGER_MS), ease: springs.entrance, }), - ); + ) } if (navItems.length > 0) { - const start = items.length * HERO_ENTRANCE_STAGGER_MS + HERO_NAV_DELAY_MS; + const start = items.length * HERO_ENTRANCE_STAGGER_MS + HERO_NAV_DELAY_MS navItems.forEach((item, i) => { - const fall = HERO_NAV_FALLS[i] ?? HERO_NAV_DEFAULT_FALL; + const fall = HERO_NAV_FALLS[i] ?? HERO_NAV_DEFAULT_FALL animations.push( waapi.animate(item, { opacity: [0, 1], - translate: [fall.translate, "0 0"], + translate: [fall.translate, '0 0'], delay: start + fall.delay, ease: springs.navEntrance, }), - ); - }); + ) + }) } - let disposed = false; - void Promise.all( - animations.map((animation) => animation.then(() => undefined)), - ).then(() => { - if (disposed) return; - setStaggerReady(true); - }); + let disposed = false + void Promise.all(animations.map((animation) => animation.then(() => undefined))).then(() => { + if (disposed) return + setStaggerReady(true) + }) return () => { - disposed = true; - for (const animation of animations) animation.cancel(); - }; - }, []); + disposed = true + for (const animation of animations) animation.cancel() + } + }, []) - const staggerStyle = staggerReady ? undefined : heroEntranceInitialStyle; - const navStaggerStyles = staggerReady - ? [] - : HERO_NAV_FALLS.map((fall) => heroNavStyle(fall)); + const staggerStyle = staggerReady ? undefined : heroEntranceInitialStyle + const navStaggerStyles = staggerReady ? [] : HERO_NAV_FALLS.map((fall) => heroNavStyle(fall)) return (
-
+
@@ -923,17 +885,15 @@ export default function Hero({ children }: { children?: ReactNode }) {
- {children - ? ( -
- {children} -
- ) - : null} + {children ? ( +
+ {children} +
+ ) : null}
- ); + ) } // Re-exported so page.tsx can position the adapter / code / balances panel // below the main browser-mockup demo section. -export { DemoSplit }; +export { DemoSplit } diff --git a/site/src/landing/icons.tsx b/site/src/landing/icons.tsx index e19147db..7f044c95 100644 --- a/site/src/landing/icons.tsx +++ b/site/src/landing/icons.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client' /** * Inline SVG icon components for the landing page. @@ -17,10 +17,10 @@ */ type IconProps = { - width?: number; - height?: number; - className?: string; -}; + width?: number + height?: number + className?: string +} export function TempoLogo({ width = 20, height = 21, className }: IconProps) { return ( @@ -31,14 +31,14 @@ export function TempoLogo({ width = 20, height = 21, className }: IconProps) { fill="none" aria-hidden className={className} - style={{ display: "block" }} + style={{ display: 'block' }} > - ); + ) } /** @@ -56,14 +56,14 @@ export function TempoIcon({ width = 36, height = 36, className }: IconProps) { fill="none" aria-hidden className={className} - style={{ display: "block" }} + style={{ display: 'block' }} > - ); + ) } /** @@ -86,10 +86,17 @@ export function PlayMark({ className }: IconProps) { - - + + - ); + ) } export function DocsIcon({ width = 16, height = 16, className }: IconProps) { @@ -101,7 +108,7 @@ export function DocsIcon({ width = 16, height = 16, className }: IconProps) { fill="none" aria-hidden className={className} - style={{ display: "block" }} + style={{ display: 'block' }} > - ); + ) } export function GithubIcon({ width = 16, height = 16, className }: IconProps) { @@ -133,14 +140,14 @@ export function GithubIcon({ width = 16, height = 16, className }: IconProps) { fill="none" aria-hidden className={className} - style={{ display: "block" }} + style={{ display: 'block' }} > - ); + ) } export function CopyIcon({ width = 18, height = 18, className }: IconProps) { @@ -152,7 +159,7 @@ export function CopyIcon({ width = 18, height = 18, className }: IconProps) { fill="none" aria-hidden className={className} - style={{ display: "block" }} + style={{ display: 'block' }} > - ); + ) } /** @@ -179,7 +186,7 @@ export function AgentCopyIcon({ width = 16, height = 16, className }: IconProps) fill="none" aria-hidden className={className} - style={{ display: "block" }} + style={{ display: 'block' }} > - ); + ) } /** @@ -246,7 +253,7 @@ export function LockIcon({ width = 12, height = 15, className }: IconProps) { fill="none" aria-hidden className={className} - style={{ display: "block" }} + style={{ display: 'block' }} > - ); + ) } diff --git a/site/src/landing/page.tsx b/site/src/landing/page.tsx index e2f840f7..5c8e903f 100644 --- a/site/src/landing/page.tsx +++ b/site/src/landing/page.tsx @@ -1,11 +1,11 @@ -"use client"; +'use client' -import Customize from "./customize"; -import Demo from "./demo/Demo"; -import Footer from "./footer"; -import Guides from "./guides"; -import Hero from "./hero"; -import { ThemeProvider } from "./useTheme"; +import Customize from './customize' +import Demo from './demo/Demo' +import Footer from './footer' +import Guides from './guides' +import Hero from './hero' +import { ThemeProvider } from './useTheme' export default function Home() { return ( @@ -21,5 +21,5 @@ export default function Home() {
- ); + ) } diff --git a/site/src/landing/sections/Accounts.tsx b/site/src/landing/sections/Accounts.tsx index ac57d31b..d27d9062 100644 --- a/site/src/landing/sections/Accounts.tsx +++ b/site/src/landing/sections/Accounts.tsx @@ -1,40 +1,41 @@ -"use client"; +'use client' -import { useState } from "react"; -import { LogInBody } from "../demo/bodies/LogIn"; -import { connectWallet, shorten } from "../demo/sdk"; -import { SectionFrame } from "./SectionFrame"; -import { TogglePills } from "./TogglePills"; -import { useTempoSession } from "./useTempoSession"; +import { useState } from 'react' + +import { LogInBody } from '../demo/bodies/LogIn' +import { connectWallet, shorten } from '../demo/sdk' +import { SectionFrame } from './SectionFrame' +import { TogglePills } from './TogglePills' +import { useTempoSession } from './useTempoSession' const LIFECYCLE = [ - { id: "connect", label: "Connect" }, - { id: "authorize", label: "Authorize" }, - { id: "disconnect", label: "Disconnect" }, -] as const; + { id: 'connect', label: 'Connect' }, + { id: 'authorize', label: 'Authorize' }, + { id: 'disconnect', label: 'Disconnect' }, +] as const -type LifecycleId = (typeof LIFECYCLE)[number]["id"]; +type LifecycleId = (typeof LIFECYCLE)[number]['id'] -const noop = () => undefined; +const noop = () => undefined export default function Accounts() { - const [action, setAction] = useState("connect"); - const { status, address, result, run } = useTempoSession(); + const [action, setAction] = useState('connect') + const { status, address, result, run } = useTempoSession() // The Accounts demo IS about sign-in, so a hydrated session should // render as "done" (so the LogInBody CTA shows "Signed in · 0x…" // instead of "Continue with Tempo"). `useTempoSession` no longer // sets status on hydration, so derive it here. - const displayStatus = status === "idle" && address ? "done" : status; + const displayStatus = status === 'idle' && address ? 'done' : status const onSignIn = () => { void run(async (provider) => { - const address = await connectWallet(provider); + const address = await connectWallet(provider) return { - summary: address ? `Signed in · ${shorten(address)}` : "Signed in", - }; - }); - }; + summary: address ? `Signed in · ${shorten(address)}` : 'Signed in', + } + }) + } return ( } /> - ); + ) } const INFO: Record = { connect: { - title: "Connect", + title: 'Connect', description: "Open the wallet — create the user's stablecoin account on first sign-in, or load an existing one.", }, authorize: { - title: "Authorize a session", + title: 'Authorize a session', description: - "Co-sign a scoped session key so follow-up payments settle without re-prompting the passkey, within the limits you set.", + 'Co-sign a scoped session key so follow-up payments settle without re-prompting the passkey, within the limits you set.', }, disconnect: { - title: "Disconnect", + title: 'Disconnect', description: - "End the session and revoke the access key. The wallet keeps the account; the app forgets it.", + 'End the session and revoke the access key. The wallet keeps the account; the app forgets it.', }, -}; +} /* ─── Syntax tokens (mono color palette matches the adapter section) ─── */ const Keyword = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Str = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Fn = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Var = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Num = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Cmnt = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) function LifecycleCode({ action }: { action: LifecycleId }) { return ( @@ -141,42 +142,42 @@ function LifecycleCode({ action }: { action: LifecycleId }) { }} > - {action === "connect" ? : null} - {action === "authorize" ? : null} - {action === "disconnect" ? : null} + {action === 'connect' ? : null} + {action === 'authorize' ? : null} + {action === 'disconnect' ? : null} - ); + ) } function ConnectSnippet() { return ( <>
- import {"{ Provider } "} + import {'{ Provider } '} from {`'accounts'`}
-
{" "}
+
- const provider ={" "} - Provider.create() + const provider = Provider. + create()
-
{" "}
+
- const {"{ "} + const {'{ '} accounts - {" } = "} + {' } = '} await provider.request - {"({"} + {'({'}
- {" method: "} + {' method: '} {`'wallet_connect'`} - {","} + {','}
-
{"})"}
+
{'})'}
- ); + ) } function AuthorizeSnippet() { @@ -184,47 +185,47 @@ function AuthorizeSnippet() { <>
await provider.request - {"({"} + {'({'}
- {" method: "} + {' method: '} {`'wallet_authorizeAccessKey'`} - {","} + {','}
-
{" params: [{"}
+
{' params: [{'}
- {" expiry: "} - Math.floor({"Date."} + {' expiry: '} + Math.floor({'Date.'} now - {"() / "} + {'() / '} 1000 - {") + "} + {') + '} 86_400 - {","} + {','}
-
{" limits: [{"}
+
{' limits: [{'}
- {" token: "} + {' token: '} PATH_USD - {","} + {','}
- {" limit: "} + {' limit: '} 5_000_000n - {", "} - {"// $5"} + {', '} + {'// $5'}
- {" period: "} + {' period: '} 3_600 - {", "} - {"// per hour"} + {', '} + {'// per hour'}
-
{" }],"}
-
{" }],"}
-
{"})"}
+
{' }],'}
+
{' }],'}
+
{'})'}
- ); + ) } function DisconnectSnippet() { @@ -232,21 +233,21 @@ function DisconnectSnippet() { <>
await provider.request - {"({"} + {'({'}
- {" method: "} + {' method: '} {`'wallet_disconnect'`} - {","} + {','}
-
{"})"}
-
{" "}
+
{'})'}
+
- {"// Subsequent eth_accounts returns []"} + {'// Subsequent eth_accounts returns []'}
- {"// until the user reconnects."} + {'// until the user reconnects.'}
- ); + ) } diff --git a/site/src/landing/sections/InteractiveQr.tsx b/site/src/landing/sections/InteractiveQr.tsx index d6b31eee..2912691e 100644 --- a/site/src/landing/sections/InteractiveQr.tsx +++ b/site/src/landing/sections/InteractiveQr.tsx @@ -1,8 +1,9 @@ -"use client"; +'use client' -import { Cuer } from "cuer"; -import { useContext, useEffect, useRef, useState } from "react"; -import { readCssVar, useTheme } from "../useTheme"; +import { Cuer } from 'cuer' +import { useContext, useEffect, useRef, useState } from 'react' + +import { readCssVar, useTheme } from '../useTheme' /* ─── Tweakables ──────────────────────────────────────────────────────── * * @@ -14,43 +15,43 @@ import { readCssVar, useTheme } from "../useTheme"; * ───────────────────────────────────────────────────────────────────── */ /** Resting opacity for data-module dots. */ -const BASE_ALPHA = 0.8; +const BASE_ALPHA = 0.8 /** Peak opacity under the cursor / brightest trail point. */ -const HOVER_ALPHA = 1; +const HOVER_ALPHA = 1 /** Multiplier on dot radius at the cursor center. Modest so dots don't overlap into a blob. */ -const HOVER_SCALE = 1.6; +const HOVER_SCALE = 1.6 /** Cursor influence radius in pixels. */ -const HOVER_RADIUS = 90; -const RESPONSE_MS = 100; -const TRAIL_LIFE_MS = 120; -const TRAIL_RADIUS = 100; +const HOVER_RADIUS = 90 +const RESPONSE_MS = 100 +const TRAIL_LIFE_MS = 120 +const TRAIL_RADIUS = 100 /** Per-dot breathing amplitude — subtle so the pattern stays scannable. */ -const BREATH_AMPLITUDE = 0.1; -const BREATH_SPEED = 0.4; +const BREATH_AMPLITUDE = 0.1 +const BREATH_SPEED = 0.4 /** Fraction of a cell that a data-module dot fills at rest. */ -const DOT_FILL = 0.45; +const DOT_FILL = 0.45 /** Trail glow contribution per recent cursor sample. */ -const TRAIL_BOOST = 0.25; +const TRAIL_BOOST = 0.25 /** Pixels a data-module dot leans toward the cursor at peak proximity. 0 = no displacement. */ -const DOT_LEAN_PX = 1.5; +const DOT_LEAN_PX = 1.5 /** Quiet-zone width in modules. QR spec requires ≥ 4 for reliable scanning. */ -const QUIET_ZONE = 4; +const QUIET_ZONE = 4 /** Length (in modules) of each finder pattern; spec value is always 7. */ -const FINDER_SIZE = 7; +const FINDER_SIZE = 7 /** Corner-rounding ratio for the finder outer ring + inner square (0 = square, 0.5 = pill). */ -const FINDER_RADIUS_RATIO = 0.22; +const FINDER_RADIUS_RATIO = 0.22 /** How far the cursor reaches into a finder before it starts reacting (px). Set to 0 to disable. */ -const FINDER_HOVER_RADIUS = 110; +const FINDER_HOVER_RADIUS = 110 /** Peak scale on hover. Keep ≤ 1.05 so spec geometry stays intact. 1 = no scale. */ -const FINDER_HOVER_SCALE = 1.04; +const FINDER_HOVER_SCALE = 1.04 /** Pixels the finder leans toward the cursor at peak. ≤ ~3 px is safe to scan. 0 = no displacement. */ -const FINDER_LEAN_PX = 2.5; +const FINDER_LEAN_PX = 2.5 -type TrailPoint = { x: number; y: number; t: number }; +type TrailPoint = { x: number; y: number; t: number } function hash(x: number, y: number) { - const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; - return s - Math.floor(s); + const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453 + return s - Math.floor(s) } /** @@ -58,10 +59,10 @@ function hash(x: number, y: number) { * patterns (top-left, top-right, bottom-left). */ function isFinderCell(x: number, y: number, edge: number) { - const inTopLeft = x < FINDER_SIZE && y < FINDER_SIZE; - const inTopRight = x >= edge - FINDER_SIZE && y < FINDER_SIZE; - const inBottomLeft = x < FINDER_SIZE && y >= edge - FINDER_SIZE; - return inTopLeft || inTopRight || inBottomLeft; + const inTopLeft = x < FINDER_SIZE && y < FINDER_SIZE + const inTopRight = x >= edge - FINDER_SIZE && y < FINDER_SIZE + const inBottomLeft = x < FINDER_SIZE && y >= edge - FINDER_SIZE + return inTopLeft || inTopRight || inBottomLeft } /** @@ -74,53 +75,35 @@ function isFinderCell(x: number, y: number, edge: number) { * `size` is the final canvas size in pixels — the QR data area is smaller * by the quiet-zone padding. */ -export function InteractiveQr({ - value, - size = 220, -}: { - value: string; - size?: number; -}) { - const [matrix, setMatrix] = useState( - null, - ); +export function InteractiveQr({ value, size = 220 }: { value: string; size?: number }) { + const [matrix, setMatrix] = useState(null) return (
{matrix ? : null}
- ); + ) } -function MatrixBridge({ - onMatrix, -}: { - onMatrix: (m: readonly (readonly boolean[])[]) => void; -}) { - const ctx = useContext(Cuer.Context); +function MatrixBridge({ onMatrix }: { onMatrix: (m: readonly (readonly boolean[])[]) => void }) { + const ctx = useContext(Cuer.Context) useEffect(() => { - onMatrix(ctx.qrcode.grid); - }, [ctx.qrcode.grid, onMatrix]); - return null; + onMatrix(ctx.qrcode.grid) + }, [ctx.qrcode.grid, onMatrix]) + return null } -function DotCanvas({ - matrix, - size, -}: { - matrix: readonly (readonly boolean[])[]; - size: number; -}) { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const { resolved } = useTheme(); +function DotCanvas({ matrix, size }: { matrix: readonly (readonly boolean[])[]; size: number }) { + const canvasRef = useRef(null) + const containerRef = useRef(null) + const { resolved } = useTheme() const stateRef = useRef({ targetX: -9999, targetY: -9999, @@ -129,53 +112,51 @@ function DotCanvas({ active: false, trail: [] as TrailPoint[], lastFrame: 0, - }); - const rafRef = useRef(0); + }) + const rafRef = useRef(0) useEffect(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const canvas = canvasRef.current + const container = containerRef.current + if (!canvas || !container) return + const ctx = canvas.getContext('2d') + if (!ctx) return - const COLOR = readCssVar(canvas, "--canvas-dot-rgb") || "150, 150, 150"; + const COLOR = readCssVar(canvas, '--canvas-dot-rgb') || '150, 150, 150' - const dpr = Math.max(1, window.devicePixelRatio || 1); - canvas.width = Math.round(size * dpr); - canvas.height = Math.round(size * dpr); - canvas.style.width = `${size}px`; - canvas.style.height = `${size}px`; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const dpr = Math.max(1, window.devicePixelRatio || 1) + canvas.width = Math.round(size * dpr) + canvas.height = Math.round(size * dpr) + canvas.style.width = `${size}px` + canvas.style.height = `${size}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) const onMove = (e: MouseEvent) => { - const rect = container.getBoundingClientRect(); - const s = stateRef.current; - s.targetX = e.clientX - rect.left; - s.targetY = e.clientY - rect.top; + const rect = container.getBoundingClientRect() + const s = stateRef.current + s.targetX = e.clientX - rect.left + s.targetY = e.clientY - rect.top if (!s.active) { - s.smoothX = s.targetX; - s.smoothY = s.targetY; + s.smoothX = s.targetX + s.smoothY = s.targetY } - s.active = true; - }; + s.active = true + } const onLeave = () => { - stateRef.current.active = false; - }; - container.addEventListener("mousemove", onMove); - container.addEventListener("mouseleave", onLeave); + stateRef.current.active = false + } + container.addEventListener('mousemove', onMove) + container.addEventListener('mouseleave', onLeave) - const reduced = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches - const edge = matrix.length; + const edge = matrix.length /** Total cells including the 4-module quiet zone on every side. */ - const totalCells = edge + 2 * QUIET_ZONE; - const cellSize = size / totalCells; - const dotRadius = (cellSize / 2) * DOT_FILL; + const totalCells = edge + 2 * QUIET_ZONE + const cellSize = size / totalCells + const dotRadius = (cellSize / 2) * DOT_FILL /** Pixel offset where the actual QR data starts. */ - const dataOrigin = QUIET_ZONE * cellSize; + const dataOrigin = QUIET_ZONE * cellSize /** * Draw a single 7×7 finder pattern as outer rounded ring + inner filled @@ -191,194 +172,182 @@ function DotCanvas({ cursorY: number, cursorActive: boolean, ) => { - const outerSize = FINDER_SIZE * cellSize; - const innerSize = 3 * cellSize; - const innerOffset = 2 * cellSize; - const outerRadius = outerSize * FINDER_RADIUS_RATIO; - const innerRadius = innerSize * FINDER_RADIUS_RATIO; - const centerX = originX + outerSize / 2; - const centerY = originY + outerSize / 2; + const outerSize = FINDER_SIZE * cellSize + const innerSize = 3 * cellSize + const innerOffset = 2 * cellSize + const outerRadius = outerSize * FINDER_RADIUS_RATIO + const innerRadius = innerSize * FINDER_RADIUS_RATIO + const centerX = originX + outerSize / 2 + const centerY = originY + outerSize / 2 // Cursor proximity in [0..1]. 0 when far / inactive / reduced motion. - let proximity = 0; + let proximity = 0 if (cursorActive && !reduced && FINDER_HOVER_RADIUS > 0) { - const dx = centerX - cursorX; - const dy = centerY - cursorY; - const dist = Math.sqrt(dx * dx + dy * dy); + const dx = centerX - cursorX + const dy = centerY - cursorY + const dist = Math.sqrt(dx * dx + dy * dy) if (dist < FINDER_HOVER_RADIUS) { - const t = 1 - dist / FINDER_HOVER_RADIUS; - proximity = t * t; + const t = 1 - dist / FINDER_HOVER_RADIUS + proximity = t * t } } - ctx.save(); + ctx.save() if (proximity > 0) { - const lean = FINDER_LEAN_PX * proximity; - const scale = 1 + (FINDER_HOVER_SCALE - 1) * proximity; - const angle = Math.atan2(cursorY - centerY, cursorX - centerX); - ctx.translate(centerX, centerY); - ctx.translate(Math.cos(angle) * lean, Math.sin(angle) * lean); - ctx.scale(scale, scale); - ctx.translate(-centerX, -centerY); + const lean = FINDER_LEAN_PX * proximity + const scale = 1 + (FINDER_HOVER_SCALE - 1) * proximity + const angle = Math.atan2(cursorY - centerY, cursorX - centerX) + ctx.translate(centerX, centerY) + ctx.translate(Math.cos(angle) * lean, Math.sin(angle) * lean) + ctx.scale(scale, scale) + ctx.translate(-centerX, -centerY) } - ctx.strokeStyle = `rgba(${COLOR}, 1)`; - ctx.lineWidth = cellSize; - ctx.beginPath(); + ctx.strokeStyle = `rgba(${COLOR}, 1)` + ctx.lineWidth = cellSize + ctx.beginPath() ctx.roundRect( originX + cellSize / 2, originY + cellSize / 2, outerSize - cellSize, outerSize - cellSize, outerRadius, - ); - ctx.stroke(); + ) + ctx.stroke() - ctx.fillStyle = `rgba(${COLOR}, 1)`; - ctx.beginPath(); - ctx.roundRect( - originX + innerOffset, - originY + innerOffset, - innerSize, - innerSize, - innerRadius, - ); - ctx.fill(); + ctx.fillStyle = `rgba(${COLOR}, 1)` + ctx.beginPath() + ctx.roundRect(originX + innerOffset, originY + innerOffset, innerSize, innerSize, innerRadius) + ctx.fill() - ctx.restore(); - }; + ctx.restore() + } const draw = (now: number) => { - const s = stateRef.current; - const dt = s.lastFrame ? Math.min(now - s.lastFrame, 100) : 16; - s.lastFrame = now; + const s = stateRef.current + const dt = s.lastFrame ? Math.min(now - s.lastFrame, 100) : 16 + s.lastFrame = now - const damping = reduced ? 1 : 1 - Math.exp(-dt / RESPONSE_MS); - s.smoothX += (s.targetX - s.smoothX) * damping; - s.smoothY += (s.targetY - s.smoothY) * damping; + const damping = reduced ? 1 : 1 - Math.exp(-dt / RESPONSE_MS) + s.smoothX += (s.targetX - s.smoothX) * damping + s.smoothY += (s.targetY - s.smoothY) * damping if (!reduced) { if (s.active) { - const last = s.trail[s.trail.length - 1]; - if ( - !last || - (s.smoothX - last.x) ** 2 + (s.smoothY - last.y) ** 2 > 16 - ) { - s.trail.push({ x: s.smoothX, y: s.smoothY, t: now }); + const last = s.trail[s.trail.length - 1] + if (!last || (s.smoothX - last.x) ** 2 + (s.smoothY - last.y) ** 2 > 16) { + s.trail.push({ x: s.smoothX, y: s.smoothY, t: now }) } } - const cutoff = now - TRAIL_LIFE_MS; - while (s.trail.length && s.trail[0] && s.trail[0].t < cutoff) - s.trail.shift(); - if (s.trail.length > 60) s.trail.splice(0, s.trail.length - 60); + const cutoff = now - TRAIL_LIFE_MS + while (s.trail.length && s.trail[0] && s.trail[0].t < cutoff) s.trail.shift() + if (s.trail.length > 60) s.trail.splice(0, s.trail.length - 60) } - ctx.clearRect(0, 0, size, size); + ctx.clearRect(0, 0, size, size) - const r2 = HOVER_RADIUS * HOVER_RADIUS; - const tr2 = TRAIL_RADIUS * TRAIL_RADIUS; - const breathPhase = (now / 1000) * BREATH_SPEED * Math.PI * 2; + const r2 = HOVER_RADIUS * HOVER_RADIUS + const tr2 = TRAIL_RADIUS * TRAIL_RADIUS + const breathPhase = (now / 1000) * BREATH_SPEED * Math.PI * 2 // Data-module dots (skip finder cells — those are drawn as solid shapes after). for (let y = 0; y < edge; y++) { - const row = matrix[y]!; + const row = matrix[y]! for (let x = 0; x < edge; x++) { - if (!row[x]) continue; - if (isFinderCell(x, y, edge)) continue; - const cx = dataOrigin + x * cellSize + cellSize / 2; - const cy = dataOrigin + y * cellSize + cellSize / 2; + if (!row[x]) continue + if (isFinderCell(x, y, edge)) continue + const cx = dataOrigin + x * cellSize + cellSize / 2 + const cy = dataOrigin + y * cellSize + cellSize / 2 const breath = reduced ? 1 - : 1 + - BREATH_AMPLITUDE * Math.sin(breathPhase + hash(x, y) * 6.28); + : 1 + BREATH_AMPLITUDE * Math.sin(breathPhase + hash(x, y) * 6.28) - let alpha = BASE_ALPHA * breath; - let radius = dotRadius; - let drawX = cx; - let drawY = cy; + let alpha = BASE_ALPHA * breath + let radius = dotRadius + let drawX = cx + let drawY = cy if (s.active && !reduced) { - const dx = cx - s.smoothX; - const dy = cy - s.smoothY; - const d2 = dx * dx + dy * dy; + const dx = cx - s.smoothX + const dy = cy - s.smoothY + const d2 = dx * dx + dy * dy if (d2 < r2) { - const t = 1 - Math.sqrt(d2) / HOVER_RADIUS; - const eased = t * t; - alpha = alpha + (HOVER_ALPHA - alpha) * eased; - radius = dotRadius + dotRadius * (HOVER_SCALE - 1) * eased; + const t = 1 - Math.sqrt(d2) / HOVER_RADIUS + const eased = t * t + alpha = alpha + (HOVER_ALPHA - alpha) * eased + radius = dotRadius + dotRadius * (HOVER_SCALE - 1) * eased if (DOT_LEAN_PX > 0) { - const angle = Math.atan2(s.smoothY - cy, s.smoothX - cx); - const lean = DOT_LEAN_PX * eased; - drawX += Math.cos(angle) * lean; - drawY += Math.sin(angle) * lean; + const angle = Math.atan2(s.smoothY - cy, s.smoothX - cx) + const lean = DOT_LEAN_PX * eased + drawX += Math.cos(angle) * lean + drawY += Math.sin(angle) * lean } } } if (s.trail.length > 0 && !reduced) { - let glow = 0; + let glow = 0 for (let i = s.trail.length - 1; i >= 0; i--) { - const p = s.trail[i]; - if (!p) continue; - const dx = cx - p.x; - if (dx > TRAIL_RADIUS || dx < -TRAIL_RADIUS) continue; - const dy = cy - p.y; - if (dy > TRAIL_RADIUS || dy < -TRAIL_RADIUS) continue; - const d2 = dx * dx + dy * dy; - if (d2 >= tr2) continue; - const distFalloff = 1 - Math.sqrt(d2) / TRAIL_RADIUS; - const ageRatio = 1 - (now - p.t) / TRAIL_LIFE_MS; - glow += distFalloff * distFalloff * ageRatio * TRAIL_BOOST; + const p = s.trail[i] + if (!p) continue + const dx = cx - p.x + if (dx > TRAIL_RADIUS || dx < -TRAIL_RADIUS) continue + const dy = cy - p.y + if (dy > TRAIL_RADIUS || dy < -TRAIL_RADIUS) continue + const d2 = dx * dx + dy * dy + if (d2 >= tr2) continue + const distFalloff = 1 - Math.sqrt(d2) / TRAIL_RADIUS + const ageRatio = 1 - (now - p.t) / TRAIL_LIFE_MS + glow += distFalloff * distFalloff * ageRatio * TRAIL_BOOST } if (glow > 0) { - const cap = Math.min(glow, 1); - alpha = alpha + (HOVER_ALPHA - alpha) * cap; - radius = - radius + dotRadius * cap * (HOVER_SCALE - 1) * 0.5; + const cap = Math.min(glow, 1) + alpha = alpha + (HOVER_ALPHA - alpha) * cap + radius = radius + dotRadius * cap * (HOVER_SCALE - 1) * 0.5 } } - ctx.fillStyle = `rgba(${COLOR}, ${alpha})`; - ctx.beginPath(); - ctx.arc(drawX, drawY, radius, 0, Math.PI * 2); - ctx.fill(); + ctx.fillStyle = `rgba(${COLOR}, ${alpha})` + ctx.beginPath() + ctx.arc(drawX, drawY, radius, 0, Math.PI * 2) + ctx.fill() } } // Finder patterns — solid, full-opacity, gently rounded squares. // Drawn after the dots so they always read as the QR anchors. // Each finder leans toward the cursor independently. - drawFinder(dataOrigin, dataOrigin, s.smoothX, s.smoothY, s.active); + drawFinder(dataOrigin, dataOrigin, s.smoothX, s.smoothY, s.active) drawFinder( dataOrigin + (edge - FINDER_SIZE) * cellSize, dataOrigin, s.smoothX, s.smoothY, s.active, - ); + ) drawFinder( dataOrigin, dataOrigin + (edge - FINDER_SIZE) * cellSize, s.smoothX, s.smoothY, s.active, - ); + ) - rafRef.current = requestAnimationFrame(draw); - }; - rafRef.current = requestAnimationFrame(draw); + rafRef.current = requestAnimationFrame(draw) + } + rafRef.current = requestAnimationFrame(draw) return () => { - cancelAnimationFrame(rafRef.current); - container.removeEventListener("mousemove", onMove); - container.removeEventListener("mouseleave", onLeave); - }; - }, [matrix, size, resolved]); + cancelAnimationFrame(rafRef.current) + container.removeEventListener('mousemove', onMove) + container.removeEventListener('mouseleave', onLeave) + } + }, [matrix, size, resolved]) return (
- ); + ) } diff --git a/site/src/landing/sections/LocalPayments.tsx b/site/src/landing/sections/LocalPayments.tsx index c7be4b36..af1d4f0d 100644 --- a/site/src/landing/sections/LocalPayments.tsx +++ b/site/src/landing/sections/LocalPayments.tsx @@ -1,66 +1,63 @@ -"use client"; +'use client' + +import { useState } from 'react' -import { useState } from "react"; import { DEPOSIT_AMOUNTS, type DepositAmountId, LocalPaymentsBody, -} from "../demo/bodies/LocalPayments"; -import { SectionFrame } from "./SectionFrame"; -import { TogglePills } from "./TogglePills"; -import { useTempoSession } from "./useTempoSession"; +} from '../demo/bodies/LocalPayments' +import { SectionFrame } from './SectionFrame' +import { TogglePills } from './TogglePills' +import { useTempoSession } from './useTempoSession' const METHODS = [ - { id: "apple-pay", label: "Apple Pay" }, - { id: "card", label: "Card" }, - { id: "sepa", label: "SEPA" }, - { id: "crypto", label: "Crypto" }, -] as const; + { id: 'apple-pay', label: 'Apple Pay' }, + { id: 'card', label: 'Card' }, + { id: 'sepa', label: 'SEPA' }, + { id: 'crypto', label: 'Crypto' }, +] as const -type MethodId = (typeof METHODS)[number]["id"]; +type MethodId = (typeof METHODS)[number]['id'] -const noop = () => undefined; +const noop = () => undefined const INFO: Record = { - "apple-pay": { - title: "Apple Pay", - description: - "Tap-to-pay deposits via Stripe. Funds settle into the account within seconds.", + 'apple-pay': { + title: 'Apple Pay', + description: 'Tap-to-pay deposits via Stripe. Funds settle into the account within seconds.', }, card: { - title: "Cards", - description: - "Visa, Mastercard, and AmEx via Stripe. 3DS challenges handled inside the wallet.", + title: 'Cards', + description: 'Visa, Mastercard, and AmEx via Stripe. 3DS challenges handled inside the wallet.', }, sepa: { - title: "SEPA", - description: - "EU bank transfers with IBAN. Funds typically arrive in one business day.", + title: 'SEPA', + description: 'EU bank transfers with IBAN. Funds typically arrive in one business day.', }, crypto: { - title: "Crypto", + title: 'Crypto', description: - "Deposit USDC, ETH, or any supported stablecoin from another chain — bridged automatically.", + 'Deposit USDC, ETH, or any supported stablecoin from another chain — bridged automatically.', }, -}; +} export default function LocalPayments() { - const [method, setMethod] = useState("apple-pay"); - const [amountId, setAmountId] = useState("50"); - const { status, balanceDisplay, result, run } = useTempoSession(); + const [method, setMethod] = useState('apple-pay') + const [amountId, setAmountId] = useState('50') + const { status, balanceDisplay, result, run } = useTempoSession() - const amount = - DEPOSIT_AMOUNTS.find((a) => a.id === amountId) ?? DEPOSIT_AMOUNTS[0]; + const amount = DEPOSIT_AMOUNTS.find((a) => a.id === amountId) ?? DEPOSIT_AMOUNTS[0] const onAction = () => { void run(async (provider) => { await provider.request({ - method: "wallet_deposit", + method: 'wallet_deposit', params: [{ value: amount.id }], - } as Parameters[0]); - return { summary: "Deposit dialog opened" }; - }); - }; + } as Parameters[0]) + return { summary: 'Deposit dialog opened' } + }) + } return (
- +
@@ -117,26 +109,26 @@ export default function LocalPayments() { /> } /> - ); + ) } /* ─── Syntax tokens (mono color palette matches the adapter section) ─── */ const Keyword = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Str = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Fn = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Var = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Cmnt = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Hl = ({ children }: { children: React.ReactNode }) => ( ( > {children} -); +) function DepositCode({ amount }: { amount: DepositAmountId }) { return ( @@ -159,43 +151,43 @@ function DepositCode({ amount }: { amount: DepositAmountId }) { >
- import {"{ Provider } "} + import {'{ Provider } '} from {`'accounts'`}
-
{" "}
+
- const provider ={" "} - Provider.create() + const provider = Provider. + create()
-
{" "}
+
await provider.request - {"({"} + {'({'}
- {" method: "} + {' method: '} {`'wallet_deposit'`} - {","} + {','}
-
{" params: [{"}
+
{' params: [{'}
- {" value: "} + {' value: '} {`'${amount}'`} - {", "} - {"// USD"} + {', '} + {'// USD'}
-
{" }],"}
-
{"})"}
-
{" "}
+
{' }],'}
+
{'})'}
+
- {"// The wallet surfaces Apple Pay, cards,"} + {'// The wallet surfaces Apple Pay, cards,'}
- {"// SEPA, and crypto — user picks at deposit."} + {'// SEPA, and crypto — user picks at deposit.'}
- ); + ) } diff --git a/site/src/landing/sections/SectionFrame.tsx b/site/src/landing/sections/SectionFrame.tsx index 0a2bb643..8e8108a2 100644 --- a/site/src/landing/sections/SectionFrame.tsx +++ b/site/src/landing/sections/SectionFrame.tsx @@ -1,9 +1,10 @@ -"use client"; +'use client' -import type { ReactNode } from "react"; -import AsciiBackground from "../ascii-bg"; +import type { ReactNode } from 'react' -const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"; +import AsciiBackground from '../ascii-bg' + +const easeOut = 'cubic-bezier(0.23, 1, 0.32, 1)' export function SectionFrame({ title, @@ -11,10 +12,10 @@ export function SectionFrame({ left, right, }: { - title: string; - subheading: ReactNode; - left: ReactNode; - right: ReactNode; + title: string + subheading: ReactNode + left: ReactNode + right: ReactNode }) { return (
@@ -45,5 +46,5 @@ export function SectionFrame({
- ); + ) } diff --git a/site/src/landing/sections/SendReceive.tsx b/site/src/landing/sections/SendReceive.tsx index 02e62ea5..a520aaf4 100644 --- a/site/src/landing/sections/SendReceive.tsx +++ b/site/src/landing/sections/SendReceive.tsx @@ -1,57 +1,51 @@ -"use client"; +'use client' -import { useState } from "react"; -import { - RECEIVE_FALLBACK_ADDRESS, - ReceiveBody, -} from "../demo/bodies/Receive"; -import { - DESTINATIONS, - type DestinationId, - SendBody, -} from "../demo/bodies/Send"; -import { DEMO_AMOUNT_USD, shorten } from "../demo/sdk"; -import { SectionFrame } from "./SectionFrame"; -import { TogglePills } from "./TogglePills"; -import { useTempoSession } from "./useTempoSession"; +import { useState } from 'react' + +import { RECEIVE_FALLBACK_ADDRESS, ReceiveBody } from '../demo/bodies/Receive' +import { DESTINATIONS, type DestinationId, SendBody } from '../demo/bodies/Send' +import { DEMO_AMOUNT_USD, shorten } from '../demo/sdk' +import { SectionFrame } from './SectionFrame' +import { TogglePills } from './TogglePills' +import { useTempoSession } from './useTempoSession' const MODES = [ - { id: "send", label: "Send" }, - { id: "receive", label: "Receive" }, -] as const; + { id: 'send', label: 'Send' }, + { id: 'receive', label: 'Receive' }, +] as const -type ModeId = (typeof MODES)[number]["id"]; +type ModeId = (typeof MODES)[number]['id'] -const noop = () => undefined; +const noop = () => undefined export default function SendReceive() { - const [mode, setMode] = useState("send"); - const [destId, setDestId] = useState(DESTINATIONS[0].id); - const { status, address, balanceDisplay, result, run } = useTempoSession(); + const [mode, setMode] = useState('send') + const [destId, setDestId] = useState(DESTINATIONS[0].id) + const { status, address, balanceDisplay, result, run } = useTempoSession() - const dest = DESTINATIONS.find((d) => d.id === destId) ?? DESTINATIONS[0]; + const dest = DESTINATIONS.find((d) => d.id === destId) ?? DESTINATIONS[0] // The Tempo wallet exposes a single account per session, so the // Receive demo always points at that account. Pre-connect we fall // back to a placeholder so the section still renders before sign-in. - const receiveAddress = address ?? RECEIVE_FALLBACK_ADDRESS; + const receiveAddress = address ?? RECEIVE_FALLBACK_ADDRESS const onSend = () => { void run(async (provider) => { const accounts = (await provider.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const self = accounts?.[0]; - if (!self) throw new Error("No account connected."); + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const self = accounts?.[0] + if (!self) throw new Error('No account connected.') const r = (await provider.request({ - method: "wallet_send", + method: 'wallet_send', params: [{ to: self, value: DEMO_AMOUNT_USD }], } as Parameters[0])) as | { receipt?: { transactionHash?: `0x${string}` } } - | undefined; - const tx = r?.receipt?.transactionHash; - return { summary: tx ? `tx ${shorten(tx)}` : "Sent" }; - }); - }; + | undefined + const tx = r?.receipt?.transactionHash + return { summary: tx ? `tx ${shorten(tx)}` : 'Sent' } + }) + } return (
- +
@@ -93,7 +82,7 @@ export default function SendReceive() { } right={ - mode === "send" ? ( + mode === 'send' ? ( - ); + ) } const INFO: Record = { send: { - title: "Send", + title: 'Send', description: - "Pay any address with wallet_send. Tempo handles routing, gas sponsorship, and confirmation — your app just signs.", + 'Pay any address with wallet_send. Tempo handles routing, gas sponsorship, and confirmation — your app just signs.', }, receive: { - title: "Receive", + title: 'Receive', description: "Share the account address (or a QR) to accept payments. Funds settle directly into the user's account — no webhooks to wire.", }, -}; +} /* ─── Syntax tokens (mono color palette matches the adapter section) ─── */ const Keyword = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Str = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Fn = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Var = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Cmnt = ({ children }: { children: React.ReactNode }) => ( - {children} -); + {children} +) const Hl = ({ children }: { children: React.ReactNode }) => ( ( > {children} -); +) -type Destination = (typeof DESTINATIONS)[number]; +type Destination = (typeof DESTINATIONS)[number] function ModeCode({ mode, dest }: { mode: ModeId; dest: Destination }) { return ( @@ -168,112 +157,111 @@ function ModeCode({ mode, dest }: { mode: ModeId; dest: Destination }) { }} > - {mode === "send" ? : null} - {mode === "receive" ? : null} + {mode === 'send' ? : null} + {mode === 'receive' ? : null} - ); + ) } function SendSnippet({ dest }: { dest: Destination }) { return ( <>
- import {"{ Provider } "} + import {'{ Provider } '} from {`'accounts'`}
-
{" "}
+
- const provider ={" "} - Provider.create() + const provider = Provider. + create()
-
{" "}
+
- const {"{ "} + const {'{ '} receipt - {" } = "} + {' } = '} await provider.request - {"({"} + {'({'}
- {" method: "} + {' method: '} {`'wallet_send'`} - {","} + {','}
-
{" params: [{"}
+
{' params: [{'}
- {" to: "} + {' to: '} {`'${shorten(dest.address)}'`} - {", "} + {', '} {`// ${dest.label}`}
- {" value: "} + {' value: '} {`'0.01'`} - {", "} - {"// USD"} + {', '} + {'// USD'}
-
{" }],"}
-
{"})"}
-
{" "}
+
{' }],'}
+
{'})'}
+
- receipt.transactionHash{" "} - {"// → 0xabc…f00d"} + receipt.transactionHash {'// → 0xabc…f00d'}
- ); + ) } function ReceiveSnippet() { return ( <>
- import {"{ Provider } "} + import {'{ Provider } '} from {`'accounts'`}
-
{" "}
+
- const provider ={" "} - Provider.create() + const provider = Provider. + create()
-
{" "}
+
- const {"["} + const {'['} account - {"] = "} + {'] = '} await provider.request - {"({"} + {'({'}
- {" method: "} + {' method: '} {`'eth_accounts'`} - {","} + {','}
-
{"})"}
-
{" "}
+
{'})'}
+
- const {"["} + const {'['} balance - {"] = "} + {'] = '} await provider.request - {"({"} + {'({'}
- {" method: "} + {' method: '} {`'wallet_getBalances'`} - {","} + {','}
- {" params: [{ account: "} + {' params: [{ account: '} account - {" }],"} + {' }],'}
-
{"})"}
+
{'})'}
- balance.display {"// → $1,247.32"} + balance.display {'// → $1,247.32'}
- ); + ) } diff --git a/site/src/landing/sections/TogglePills.tsx b/site/src/landing/sections/TogglePills.tsx index efb89ee1..c00d1723 100644 --- a/site/src/landing/sections/TogglePills.tsx +++ b/site/src/landing/sections/TogglePills.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from 'react' -const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"; +const easeOut = 'cubic-bezier(0.23, 1, 0.32, 1)' /** * Animated toggle pill group with a sliding active highlight. Modeled on @@ -15,31 +15,29 @@ export function TogglePills({ onChange, label, }: { - options: readonly { id: T; label: string }[]; - value: T; - onChange: (next: T) => void; - label?: string; + options: readonly { id: T; label: string }[] + value: T + onChange: (next: T) => void + label?: string }) { - const ref = useRef(null); + const ref = useRef(null) const [highlight, setHighlight] = useState({ left: 0, width: 0, ready: false, - }); + }) useEffect(() => { - const container = ref.current; - if (!container) return; - const button = container.querySelector( - `button[data-toggle="${value}"]`, - ); - if (!button) return; + const container = ref.current + if (!container) return + const button = container.querySelector(`button[data-toggle="${value}"]`) + if (!button) return setHighlight({ left: button.offsetLeft, width: button.offsetWidth, ready: true, - }); - }, [value]); + }) + }, [value]) return (
@@ -58,24 +56,24 @@ export function TogglePills({ opacity: highlight.ready ? 1 : 0, transition: highlight.ready ? `transform 280ms ${easeOut}, width 280ms ${easeOut}, opacity 200ms ease-out` - : "opacity 200ms ease-out", + : 'opacity 200ms ease-out', }} /> {options.map((o) => { - const active = value === o.id; + const active = value === o.id return ( - ); + ) })}
- ); + ) } diff --git a/site/src/landing/sections/useTempoSession.ts b/site/src/landing/sections/useTempoSession.ts index 2933fe74..74b2e2d4 100644 --- a/site/src/landing/sections/useTempoSession.ts +++ b/site/src/landing/sections/useTempoSession.ts @@ -1,12 +1,9 @@ -"use client"; +'use client' -import { useEffect, useRef, useState } from "react"; -import { getDemoProvider, PATH_USD, shorten } from "../demo/sdk"; -import type { - AccountsProvider, - DemoResult, - Status, -} from "../demo/types"; +import { useEffect, useRef, useState } from 'react' + +import { getDemoProvider, PATH_USD, shorten } from '../demo/sdk' +import type { AccountsProvider, DemoResult, Status } from '../demo/types' /** * Shared session state for landing sections. Mirrors the lifecycle in @@ -17,88 +14,83 @@ import type { * sections plus `DemoSplit` and the browser-mockup `Demo` on one session. */ export function useTempoSession() { - const [status, setStatus] = useState("idle"); - const [address, setAddress] = useState<`0x${string}` | null>(null); - const [balanceDisplay, setBalanceDisplay] = useState(null); - const [result, setResult] = useState(null); - const providerRef = useRef(null); + const [status, setStatus] = useState('idle') + const [address, setAddress] = useState<`0x${string}` | null>(null) + const [balanceDisplay, setBalanceDisplay] = useState(null) + const [result, setResult] = useState(null) + const providerRef = useRef(null) const getProvider = async () => { - providerRef.current ??= await getDemoProvider(); - return providerRef.current; - }; + providerRef.current ??= await getDemoProvider() + return providerRef.current + } - const refreshBalance = async ( - p: AccountsProvider, - addr: `0x${string}`, - ) => { + const refreshBalance = async (p: AccountsProvider, addr: `0x${string}`) => { try { const balances = (await p.request({ - method: "wallet_getBalances", + method: 'wallet_getBalances', params: [{ account: addr, tokens: [PATH_USD] }], } as Parameters[0])) as ReadonlyArray<{ - display: string; - }>; - setBalanceDisplay(balances?.[0]?.display ?? "$0.00"); + display: string + }> + setBalanceDisplay(balances?.[0]?.display ?? '$0.00') } catch { - setBalanceDisplay("$0.00"); + setBalanceDisplay('$0.00') } - }; + } useEffect(() => { - let cancelled = false; - (async () => { + let cancelled = false + ;(async () => { try { - const p = await getProvider(); + const p = await getProvider() const accounts = (await p.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - if (cancelled) return; - const addr = accounts?.[0]; + method: 'eth_accounts', + })) as readonly `0x${string}`[] + if (cancelled) return + const addr = accounts?.[0] if (addr) { - setAddress(addr); + setAddress(addr) // Don't touch `status` — hydration is not an action completion. // Bodies that want to surface the signed-in state (e.g. the // Accounts section's LogInBody) can derive it from `address`. - setResult({ summary: `Signed in · ${shorten(addr)}` }); - await refreshBalance(p, addr); + setResult({ summary: `Signed in · ${shorten(addr)}` }) + await refreshBalance(p, addr) } } catch { // No persisted session — stay idle. } - })(); + })() return () => { - cancelled = true; - }; - }, []); + cancelled = true + } + }, []) - const run = async ( - fn: (provider: AccountsProvider) => Promise, - ) => { - if (status === "running") return; - setStatus("running"); + const run = async (fn: (provider: AccountsProvider) => Promise) => { + if (status === 'running') return + setStatus('running') try { - const p = await getProvider(); - const r = await fn(p); - setResult(r); - setStatus("done"); + const p = await getProvider() + const r = await fn(p) + setResult(r) + setStatus('done') try { const accounts = (await p.request({ - method: "eth_accounts", - })) as readonly `0x${string}`[]; - const addr = accounts?.[0]; + method: 'eth_accounts', + })) as readonly `0x${string}`[] + const addr = accounts?.[0] if (addr) { - setAddress(addr); - await refreshBalance(p, addr); + setAddress(addr) + await refreshBalance(p, addr) } } catch { // ignore — keep prior balance/address. } } catch { - setStatus("idle"); - setResult(null); + setStatus('idle') + setResult(null) } - }; + } - return { status, address, balanceDisplay, result, run }; + return { status, address, balanceDisplay, result, run } } diff --git a/site/src/landing/styles.css b/site/src/landing/styles.css index ba430f7a..92ff61bb 100644 --- a/site/src/landing/styles.css +++ b/site/src/landing/styles.css @@ -1,99 +1,99 @@ -@import "tailwindcss/theme.css"; -@import "tailwindcss/utilities.css"; +@import 'tailwindcss/theme.css'; +@import 'tailwindcss/utilities.css'; @source "./**/*.{ts,tsx}"; @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Thin.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Thin.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 100; } @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Light.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Light.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 300; } @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Book.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Book.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 350; } @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Regular.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Regular.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 400; } @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Demi.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Demi.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 600; } @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Bold.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Bold.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 700; } @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Heavy.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Heavy.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 800; } @font-face { - font-family: "Pilat"; - src: url("/fonts/Pilat/Pilat-Black.woff2") format("woff2"); + font-family: 'Pilat'; + src: url('/fonts/Pilat/Pilat-Black.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 900; } @font-face { - font-family: "HBSet"; - src: url("/fonts/Hbset/HBSetv0.96-Light.woff2") format("woff2"); + font-family: 'HBSet'; + src: url('/fonts/Hbset/HBSetv0.96-Light.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 300; } @font-face { - font-family: "JetBrains Mono"; - src: url("/fonts/Jetbrains-Mono/JetBrainsMono-Light.woff2") format("woff2"); + font-family: 'JetBrains Mono'; + src: url('/fonts/Jetbrains-Mono/JetBrainsMono-Light.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 300; } @font-face { - font-family: "JetBrains Mono"; - src: url("/fonts/Jetbrains-Mono/JetBrainsMono-Regular.woff2") format("woff2"); + font-family: 'JetBrains Mono'; + src: url('/fonts/Jetbrains-Mono/JetBrainsMono-Regular.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 400; } @font-face { - font-family: "JetBrains Mono"; - src: url("/fonts/Jetbrains-Mono/JetBrainsMono-Medium.woff2") format("woff2"); + font-family: 'JetBrains Mono'; + src: url('/fonts/Jetbrains-Mono/JetBrainsMono-Medium.woff2') format('woff2'); font-display: swap; font-style: normal; font-weight: 500; @@ -283,9 +283,9 @@ --canvas-dot-alpha-hover: 0.1; /* Fonts + motion */ - --font-pilat: "Pilat", Arial, Helvetica, sans-serif; - --font-hbset: "HBSet", "Pilat", Arial, Helvetica, sans-serif; - --font-jetbrains-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-pilat: 'Pilat', Arial, Helvetica, sans-serif; + --font-hbset: 'HBSet', 'Pilat', Arial, Helvetica, sans-serif; + --font-jetbrains-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; } /* Light-mode overrides key off the `color-scheme` that vocs writes onto @@ -293,7 +293,7 @@ * landing tokens flip in lockstep with the rest of the page and there is no * flash-of-dark on first paint. We piggyback on vocs's single theme source * of truth instead of maintaining a parallel `data-theme` attribute. */ -html[style*="color-scheme: light"] .accounts-landing { +html[style*='color-scheme: light'] .accounts-landing { /* Tier 1 — chrome */ --background: #ffffff; --surface: #f8f8f8; @@ -367,8 +367,8 @@ html[style*="color-scheme: light"] .accounts-landing { * outline on a near-black page; on a white page that same colour * reads as a heavy black stroke around the demo cards. */ --preview-dark-border: rgba(0, 0, 0, 0.16); - --preview-pizza-border: rgba(0, 0, 0, 0.10); - --preview-reload-border: rgba(0, 0, 0, 0.10); + --preview-pizza-border: rgba(0, 0, 0, 0.1); + --preview-reload-border: rgba(0, 0, 0, 0.1); /* Tier 7 — dino-game canvas (orange + skin stay constant; greys invert * so the miner/salt/bird stay visible against a white sky). */ @@ -450,18 +450,18 @@ html[style*="color-scheme: light"] .accounts-landing { --ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1); } -[data-layout="blank"]:has(.accounts-landing) [data-v-main], -[data-layout="blank"]:has(.accounts-landing) [data-v-content] { +[data-layout='blank']:has(.accounts-landing) [data-v-main], +[data-layout='blank']:has(.accounts-landing) [data-v-content] { max-width: none; width: 100%; padding: 0; } -[data-layout="blank"]:has(.accounts-landing) [data-v-main] { +[data-layout='blank']:has(.accounts-landing) [data-v-main] { padding-bottom: 0; } -[data-layout="blank"]:has(.accounts-landing) [data-v-content] { +[data-layout='blank']:has(.accounts-landing) [data-v-content] { margin: 0; } @@ -507,9 +507,7 @@ html[style*="color-scheme: light"] .accounts-landing { } .accounts-landing .text-halo svg { - filter: - drop-shadow(0 0 3px var(--background)) - drop-shadow(0 0 3px var(--background)) + filter: drop-shadow(0 0 3px var(--background)) drop-shadow(0 0 3px var(--background)) drop-shadow(0 0 6px var(--background)); } @@ -584,7 +582,7 @@ html[style*="color-scheme: light"] .accounts-landing { .accounts-landing .dash-y::before, .accounts-landing .dash-y::after, .accounts-landing .dash-l::before { - content: ""; + content: ''; position: absolute; top: 0; bottom: 0; @@ -609,7 +607,7 @@ html[style*="color-scheme: light"] .accounts-landing { .accounts-landing .dash-t::before, .accounts-landing .dash-b::after { - content: ""; + content: ''; position: absolute; left: 0; right: 0; diff --git a/site/src/landing/theme-switch.tsx b/site/src/landing/theme-switch.tsx index 6aa013f3..28c5f27b 100644 --- a/site/src/landing/theme-switch.tsx +++ b/site/src/landing/theme-switch.tsx @@ -1,17 +1,10 @@ -"use client"; +'use client' -import { useTheme } from "./useTheme"; +import { useTheme } from './useTheme' function SunIcon({ className }: { className?: string }) { return ( - + - ); + ) } function MoonIcon({ className }: { className?: string }) { return ( - + - ); + ) } /** Two-state light/dark toggle. The icon shown represents the CURRENT @@ -49,17 +35,17 @@ function MoonIcon({ className }: { className?: string }) { * initial first-paint default; once the user clicks, the choice is * persisted to `localStorage` and the toggle owns the truth. */ export function ThemeSwitch({ className }: { className?: string }) { - const { resolved, setTheme } = useTheme(); - const next = resolved === "light" ? "dark" : "light"; + const { resolved, setTheme } = useTheme() + const next = resolved === 'light' ? 'dark' : 'light' return ( - ); + ) } diff --git a/site/src/landing/useTheme.tsx b/site/src/landing/useTheme.tsx index 02bb88d4..33ddf3b5 100644 --- a/site/src/landing/useTheme.tsx +++ b/site/src/landing/useTheme.tsx @@ -1,182 +1,172 @@ -"use client"; +'use client' -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' /** Three states the toggle cycles through. `system` follows the OS. */ -export type Theme = "system" | "light" | "dark"; +export type Theme = 'system' | 'light' | 'dark' /** The concrete theme that ends up on the DOM. */ -export type ResolvedTheme = "light" | "dark"; +export type ResolvedTheme = 'light' | 'dark' // We piggyback on vocs's theme rather than running a parallel themer: vocs // owns the `vocs-theme` localStorage key and writes the resolved value onto // ``'s inline `color-scheme` (via a blocking head script, so it is set // before first paint). The landing CSS keys its light overrides off that same // `color-scheme`, so reading/writing it here keeps everything in lockstep. -const STORAGE_KEY = "vocs-theme"; +const STORAGE_KEY = 'vocs-theme' -const isBrowser = typeof window !== "undefined"; +const isBrowser = typeof window !== 'undefined' // Mirror vocs's own transition-kill so flipping the theme doesn't cross-fade. -const DISABLE_TRANSITIONS_CSS = - "*,*::before,*::after{transition:none!important}"; +const DISABLE_TRANSITIONS_CSS = '*,*::before,*::after{transition:none!important}' function readStored(): Theme { - if (!isBrowser) return "system"; + if (!isBrowser) return 'system' try { - const v = window.localStorage.getItem(STORAGE_KEY); - if (v === "light" || v === "dark" || v === "system") return v; + const v = window.localStorage.getItem(STORAGE_KEY) + if (v === 'light' || v === 'dark' || v === 'system') return v } catch {} - return "system"; + return 'system' } function systemTheme(): ResolvedTheme { - if (!isBrowser || typeof window.matchMedia !== "function") return "dark"; - return window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; + if (!isBrowser || typeof window.matchMedia !== 'function') return 'dark' + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } /** Resolves `system` against the live OS preference. */ export function resolveTheme(theme: Theme): ResolvedTheme { - if (theme === "light" || theme === "dark") return theme; - return systemTheme(); + if (theme === 'light' || theme === 'dark') return theme + return systemTheme() } /** Reads vocs's authoritative signal: the `color-scheme` it writes on . * Falls back to a fresh resolve from `vocs-theme` + matchMedia. */ function readResolved(): ResolvedTheme { - if (!isBrowser) return "dark"; - const scheme = document.documentElement.style.colorScheme; - if (scheme === "light" || scheme === "dark") return scheme; - return resolveTheme(readStored()); + if (!isBrowser) return 'dark' + const scheme = document.documentElement.style.colorScheme + if (scheme === 'light' || scheme === 'dark') return scheme + return resolveTheme(readStored()) } /** Writes the resolved theme onto the same way vocs does, with a * transient transition-kill to avoid a flash. */ function applyResolved(resolved: ResolvedTheme) { - if (!isBrowser) return; - const style = document.createElement("style"); - style.appendChild(document.createTextNode(DISABLE_TRANSITIONS_CSS)); - document.head.appendChild(style); - document.documentElement.style.colorScheme = resolved; + if (!isBrowser) return + const style = document.createElement('style') + style.appendChild(document.createTextNode(DISABLE_TRANSITIONS_CSS)) + document.head.appendChild(style) + document.documentElement.style.colorScheme = resolved // Force a reflow so the color-scheme change commits before transitions // are re-enabled on the next frame. - void window.getComputedStyle(document.body).opacity; + void window.getComputedStyle(document.body).opacity window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { - if (style.parentNode) document.head.removeChild(style); - }); - }); + if (style.parentNode) document.head.removeChild(style) + }) + }) } type Ctx = { - theme: Theme; - resolved: ResolvedTheme; - setTheme: (next: Theme) => void; - cycleTheme: () => void; -}; + theme: Theme + resolved: ResolvedTheme + setTheme: (next: Theme) => void + cycleTheme: () => void +} -const ThemeContext = createContext(null); +const ThemeContext = createContext(null) /** Mounted at the landing root. Bridges the landing UI to vocs's theme: * reads the resolved value vocs put on , and writes back through the * same `vocs-theme` + `color-scheme` channel when the landing toggle fires. */ export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setThemeState] = useState(() => readStored()); - const [resolved, setResolved] = useState(() => readResolved()); + const [theme, setThemeState] = useState(() => readStored()) + const [resolved, setResolved] = useState(() => readResolved()) const setTheme = useCallback((next: Theme) => { - setThemeState(next); - const r = resolveTheme(next); - setResolved(r); + setThemeState(next) + const r = resolveTheme(next) + setResolved(r) try { - window.localStorage.setItem(STORAGE_KEY, next); + window.localStorage.setItem(STORAGE_KEY, next) } catch {} - applyResolved(r); - }, []); + applyResolved(r) + }, []) const cycleTheme = useCallback(() => { setThemeState((prev) => { - const order: Theme[] = ["system", "light", "dark"]; - const next = order[(order.indexOf(prev) + 1) % order.length] ?? "system"; - const r = resolveTheme(next); - setResolved(r); + const order: Theme[] = ['system', 'light', 'dark'] + const next = order[(order.indexOf(prev) + 1) % order.length] ?? 'system' + const r = resolveTheme(next) + setResolved(r) try { - window.localStorage.setItem(STORAGE_KEY, next); + window.localStorage.setItem(STORAGE_KEY, next) } catch {} - applyResolved(r); - return next; - }); - }, []); + applyResolved(r) + return next + }) + }, []) // Track the OS preference while in `system` mode. useEffect(() => { - if (theme !== "system" || !isBrowser) return; - const mq = window.matchMedia("(prefers-color-scheme: dark)"); + if (theme !== 'system' || !isBrowser) return + const mq = window.matchMedia('(prefers-color-scheme: dark)') const onChange = () => { - const r: ResolvedTheme = mq.matches ? "dark" : "light"; - setResolved(r); - applyResolved(r); - }; - if (mq.addEventListener) mq.addEventListener("change", onChange); - else mq.addListener(onChange); + const r: ResolvedTheme = mq.matches ? 'dark' : 'light' + setResolved(r) + applyResolved(r) + } + if (mq.addEventListener) mq.addEventListener('change', onChange) + else mq.addListener(onChange) return () => { - if (mq.removeEventListener) mq.removeEventListener("change", onChange); - else mq.removeListener(onChange); - }; - }, [theme]); + if (mq.removeEventListener) mq.removeEventListener('change', onChange) + else mq.removeListener(onChange) + } + }, [theme]) // Sync with theme changes made outside the landing toggle — vocs's own nav // toggle (writes `color-scheme` on ) or another tab (storage event). useEffect(() => { - if (!isBrowser) return; - setResolved(readResolved()); - setThemeState(readStored()); + if (!isBrowser) return + setResolved(readResolved()) + setThemeState(readStored()) - const observer = new MutationObserver(() => setResolved(readResolved())); + const observer = new MutationObserver(() => setResolved(readResolved())) observer.observe(document.documentElement, { attributes: true, - attributeFilter: ["style"], - }); + attributeFilter: ['style'], + }) const onStorage = (event: StorageEvent) => { - if (event.key && event.key !== STORAGE_KEY) return; - setThemeState(readStored()); - setResolved(readResolved()); - }; - window.addEventListener("storage", onStorage); + if (event.key && event.key !== STORAGE_KEY) return + setThemeState(readStored()) + setResolved(readResolved()) + } + window.addEventListener('storage', onStorage) return () => { - observer.disconnect(); - window.removeEventListener("storage", onStorage); - }; - }, []); + observer.disconnect() + window.removeEventListener('storage', onStorage) + } + }, []) const ctx = useMemo( () => ({ theme, resolved, setTheme, cycleTheme }), [theme, resolved, setTheme, cycleTheme], - ); + ) - return {children}; + return {children} } /** Read theme state from any landing component. */ export function useTheme(): Ctx { - const ctx = useContext(ThemeContext); - if (!ctx) throw new Error("useTheme must be used inside "); - return ctx; + const ctx = useContext(ThemeContext) + if (!ctx) throw new Error('useTheme must be used inside ') + return ctx } /** Convenience: read a CSS custom property from a themed root. Used by * canvas components to consume the colour catalogue at draw time. */ export function readCssVar(el: Element | null, name: string): string { - if (!el || !isBrowser) return ""; - return getComputedStyle(el).getPropertyValue(name).trim(); + if (!el || !isBrowser) return '' + return getComputedStyle(el).getPropertyValue(name).trim() } diff --git a/site/src/pages.gen.ts b/site/src/pages.gen.ts index 7e87cce4..fa4c7eba 100644 --- a/site/src/pages.gen.ts +++ b/site/src/pages.gen.ts @@ -98,7 +98,6 @@ type Page = | { path: '/docs/rpc/wallet_switchEthereumChain'; render: 'static' } | { path: '/docs/rpc/wallet_withdrawZone'; render: 'static' } | { path: '/docs/server/handler.auth'; render: 'static' } - | { path: '/docs/server/handler.codeAuth'; render: 'static' } | { path: '/docs/server/handler.compose'; render: 'static' } | { path: '/docs/server/handler.exchange'; render: 'static' } | { path: '/docs/server/handler.feePayer'; render: 'static' } diff --git a/site/src/pages/_root.css b/site/src/pages/_root.css index 37a2bd5c..33662456 100644 --- a/site/src/pages/_root.css +++ b/site/src/pages/_root.css @@ -1,7 +1,7 @@ -@import "tailwindcss"; -@import "regen-ui/tailwind.css"; -@import "./_vocs.generated.css"; -@import "../landing/styles.css"; +@import 'tailwindcss'; +@import 'regen-ui/tailwind.css'; +@import './_vocs.generated.css'; +@import '../landing/styles.css'; @source "../"; @@ -24,8 +24,8 @@ button { } [data-active], -[data-v-active="true"], -[aria-current="page"] { +[data-v-active='true'], +[aria-current='page'] { border-style: solid; } @@ -53,8 +53,16 @@ table[data-v] th { --background-color-surface: light-dark(#ffffff, #141414); --vocs-background-color-primary: var(--background-color-background); --vocs-background-color-surface: var(--background-color-surface); - --vocs-background-color-code-block: color-mix(in srgb, var(--background-color-secondary) 50%, transparent); - --vocs-background-color-inline-code: color-mix(in srgb, var(--background-color-secondary) 50%, transparent); + --vocs-background-color-code-block: color-mix( + in srgb, + var(--background-color-secondary) 50%, + transparent + ); + --vocs-background-color-inline-code: color-mix( + in srgb, + var(--background-color-secondary) 50%, + transparent + ); } /* @@ -65,11 +73,11 @@ table[data-v] th { * Override the polyfill variables (--lightningcss-light / --lightningcss-dark) * to respect the inline color-scheme attribute that Vocs sets on . */ -html[style*="color-scheme: light"] { +html[style*='color-scheme: light'] { --lightningcss-light: initial; --lightningcss-dark: ; } -html[style*="color-scheme: dark"] { +html[style*='color-scheme: dark'] { --lightningcss-light: ; --lightningcss-dark: initial; } diff --git a/site/src/pages/docs/adapters/index.mdx b/site/src/pages/docs/adapters/index.mdx index a8bd0f52..79fba956 100644 --- a/site/src/pages/docs/adapters/index.mdx +++ b/site/src/pages/docs/adapters/index.mdx @@ -11,14 +11,14 @@ import { Cards, Card } from 'vocs' Adapters decide where keys live, who runs authentication, and how account requests are approved. Your app talks to the Tempo Accounts SDK through one provider surface while the adapter handles signing. -| Adapter | Who owns auth | Best for | -| ------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------- | -| [Tempo Wallet](/docs/adapters/tempo-wallet) | Tempo Wallet | Apps that want a universal wallet without owning signing infrastructure. | -| [WebAuthn (Passkeys)](/docs/adapters/webauthn) | Your app origin | Teams that want domain-bound passkeys and Tempo account features. | -| [Turnkey](/docs/adapters/turnkey) | Turnkey | Apps that want Turnkey to own key custody and authentication. | -| [Privy](/docs/adapters/privy) | Privy | Apps that want Privy auth and embedded wallets with Tempo account features. | -| [Private Key](/docs/adapters/private-key) | Your environment | Server-side signers with a pinned key, or development with a random in-storage key. | -| [Custom](/docs/adapters/custom) | Your infrastructure | AWS KMS, internal signers, and other hosted wallet products. | +| Adapter | Who owns auth | Best for | +| ---------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------- | +| [Tempo Wallet](/docs/adapters/tempo-wallet) | Tempo Wallet | Apps that want a universal wallet without owning signing infrastructure. | +| [WebAuthn (Passkeys)](/docs/adapters/webauthn) | Your app origin | Teams that want domain-bound passkeys and Tempo account features. | +| [Turnkey](/docs/adapters/turnkey) | Turnkey | Apps that want Turnkey to own key custody and authentication. | +| [Privy](/docs/adapters/privy) | Privy | Apps that want Privy auth and embedded wallets with Tempo account features. | +| [Private Key](/docs/adapters/private-key) | Your environment | Server-side signers with a pinned key, or development with a random in-storage key. | +| [Custom](/docs/adapters/custom) | Your infrastructure | AWS KMS, internal signers, and other hosted wallet products. | ```mermaid sequenceDiagram diff --git a/site/src/pages/docs/adapters/webauthn.mdx b/site/src/pages/docs/adapters/webauthn.mdx index 2e671aed..46e6e7cf 100644 --- a/site/src/pages/docs/adapters/webauthn.mdx +++ b/site/src/pages/docs/adapters/webauthn.mdx @@ -156,6 +156,7 @@ export const handler = Handler.webAuthn({ session: false, // [!code focus] }) ``` + ::: ### Account Recovery diff --git a/site/src/pages/docs/api/adapters.mdx b/site/src/pages/docs/api/adapters.mdx index bd95611c..c826a770 100644 --- a/site/src/pages/docs/api/adapters.mdx +++ b/site/src/pages/docs/api/adapters.mdx @@ -9,16 +9,16 @@ import { Cards, Card } from 'vocs' Adapters control how accounts are created and managed. Pass an adapter to `Provider.create()` to configure the account management strategy. -| Adapter | Uses | Best for | -| -------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- | -| [`dialog`](/docs/api/dialog) (default) | Hosted dialog | Apps wanting a universal account experience | -| [`mobileWebAuth`](/docs/api/mobileWebAuth) | Mobile browser auth session | React Native apps and mobile clients connecting to a hosted wallet | -| [`postMessage`](/docs/api/postMessage) | Wallet page popup | Web apps connecting to a hosted wallet through a popup session | -| [`webAuthn`](/docs/api/webAuthn) | Domain-bound passkeys | Wallets integrating Passkey accounts, or Apps wanting to host and manage their own Passkey accounts | -| [`turnkey`](/docs/api/turnkey) | `@turnkey/core` client sessions | Apps using Turnkey-managed wallet accounts | -| [`privy`](/docs/api/privy) | Privy React Auth | Apps using Privy auth and embedded wallets | -| [`secp256k1`](/docs/api/secp256k1) | In-process `secp256k1` private key | Server-side signers with a pinned key, or development with a random in-storage key | -| [`local`](/docs/api/local) | Arbitrary keys | Custom signing and key management | +| Adapter | Uses | Best for | +| ------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------- | +| [`dialog`](/docs/api/dialog) (default) | Hosted dialog | Apps wanting a universal account experience | +| [`mobileWebAuth`](/docs/api/mobileWebAuth) | Mobile browser auth session | React Native apps and mobile clients connecting to a hosted wallet | +| [`postMessage`](/docs/api/postMessage) | Wallet page popup | Web apps connecting to a hosted wallet through a popup session | +| [`webAuthn`](/docs/api/webAuthn) | Domain-bound passkeys | Wallets integrating Passkey accounts, or Apps wanting to host and manage their own Passkey accounts | +| [`turnkey`](/docs/api/turnkey) | `@turnkey/core` client sessions | Apps using Turnkey-managed wallet accounts | +| [`privy`](/docs/api/privy) | Privy React Auth | Apps using Privy auth and embedded wallets | +| [`secp256k1`](/docs/api/secp256k1) | In-process `secp256k1` private key | Server-side signers with a pinned key, or development with a random in-storage key | +| [`local`](/docs/api/local) | Arbitrary keys | Custom signing and key management | --- diff --git a/site/src/pages/docs/api/postMessage.mdx b/site/src/pages/docs/api/postMessage.mdx index 9277d4cf..67a82c06 100644 --- a/site/src/pages/docs/api/postMessage.mdx +++ b/site/src/pages/docs/api/postMessage.mdx @@ -5,8 +5,7 @@ description: Connect to wallet from anywhere on the web # `postMessage` -Connect to a wallet hosted on another domain from anywhere on the web. Communication happens over the [uRPC window](https://github.com/tempoxyz/urpc/blob/main/specs/transport-window.md) transport. - +Connect to a wallet hosted on another domain from anywhere on the web. Communication happens over the [uRPC window](https://github.com/tempoxyz/urpc/blob/main/specs/transport-window.md) transport. ## Usage @@ -27,7 +26,7 @@ const provider = Provider.create({ To connect to the Tempo Wallet, use the preconfigured [`tempoWallet`](#tempowallet) below. -```ts twoslash +````ts twoslash import { Provider, tempoWallet } from 'accounts' const provider = Provider.create({ @@ -80,4 +79,4 @@ const adapter = postMessage({ name: 'Example Wallet', rdns: 'com.example.wallet', }) -``` +```` diff --git a/site/src/pages/docs/api/provider.mdx b/site/src/pages/docs/api/provider.mdx index 5e3cbae5..c46d645c 100644 --- a/site/src/pages/docs/api/provider.mdx +++ b/site/src/pages/docs/api/provider.mdx @@ -188,7 +188,7 @@ const provider = Provider.create({ - **Type:** `Keystore.Keystores` - **Default:** `Keystore.defaults` — `{ p256: Keystore.webCryptoP256() }` -[Keystores](/docs/api/keystore) backing provider-generated access keys, one per key type. +[Keystores](/docs/api/keystore) backing provider-generated access keys, one per key type. ```ts twoslash import { Keystore, Provider } from 'accounts' diff --git a/site/src/pages/docs/api/storage.mdx b/site/src/pages/docs/api/storage.mdx index 93e7ecb8..b4522dcd 100644 --- a/site/src/pages/docs/api/storage.mdx +++ b/site/src/pages/docs/api/storage.mdx @@ -73,13 +73,13 @@ React Native adapters ship as optional subpaths so apps only install the native ## Comparison -| Adapter | Persistence | Environment | Notes | -| --- | --- | --- | --- | -| [`Storage.idb`](/docs/api/storage.idb) | Persistent | Browser | Default in the browser. Stores raw values (no JSON serialization). | -| [`Storage.localStorage`](/docs/api/storage.localStorage) | Persistent | Browser | Synchronous; values are JSON-serialized. | -| [`Storage.cookie`](/docs/api/storage.cookie) | Persistent | Browser | Uses `SameSite=None; Secure`. Deep objects are flattened to stay within the 4 KB-per-cookie limit. | -| [`Storage.memory`](/docs/api/storage.memory) | Ephemeral | Any | Default outside the browser. Useful for SSR and tests. | -| [`asyncStorage`](/docs/api/storage.asyncStorage) | Persistent | React Native | Plain-text AsyncStorage; for non-sensitive state. | -| [`secureMmkv`](/docs/api/storage.secureMmkv) | Persistent | React Native | AES-256-encrypted MMKV with the key in the Keychain/Keystore; use when the provider manages access keys. | -| [`Storage.combine`](/docs/api/storage.combine) | — | Any | Reads return the first non-null result; writes propagate to every storage. | -| [`Storage.from`](/docs/api/storage.from) | — | Any | Wraps a custom implementation, optionally scoping all keys under a prefix. | +| Adapter | Persistence | Environment | Notes | +| -------------------------------------------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------- | +| [`Storage.idb`](/docs/api/storage.idb) | Persistent | Browser | Default in the browser. Stores raw values (no JSON serialization). | +| [`Storage.localStorage`](/docs/api/storage.localStorage) | Persistent | Browser | Synchronous; values are JSON-serialized. | +| [`Storage.cookie`](/docs/api/storage.cookie) | Persistent | Browser | Uses `SameSite=None; Secure`. Deep objects are flattened to stay within the 4 KB-per-cookie limit. | +| [`Storage.memory`](/docs/api/storage.memory) | Ephemeral | Any | Default outside the browser. Useful for SSR and tests. | +| [`asyncStorage`](/docs/api/storage.asyncStorage) | Persistent | React Native | Plain-text AsyncStorage; for non-sensitive state. | +| [`secureMmkv`](/docs/api/storage.secureMmkv) | Persistent | React Native | AES-256-encrypted MMKV with the key in the Keychain/Keystore; use when the provider manages access keys. | +| [`Storage.combine`](/docs/api/storage.combine) | — | Any | Reads return the first non-null result; writes propagate to every storage. | +| [`Storage.from`](/docs/api/storage.from) | — | Any | Wraps a custom implementation, optionally scoping all keys under a prefix. | diff --git a/site/src/pages/docs/cli/provider.mdx b/site/src/pages/docs/cli/provider.mdx index a1c9b739..809995e0 100644 --- a/site/src/pages/docs/cli/provider.mdx +++ b/site/src/pages/docs/cli/provider.mdx @@ -5,11 +5,11 @@ description: Create a Provider for CLI environments. # `Provider` -Creates a Provider that bootstraps account access from the terminal via the device-code flow. +Creates a Provider that bootstraps account access from the terminal via the Wata device-code flow (OAuth 2.0 Device Authorization Grant, RFC 8628 with PKCE). When `wallet_connect` or `wallet_authorizeAccessKey` runs, the provider: -- opens the configured `host` in the user's default browser +- registers a device code at the configured `host` and opens the verification page in the user's default browser - prompts them to approve the access key - persists the resulting key in provider storage for re-use across CLI invocations @@ -105,17 +105,17 @@ const provider = Provider.create({ ### host - **Type:** `string` -- **Default:** `'https://wallet.tempo.xyz/api/auth/cli'` +- **Default:** `'https://wallet.tempo.xyz/auth/device'` -Host URL for the device-code flow. The browser is opened at `${host}?code=` and the CLI polls `${host}/poll/` for completion. +Base URL of the host's device-code endpoints. The CLI registers at `${host}/register`, opens the browser at `${host}/verify?user_code=`, and polls `${host}/token` for completion. -Designed to point at a service exposing the `CliAuth` handlers from `accounts/server`. +Designed to point at a host mounting the [`wata`](https://github.com/wevm/wata) device-code transport. ```ts twoslash [cli.ts] import { Provider } from 'accounts/cli' const provider = Provider.create({ - host: 'https://myapp.com/api/auth/cli', // [!code focus] + host: 'https://myapp.com/auth/device', // [!code focus] }) ``` @@ -180,17 +180,17 @@ const provider = Provider.create({ ### open -- **Type:** `(url: string) => Promise | void` +- **Type:** `(url: string, prompt: DeviceCode.Prompt) => Promise | void` - **Default:** `open`/`xdg-open`/`start` depending on platform -Browser opener override. Useful for headless environments, CI, or to intercept the URL before opening (e.g. to print it to the terminal). +Browser opener override. Useful for headless environments, CI, or to intercept the URL before opening (e.g. to print it to the terminal). The second argument carries the full pairing prompt (`userCode`, `verificationUri`, expiry, poll interval). ```ts twoslash [cli.ts] import { Provider } from 'accounts/cli' const provider = Provider.create({ - open: (url) => { // [!code focus] - console.log(`Open ${url} in your browser to continue.`) // [!code focus] + open: (url, prompt) => { // [!code focus] + console.log(`Open ${url} and confirm code ${prompt.userCode}.`) // [!code focus] }, // [!code focus] }) ``` @@ -210,18 +210,18 @@ const provider = Provider.create({ }) ``` -### pollIntervalMs +### pollingInterval - **Type:** `number` -- **Default:** `2000` +- **Default:** the host-advertised `interval` (5000 when the host omits one) -How often (in milliseconds) the CLI polls the device-code endpoint while waiting for the user to approve the request in the browser. +How often (in milliseconds) the CLI polls the device-code token endpoint while waiting for the user to approve the request in the browser. ```ts twoslash [cli.ts] import { Provider } from 'accounts/cli' const provider = Provider.create({ - pollIntervalMs: 1000, // [!code focus] + pollingInterval: 1000, // [!code focus] }) ``` @@ -287,7 +287,7 @@ const provider = Provider.create({ }) ``` -### timeoutMs +### timeout - **Type:** `number` - **Default:** `300000` (5 minutes) @@ -298,7 +298,7 @@ How long (in milliseconds) the CLI waits for the user to complete the device-cod import { Provider } from 'accounts/cli' const provider = Provider.create({ - timeoutMs: 60_000, // [!code focus] + timeout: 60_000, // [!code focus] }) ``` diff --git a/site/src/pages/docs/enterprise/bring-your-auth/index.mdx b/site/src/pages/docs/enterprise/bring-your-auth/index.mdx index 99865aea..134e0ef4 100644 --- a/site/src/pages/docs/enterprise/bring-your-auth/index.mdx +++ b/site/src/pages/docs/enterprise/bring-your-auth/index.mdx @@ -11,12 +11,12 @@ This path is for teams that already have user identity, compliance, custody, or ## Common patterns -| Pattern | Use it when | -| --- | --- | -| [Privy](/docs/enterprise/bring-your-auth/privy) | Privy owns app auth and wallet state. | -| [AWS KMS](/docs/enterprise/bring-your-auth/aws-kms) | Keys live in AWS-controlled infrastructure. | -| [Turnkey](/docs/enterprise/bring-your-auth/turnkey) | Turnkey owns signer infrastructure. | -| [Custom](/docs/enterprise/bring-your-auth/custom) | Your own service owns auth, signing, or wallet hosting. | +| Pattern | Use it when | +| --------------------------------------------------- | ------------------------------------------------------- | +| [Privy](/docs/enterprise/bring-your-auth/privy) | Privy owns app auth and wallet state. | +| [AWS KMS](/docs/enterprise/bring-your-auth/aws-kms) | Keys live in AWS-controlled infrastructure. | +| [Turnkey](/docs/enterprise/bring-your-auth/turnkey) | Turnkey owns signer infrastructure. | +| [Custom](/docs/enterprise/bring-your-auth/custom) | Your own service owns auth, signing, or wallet hosting. | ## Integration shape diff --git a/site/src/pages/docs/guides/cli.mdx b/site/src/pages/docs/guides/cli.mdx index 170a9268..5d1bf68f 100644 --- a/site/src/pages/docs/guides/cli.mdx +++ b/site/src/pages/docs/guides/cli.mdx @@ -9,7 +9,7 @@ import { Cards, Card } from 'vocs' Build a command-line tool that asks a user to approve a scoped access key in the browser, then signs future commands locally from the terminal. -The CLI provider uses a device-code flow. The command creates an access-key request, opens a Tempo Wallet approval page, polls until the user approves it, and stores the signed key in provider storage. After that, matching transfers or paid API calls can run without reopening the browser until the key expires. +The CLI provider uses the Wata device-code flow (OAuth 2.0 Device Authorization Grant, RFC 8628). The command registers an access-key request, opens a Tempo Wallet approval page, polls until the user approves it, and stores the signed key in provider storage. After that, matching transfers or paid API calls can run without reopening the browser until the key expires. ## Example @@ -126,7 +126,7 @@ cli.command('connect', { }) ``` -When this runs, the provider creates a device code, opens the approval URL, and polls the code-auth service until Tempo Wallet returns the signed key authorization. +When this runs, the provider registers a device code, opens the verification URL, and polls the token endpoint until Tempo Wallet returns the signed key authorization. ### Submit a Transfer @@ -216,14 +216,20 @@ const provider = Provider.create({ ### Use a Durable Code Store -If you host your own CLI approval flow, back [`Handler.codeAuth`](/docs/server/handler.codeAuth) with durable storage. A memory store is fine for local development, but production device codes should survive process restarts while they are pending. +If you host your own CLI approval flow, mount the [`wata`](https://github.com/wevm/wata) device-code host transport and back it with durable storage. A memory store is fine for local development, but production device codes should survive process restarts while they are pending. ```ts twoslash [server.ts] // @noErrors -import { CliAuth, Handler } from 'accounts/server' - -const handler = Handler.codeAuth({ - store: CliAuth.Store.kv(env.KV), +import { Store, Wata, deviceCode } from 'wata/host' + +const wata = Wata.create({ + transports: [ + deviceCode({ + html: { render, authenticate }, + path: '/auth/device', + store: Store.cloudflare(env.KV), + }), + ], }) ``` @@ -236,12 +242,6 @@ const handler = Handler.codeAuth({ to="/docs/cli/provider" title="CLI Provider" /> - relay.fetch(c.req.raw)) // Hono export const GET = relay.fetch // Next.js export const POST = relay.fetch // Next.js ``` + ::: :::warning[Add a sponsorship policy] diff --git a/site/src/pages/docs/rpc/wallet_connect.mdx b/site/src/pages/docs/rpc/wallet_connect.mdx index fa5c7ecc..6fc4616b 100644 --- a/site/src/pages/docs/rpc/wallet_connect.mdx +++ b/site/src/pages/docs/rpc/wallet_connect.mdx @@ -286,7 +286,7 @@ const { accounts } = await provider.request({ }) ``` -The restriction applies to email *entry* only: an account whose email is +The restriction applies to email _entry_ only: an account whose email is already verified still shares it, since your server may accept existing or invited users beyond the allowlist. Always enforce the policy server-side by verifying the `idToken`. An exact `address` takes precedence over `domains`. diff --git a/site/src/pages/docs/server/handler.codeAuth.mdx b/site/src/pages/docs/server/handler.codeAuth.mdx index 6f6fdeb0..382b74dd 100644 --- a/site/src/pages/docs/server/handler.codeAuth.mdx +++ b/site/src/pages/docs/server/handler.codeAuth.mdx @@ -1,9 +1,11 @@ --- -title: Handler.codeAuth -description: Server handler for the device-code (PKCE) access-key bootstrap flow. +title: Handler.codeAuth (Deprecated) +description: Deprecated compatibility handler for the legacy device-code access-key bootstrap flow. --- -# `Handler.codeAuth` +# `Handler.codeAuth` (Deprecated) + +**Deprecated.** Use [`Handler.deviceCode`](/docs/server/handler.deviceCode) for new integrations. `Handler.codeAuth` remains available while existing CLI consumers migrate to `/auth/device`. Creates a generic device-code handler used to bootstrap access keys for non-browser SDK contexts (CLIs, scripts). Mounts four endpoints under `path`: diff --git a/site/src/pages/docs/server/handler.compose.mdx b/site/src/pages/docs/server/handler.compose.mdx index 3f580803..232c8014 100644 --- a/site/src/pages/docs/server/handler.compose.mdx +++ b/site/src/pages/docs/server/handler.compose.mdx @@ -113,7 +113,7 @@ Because the composed `Handler` is a Hono app, you can pass `typeof handler` to ` import { Handler, hc } from 'accounts/server' const handler = Handler.compose( - [Handler.exchange(), Handler.codeAuth({ store })], + [Handler.exchange(), Handler.relay()], { path: '/api' }, ) diff --git a/site/src/pages/docs/server/handler.deviceCode.mdx b/site/src/pages/docs/server/handler.deviceCode.mdx new file mode 100644 index 00000000..763eb478 --- /dev/null +++ b/site/src/pages/docs/server/handler.deviceCode.mdx @@ -0,0 +1,133 @@ +--- +title: Handler.deviceCode +description: Host Wata device-code authorization for wallet RPC requests. +--- + +# `Handler.deviceCode` + +Creates a Wata device-code handler for cross-device wallet RPC requests. The handler implements the OAuth 2.0 Device Authorization Grant with PKCE and returns approval-page RPC responses to the polling client. + +The handler is method-agnostic. The consuming host must decide which methods its approval UI accepts, as it does for window and `postMessage` transports. + +## Usage + +```ts +import { Handler } from 'accounts/server' +import { Store } from 'wata/host' + +const handler = Handler.deviceCode({ + baseUrl: 'https://wallet.example.com', + html: { + render({ record, request, userCode }) { + if (request.headers.get('accept')?.includes('application/json')) + return Response.json({ record, user_code: userCode }) + const search = userCode ? `?user_code=${encodeURIComponent(userCode)}` : '' + return new Response(null, { + headers: { location: `https://wallet.example.com/approve${search}` }, + status: 302, + }) + }, + }, + store: Store.memory(), + validate({ request, result }) { + if (request.method !== 'wallet_connect') + return Response.json({ error: 'unsupported_method' }, { status: 400 }) + // Verify the submitted result against the pending request here. + return undefined + }, +}) +``` + +Mount `handler.fetch` in any server framework that accepts Fetch API handlers. The default endpoint prefix is `/auth/device`: + +- `POST /auth/device/register` registers the authorization request. +- `POST /auth/device/token` polls for the approved response. +- `GET /auth/device/verify` calls `html.render`. +- `POST /auth/device/verify` approves or denies the request. + +Use persistent shared storage in production. `Store.memory()` is suitable only for tests and single-process development. + +## Approval Page Contract + +The `html.render` hook receives the pending record and consumer metadata. Render the approval page directly, or expose that data to a separate approval UI. The usage example returns the record as JSON when the request accepts JSON. + +Submit the approved result to `POST {path}/verify`: + +```ts +await fetch('/auth/device/verify', { + body: JSON.stringify({ + action: 'approve', + results: [{ id: request.id, result }], + user_code: 'ABCDEFGH', + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', +}) +``` + +The handler matches the submitted result to the pending request by `id` before releasing the response. To relay an RPC failure, submit `{ id, error: { code, message, data? } }` instead of a result. To deny the request, omit `results` and send `action: 'deny'`. + +## Parameters + +### `baseUrl` + +- **Type:** `string | ((request: Request) => string)` +- **Optional** + +Public origin used to build the verification URL. Use a function when a proxy or tenant determines the origin from each request. + +### `expiresIn` + +- **Type:** `number` +- **Default:** `600` + +Authorization intent lifetime in seconds. + +### `fetch` + +- **Type:** `typeof globalThis.fetch` +- **Optional** + +Fetch implementation used to resolve consumer discovery metadata. + +### `html.render` + +- **Type:** `(options) => Response | Promise` +- **Required** + +Renders or redirects the approval page for `GET {path}/verify`. The options include the pending record, consumer metadata, request, and user code. + +### `maxBodyBytes` + +- **Type:** `number` +- **Default:** `65536` + +Maximum JSON body size accepted by `POST {path}/verify`. + +### `path` + +- **Type:** `string` +- **Default:** `'/auth/device'` + +Endpoint prefix for device-code registration, polling, and verification. + +### `pollingInterval` + +- **Type:** `number` +- **Optional** + +Suggested polling interval in milliseconds returned to the client. + +### `store` + +- **Type:** `Store` +- **Default:** `Store.memory()` + +Wata store used for pending device codes and response envelopes. Use a shared persistent adapter for multi-instance deployments. + +### `validate` + +- **Type:** `(options) => Response | undefined | Promise` +- **Required** + +Applies host policy before the handler approves a submitted result. Protect the approval route with the host's authentication, allow only supported methods, and verify the result against the pending request. Return a response to reject the submission, or `undefined` to approve it. diff --git a/site/src/pages/docs/server/hc.mdx b/site/src/pages/docs/server/hc.mdx index 703cbcbe..5e586220 100644 --- a/site/src/pages/docs/server/hc.mdx +++ b/site/src/pages/docs/server/hc.mdx @@ -47,7 +47,7 @@ if (res.ok) { import { Handler, hc } from 'accounts/server' const handler = Handler.compose( - [Handler.exchange(), Handler.codeAuth()], + [Handler.exchange(), Handler.relay()], { path: '/api' }, ) diff --git a/site/src/pages/docs/server/index.mdx b/site/src/pages/docs/server/index.mdx index 7a5ea99a..19e7f0b9 100644 --- a/site/src/pages/docs/server/index.mdx +++ b/site/src/pages/docs/server/index.mdx @@ -39,6 +39,12 @@ export const POST = handler.fetch // Next.js to="/docs/server/handler.compose" title="Handler.compose" /> + ( - request: Request, - ) => Promise) & { + subscription: (( + options: typeof subscriptionPlan, + ) => (request: Request) => Promise) & { renew: (parameters: { subscriptionId: string }) => Promise<{ receipt: unknown } | null> diff --git a/site/src/vite-env.d.ts b/site/src/vite-env.d.ts index cbe652db..5894ae0b 100644 --- a/site/src/vite-env.d.ts +++ b/site/src/vite-env.d.ts @@ -1 +1 @@ -declare module "*.css"; +declare module '*.css' diff --git a/site/vocs.config.ts b/site/vocs.config.ts index 4b1348fc..c021d675 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -172,8 +172,8 @@ const config: Config = defineConfig({ collapsed: true, items: [ { text: '.auth', link: '/docs/server/handler.auth' }, - { text: '.codeAuth', link: '/docs/server/handler.codeAuth' }, { text: '.compose', link: '/docs/server/handler.compose' }, + { text: '.deviceCode', link: '/docs/server/handler.deviceCode' }, { text: '.exchange', link: '/docs/server/handler.exchange' }, { text: '.relay', link: '/docs/server/handler.relay' }, { text: '.webAuthn', link: '/docs/server/handler.webAuthn' }, diff --git a/src/cli/Provider.localnet.test.ts b/src/cli/Provider.localnet.test.ts index c17e1be3..e78e7b8d 100644 --- a/src/cli/Provider.localnet.test.ts +++ b/src/cli/Provider.localnet.test.ts @@ -1,17 +1,17 @@ import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Address, Hex, PublicKey } from 'ox' -import { KeyAuthorization } from 'ox/tempo' +import type { Address, Hex } from 'ox' +import { Hex as ox_Hex } from 'ox' import { type Address as ViemAddress, parseUnits } from 'viem' import { Actions, Addresses } from 'viem/tempo' -import { describe, expect, test } from 'vp/test' -import * as z from 'zod/mini' +import { describe, expect, test, vi } from 'vp/test' +import type * as z from 'zod/mini' import { accounts, chain, getClient } from '../../test/config.js' -import { createServer } from '../../test/utils.js' -import * as CliAuth from '../server/CliAuth.js' -import * as Handler from '../server/Handler.js' +import { createDeviceCodeHost, submitVerify } from '../../test/deviceCode.js' +import { createJsonStorage, createServer } from '../../test/utils.js' +import type * as Rpc from '../core/zod/rpc.js' import * as Provider from './Provider.js' import * as Storage from './storage.js' @@ -21,43 +21,12 @@ const accessKey_2 = accounts[2]! const expiry = Math.floor(Date.now() / 1000) + 3_600 const expiry_2 = expiry + 60 -async function authorize( - code: string, - options: { - accessKey?: typeof accessKey | undefined - expiry?: number | undefined - } = {}, -) { - const { accessKey: key = accessKey, expiry: expiry_ = expiry } = options - - const signed = await root.signKeyAuthorization( - { - accessKeyAddress: key.address, - keyType: key.keyType, - }, - { - chainId: BigInt(chain.id), - expiry: expiry_, - }, - ) - const keyAuthorization = KeyAuthorization.toRpc(signed) - - return z.encode(CliAuth.authorizeRequest, { - accountAddress: root.address, - code, - keyAuthorization: z.decode(CliAuth.keyAuthorization, { - ...keyAuthorization, - address: keyAuthorization.keyId, - }), - }) -} - function connectRequest( options: { accessKey?: typeof accessKey | undefined expiry?: number | undefined method?: 'login' | 'register' | undefined - showDeposit?: z.output['showDeposit'] | undefined + showDeposit?: z.output | undefined } = {}, ) { const { accessKey: key = accessKey, expiry: expiry_ = expiry, method, showDeposit } = options @@ -80,104 +49,6 @@ function connectRequest( } as const } -function createHandler() { - let random = 0 - - return Handler.codeAuth({ - path: '/cli-auth', - chains: [chain], - policy: { - update({ limits }) { - return { limits } - }, - validate({ expiry: requestedExpiry, limits }) { - return { - expiry: requestedExpiry ?? expiry, - ...(limits ? { limits } : {}), - } - }, - }, - random: () => { - const out = new Uint8Array(Array.from({ length: 8 }, (_, i) => random + i)) - random += 8 - return out - }, - }) -} - -async function authorizePending(serverUrl: string, code: string) { - const response = await fetch(`${serverUrl}/cli-auth/pending/${code}`) - const pending = z.decode(CliAuth.pendingResponse, (await response.json()) as never) - if (pending.action === 'updateAccessKey') throw new Error('Expected authorization request.') - const signed = await root.signKeyAuthorization( - { - accessKeyAddress: Address.fromPublicKey(PublicKey.from(pending.pubKey)), - keyType: pending.keyType, - }, - { - chainId: pending.chainId, - expiry: pending.expiry, - ...(pending.limits ? { limits: pending.limits } : {}), - }, - ) - const keyAuthorization = KeyAuthorization.toRpc(signed) - - return z.encode(CliAuth.authorizeRequest, { - accountAddress: root.address, - code, - keyAuthorization: z.decode(CliAuth.keyAuthorization, { - ...keyAuthorization, - address: keyAuthorization.keyId, - }), - }) -} - -async function completePending(serverUrl: string, code: string) { - const response = await fetch(`${serverUrl}/cli-auth/pending/${code}`) - const pending = z.decode(CliAuth.pendingResponse, (await response.json()) as never) - if (pending.action !== 'updateAccessKey') return authorizePending(serverUrl, code) - - if (pending.keyAuthorization) { - const signed = await root.signKeyAuthorization( - { - accessKeyAddress: pending.accessKeyAddress, - keyType: pending.keyAuthorization.keyType, - }, - { - chainId: pending.chainId, - expiry: pending.keyAuthorization.expiry, - limits: pending.limits, - }, - ) - const replacement = KeyAuthorization.toRpc(signed) - return z.encode(CliAuth.authorizeRequest, { - action: 'updateAccessKey', - accountAddress: root.address, - chainId: pending.chainId, - code, - keyAuthorization: z.decode(CliAuth.keyAuthorization, { - ...replacement, - address: replacement.keyId, - }), - }) - } - - const client = getClient({ account: root }) - for (const item of pending.limits) - await Actions.accessKey.updateLimitSync(client, { - accessKey: pending.accessKeyAddress, - limit: item.limit, - token: item.token, - }) - - return z.encode(CliAuth.authorizeRequest, { - action: 'updateAccessKey', - accountAddress: root.address, - chainId: pending.chainId, - code, - }) -} - async function createStoragePath() { return join(await mkdtemp(join(tmpdir(), 'accounts-cli-')), 'store.json') } @@ -214,25 +85,28 @@ const transferCall = Actions.token.transfer.call({ amount: parseUnits('1', 6), }) +function connectCapabilities(host: ReturnType, index: number) { + const params = host.requests()[index]!.params as readonly { + capabilities?: Record | undefined + }[] + return params[0]?.capabilities +} + describe('Provider.create', () => { - test('default: bootstraps wallet_connect through the device code flow', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + test('default: bootstraps wallet_connect through the device-code flow', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) const opened: string[] = [] try { const provider = Provider.create({ chains: [chain], - open: async (url) => { + open: async (url, prompt) => { opened.push(url) - const code = new URL(url).searchParams.get('code')! - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorize(code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) const result = await provider.request(connectRequest()) @@ -246,22 +120,25 @@ describe('Provider.create', () => { } : undefined + expect(opened).toHaveLength(1) + expect(opened[0]).toMatch( + new RegExp(`^${server.url}/auth/device/verify\\?user_code=[A-Z]{4}-[A-Z]{4}$`), + ) expect({ account: { address: account.address, capabilities: keyAuthorization ? { keyAuthorization } : {}, }, - opened: opened.map((url) => url.replace(server.url, 'http://service')), }).toMatchInlineSnapshot(` { "account": { "address": "${root.address}", "capabilities": { "keyAuthorization": { - "address": "${accessKey.address}", - "chainId": "${Hex.fromNumber(chain.id)}", - "expiry": "${Hex.fromNumber(expiry)}", - "keyId": "${accessKey.address}", + "address": "${accessKey.address.toLowerCase()}", + "chainId": "${ox_Hex.fromNumber(chain.id)}", + "expiry": "${ox_Hex.fromNumber(expiry)}", + "keyId": "${accessKey.address.toLowerCase()}", "keyType": "secp256k1", "signature": { "type": "secp256k1", @@ -269,9 +146,6 @@ describe('Provider.create', () => { }, }, }, - "opened": [ - "http://service/cli-auth?code=ABCDEFGH", - ], } `) } finally { @@ -279,26 +153,18 @@ describe('Provider.create', () => { } }) - test('behavior: forwards showDeposit through registration device code requests', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) - const pendingShowDeposit: z.output['showDeposit'][] = [] + test('behavior: forwards showDeposit through registration device-code requests', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - const response = await fetch(`${server.url}/cli-auth/pending/${code}`) - const pending = z.decode(CliAuth.pendingResponse, (await response.json()) as never) - pendingShowDeposit.push(pending.showDeposit) - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorizePending(server.url, code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) await provider.request( @@ -313,82 +179,56 @@ describe('Provider.create', () => { }), ) - expect(pendingShowDeposit).toMatchInlineSnapshot(` - [ - { - "amount": "50", - "displayName": "DoorDash", - "on": "register", - "token": "USDC", - }, - ] + expect(connectCapabilities(host, 0)?.showDeposit).toMatchInlineSnapshot(` + { + "amount": "50", + "displayName": "DoorDash", + "on": "register", + "token": "USDC", + } `) } finally { await server.closeAsync() } }) - test('behavior: forwards showDeposit through login device code requests', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) - const pendingShowDeposit: z.output['showDeposit'][] = [] + test('behavior: forwards showDeposit through login device-code requests', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - const response = await fetch(`${server.url}/cli-auth/pending/${code}`) - const pending = z.decode(CliAuth.pendingResponse, (await response.json()) as never) - pendingShowDeposit.push(pending.showDeposit) - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorizePending(server.url, code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) - await provider.request( - connectRequest({ - method: 'login', - showDeposit: true, - }), - ) + await provider.request(connectRequest({ method: 'login', showDeposit: true })) - expect(pendingShowDeposit).toMatchInlineSnapshot(` - [ - true, - ] - `) + expect(connectCapabilities(host, 0)?.showDeposit).toBe(true) } finally { await server.closeAsync() } }) - test('behavior: forwards showDeposit through wallet_authorizeAccessKey device code requests', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) - const pendingShowDeposit: z.output['showDeposit'][] = [] + test('behavior: forwards showDeposit through wallet_authorizeAccessKey device-code requests', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - const response = await fetch(`${server.url}/cli-auth/pending/${code}`) - const pending = z.decode(CliAuth.pendingResponse, (await response.json()) as never) - pendingShowDeposit.push(pending.showDeposit) - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorizePending(server.url, code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) + await provider.request(connectRequest()) await provider.request({ method: 'wallet_authorizeAccessKey', params: [ @@ -401,19 +241,17 @@ describe('Provider.create', () => { ], }) - expect(pendingShowDeposit).toMatchInlineSnapshot(` - [ - true, - ] - `) + const params = host.requests()[1]!.params as readonly { showDeposit?: unknown }[] + expect(host.requests()[1]!.method).toBe('wallet_authorizeAccessKey') + expect(params[0]?.showDeposit).toBe(true) } finally { await server.closeAsync() } }) test('behavior: browser-open failures surface the URL and code', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + const host = createDeviceCodeHost() + const server = await createServer(host.listener) try { const provider = Provider.create({ @@ -421,154 +259,117 @@ describe('Provider.create', () => { open() { throw new Error('browser unavailable') }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) - await expect( - provider - .request(connectRequest()) - .catch((error: { code: number; message: string; name: string }) => { - return { - code: error.code, - message: error.message.replace(server.url, 'http://service'), - name: error.name, - } - }), - ).resolves.toMatchInlineSnapshot(` - { - "code": -32603, - "message": "Failed to open browser for device code ABCD-EFGH. Open http://service/cli-auth?code=ABCDEFGH manually.", - "name": "RpcResponse.InternalError", - } - `) + const error = await provider.request(connectRequest()).then( + () => undefined, + (error: Error) => error, + ) + expect(error?.message).toMatch( + /^Failed to surface device code [A-Z]{4}-[A-Z]{4}\. Open .*\/auth\/device\/verify\?user_code=[A-Z]{4}-[A-Z]{4} manually\.$/, + ) + } finally { + await server.closeAsync() + } + }) + + test('behavior: prints the user code when the complete verification URI is absent', async () => { + const host = createDeviceCodeHost({ omitVerificationUriFull: true }) + const server = await createServer(host.listener) + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + try { + const provider = Provider.create({ + chains: [chain], + open: async (url, prompt) => { + expect(url).toBe(prompt.verificationUri) + await submitVerify(prompt) + }, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), + }) + + await provider.request(connectRequest()) + + expect(write).toHaveBeenCalledWith( + expect.stringMatching(/^Enter code [A-Z]{4}-[A-Z]{4} at http/), + ) } finally { + write.mockRestore() await server.closeAsync() } }) test('behavior: times out while waiting for authorization', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + const host = createDeviceCodeHost({ pollingInterval: 10 }) + const server = await createServer(host.listener) try { const provider = Provider.create({ chains: [chain], open() {}, - pollIntervalMs: 1, - host: `${server.url}/cli-auth`, - timeoutMs: 10, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), + timeout: 100, }) - await expect( - provider - .request(connectRequest()) - .catch((error: { code: number; message: string; name: string }) => { - return { - code: error.code, - message: error.message.replace(server.url, 'http://service'), - name: error.name, - } - }), - ).resolves.toMatchInlineSnapshot(` - { - "code": -32603, - "message": "Timed out waiting for device code ABCD-EFGH. Continue at http://service/cli-auth?code=ABCDEFGH.", - "name": "RpcResponse.InternalError", - } - `) + const error = await provider.request(connectRequest()).then( + () => undefined, + (error: Error) => error, + ) + expect(error?.message).toMatch( + /^Timed out waiting for device code [A-Z]{4}-[A-Z]{4}\. Continue at /, + ) } finally { await server.closeAsync() } }) - test('behavior: authorizes an access key while disconnected when publicKey is provided', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) - const opened: string[] = [] + test('behavior: wallet_authorizeAccessKey requires a connected account', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - opened.push(url) - const code = new URL(url).searchParams.get('code')! - try { - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify( - await authorize(code, { accessKey: accessKey_2, expiry: expiry_2 }), - ), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) - } catch {} + open() { + throw new Error('Unexpected browser open.') }, - host: `${server.url}/cli-auth`, - }) - - const result = await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [ - { expiry: expiry_2, keyType: accessKey_2.keyType, publicKey: accessKey_2.publicKey }, - ], + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) - const keyAuthorization = { - ...result.keyAuthorization, - signature: { - type: result.keyAuthorization.signature.type, - }, - } - expect({ - keyAuthorization, - rootAddress: result.rootAddress, - opened: opened.map((url) => url.replace(server.url, 'http://service')), - }).toMatchInlineSnapshot(` - { - "keyAuthorization": { - "address": "${accessKey_2.address}", - "chainId": "${Hex.fromNumber(chain.id)}", - "expiry": "${Hex.fromNumber(expiry_2)}", - "keyId": "${accessKey_2.address}", - "keyType": "secp256k1", - "signature": { - "type": "secp256k1", - }, - }, - "opened": [ - "http://service/cli-auth?code=ABCDEFGH", + await expect( + provider.request({ + method: 'wallet_authorizeAccessKey', + params: [ + { expiry: expiry_2, keyType: accessKey_2.keyType, publicKey: accessKey_2.publicKey }, ], - "rootAddress": "${root.address}", - } - `) + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Provider.DisconnectedError: No active account.]`, + ) } finally { await server.closeAsync() } }) test('behavior: authorizes an access key for the active account', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + const host = createDeviceCodeHost() + const server = await createServer(host.listener) const opened: string[] = [] try { - const approvals = [ - (code: string) => authorize(code), - (code: string) => authorize(code, { accessKey: accessKey_2, expiry: expiry_2 }), - ] const provider = Provider.create({ chains: [chain], - open: async (url) => { + open: async (url, prompt) => { opened.push(url) - const approve = approvals.shift() - if (!approve) throw new Error('Unexpected device code approval request.') - const code = new URL(url).searchParams.get('code')! - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await approve(code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) await provider.request(connectRequest()) @@ -586,26 +387,22 @@ describe('Provider.create', () => { }, } + expect(opened).toHaveLength(2) expect({ keyAuthorization, rootAddress: result.rootAddress, - opened: opened.map((url) => url.replace(server.url, 'http://service')), }).toMatchInlineSnapshot(` { "keyAuthorization": { - "address": "${accessKey_2.address}", - "chainId": "${Hex.fromNumber(chain.id)}", - "expiry": "${Hex.fromNumber(expiry_2)}", - "keyId": "${accessKey_2.address}", + "address": "${accessKey_2.address.toLowerCase()}", + "chainId": "${ox_Hex.fromNumber(chain.id)}", + "expiry": "${ox_Hex.fromNumber(expiry_2)}", + "keyId": "${accessKey_2.address.toLowerCase()}", "keyType": "secp256k1", "signature": { "type": "secp256k1", }, }, - "opened": [ - "http://service/cli-auth?code=ABCDEFGH", - "http://service/cli-auth?code=JKLMNPQR", - ], "rootAddress": "${root.address}", } `) @@ -615,21 +412,17 @@ describe('Provider.create', () => { }) test('behavior: rejects unsupported revokeAccessKey after bootstrap', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + const host = createDeviceCodeHost() + const server = await createServer(host.listener) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorize(code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), }) await provider.request(connectRequest()) @@ -639,32 +432,25 @@ describe('Provider.create', () => { method: 'wallet_revokeAccessKey', params: [{ accessKeyAddress: accessKey.address, address: root.address }], }), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Provider.UnsupportedMethodError: \`wallet_revokeAccessKey\` not supported by CLI adapter.]`, - ) + ).rejects.toThrow('`wallet_revokeAccessKey` not supported by device-code adapter.') } finally { await server.closeAsync() } }) test('behavior: generates, persists, and reuses a managed key during wallet_connect', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + const host = createDeviceCodeHost() + const server = await createServer(host.listener) const storagePath = await createStoragePath() const storage = Storage.filesystem({ path: storagePath }) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorizePending(server.url, code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, storage, }) @@ -685,7 +471,7 @@ describe('Provider.create', () => { open() { throw new Error('Unexpected browser open.') }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, storage: storage_2, }) const receipt_2 = await provider_2.request({ @@ -716,27 +502,73 @@ describe('Provider.create', () => { } }) + test('behavior: signs messages locally with the managed access key', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) + + try { + const provider = Provider.create({ + chains: [chain], + open: async (_url, prompt) => { + await submitVerify(prompt) + }, + host: `${server.url}/auth/device`, + storage: createJsonStorage(), + }) + const connected = await provider.request({ + method: 'wallet_connect', + params: [{ capabilities: { authorizeAccessKey: { expiry: expiry_2 } } }], + }) + const address = connected.accounts[0]!.address + + const personal = await provider.request({ + method: 'personal_sign', + params: ['0x68656c6c6f', address], + }) + const typedData = await provider.request({ + method: 'eth_signTypedData_v4', + params: [ + address, + JSON.stringify({ + domain: {}, + message: { contents: 'hello' }, + primaryType: 'Message', + types: { + EIP712Domain: [], + Message: [{ name: 'contents', type: 'string' }], + }, + }), + ], + }) + + expect(personal).toMatch(/^0x/) + expect(typedData).toMatch(/^0x/) + expect(host.requests()).toHaveLength(1) + } finally { + await server.closeAsync() + } + }) + test('behavior: generates a managed key for wallet_authorizeAccessKey without publicKey', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + const host = createDeviceCodeHost() + const server = await createServer(host.listener) const storagePath = await createStoragePath() const storage = Storage.filesystem({ path: storagePath }) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorizePending(server.url, code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, storage, }) + const connected = await provider.request({ + method: 'wallet_connect', + params: [{ capabilities: {} }], + }) const result = await provider.request({ method: 'wallet_authorizeAccessKey', params: [{ expiry: expiry_2 }], @@ -749,6 +581,7 @@ describe('Provider.create', () => { }) const [entry] = await readAccessKeys(storage) + expect(connected.accounts[0]!.address).toBe(root.address) expect(receipt.status).toMatchInlineSnapshot(`"0x1"`) expect({ access: entry!.access, @@ -764,97 +597,53 @@ describe('Provider.create', () => { } }) - test('behavior: regenerates a managed key when the requested key type changes', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + test('behavior: wallet_updateAccessKey replaces an unpublished key authorization', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) const storagePath = await createStoragePath() const storage = Storage.filesystem({ path: storagePath }) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(await authorizePending(server.url, code)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, storage, }) - // secp256k1 is requested explicitly; p256 is the default — a genuine - // type change that regenerates the managed key. - const first = await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [{ expiry, keyType: 'secp256k1' }], - }) - const second = await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [{ expiry: expiry_2, keyType: 'p256' }], - }) - await fund(second.rootAddress) - - const receipt = await provider.request({ - method: 'eth_sendTransactionSync', - params: [{ calls: [transferCall] }], + const result = await provider.request({ + method: 'wallet_connect', + params: [ + { + capabilities: { + authorizeAccessKey: { + expiry: expiry_2, + limits: [{ limit: ox_Hex.fromNumber(1n), token: Addresses.pathUsd }], + }, + }, + }, + ], }) - const keys = await readAccessKeys(storage) - - expect(first.keyAuthorization.keyType).toBe('secp256k1') - expect(second.keyAuthorization.keyType).toBe('p256') - expect(first.keyAuthorization.keyId).not.toBe(second.keyAuthorization.keyId) - expect(new Set(keys.map((key) => key.keyType))).toEqual(new Set(['secp256k1', 'p256'])) - expect(receipt.status).toBe('0x1') - } finally { - await server.closeAsync() - } - }) - - test('wallet_updateAccessKey: replaces an unpublished key authorization', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) - const storagePath = await createStoragePath() - const storage = Storage.filesystem({ path: storagePath }) + const account = result.accounts[0]! + const accessKeyAddress = account.capabilities.keyAuthorization!.keyId - try { - const provider = Provider.create({ - chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - const body = await completePending(server.url, code) - const response = await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(body), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) - if (!response.ok) throw new Error(await response.text()) - }, - host: `${server.url}/cli-auth`, - storage, - }) const updated = parseUnits('9', 6) - const { keyAuthorization, rootAddress } = await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [{ expiry, limits: [{ limit: Hex.fromNumber(1), token: Addresses.pathUsd }] }], - }) - await provider.request({ method: 'wallet_updateAccessKey', params: [ { - address: rootAddress, - accessKeyAddress: keyAuthorization.address!, - limits: [{ limit: Hex.fromNumber(updated), token: Addresses.pathUsd }], + address: account.address, + accessKeyAddress, + limits: [{ limit: ox_Hex.fromNumber(updated), token: Addresses.pathUsd }], }, ], }) const [stored] = provider.store.accessKeys.list({ - account: rootAddress, - accessKey: keyAuthorization.address!, + accessKey: accessKeyAddress, + account: account.address, chainId: chain.id, }) expect(stored?.keyAuthorization?.limits).toEqual([ @@ -862,8 +651,8 @@ describe('Provider.create', () => { ]) expect( await provider.store.accessKeys.getStatus({ - account: rootAddress, - accessKey: keyAuthorization.address!, + accessKey: accessKeyAddress, + account: account.address, chainId: chain.id, client: getClient(), }), @@ -873,53 +662,49 @@ describe('Provider.create', () => { } }) - test('wallet_updateAccessKey: updates published spending limits through browser approval', async () => { - const handler = createHandler() - const server = await createServer(handler.listener) + test('behavior: wallet_updateAccessKey updates published spending limits through approval', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) const storagePath = await createStoragePath() const storage = Storage.filesystem({ path: storagePath }) try { const provider = Provider.create({ chains: [chain], - open: async (url) => { - const code = new URL(url).searchParams.get('code')! - const body = await completePending(server.url, code) - const response = await fetch(`${server.url}/cli-auth`, { - body: JSON.stringify(body), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) - if (!response.ok) throw new Error(await response.text()) + open: async (_url, prompt) => { + await submitVerify(prompt) }, - host: `${server.url}/cli-auth`, + host: `${server.url}/auth/device`, storage, }) - const updated = parseUnits('9', 6) - const { keyAuthorization, rootAddress } = await provider.request({ - method: 'wallet_authorizeAccessKey', - params: [{ expiry }], + + const result = await provider.request({ + method: 'wallet_connect', + params: [{ capabilities: { authorizeAccessKey: { expiry: expiry_2 } } }], }) - await fund(rootAddress) + const account = result.accounts[0]! + const accessKeyAddress = account.capabilities.keyAuthorization!.keyId + await fund(account.address) await provider.request({ method: 'eth_sendTransactionSync', params: [{ calls: [transferCall] }], }) + const updated = parseUnits('9', 6) await provider.request({ method: 'wallet_updateAccessKey', params: [ { - address: rootAddress, - accessKeyAddress: keyAuthorization.address!, - limits: [{ limit: Hex.fromNumber(updated), token: Addresses.pathUsd }], + address: account.address, + accessKeyAddress, + limits: [{ limit: ox_Hex.fromNumber(updated), token: Addresses.pathUsd }], }, ], }) const { remaining } = await Actions.accessKey.getRemainingLimit(getClient(), { - account: rootAddress, - accessKey: keyAuthorization.address!, + account: account.address, + accessKey: accessKeyAddress, token: Addresses.pathUsd, }) expect(remaining).toBe(updated) @@ -927,4 +712,52 @@ describe('Provider.create', () => { await server.closeAsync() } }) + + test('behavior: regenerates a managed key when the requested key type changes', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) + const storagePath = await createStoragePath() + const storage = Storage.filesystem({ path: storagePath }) + + try { + const provider = Provider.create({ + chains: [chain], + open: async (_url, prompt) => { + await submitVerify(prompt) + }, + host: `${server.url}/auth/device`, + storage, + }) + + await provider.request({ + method: 'wallet_connect', + params: [{ capabilities: {} }], + }) + // secp256k1 is requested explicitly; p256 is the default — a genuine + // type change that regenerates the managed key. + const first = await provider.request({ + method: 'wallet_authorizeAccessKey', + params: [{ expiry, keyType: 'secp256k1' }], + }) + const second = await provider.request({ + method: 'wallet_authorizeAccessKey', + params: [{ expiry: expiry_2, keyType: 'p256' }], + }) + await fund(second.rootAddress) + + const receipt = await provider.request({ + method: 'eth_sendTransactionSync', + params: [{ calls: [transferCall] }], + }) + const keys = await readAccessKeys(storage) + + expect(first.keyAuthorization.keyType).toBe('secp256k1') + expect(second.keyAuthorization.keyType).toBe('p256') + expect(first.keyAuthorization.keyId).not.toBe(second.keyAuthorization.keyId) + expect(new Set(keys.map((key) => key.keyType))).toEqual(new Set(['secp256k1', 'p256'])) + expect(receipt.status).toBe('0x1') + } finally { + await server.closeAsync() + } + }) }) diff --git a/src/cli/Provider.test-d.ts b/src/cli/Provider.test-d.ts index 128205fc..98b5b9ba 100644 --- a/src/cli/Provider.test-d.ts +++ b/src/cli/Provider.test-d.ts @@ -1,4 +1,5 @@ import { describe, expectTypeOf, test } from 'vp/test' +import type { DeviceCode } from 'wata' import type * as CoreProvider from '../core/Provider.js' import type { cli } from './adapter.js' @@ -8,9 +9,9 @@ describe('create', () => { test('accepts CLI bootstrap options', () => { expectTypeOf[0]>().toMatchTypeOf<{ host?: string | undefined - open?: ((url: string) => Promise | void) | undefined - pollIntervalMs?: number | undefined - timeoutMs?: number | undefined + open?: ((url: string, prompt: DeviceCode.Prompt) => Promise | void) | undefined + pollingInterval?: number | undefined + timeout?: number | undefined }>() }) diff --git a/src/cli/Provider.ts b/src/cli/Provider.ts index 97b22ed3..9aff1abb 100644 --- a/src/cli/Provider.ts +++ b/src/cli/Provider.ts @@ -8,11 +8,11 @@ import * as Storage from './storage.js' */ export function create(options: create.Options): create.ReturnType { const { - host = 'https://wallet.tempo.xyz/api/auth/cli', + host = 'https://wallet.tempo.xyz/auth/device', open, - pollIntervalMs, + pollingInterval, storage = Storage.filesystem(), - timeoutMs, + timeout, ...rest } = options @@ -28,8 +28,8 @@ export function create(options: create.Options): create.ReturnType { adapter: cli({ host, ...(open ? { open } : {}), - ...(typeof pollIntervalMs !== 'undefined' ? { pollIntervalMs } : {}), - ...(typeof timeoutMs !== 'undefined' ? { timeoutMs } : {}), + ...(pollingInterval !== undefined ? { pollingInterval } : {}), + ...(timeout !== undefined ? { timeout } : {}), }), ...(mpp ? { mpp } : {}), storage, @@ -41,7 +41,7 @@ export declare namespace create { CoreProvider.create.Options & cli.Options, 'adapter' | 'authorizeAccessKey' | 'host' > & { - /** Host URL for the device-code flow. @default "https://wallet.tempo.xyz/api/auth/cli" */ + /** Base URL of the wallet's device-code endpoints. @default "https://wallet.tempo.xyz/auth/device" */ host?: string | undefined } export type ReturnType = CoreProvider.create.ReturnType diff --git a/src/cli/adapter.ts b/src/cli/adapter.ts index 8821181e..2905a770 100644 --- a/src/cli/adapter.ts +++ b/src/cli/adapter.ts @@ -1,377 +1,149 @@ import { spawn } from 'node:child_process' -import { setTimeout as sleep } from 'node:timers/promises' -import { Base64, Hash, Hex, Provider as core_Provider, RpcResponse } from 'ox' +import { Provider as core_Provider } from 'ox' import { KeyAuthorization } from 'ox/tempo' +import { hashMessage, hashTypedData } from 'viem' +import type { DeviceCode } from 'wata' import * as z from 'zod/mini' -import * as Adapter from '../core/Adapter.js' +import type * as Adapter from '../core/Adapter.js' +import { deviceCode } from '../core/adapters/deviceCode/deviceCode.js' import * as Keystore from '../core/Keystore.js' -import * as CliAuth from '../server/CliAuth.js' +import * as Rpc from '../core/zod/rpc.js' /** - * Creates a CLI bootstrap adapter backed by the device code protocol. + * Creates a CLI bootstrap adapter backed by the Wata device-code transport + * (OAuth 2.0 Device Authorization Grant, RFC 8628 with PKCE). + * + * Authorization opens the wallet's verification page in a browser; the wallet + * responds with a signed key authorization once the user approves. */ export function cli(options: cli.Options): Adapter.Adapter { - const { name = 'Tempo CLI', rdns = 'xyz.tempo.cli' } = options - - return Adapter.define({ name, rdns }, ({ getClient, store }) => { - // The CLI persists to string-based filesystem storage, so its p256 - // default opts into an extractable WebCrypto key (a non-extractable one - // could not survive a restart). secp256k1 stays available for explicit - // requests. Shared between the device code ceremony and the instance - // declaration so records hydrate through the same keystores. - const keystores = { - p256: Keystore.webCryptoP256({ extractable: true }), - secp256k1: Keystore.secp256k1(), - } - - async function saveGeneratedAccessKey( - address: Adapter.authorizeAccessKey.ReturnType['rootAddress'], - accessKey: Awaited>, - keyAuthorization: z.output, - ) { - const signed = KeyAuthorization.fromRpc(z.encode(CliAuth.keyAuthorization, keyAuthorization)) - store.accessKeys.add({ - account: address, - authorization: signed, - handle: accessKey.handle, - publicKey: accessKey.publicKey, - }) - } - - async function authorize(request: { - account?: Adapter.authorizeAccessKey.ReturnType['rootAddress'] | undefined - authorizeAccessKey: Adapter.authorizeAccessKey.Parameters | undefined - method: 'wallet_authorizeAccessKey' | 'wallet_connect' - showDeposit?: Adapter.createAccount.Parameters['showDeposit'] | undefined - }) { - const { - host, - open = defaultOpen, - pollIntervalMs = 2_000, - timeoutMs = 5 * 60 * 1_000, - } = options - const { account, authorizeAccessKey, method } = request - - // p256 by default; secp256k1 only when explicitly requested. - const generatedKeyType = authorizeAccessKey?.keyType === 'secp256k1' ? 'secp256k1' : 'p256' - const generatedAccessKey = - authorizeAccessKey && !authorizeAccessKey.publicKey && !authorizeAccessKey.address - ? await keystores[generatedKeyType].createKey() - : undefined - - const publicKey = authorizeAccessKey?.publicKey ?? generatedAccessKey?.publicKey - const keyType = - authorizeAccessKey?.keyType ?? (generatedAccessKey ? generatedKeyType : undefined) - - if (!publicKey) - throw new RpcResponse.InvalidParamsError({ - message: - method === 'wallet_connect' - ? '`wallet_connect` on the CLI adapter requires `capabilities.authorizeAccessKey`.' - : '`wallet_authorizeAccessKey` on the CLI adapter requires key parameters.', - }) - - const codeVerifier = createCodeVerifier() - const codeChallenge = createCodeChallenge(codeVerifier) - const body: z.output = { - ...(account ? { account } : {}), - chainId: BigInt(store.getState().chainId), - codeChallenge, - ...(typeof authorizeAccessKey?.expiry !== 'undefined' - ? { expiry: authorizeAccessKey.expiry } - : {}), - ...(keyType ? { keyType } : {}), - ...(authorizeAccessKey?.limits ? { limits: authorizeAccessKey.limits } : {}), - pubKey: publicKey, - ...(request.showDeposit !== undefined ? { showDeposit: request.showDeposit } : {}), - } - const created = await post({ - body, - request: CliAuth.createRequest, - response: CliAuth.createResponse, - url: getApiUrl(host, 'code'), - }) - const url = getBrowserUrl(host, created.code) - - try { - await open(url) - } catch (error) { - throw new OpenError(url, created.code, error) - } - - const startedAt = Date.now() - - while (Date.now() - startedAt < timeoutMs) { - const result = await post({ - body: { - codeVerifier, - } satisfies z.output, - request: CliAuth.pollRequest, - response: CliAuth.pollResponse, - url: getApiUrl(host, `poll/${created.code}`), - }) - - if (result.status === 'pending') { - await sleep(pollIntervalMs) - continue - } - if (result.status === 'expired') - throw new Error('Device code expired before authorization completed.') - if (result.action === 'updateAccessKey') - throw new Error('Device code action does not match the authorization request.') - - if (generatedAccessKey) - await saveGeneratedAccessKey( - result.accountAddress, - generatedAccessKey, - result.keyAuthorization, - ) - - return result - } - - throw new TimeoutError(url, created.code) - } - - async function update(parameters: Adapter.updateAccessKey.Parameters) { - const { - host, - open = defaultOpen, - pollIntervalMs = 2_000, - timeoutMs = 5 * 60 * 1_000, - } = options - const chainId = Number(parameters.chainId ?? store.getState().chainId) - const current = store.accessKeys.list({ - accessKey: parameters.accessKeyAddress, - account: parameters.address, - chainId, - })[0] - const status = current?.keyAuthorization - ? await store.accessKeys.getStatus({ - accessKey: parameters.accessKeyAddress, - account: parameters.address, - chainId, - client: getClient({ chainId }), - }) - : undefined - const pendingKeyAuthorization = - status === 'pending' && current?.keyAuthorization - ? KeyAuthorization.toRpc(current.keyAuthorization) - : undefined - const codeVerifier = createCodeVerifier() - const body = { - action: 'updateAccessKey', - accessKeyAddress: parameters.accessKeyAddress, - account: parameters.address, - chainId: BigInt(chainId), - codeChallenge: createCodeChallenge(codeVerifier), - ...(pendingKeyAuthorization - ? { - keyAuthorization: z.decode(CliAuth.keyAuthorization, { - ...pendingKeyAuthorization, - address: pendingKeyAuthorization.keyId, - }), - } - : {}), - limits: parameters.limits, - } satisfies z.output - const created = await post({ - body, - request: CliAuth.createRequest, - response: CliAuth.createResponse, - url: getApiUrl(host, 'code'), - }) - const url = getBrowserUrl(host, created.code) - - try { - await open(url) - } catch (error) { - throw new OpenError(url, created.code, error) - } - - const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - const result = await post({ - body: { codeVerifier }, - request: CliAuth.pollRequest, - response: CliAuth.pollResponse, - url: getApiUrl(host, `poll/${created.code}`), + const { + host, + name = 'Tempo CLI', + open = defaultOpen, + pollingInterval, + rdns = 'xyz.tempo.cli', + timeout, + } = options + + return deviceCode({ + actions: ({ getClient, request, store }) => { + async function getAccessKey(address: `0x${string}`) { + const account = await store.accessKeys.select({ + account: address, + chainId: store.getState().chainId, }) - if (result.status === 'pending') { - await sleep(pollIntervalMs) - continue - } - if (result.status === 'expired') - throw new Error('Device code expired before access key update completed.') - if (result.action !== 'updateAccessKey') - throw new Error('Device code action does not match the access key update request.') - if (result.keyAuthorization) { - store.accessKeys.updateAuthorization({ - accessKey: parameters.accessKeyAddress, - account: parameters.address, - authorization: KeyAuthorization.fromRpc( - z.encode(CliAuth.keyAuthorization, result.keyAuthorization), - ), - chainId, + if (!account) + throw new core_Provider.UnauthorizedError({ + message: `Account "${address}" cannot sign with an access key.`, }) - } - return + return account } - throw new TimeoutError(url, created.code) - } - - return { - actions: { - async authorizeAccessKey(parameters) { - const { accounts, activeAccount } = store.getState() - const account = accounts[activeAccount]?.address - const result = await authorize({ - ...(account ? { account } : {}), - authorizeAccessKey: parameters, - method: 'wallet_authorizeAccessKey', - ...(parameters.showDeposit !== undefined - ? { showDeposit: parameters.showDeposit } - : {}), - }) - - if (!account) - store.setState({ - accounts: [{ address: result.accountAddress }], - activeAccount: 0, - }) - - return { - keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization), - rootAddress: result.accountAddress, - } + return { + async signPersonalMessage(parameters) { + const account = await getAccessKey(parameters.address) + return await account.sign({ hash: hashMessage({ raw: parameters.data }) }) }, - async createAccount(parameters) { - if (parameters?.digest) - throw unsupported('`wallet_connect` digest signing not supported by CLI adapter.') - - const result = await authorize({ - authorizeAccessKey: parameters?.authorizeAccessKey, - method: 'wallet_connect', - ...(parameters?.showDeposit !== undefined - ? { showDeposit: parameters.showDeposit } - : {}), + async signTypedData(parameters) { + const account = await getAccessKey(parameters.address) + return await account.sign({ + hash: hashTypedData(JSON.parse(parameters.data) as never), }) - - return { - accounts: [ - { - address: result.accountAddress, - capabilities: {}, - }, - ], - keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization), - } }, - async loadAccounts(parameters) { - if (parameters?.digest) - throw unsupported('`wallet_connect` digest signing not supported by CLI adapter.') - - const result = await authorize({ - authorizeAccessKey: parameters?.authorizeAccessKey, - method: 'wallet_connect', - ...(parameters?.showDeposit !== undefined - ? { showDeposit: parameters.showDeposit } - : {}), + // Limit updates run their own ceremony: the wallet returns a + // replacement signed authorization for a pending (unpublished) key, + // or applies the update on-chain for a published one. + async updateAccessKey(parameters) { + const chainId = Number(parameters.chainId ?? store.getState().chainId) + const current = store.accessKeys.list({ + accessKey: parameters.accessKeyAddress, + account: parameters.address, + chainId, + })[0] + const status = current?.keyAuthorization + ? await store.accessKeys.getStatus({ + accessKey: parameters.accessKeyAddress, + account: parameters.address, + chainId, + client: getClient({ chainId }), + }) + : undefined + const pendingKeyAuthorization = + status === 'pending' && current?.keyAuthorization + ? KeyAuthorization.toRpc(current.keyAuthorization) + : undefined + + const encoded = z.encode(Rpc.wallet_updateAccessKey.parameters, { + ...parameters, + chainId: BigInt(chainId), }) - - return { - accounts: [ + const result = (await request({ + context: { account: parameters.address, chainId }, + method: 'wallet_updateAccessKey', + params: [ { - address: result.accountAddress, - capabilities: {}, + ...encoded, + ...(pendingKeyAuthorization ? { keyAuthorization: pendingKeyAuthorization } : {}), }, ], - keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization), - } - }, - async revokeAccessKey() { - throw unsupported('`wallet_revokeAccessKey` not supported by CLI adapter.') - }, - async updateAccessKey(parameters) { - await update(parameters) + })) as { keyAuthorization?: KeyAuthorization.Rpc | undefined } | null | undefined + + if (result?.keyAuthorization) + store.accessKeys.updateAuthorization({ + accessKey: parameters.accessKeyAddress, + account: parameters.address, + authorization: KeyAuthorization.fromRpc(result.keyAuthorization), + chainId, + }) }, - }, - async getAccount(options = {}) { - const { accounts, activeAccount, chainId } = store.getState() - const address = options.address ?? accounts[activeAccount]?.address - if (!address) throw new core_Provider.DisconnectedError({ message: 'No active account.' }) - const account = await store.accessKeys.select({ account: address, chainId }) - if (!account) - throw new core_Provider.UnauthorizedError({ - message: `Account "${address}" cannot sign with an access key.`, - }) - return { account } - }, - accessKey: { keystores }, - } + } + }, + // The CLI persists to string-based filesystem storage, so its p256 + // default opts into an extractable WebCrypto key (a non-extractable one + // could not survive a restart). secp256k1 stays available for explicit + // requests. + keystores: { + p256: Keystore.webCryptoP256({ extractable: true }), + secp256k1: Keystore.secp256k1(), + }, + meta: { name }, + // Only the ceremonies (bootstrap and limit updates) open a browser; + // everything else signs locally with the stored access key or fails fast. + methods: ['wallet_connect', 'wallet_authorizeAccessKey', 'wallet_updateAccessKey'], + name, + async onPrompt(prompt) { + if (!prompt.verificationUriFull) { + const code = prompt.userCode.replace(/^(.{4})(.{4})$/, '$1-$2') + process.stdout.write(`Enter code ${code} at ${prompt.verificationUri}\n`) + } + await open(prompt.verificationUriFull ?? prompt.verificationUri, prompt) + }, + rdns, + ...(pollingInterval !== undefined ? { pollingInterval } : {}), + ...(timeout !== undefined ? { timeout } : {}), + url: host, }) } export declare namespace cli { export type Options = { - /** Host URL for the device code flow. API calls are made under the same base path. */ + /** Base URL of the wallet's device-code endpoints (derives `/register` and `/token`). */ host: string /** Provider display name. @default "Tempo CLI" */ name?: string | undefined /** Browser opener override. */ - open?: ((url: string) => Promise | void) | undefined - /** Poll interval in milliseconds. @default 2000 */ - pollIntervalMs?: number | undefined + open?: ((url: string, prompt: DeviceCode.Prompt) => Promise | void) | undefined + /** Poll cadence override in milliseconds. Defaults to the host-advertised interval. */ + pollingInterval?: number | undefined /** Reverse-DNS provider identifier. @default "xyz.tempo.cli" */ rdns?: string | undefined - /** Poll timeout in milliseconds. @default 300000 */ - timeoutMs?: number | undefined - } -} - -class OpenError extends Error { - code: string - override cause?: unknown | undefined - url: string - - constructor(url: string, code: string, cause?: unknown) { - super(`Failed to open browser for device code ${formatCode(code)}. Open ${url} manually.`) - this.name = 'OpenError' - this.code = code - this.cause = cause - this.url = url - } -} - -class TimeoutError extends Error { - code: string - url: string - - constructor(url: string, code: string) { - super(`Timed out waiting for device code ${formatCode(code)}. Continue at ${url}.`) - this.name = 'TimeoutError' - this.code = code - this.url = url + /** Overall wait for user approval in milliseconds. @default 300000 */ + timeout?: number | undefined } } -function createCodeChallenge(codeVerifier: string) { - return Base64.fromBytes(Hash.sha256(Hex.fromString(codeVerifier), { as: 'Bytes' }), { - pad: false, - url: true, - }) -} - -function createCodeVerifier() { - return Base64.fromBytes(Hex.toBytes(Hex.random(32)), { pad: false, url: true }) -} - -function formatCode(code: string) { - return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code -} - function defaultOpen(url: string) { const command = process.platform === 'darwin' @@ -386,44 +158,3 @@ function defaultOpen(url: string) { }) child.unref() } - -function getApiUrl(serviceUrl: string, path: string) { - const url = new URL(serviceUrl) - url.pathname = `${url.pathname.replace(/\/$/, '')}/${path.replace(/^\//, '')}` - url.search = '' - return url.toString() -} - -function getBrowserUrl(serviceUrl: string, code: string) { - const url = new URL(serviceUrl) - url.searchParams.set('code', code) - return url.toString() -} - -async function post< - const request extends z.ZodMiniType, - const response extends z.ZodMiniType, ->(options: { - body: z.output - request: request - response: response - url: string -}): Promise> { - const result = await fetch(options.url, { - body: JSON.stringify(z.encode(options.request, options.body)), - headers: { 'content-type': 'application/json' }, - method: 'POST', - }) - const json = (await result.json().catch(() => ({}))) as z.input - - if (!result.ok) { - const error = (json as { error?: unknown }).error - throw new Error(typeof error === 'string' ? error : `Request failed: ${result.status}`) - } - - return z.decode(options.response, json) -} - -function unsupported(message: string) { - return new core_Provider.UnsupportedMethodError({ message }) -} diff --git a/src/core/adapters/deviceCode/deviceCode.localnet.test.ts b/src/core/adapters/deviceCode/deviceCode.localnet.test.ts new file mode 100644 index 00000000..d01012db --- /dev/null +++ b/src/core/adapters/deviceCode/deviceCode.localnet.test.ts @@ -0,0 +1,226 @@ +import { Hex } from 'ox' +import { describe, expect, test } from 'vp/test' +import type { DeviceCode } from 'wata' + +import { accounts, chain } from '../../../../test/config.js' +import { createDeviceCodeHost, submitVerify } from '../../../../test/deviceCode.js' +import { createServer } from '../../../../test/utils.js' +import * as Provider from '../../Provider.js' +import { deviceCode } from './deviceCode.js' + +const root = accounts[0]! +const accessKey = accounts[1]! +const expiry = Math.floor(Date.now() / 1000) + 3_600 + +function connectRequest() { + return { + method: 'wallet_connect', + params: [ + { + capabilities: { + authorizeAccessKey: { + expiry, + keyType: accessKey.keyType, + publicKey: accessKey.publicKey, + }, + }, + }, + ], + } as const +} + +function createProvider(options: Partial & { url: string }) { + return Provider.create({ + adapter: deviceCode({ + name: 'Accounts Test CLI', + onPrompt: (prompt) => void submitVerify(prompt), + rdns: 'xyz.tempo.accounts.test', + ...options, + }), + chains: [chain], + }) +} + +describe('deviceCode', () => { + test('default: bootstraps wallet_connect through the device-code flow', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) + const prompts: DeviceCode.Prompt[] = [] + + try { + const provider = createProvider({ + onPrompt: (prompt) => { + prompts.push(prompt) + void submitVerify(prompt) + }, + url: `${server.url}/auth/device`, + }) + + const result = await provider.request(connectRequest()) + const account = result.accounts[0]! + const keyAuthorization = account.capabilities.keyAuthorization + ? { + ...account.capabilities.keyAuthorization, + signature: { type: account.capabilities.keyAuthorization.signature.type }, + } + : undefined + + expect(prompts).toHaveLength(1) + expect(prompts[0]!.userCode).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/) + expect(prompts[0]!.verificationUri).toBe(`${server.url}/auth/device/verify`) + expect(prompts[0]!.verificationUriFull).toBe( + `${server.url}/auth/device/verify?user_code=${prompts[0]!.userCode}`, + ) + expect({ + account: { + address: account.address, + capabilities: keyAuthorization ? { keyAuthorization } : {}, + }, + }).toMatchInlineSnapshot(` + { + "account": { + "address": "${root.address}", + "capabilities": { + "keyAuthorization": { + "address": "${accessKey.address.toLowerCase()}", + "chainId": "${Hex.fromNumber(chain.id)}", + "expiry": "${Hex.fromNumber(expiry)}", + "keyId": "${accessKey.address.toLowerCase()}", + "keyType": "secp256k1", + "signature": { + "type": "secp256k1", + }, + }, + }, + }, + } + `) + } finally { + await server.closeAsync() + } + }) + + test('behavior: forwards request context to the host', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) + + try { + const provider = createProvider({ url: `${server.url}/auth/device` }) + await provider.request(connectRequest()) + + expect(host.requests()).toHaveLength(1) + expect(host.requests()[0]!.method).toBe('wallet_connect') + } finally { + await server.closeAsync() + } + }) + + test('behavior: preserves a null username capability', async () => { + const host = createDeviceCodeHost({ username: null }) + const server = await createServer(host.listener) + + try { + const provider = createProvider({ url: `${server.url}/auth/device` }) + const result = await provider.request(connectRequest()) + + expect(result.accounts[0]!.capabilities.username).toBeNull() + } finally { + await server.closeAsync() + } + }) + + test('behavior: denial rejects with a user-rejected error', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) + + try { + const provider = createProvider({ + onPrompt: (prompt) => void submitVerify(prompt, { action: 'deny' }), + url: `${server.url}/auth/device`, + }) + + await expect( + provider.request(connectRequest()).catch((error: { code: number; message: string }) => ({ + code: error.code, + message: error.message, + })), + ).resolves.toMatchInlineSnapshot(` + { + "code": 4001, + "message": "User denied the device-code request.", + } + `) + } finally { + await server.closeAsync() + } + }) + + test('behavior: times out while waiting for authorization', async () => { + const host = createDeviceCodeHost({ pollingInterval: 10 }) + const server = await createServer(host.listener) + + try { + const provider = createProvider({ + onPrompt: () => {}, + timeout: 100, + url: `${server.url}/auth/device`, + }) + + const error = await provider.request(connectRequest()).then( + () => undefined, + (error: Error) => error, + ) + expect(error?.message).toMatch(/^Timed out waiting for device code [A-Z-]+\. Continue at /) + } finally { + await server.closeAsync() + } + }) + + test('behavior: onPrompt failures surface the URL and code', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) + + try { + const provider = createProvider({ + onPrompt: () => { + throw new Error('browser unavailable') + }, + url: `${server.url}/auth/device`, + }) + + const error = await provider.request(connectRequest()).then( + () => undefined, + (error: Error) => error, + ) + expect(error?.message).toMatch( + /^Failed to surface device code [A-Z-]+\. Open .*\/auth\/device\/verify\?user_code=[A-Z-]+ manually\.$/, + ) + } finally { + await server.closeAsync() + } + }) + + test('behavior: rejects methods outside the allowlist without a ceremony', async () => { + const host = createDeviceCodeHost() + const server = await createServer(host.listener) + const prompts: DeviceCode.Prompt[] = [] + + try { + const provider = createProvider({ + methods: ['wallet_authorizeAccessKey'], + onPrompt: (prompt) => { + prompts.push(prompt) + void submitVerify(prompt) + }, + url: `${server.url}/auth/device`, + }) + + await expect(provider.request(connectRequest())).rejects.toThrowErrorMatchingInlineSnapshot( + `[Provider.UnsupportedMethodError: \`wallet_connect\` not supported by device-code adapter.]`, + ) + expect(prompts).toHaveLength(0) + } finally { + await server.closeAsync() + } + }) +}) diff --git a/src/core/adapters/deviceCode/deviceCode.test-d.ts b/src/core/adapters/deviceCode/deviceCode.test-d.ts new file mode 100644 index 00000000..f3b8dcc8 --- /dev/null +++ b/src/core/adapters/deviceCode/deviceCode.test-d.ts @@ -0,0 +1,19 @@ +import { describe, test } from 'vp/test' + +import { deviceCode } from './deviceCode.js' + +describe('deviceCode', () => { + test('accepts explicitly undefined optional metadata', () => { + const description: string | undefined = undefined + const icon: string | undefined = undefined + const websiteUrl: string | undefined = undefined + + deviceCode({ + meta: { name: 'Example', description, icon, websiteUrl }, + name: 'Example', + onPrompt() {}, + rdns: 'com.example', + url: 'https://example.com/auth/device', + }) + }) +}) diff --git a/src/core/adapters/deviceCode/deviceCode.ts b/src/core/adapters/deviceCode/deviceCode.ts new file mode 100644 index 00000000..5581e2c8 --- /dev/null +++ b/src/core/adapters/deviceCode/deviceCode.ts @@ -0,0 +1,157 @@ +import { Provider as core_Provider } from 'ox' +import { DeviceCode, Transport, Wata, deviceCode as core_deviceCode } from 'wata' + +import type * as Adapter from '../../Adapter.js' +import type * as Keystore from '../../Keystore.js' +import { fromRequest } from '../internal/fromRequest.js' + +/** + * Creates a device-code adapter that forwards wallet RPC through Wata. + * + * Each request runs one OAuth 2.0 Device Authorization Grant exchange + * (RFC 8628 with PKCE): the host issues a user code surfaced via + * `onPrompt`, the user approves on the host's verification page, and the + * adapter polls until the response arrives. + */ +export function deviceCode(options: deviceCode.Options): Adapter.Adapter { + const { + actions, + fetch, + icon, + keystores, + meta, + methods, + name, + onPrompt, + pollingInterval, + rdns, + timeout = 300_000, + url, + } = options + + return fromRequest({ + ...(actions ? { actions } : {}), + ...(icon ? { icon } : {}), + ...(keystores ? { keystores } : {}), + name, + rdns, + async request(request) { + if (methods && !methods.includes(request.method)) + throw new core_Provider.UnsupportedMethodError({ + message: `\`${request.method}\` not supported by device-code adapter.`, + }) + + // Device code is single-exchange: one register + poll cycle carries one + // RPC request envelope, so a fresh Wata instance backs every request. + const wata = Wata.create({ + transports: [ + core_deviceCode({ + ...(fetch !== undefined ? { fetch } : {}), + ...(meta ? { meta } : {}), + ...(pollingInterval !== undefined ? { pollingInterval } : {}), + url, + }), + ], + }) + const session = wata.start() + + // A failed prompt surface (e.g. browser open) fails the exchange — the + // user can never learn the code, so polling would only run out the clock. + session.onPrompt((prompt) => { + void (async () => onPrompt(prompt))().catch((error) => { + void session.close(new PromptError(prompt, error)) + }) + }) + + const timer = setTimeout(() => { + void session.close(new TimeoutError(session.prompt)) + }, timeout) + + try { + const { result } = await session.send({ + method: request.method, + params: request.params ?? [], + ...(request.context ? { context: request.context } : {}), + }) + return result + } catch (error) { + if (error instanceof DeviceCode.UserRejectedError) + throw new core_Provider.UserRejectedRequestError({ + message: 'User denied the device-code request.', + }) + if (error instanceof Transport.ClosedError) + throw new Error('Device code expired before authorization completed.', { cause: error }) + throw error + } finally { + clearTimeout(timer) + } + }, + }) +} + +export declare namespace deviceCode { + /** Options for {@link deviceCode}. */ + export type Options = { + /** Adapter-specific action overrides layered over the request-forwarding defaults. */ + actions?: fromRequest.Options['actions'] | undefined + /** Override the transport `fetch` implementation. */ + fetch?: typeof globalThis.fetch | undefined + /** Data URI of the provider icon, announced via EIP-6963. */ + icon?: Adapter.Meta['icon'] | undefined + /** Keystores backing locally generated access keys (SDK default otherwise). */ + keystores?: Keystore.Keystores | undefined + /** Consumer metadata shown on the host's approval page. */ + meta?: + | { + name: string + description?: string | undefined + icon?: string | undefined + websiteUrl?: string | undefined + } + | undefined + /** Allowlist of RPC methods forwarded over the transport. Forwards all when omitted. */ + methods?: readonly string[] | undefined + /** Provider display name. */ + name: string + /** Surfaces the pairing prompt (user code + verification URL) to the user. */ + onPrompt: (prompt: DeviceCode.Prompt) => Promise | void + /** Poll cadence override in milliseconds. Defaults to the host-advertised interval. */ + pollingInterval?: number | undefined + /** Reverse-DNS provider identifier. */ + rdns: string + /** Overall wait for user approval in milliseconds. @default 300000 */ + timeout?: number | undefined + /** Base URL of the host's device-code endpoints (derives `/register` and `/token`). */ + url: string + } +} + +/** Thrown when the `onPrompt` callback fails (e.g. no browser could be opened). */ +export class PromptError extends Error { + override cause?: unknown | undefined + prompt: DeviceCode.Prompt + + constructor(prompt: DeviceCode.Prompt, cause?: unknown) { + super( + `Failed to surface device code ${prompt.userCode}. Open ${prompt.verificationUriFull ?? prompt.verificationUri} manually.`, + ) + this.name = 'PromptError' + this.cause = cause + this.prompt = prompt + } +} + +/** Thrown when the user did not approve within {@link deviceCode.Options.timeout}. */ +export class TimeoutError extends Error { + prompt: DeviceCode.Prompt | undefined + + constructor(prompt: DeviceCode.Prompt | undefined) { + super( + prompt + ? `Timed out waiting for device code ${prompt.userCode}. Continue at ${prompt.verificationUriFull ?? prompt.verificationUri}.` + : 'Timed out waiting for device code approval.', + ) + this.name = 'TimeoutError' + this.prompt = prompt + } +} diff --git a/src/core/adapters/deviceCode/index.ts b/src/core/adapters/deviceCode/index.ts new file mode 100644 index 00000000..32180235 --- /dev/null +++ b/src/core/adapters/deviceCode/index.ts @@ -0,0 +1,2 @@ +export { PromptError, TimeoutError, deviceCode } from './deviceCode.js' +export { tempoWallet } from './tempoWallet.js' diff --git a/src/core/adapters/deviceCode/tempoWallet.ts b/src/core/adapters/deviceCode/tempoWallet.ts new file mode 100644 index 00000000..f40d2584 --- /dev/null +++ b/src/core/adapters/deviceCode/tempoWallet.ts @@ -0,0 +1,17 @@ +import type * as Adapter from '../../Adapter.js' +import { deviceCode } from './deviceCode.js' + +/** Creates the Tempo Wallet adapter using the Wata device-code transport. */ +export function tempoWallet(options: tempoWallet.Options): Adapter.Adapter { + return deviceCode({ + ...options, + name: 'Tempo Wallet', + rdns: 'xyz.tempo', + url: 'https://wallet.tempo.xyz/auth/device', + }) +} + +export declare namespace tempoWallet { + /** Options for {@link tempoWallet}. */ + export type Options = Omit +} diff --git a/src/core/adapters/internal/fromRequest.ts b/src/core/adapters/internal/fromRequest.ts index 9b6574aa..d2b337c8 100644 --- a/src/core/adapters/internal/fromRequest.ts +++ b/src/core/adapters/internal/fromRequest.ts @@ -16,106 +16,133 @@ import * as Rpc from '../../zod/rpc.js' * `request`. */ export function fromRequest(options: fromRequest.Options): Adapter.Adapter { - const { bind, cleanup, close, icon, keystores, name, rdns, request: requestRemote } = options + const { + actions, + bind, + cleanup, + close, + icon, + keystores, + name, + rdns, + request: requestRemote, + } = options - return Adapter.define({ ...(icon ? { icon } : {}), name, rdns }, ({ getAccount, store }) => { - // Late-bind the store so the request channel can reconcile local - // connection state (e.g. a wallet `accountsChanged` notification). - bind?.({ store }) + return Adapter.define( + { ...(icon ? { icon } : {}), name, rdns }, + ({ getAccount, getClient, store }) => { + // Late-bind the store so the request channel can reconcile local + // connection state (e.g. a wallet `accountsChanged` notification). + bind?.({ store }) - const provider = core_Provider.from({ - async request(request) { - const { accounts, activeAccount, chainId } = store.getState() - if (request.method === 'eth_chainId') return Hex.fromNumber(chainId) - // Forward the active account + chain as request context so the wallet - // acts against the session the app believes it is using rather than - // whatever the wallet last selected for itself. - const account = accounts[activeAccount]?.address - return await requestRemote({ - method: request.method, - params: request.params as readonly unknown[] | undefined, - context: { chainId, ...(account ? { account } : {}) }, - }) - }, - }) + const provider = core_Provider.from({ + async request(request) { + const { accounts, activeAccount, chainId } = store.getState() + if (request.method === 'eth_chainId') return Hex.fromNumber(chainId) + // Forward the active account + chain as request context so the wallet + // acts against the session the app believes it is using rather than + // whatever the wallet last selected for itself. + const account = accounts[activeAccount]?.address + return await requestRemote({ + method: request.method, + params: request.params as readonly unknown[] | undefined, + context: { chainId, ...(account ? { account } : {}) }, + }) + }, + }) - // Connect capabilities are read from the first account only: the remote - // wallet connects a single account per exchange. - function toConnectReturn(result: Rpc.wallet_connect.Encoded['returns']) { - const capabilities = result.accounts[0]?.capabilities - return { - accounts: result.accounts.map((account) => ({ address: account.address })), - ...(capabilities?.auth ? { auth: capabilities.auth } : {}), - ...(capabilities?.identity ? { identity: capabilities.identity } : {}), - ...(capabilities?.keyAuthorization - ? { keyAuthorization: capabilities.keyAuthorization } - : {}), - ...(capabilities?.personalSign ? { personalSign: capabilities.personalSign } : {}), - ...(capabilities?.signature ? { signature: capabilities.signature } : {}), + // Connect capabilities are read from the first account only: the remote + // wallet connects a single account per exchange. + function toConnectReturn(result: Rpc.wallet_connect.Encoded['returns']) { + const capabilities = result.accounts[0]?.capabilities + return { + accounts: result.accounts.map((account) => ({ address: account.address })), + ...(capabilities?.auth ? { auth: capabilities.auth } : {}), + ...(capabilities?.identity ? { identity: capabilities.identity } : {}), + ...(capabilities?.keyAuthorization + ? { keyAuthorization: capabilities.keyAuthorization } + : {}), + ...(capabilities?.personalSign ? { personalSign: capabilities.personalSign } : {}), + ...(capabilities?.signature ? { signature: capabilities.signature } : {}), + ...(capabilities?.username !== undefined ? { username: capabilities.username } : {}), + } } - } - return { - forwardsAuth: true, - ...(keystores ? { accessKey: { keystores } } : {}), - actions: { - async createAccount(_parameters, request) { - return toConnectReturn( - (await provider.request(request)) as Rpc.wallet_connect.Encoded['returns'], - ) - }, - async loadAccounts(_parameters, request) { - return toConnectReturn( - (await provider.request(request)) as Rpc.wallet_connect.Encoded['returns'], - ) - }, - async deposit(_parameters, request) { - return (await provider.request(request)) as Rpc.wallet_deposit.Encoded['returns'] - }, - async depositZone(parameters, request) { - return (await provider.request({ - ...request, - params: [z.encode(Rpc.wallet_depositZone.parameters, parameters)] as const, - })) as Rpc.wallet_depositZone.Encoded['returns'] - }, - // `disconnect` (when `close` is set) only tears down the local - // wallet session — it is never forwarded. No `switchChain` action: - // chain selection is local state the provider handles itself, and - // forwarding it would open a wallet session for nothing. - ...(close ? { disconnect: close } : {}), - async swap(_parameters, request) { - return (await provider.request(request)) as Rpc.wallet_swap.Encoded['returns'] - }, - async transfer(parameters, request) { - return (await provider.request({ - ...request, - params: [z.encode(Rpc.wallet_transfer.parameters, parameters)] as const, - })) as Rpc.wallet_transfer.Encoded['returns'] + return { + forwardsAuth: true, + ...(keystores ? { accessKey: { keystores } } : {}), + actions: { + async createAccount(_parameters, request) { + return toConnectReturn( + (await provider.request(request)) as Rpc.wallet_connect.Encoded['returns'], + ) + }, + async loadAccounts(_parameters, request) { + return toConnectReturn( + (await provider.request(request)) as Rpc.wallet_connect.Encoded['returns'], + ) + }, + async deposit(_parameters, request) { + return (await provider.request(request)) as Rpc.wallet_deposit.Encoded['returns'] + }, + async depositZone(parameters, request) { + return (await provider.request({ + ...request, + params: [z.encode(Rpc.wallet_depositZone.parameters, parameters)] as const, + })) as Rpc.wallet_depositZone.Encoded['returns'] + }, + // `disconnect` (when `close` is set) only tears down the local + // wallet session — it is never forwarded. No `switchChain` action: + // chain selection is local state the provider handles itself, and + // forwarding it would open a wallet session for nothing. + ...(close ? { disconnect: close } : {}), + async swap(_parameters, request) { + return (await provider.request(request)) as Rpc.wallet_swap.Encoded['returns'] + }, + async transfer(parameters, request) { + return (await provider.request({ + ...request, + params: [z.encode(Rpc.wallet_transfer.parameters, parameters)] as const, + })) as Rpc.wallet_transfer.Encoded['returns'] + }, + async withdrawZone(parameters, request) { + return (await provider.request({ + ...request, + params: [z.encode(Rpc.wallet_withdrawZone.parameters, parameters)] as const, + })) as Rpc.wallet_withdrawZone.Encoded['returns'] + }, + ...actions?.({ getClient, request: requestRemote, store }), }, - async withdrawZone(parameters, request) { - return (await provider.request({ - ...request, - params: [z.encode(Rpc.wallet_withdrawZone.parameters, parameters)] as const, - })) as Rpc.wallet_withdrawZone.Encoded['returns'] + ...(cleanup ? { cleanup } : close ? { cleanup: () => void close() } : {}), + getAccount(parameters = {}) { + return { + account: { + address: parameters.address ?? getAccount({ signable: false }).address, + type: 'json-rpc' as const, + }, + transport: custom(provider), + } }, - }, - ...(cleanup ? { cleanup } : close ? { cleanup: () => void close() } : {}), - getAccount(parameters = {}) { - return { - account: { - address: parameters.address ?? getAccount({ signable: false }).address, - type: 'json-rpc' as const, - }, - transport: custom(provider), - } - }, - } - }) + } + }, + ) } export declare namespace fromRequest { /** Options for {@link fromRequest}. */ export type Options = { + /** + * Adapter-specific action overrides layered over the request-forwarding + * defaults. Receives the request channel plus the adapter's client and + * store for actions that need local bookkeeping around an exchange. + */ + actions?: + | ((context: { + getClient: Adapter.SetupFn.Parameters['getClient'] + request: Options['request'] + store: Adapter.SetupFn.Parameters['store'] + }) => Partial) + | undefined /** * Called once during setup with the adapter's store, for late wiring * that needs to mutate local state (e.g. reconciling connection state diff --git a/src/server/CliAuth.test.ts b/src/server/CliAuth.test.ts index e17cbeca..b72df968 100644 --- a/src/server/CliAuth.test.ts +++ b/src/server/CliAuth.test.ts @@ -1,4 +1,4 @@ -import { Base64 } from 'ox' +import { Base64, Hex } from 'ox' import { KeyAuthorization } from 'ox/tempo' import { createClient, custom, encodeErrorResult, encodeFunctionResult } from 'viem' import { Abis, Account as TempoAccount } from 'viem/tempo' @@ -23,6 +23,22 @@ const limits = [ }, ] as const +describe('keyAuthorization', () => { + test('behavior: preserves spending limit periods', () => { + const authorization = z.decode(CliAuth.keyAuthorization, { + address: accessKey.address, + chainId: '0xa5bf', + expiry: Hex.fromNumber(expiry), + keyId: accessKey.address, + keyType: accessKey.keyType, + limits: [{ limit: '0x3e8', period: Hex.fromNumber(86_400), token }], + signature: { r: '0x1', s: '0x2', type: 'secp256k1', yParity: '0x0' }, + }) + + expect(authorization.limits).toEqual([{ limit, period: 86_400, token }]) + }) +}) + function createMissingAccessKeyClient() { return createClient({ chain, @@ -744,23 +760,18 @@ Received 2 bytes.]`) limits: Array.from({ length: 11 }, () => item), }) - expect(result).toMatchInlineSnapshot(` - { - "error": [$ZodError: [ - { - "origin": "array", - "code": "too_big", - "maximum": 10, - "inclusive": true, - "path": [ - "limits" - ], - "message": "Invalid input" - } - ]], - "success": false, - } - `) + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.issues).toEqual([ + { + code: 'too_big', + inclusive: true, + maximum: 10, + message: 'Invalid input', + origin: 'array', + path: ['limits'], + }, + ]) }) test('behavior: rejects malformed limit tokens', async () => { diff --git a/src/server/CliAuth.ts b/src/server/CliAuth.ts index 354f934e..ef4626bc 100644 --- a/src/server/CliAuth.ts +++ b/src/server/CliAuth.ts @@ -12,7 +12,11 @@ import type { MaybePromise } from '../internal/types.js' import type { Kv } from './Kv.js' const maxLimits = 10 -const limit = z.object({ token: u.address(), limit: u.bigint() }) +const limit = z.object({ + token: u.address(), + limit: u.bigint(), + period: z.optional(u.number()), +}) const limits = z.readonly(z.array(limit).check(z.maxLength(maxLimits))) const showDeposit = z.optional( z.union([ @@ -249,7 +253,10 @@ export const entry = u.oneOf([ }), ]) -/** Shared CLI auth helper with pre-bound defaults and cached clients. */ +/** + * Shared CLI auth helper with pre-bound defaults and cached clients. + * @deprecated Use `Handler.deviceCode` with the Wata device-code transport. + */ export type CliAuth = { /** Creates and stores a new device code. */ createDeviceCode: (options: createDeviceCode.Parameters) => Promise @@ -622,6 +629,7 @@ export const RateLimit = { * Instantiates a CLI auth helper with shared defaults and cached clients. * * + * @deprecated Use `Handler.deviceCode` with the Wata device-code transport. * @param {from.Options} options - Shared CLI auth defaults. * @returns {CliAuth} CLI auth helper. * diff --git a/src/server/Handler.test-d.ts b/src/server/Handler.test-d.ts index 62052db5..173a10c1 100644 --- a/src/server/Handler.test-d.ts +++ b/src/server/Handler.test-d.ts @@ -1,42 +1,28 @@ import { hc } from 'hono/client' import type { ExtractSchema } from 'hono/types' -import { http, type Chain, type Transport } from 'viem' -import { tempo, tempoModerato } from 'viem/tempo/chains' import { describe, expectTypeOf, test } from 'vp/test' +import { Store } from 'wata/host' -import * as CliAuth from './CliAuth.js' import * as Handler from './Handler.js' -describe('codeAuth options', () => { - test('supports chain-agnostic chains/transports configuration', () => { - expectTypeOf().toMatchTypeOf<{ - chains?: readonly [Chain, ...Chain[]] | undefined - transports?: Record | undefined - }>() - }) - - test('accepts derived clients from chains/transports', () => { - void Handler.codeAuth({ - chains: [tempo, tempoModerato], - transports: { - [tempo.id]: http('https://rpc.tempo.xyz'), - [tempoModerato.id]: http('https://rpc.moderato.tempo.xyz'), +describe('deviceCode', () => { + test('accepts host deployment options', () => { + const handler = Handler.deviceCode({ + baseUrl: (request) => new URL(request.url).origin, + html: { + render: () => new Response('verify'), }, - }) - }) - - test('accepts a shared rate limiter', () => { - void Handler.codeAuth({ - rateLimit: CliAuth.RateLimit.memory({ max: 120, windowMs: 60_000 }), - }) - void Handler.codeAuth({ - rateLimit: false, - }) - void Handler.codeAuth({ - rateLimitKey(request) { - return request.headers.get('x-forwarded-for') ?? 'unknown' + store: Store.memory(), + validate(options) { + expectTypeOf(options.record).toEqualTypeOf() + expectTypeOf(options.request.id).toEqualTypeOf() + expectTypeOf(options.result).toBeUnknown() + expectTypeOf(options.userCode).toBeString() + return undefined }, }) + + expectTypeOf(handler).toMatchTypeOf() }) }) @@ -125,11 +111,11 @@ describe('compose', () => { }) test('handlers without a route schema (legacy `Handler`) compose without errors', () => { - // `Handler.codeAuth` returns plain `Handler` (default `Hono`), no route + // `Handler.from` returns plain `Handler` (default `Hono`), no route // schema. Mixing it with typed handlers must not break inference for the // typed siblings. - const codeAuth = Handler.codeAuth() as unknown as Handler.Handler - const composed = Handler.compose([codeAuth, makeAlpha()], { path: '/api' }) + const plain = Handler.from() as unknown as Handler.Handler + const composed = Handler.compose([plain, makeAlpha()], { path: '/api' }) const client = hc('http://localhost') expectTypeOf(client.api.alpha.ping.$post).toBeFunction() diff --git a/src/server/Handler.ts b/src/server/Handler.ts index e31f99ca..b577c5fe 100644 --- a/src/server/Handler.ts +++ b/src/server/Handler.ts @@ -7,6 +7,7 @@ import * as RequestListener from './internal/requestListener.js' export { auth } from './internal/handlers/auth.js' export { type SessionRequest } from './internal/handlers/session.js' export { codeAuth } from './internal/handlers/codeAuth.js' +export { deviceCode } from './internal/handlers/deviceCode.js' export { exchange } from './internal/handlers/exchange.js' export { oidcProvider } from './internal/handlers/oidcProvider.js' export { relay } from './internal/handlers/relay.js' @@ -51,7 +52,7 @@ export type ComposedSchema * import { hc } from 'hono/client' * * const app = Handler.compose( - * [Handler.exchange(), Handler.codeAuth({ store })], + * [Handler.exchange(), Handler.relay()], * { path: '/api' }, * ) * type App = typeof app diff --git a/src/server/index.ts b/src/server/index.ts index 8133f1d5..e1fbeb45 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ export { hc } from 'hono/client' +/** @deprecated Use `Handler.deviceCode` with the Wata device-code transport. */ export * as CliAuth from './CliAuth.js' export * as Handler from './Handler.js' export * as Identity from './Identity.js' diff --git a/src/server/internal/handlers/codeAuth.ts b/src/server/internal/handlers/codeAuth.ts index 6aaf05ba..c9d2bb2c 100644 --- a/src/server/internal/handlers/codeAuth.ts +++ b/src/server/internal/handlers/codeAuth.ts @@ -15,6 +15,7 @@ import { type Handler, from } from '../../Handler.js' * - `POST /auth/pkce/poll/:code` * - `POST /auth/pkce` * + * @deprecated Use `Handler.deviceCode` with the Wata device-code transport. * @param {codeAuth.Options} options - Options. * @returns {Handler} Request handler. */ diff --git a/src/server/internal/handlers/deviceCode.test.ts b/src/server/internal/handlers/deviceCode.test.ts new file mode 100644 index 00000000..82ff7c7a --- /dev/null +++ b/src/server/internal/handlers/deviceCode.test.ts @@ -0,0 +1,140 @@ +import { Base64, Bytes, Hash } from 'ox' +import { describe, expect, test, vi } from 'vp/test' + +import { deviceCode } from './deviceCode.js' + +const verifier = 'test-device-code-verifier-0123456789' + +function createHandler(validate = vi.fn(() => undefined)) { + return { + handler: deviceCode({ + html: { render: () => new Response('verify') }, + pollingInterval: 1, + validate, + }), + validate, + } +} + +async function register(handler: ReturnType) { + const response = await handler.fetch( + new Request('https://wallet.example.com/auth/device/register', { + body: JSON.stringify({ + code_challenge: Base64.fromBytes(Hash.sha256(Bytes.fromString(verifier), { as: 'Bytes' }), { + pad: false, + url: true, + }), + code_challenge_method: 'S256', + message: { + payload: [{ id: 1, jsonrpc: '2.0', method: 'wallet_connect', params: [] }], + type: 'rpc-requests', + }, + meta: { name: 'Test CLI' }, + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }), + ) + return (await response.json()) as { device_code: string; user_code: string } +} + +async function verify( + handler: ReturnType, + userCode: string, + response: + | { error: { code: number; data?: unknown; message: string }; id: number } + | { + id: number + result: unknown + }, +) { + return await handler.fetch( + new Request('https://wallet.example.com/auth/device/verify', { + body: JSON.stringify({ action: 'approve', results: [response], user_code: userCode }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }), + ) +} + +async function token(handler: ReturnType, code: string) { + const response = await handler.fetch( + new Request('https://wallet.example.com/auth/device/token', { + body: JSON.stringify({ + code_verifier: verifier, + device_code: code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }), + ) + return { body: await response.json(), status: response.status } +} + +describe('deviceCode', () => { + test('default: validates approved results before relaying them', async () => { + const { handler, validate } = createHandler() + const registered = await register(handler) + + const approved = await verify(handler, registered.user_code, { id: 1, result: '0x1' }) + + expect(approved.status).toMatchInlineSnapshot(`200`) + expect(validate).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ id: 1, method: 'wallet_connect' }), + result: '0x1', + userCode: registered.user_code, + }), + ) + await expect(token(handler, registered.device_code)).resolves.toMatchInlineSnapshot(` + { + "body": { + "payload": [ + { + "id": 1, + "jsonrpc": "2.0", + "result": "0x1", + }, + ], + "type": "rpc-responses", + }, + "status": 200, + } + `) + }) + + test('behavior: relays submitted RPC errors', async () => { + const { handler, validate } = createHandler() + const registered = await register(handler) + + const approved = await verify(handler, registered.user_code, { + error: { code: 4001, data: { reason: 'cancelled' }, message: 'Request rejected.' }, + id: 1, + }) + + expect(approved.status).toMatchInlineSnapshot(`200`) + expect(validate).not.toHaveBeenCalled() + await expect(token(handler, registered.device_code)).resolves.toMatchInlineSnapshot(` + { + "body": { + "payload": [ + { + "error": { + "code": 4001, + "data": { + "reason": "cancelled", + }, + "message": "Request rejected.", + }, + "id": 1, + "jsonrpc": "2.0", + }, + ], + "type": "rpc-responses", + }, + "status": 200, + } + `) + }) +}) diff --git a/src/server/internal/handlers/deviceCode.ts b/src/server/internal/handlers/deviceCode.ts new file mode 100644 index 00000000..41687956 --- /dev/null +++ b/src/server/internal/handlers/deviceCode.ts @@ -0,0 +1,323 @@ +import { type DeviceCode, Store, Wata, deviceCode as core_deviceCode } from 'wata/host' +import * as z from 'zod/mini' + +import { type Handler, from } from '../../Handler.js' + +const maxVerifyBodyBytes = 65_536 + +const verifyRequest = z.object({ + action: z.union([z.literal('approve'), z.literal('deny')]), + results: z.optional( + z.array( + z.union([ + z.object({ + error: z.object({ + code: z.number(), + data: z.optional(z.json()), + message: z.string(), + }), + id: z.union([z.string(), z.number()]), + }), + z.object({ id: z.union([z.string(), z.number()]), result: z.json() }), + ]), + ), + ), + user_code: z.string(), +}) + +type PendingRequest = { + context?: { account?: string | undefined; chainId?: number | undefined } | undefined + id: string | number + method: string + params?: unknown +} + +/** + * Instantiates a Wata device-code handler for wallet RPC requests. + * + * The handler implements the OAuth 2.0 Device Authorization Grant endpoints, + * accepts browser-submitted RPC results, and returns them to the polling + * consumer. The consuming host is responsible for deciding which methods + * its approval UI supports. + * + * @param options - Options. + * @returns Request handler. + */ +export function deviceCode(options: deviceCode.Options): Handler { + const { + baseUrl, + expiresIn, + fetch, + html, + maxBodyBytes = maxVerifyBodyBytes, + path = '/auth/device', + pollingInterval, + store = Store.memory(), + validate, + ...rest + } = options + + const router = from(rest) + router.all(`${path}/*`, async (c) => { + const request = c.req.raw + const origin = typeof baseUrl === 'function' ? baseUrl(request) : baseUrl + const results = new Map< + string | number, + { result: unknown } | { error: { code: number; data?: unknown; message: string } } + >() + let settle: ((error?: Error) => void) | undefined + const responded = new Promise((resolve, reject) => { + settle = (error) => (error ? reject(error) : resolve()) + }) + + const wata = Wata.create({ + transports: [ + core_deviceCode({ + ...(origin ? { baseUrl: origin } : {}), + ...(expiresIn !== undefined ? { expiresIn } : {}), + ...(fetch !== undefined ? { fetch } : {}), + html: { + async authenticate({ actions, request }) { + const body = await readVerifyBody(request, maxBodyBytes) + if (!body.ok) return body.response + const { action, results: submitted, user_code } = body.value + + const userCode = normalizeUserCode(user_code) + const record = await actions.get(userCode) + if (!record) + return Response.json( + { error: 'unknown_code', error_description: 'Unknown or expired device code.' }, + { status: 404 }, + ) + if (record.status !== 'pending') + return Response.json( + { error: 'not_pending', error_description: 'Device code already completed.' }, + { status: 409 }, + ) + + if (action === 'deny') { + await actions.deny(userCode) + return Response.json({ status: 'denied' }) + } + + const requests = pendingRequests(record) + const [pending] = requests + if (requests.length !== 1 || !pending) + return Response.json( + { + error: 'invalid_request', + error_description: 'Expected one pending request.', + }, + { status: 400 }, + ) + + const response = submitted?.find((entry) => entry.id === pending.id) + if (!response) + return Response.json( + { + error: 'invalid_request', + error_description: 'Missing response for pending request.', + }, + { status: 400 }, + ) + + if ('result' in response) { + const validation = await validate({ + record, + request: pending, + result: response.result, + userCode, + }) + if (validation) return validation + } + + results.set(pending.id, response) + await actions.approve(userCode) + await responded + return Response.json({ status: 'approved' }) + }, + render: html.render, + }, + path, + ...(pollingInterval !== undefined ? { pollingInterval } : {}), + store: normalizeStore(store), + }), + ], + }) + + const session = wata.start() + session.onRequest(async (event) => { + const response = results.get(event.id) + try { + if (!response) + await event.reject({ code: -32603, message: 'No result supplied for request.' }) + else if ('error' in response) await event.reject(response.error) + else await event.respond(response.result) + settle?.() + } catch (error) { + settle?.(error as Error) + } + }) + + return await normalizeRegisterResponse(await wata.fetch(request), request, path) + }) + + return router +} + +export declare namespace deviceCode { + /** Options for {@link deviceCode}. */ + export type Options = from.Options & { + /** Public host origin or a request-based origin resolver. */ + baseUrl?: string | ((request: Request) => string) | undefined + /** Authorization intent lifetime in seconds. @default 600 */ + expiresIn?: number | undefined + /** Discovery fetch implementation. */ + fetch?: typeof globalThis.fetch | undefined + /** Approval page hooks. */ + html: { + /** Renders or redirects the approval page. */ + render: DeviceCode.html.Hooks['render'] + } + /** Maximum approval request body size in bytes. @default 65536 */ + maxBodyBytes?: number | undefined + /** Device-code endpoint path. @default "/auth/device" */ + path?: string | undefined + /** Suggested polling interval in milliseconds. */ + pollingInterval?: number | undefined + /** Device-code persistence. @default in-memory */ + store?: Store.Store | undefined + /** Host policy applied before an approved result is relayed to the consumer. */ + validate: (options: { + /** Pending device-code record. */ + record: DeviceCode.PendingRecord + /** Pending JSON-RPC request matched to the submitted result. */ + request: PendingRequest + /** Browser-submitted result. */ + result: unknown + /** Normalized raw user code. */ + userCode: string + }) => Promise | Response | undefined + } +} + +function pendingRequests(record: DeviceCode.PendingRecord): PendingRequest[] { + if (record.message.type !== 'rpc-requests') return [] + return record.message.payload.filter( + (message) => typeof message === 'object' && message !== null && 'id' in message, + ) as unknown as PendingRequest[] +} + +function normalizeUserCode(value: string): string { + return value.replace(/[\s-]/g, '').toUpperCase() +} + +function normalizeStore(store: Store.Store): Store.Store { + function key(value: string) { + if (!value.startsWith('user:')) return value + return `user:${normalizeUserCode(value.slice('user:'.length))}` + } + + return Store.from({ + delete: (value) => store.delete(key(value)), + get: (name: string) => store.get(key(name)), + set: (name, value, options) => store.set(key(name), value, options), + ...(store.take + ? { take: (name: string) => store.take!(key(name)) } + : {}), + }) +} + +async function normalizeRegisterResponse(response: Response, request: Request, path: string) { + if (!response.ok || new URL(request.url).pathname !== `${path}/register`) return response + + const body = (await response.clone().json()) as { + user_code?: unknown + verification_uri_complete?: unknown + } + if (typeof body.user_code !== 'string') return response + + const userCode = normalizeUserCode(body.user_code) + const verificationUriComplete = + typeof body.verification_uri_complete === 'string' + ? (() => { + const url = new URL(body.verification_uri_complete) + url.searchParams.set('user_code', userCode) + return url.toString() + })() + : body.verification_uri_complete + const headers = new Headers(response.headers) + headers.delete('content-length') + return Response.json( + { + ...body, + user_code: userCode, + ...(verificationUriComplete ? { verification_uri_complete: verificationUriComplete } : {}), + }, + { headers, status: response.status }, + ) +} + +async function readVerifyBody( + request: Request, + maxBodyBytes: number, +): Promise< + { ok: true; value: z.output } | { ok: false; response: Response } +> { + const invalid = (description: string) => ({ + ok: false as const, + response: Response.json( + { error: 'invalid_request', error_description: description }, + { status: 400 }, + ), + }) + + let json: unknown + try { + const body = await readBody(request, maxBodyBytes) + if (!body.ok) return invalid('Request body is too large.') + json = JSON.parse(body.text) + } catch { + return invalid('Expected a JSON body.') + } + + const parsed = z.safeParse(verifyRequest, json) + if (!parsed.success) return invalid('Malformed verify request.') + return { ok: true, value: parsed.data } +} + +async function readBody( + request: Request, + maxBodyBytes: number, +): Promise<{ ok: true; text: string } | { ok: false }> { + const length = request.headers.get('content-length') + if (length && Number(length) > maxBodyBytes) return { ok: false } + if (!request.body) return { ok: true, text: '' } + + const reader = request.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > maxBodyBytes) { + await reader.cancel().catch(() => undefined) + return { ok: false } + } + chunks.push(value) + } + } finally { + reader.releaseLock() + } + + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return { ok: true, text: new TextDecoder().decode(bytes) } +} diff --git a/test/deviceCode.ts b/test/deviceCode.ts new file mode 100644 index 00000000..246fa89e --- /dev/null +++ b/test/deviceCode.ts @@ -0,0 +1,245 @@ +import type { RequestListener } from 'node:http' +import { Address, Hex, PublicKey } from 'ox' +import { KeyAuthorization } from 'ox/tempo' +import { Actions } from 'viem/tempo' +import { Store, Wata, deviceCode } from 'wata/host' +import { Server } from 'wata/server' +import * as z from 'zod/mini' + +import * as Rpc from '../src/core/zod/rpc.js' +import { accounts, chain, getClient } from './config.js' + +const root = accounts[0]! + +/** + * In-process wata device-code host that answers approved requests by signing + * with the localnet root account. Mirrors the wallet host contract: JSON GET + * on `/verify` returns the pending record, JSON POST approves or denies. + */ +export function createDeviceCodeHost(options: createDeviceCodeHost.Options = {}) { + const { + omitVerificationUriFull = false, + path = '/auth/device', + pollingInterval = 50, + username, + } = options + const store = Store.memory() + const requests: { method: string; params: unknown }[] = [] + + async function handle(request: Request): Promise { + // The response envelope is persisted by the host session's `onRequest` + // dispatch, which `actions.approve` kicks off without awaiting — hold the + // approve response until it lands so the record is settled before the + // consumer's next poll. + let settle: (() => void) | undefined + const responded = new Promise((resolve) => { + settle = resolve + }) + + // Results are computed before `approve` (mirroring the wallet worker, + // where the browser supplies them) so the approved-but-unanswered window + // stays sub-millisecond and fast pollers cannot observe it. + const results = new Map() + + const wata = Wata.create({ + transports: [ + deviceCode({ + html: { + async authenticate({ actions, request }) { + const body = (await request.json()) as { + action?: 'approve' | 'deny' | undefined + user_code?: string | undefined + } + const userCode = String(body.user_code ?? '') + const record = await actions.get(userCode) + if (!record) return Response.json({ error: 'unknown_code' }, { status: 404 }) + if (body.action === 'deny') { + await actions.deny(userCode) + return Response.json({ status: 'denied' }) + } + if (record.message.type === 'rpc-requests') + for (const message of record.message.payload) { + if (typeof message !== 'object' || message === null || !('id' in message)) + continue + results.set(message.id, await respondTo(message, { username })) + } + await actions.approve(userCode) + await responded + return Response.json({ status: 'approved' }) + }, + async render({ record, userCode }) { + if (!record) return Response.json({ error: 'unknown_code' }, { status: 404 }) + return Response.json({ + requests: record.message.payload, + status: 'pending', + user_code: userCode, + }) + }, + }, + path, + pollingInterval, + store, + }), + ], + }) + + const session = wata.start() + session.onRequest(async (event) => { + requests.push({ method: event.method, params: event.params }) + try { + if (results.has(event.id)) await event.respond(results.get(event.id)) + else await event.reject({ code: -32601, message: `unsupported method \`${event.method}\`` }) + } finally { + settle?.() + } + }) + + const response = await wata.fetch(request) + if (!omitVerificationUriFull || !new URL(request.url).pathname.endsWith('/register')) + return response + + const body = (await response.json()) as Record + delete body.verification_uri_complete + return Response.json(body, { status: response.status }) + } + + return { + fetch: handle, + listener: Server.node({ fetch: handle }).listener as RequestListener, + requests: () => requests, + } +} + +export declare namespace createDeviceCodeHost { + type Options = { + /** Omits the optional complete verification URI from registration responses. */ + omitVerificationUriFull?: boolean | undefined + /** Mount path for the device-code routes. @default '/auth/device' */ + path?: string | undefined + /** Advertised poll interval in milliseconds. @default 50 */ + pollingInterval?: number | undefined + /** Username capability returned by `wallet_connect`. */ + username?: string | null | undefined + } +} + +/** Approves or denies a pending device code through the verify endpoint. */ +export async function submitVerify( + prompt: { userCode: string; verificationUri: string }, + options: { action?: 'approve' | 'deny' | undefined } = {}, +) { + const response = await fetch(prompt.verificationUri, { + body: JSON.stringify({ action: options.action ?? 'approve', user_code: prompt.userCode }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + if (!response.ok) throw new Error(`verify submit failed: ${response.status}`) + return (await response.json()) as { status: 'approved' | 'denied' } +} + +async function signKeyAuthorization( + parameters: NonNullable[number], +) { + return await root.signKeyAuthorization( + { + accessKeyAddress: accessKeyAddress(parameters), + keyType: parameters.keyType ?? 'p256', + }, + { + chainId: parameters.chainId ?? BigInt(chain.id), + expiry: parameters.expiry, + ...(parameters.limits ? { limits: parameters.limits } : {}), + }, + ) +} + +function accessKeyAddress( + parameters: NonNullable[number], +) { + if (parameters.address) return parameters.address + if (!parameters.publicKey) + throw new Error('Expected access key address or public key in wallet request.') + return Address.fromPublicKey(PublicKey.fromHex(parameters.publicKey)) +} + +/** Produces the signed response for a queued request, before approval. */ +async function respondTo( + message: { method: string; params?: unknown }, + options: { username?: string | null | undefined } = {}, +): Promise { + if (message.method === 'wallet_connect') { + const [parameters] = z.decode(Rpc.wallet_connect.schema.params!, message.params as never) ?? [] + const authorization = parameters?.capabilities?.authorizeAccessKey + return { + accounts: [ + { + address: root.address, + capabilities: authorization + ? { + keyAuthorization: KeyAuthorization.toRpc(await signKeyAuthorization(authorization)), + ...(options.username !== undefined ? { username: options.username } : {}), + } + : options.username !== undefined + ? { username: options.username } + : {}, + }, + ], + } + } + if (message.method === 'wallet_authorizeAccessKey') { + const [parameters] = z.decode( + Rpc.wallet_authorizeAccessKey.schema.params!, + message.params as never, + ) + return { + keyAuthorization: KeyAuthorization.toRpc(await signKeyAuthorization(parameters)), + rootAddress: root.address, + } + } + if (message.method === 'wallet_updateAccessKey') return await completeUpdate(message.params) + throw new Error(`unsupported method \`${message.method}\``) +} + +/** + * Mirrors the wallet's update-approval behavior: a pending key (request + * carries its current authorization) receives a replacement signed by the + * root account; a published key is updated on-chain. + */ +async function completeUpdate(params: unknown) { + const raw = (params as readonly unknown[])[0] as Record + const parameters = z.decode(Rpc.wallet_updateAccessKey.parameters, raw as never) + const carried = raw.keyAuthorization as + | { + expiry?: number | `0x${string}` | null | undefined + keyType?: 'secp256k1' | 'p256' | 'webAuthn' | undefined + } + | undefined + + if (carried) { + const expiry = + typeof carried.expiry === 'string' + ? Hex.toNumber(carried.expiry) + : (carried.expiry ?? undefined) + const signed = await root.signKeyAuthorization( + { + accessKeyAddress: parameters.accessKeyAddress, + keyType: carried.keyType ?? 'p256', + }, + { + chainId: parameters.chainId ?? BigInt(chain.id), + ...(expiry !== undefined && expiry !== null ? { expiry } : {}), + limits: parameters.limits, + }, + ) + return { keyAuthorization: KeyAuthorization.toRpc(signed) } + } + + const client = getClient({ account: root }) + for (const item of parameters.limits) + await Actions.accessKey.updateLimitSync(client, { + accessKey: parameters.accessKeyAddress, + limit: item.limit, + token: item.token, + }) + return {} +}