├── .env.example ├── .gitattributes ├── .github ├── cover.svg └── workflows │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── biome.json ├── client ├── .env.example ├── .gitignore ├── index.html ├── package.json ├── public │ └── exp-0003.png ├── src │ ├── App.tsx │ ├── config.ts │ ├── constants.ts │ ├── contracts.ts │ ├── hooks.ts │ ├── main.css │ ├── main.tsx │ ├── utilities.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── wrangler.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── server ├── .dev.vars.example ├── env.d.ts ├── package.json ├── schema.sql ├── scripts │ └── d1-gui.sh ├── src │ ├── calls.ts │ ├── config.ts │ ├── contracts.ts │ ├── debug.ts │ ├── keys.ts │ ├── main.ts │ ├── types.ts │ └── workflow.ts ├── tsconfig.json ├── worker-configuration.d.ts └── wrangler.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | CLOUDFLARE_ACCOUNT_ID="" 2 | CLOUDFLARE_API_TOKEN="" 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | .gitattributes !filter !diff 3 | 4 | .dev.vars linguist-language=Dotenv 5 | .env.example linguist-language=Dotenv 6 | .dev.vars.example linguist-language=Dotenv 7 | 8 | wrangler.json linguist-language=JSON-with-Comments 9 | biome.json linguist-language=JSON-with-Comments 10 | .vscode/*.json linguist-language=JSON-with-Comments 11 | tsconfig.json linguist-language=JSON-with-Comments 12 | tsconfig.*.json linguist-language=JSON-with-Comments -------------------------------------------------------------------------------- /.github/cover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | env: 13 | ACTIONS_RUNNER_DEBUG: true 14 | 15 | jobs: 16 | deploy: 17 | timeout-minutes: 3 18 | runs-on: ['ubuntu-latest'] 19 | steps: 20 | - name: '🔑 Checkout' 21 | uses: actions/checkout@v4 22 | 23 | - name: 'Setup pnpm' 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: 'Setup Node.js' 27 | uses: actions/setup-node@v4 28 | with: 29 | cache: 'pnpm' 30 | node-version: 'lts/*' 31 | 32 | - name: 'Install Dependencies' 33 | run: pnpm install --frozen-lockfile 34 | 35 | - name: 'Lint, Check, Build' 36 | run: | 37 | pnpm dlx @biomejs/biome check . --reporter='github' 38 | pnpm build 39 | pnpm typecheck 40 | 41 | - name: '🔸 Cloudflare Workers - Deploy Server' 42 | working-directory: 'server' 43 | env: 44 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 45 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 46 | run: | 47 | pnpm dlx wrangler@latest deploy \ 48 | --config='wrangler.json' \ 49 | --keep-vars \ 50 | --var 'ENVIRONMENT:production' 51 | 52 | - name: '🔸 Cloudflare Workers - Deploy Client' 53 | working-directory: 'client' 54 | env: 55 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 56 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 57 | run: | 58 | pnpm build 59 | 60 | pnpm dlx wrangler@latest deploy \ 61 | --config='wrangler.json' \ 62 | --keep-vars \ 63 | --var 'NODE_ENV:production' \ 64 | --var 'ENVIRONMENT:production' \ 65 | --var 'VITE_ENVIRONMENT:production' \ 66 | --var 'VITE_SERVER_URL:https://exp-0003-server.evm.workers.dev' 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | anvil.dump.json 25 | _ 26 | .dev.vars 27 | !.dev.vars.example 28 | .env 29 | !.env.example 30 | 31 | .wrangler -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | link-workspace-packages=deep 3 | strict-peer-dependencies=false 4 | node-options='--disable-warning=ExperimentalWarning' 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "biomejs.biome", 4 | "tobermory.es6-string-html", 5 | "yzhang.markdown-all-in-one" 6 | ], 7 | "unwantedRecommendations": [ 8 | "esbenp.prettier-vscode", 9 | "dbaeumer.vscode-eslint" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "typescript.tsdk": "node_modules/typescript/lib", 5 | "typescript.enablePromptUseWorkspaceTsdk": true, 6 | "typescript.preferences.preferTypeOnlyAutoImports": true, 7 | "javascript.preferences.autoImportFileExcludePatterns": [ 8 | "**/index.js" 9 | ], 10 | "typescript.preferences.autoImportFileExcludePatterns": [ 11 | "**/index.js" 12 | ], 13 | "[json]": { 14 | "editor.defaultFormatter": "biomejs.biome" 15 | }, 16 | "[jsonc]": { 17 | "editor.defaultFormatter": "biomejs.biome" 18 | }, 19 | "[javascript]": { 20 | "editor.defaultFormatter": "biomejs.biome" 21 | }, 22 | "[javascriptreact]": { 23 | "editor.defaultFormatter": "biomejs.biome" 24 | }, 25 | "[typescript]": { 26 | "editor.defaultFormatter": "biomejs.biome" 27 | }, 28 | "[typescriptreact]": { 29 | "editor.defaultFormatter": "biomejs.biome" 30 | }, 31 | "[markdown]": { 32 | "editor.defaultFormatter": "yzhang.markdown-all-in-one" 33 | }, 34 | "[html]": { 35 | "editor.defaultFormatter": "vscode.html-language-features" 36 | }, 37 | "files.exclude": { 38 | "**/dist": true, 39 | "**/node_modules": true 40 | }, 41 | "files.associations": { 42 | ".env.*": "dotenv", 43 | ".dev.vars": "dotenv", 44 | "biome.json": "jsonc", 45 | "wrangler.json": "jsonc", 46 | ".vscode/*.json": "jsonc", 47 | ".dev.vars.example": "dotenv" 48 | }, 49 | "search.exclude": { 50 | "**/dist": true, 51 | "pnpm-lock.yaml": true, 52 | "**/node_modules": true 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ithaca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EXP-0003: Application Subscriptions 2 | 3 | ![Cover](./.github/cover.svg) 4 | 5 | [Read the Blog Post](https://ithaca.xyz/updates/exp-0003) 6 | 7 | ## Overview 8 | 9 |
10 | Sequence Diagram 11 | 12 | ```mermaid 13 | sequenceDiagram 14 | autonumber 15 | participant C as Client 16 | participant S as Server 17 | participant P as Porto 18 | 19 | C ->> S: GET /keys/:address?expiry&expiry= 20 | S ->> S: keyPair = P256.randomKeyPair() 21 | note right of S: Server encrypts and saves privateKey 22 | S -->> C: { type: 'p256', publicKey } 23 | C ->> P: experimental_grantPermissions(permissions) 24 | C -) S: POST /schedule
{ address, action: "mint", "schedule": "*****" } 25 | loop CRON 26 | S ->> P: { digest, request } = wallet_prepareCalls(calls) 27 | S ->> S: signature = P256.sign(digest, key) 28 | S ->> P: hash = wallet_sendPreparedCalls(request, signature) 29 | end 30 | ``` 31 | 32 |
33 | 34 | ### Live demo 35 | 36 | - exp-0003-client.evm.workers.dev - Client 37 | - exp-0003-server.evm.workers.dev - Server 38 | 39 | ### Keywords 40 | 41 | - Client: frontend application running in the browser, 42 | - Server: handles key generation, preparing and sending calls, scheduling and managing CRON jobs. 43 | 44 | ## Getting Started 45 | 46 | ### Prerequisites 47 | 48 | ```shell 49 | # install / update pnpm 50 | npm install --global pnpm@latest 51 | # install dependencies 52 | pnpm install 53 | ``` 54 | 55 | Setup environment variables 56 | 57 | ```shell 58 | # replace values with your own cloudflare account id and API token 59 | cp .env.example .env 60 | ``` 61 | 62 | ### Setup Server database (Cloudflare D1) 63 | 64 | ```shell 65 | # create database (this will fail if the database already exists) 66 | pnpm --filter='server' db:create 67 | # bootstrap the existing database. for local development 68 | pnpm --filter='server' db:bootstrap 69 | # bootstrap the remote database 70 | pnpm --filter='server' db:bootstrap:remote 71 | ``` 72 | 73 | ### Start dev for worker and client 74 | 75 | ```shell 76 | pnpm --filter='server' --filter='client' dev 77 | ``` 78 | 79 | ## Deploying 80 | 81 | ### Requirements 82 | 83 | - a Cloudflare account 84 | - `wrangler` CLI: `pnpm add --global wrangler@latest`, 85 | - authenticate with `wrangler login` 86 | 87 | ### Deploy client 88 | 89 | ```shell 90 | cd client 91 | pnpm build 92 | 93 | pnpm wrangler deploy --config='wrangler.json' 94 | ``` 95 | 96 | ### Deploy worker 97 | 98 | ```shell 99 | cd server 100 | 101 | pnpm wrangler deploy --config='wrangler.json' 102 | ``` 103 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignore": [ 10 | "_" 11 | ], 12 | "ignoreUnknown": true 13 | }, 14 | "organizeImports": { 15 | "enabled": false 16 | }, 17 | "formatter": { 18 | "enabled": true, 19 | "indentWidth": 2, 20 | "indentStyle": "space" 21 | }, 22 | "linter": { 23 | "enabled": true, 24 | "rules": { 25 | "recommended": true, 26 | "complexity": { 27 | "noBannedTypes": "off", 28 | "noStaticOnlyClass": "off", 29 | "noUselessConstructor": "off" 30 | }, 31 | "performance": { 32 | "noDelete": "off" 33 | }, 34 | "style": { 35 | "noVar": "off", 36 | "useTemplate": "off", 37 | "noNonNullAssertion": "off", 38 | "useNamingConvention": "off", 39 | "noUnusedTemplateLiteral": "off" 40 | }, 41 | "suspicious": { 42 | "noExplicitAny": "off", 43 | "noEmptyInterface": "off", 44 | "noConfusingVoidType": "off", 45 | "noAssignInExpressions": "off" 46 | } 47 | } 48 | }, 49 | "json": { 50 | "formatter": { 51 | "enabled": true, 52 | "lineWidth": 1 53 | }, 54 | "parser": { 55 | "allowComments": true, 56 | "allowTrailingCommas": true 57 | }, 58 | "linter": { 59 | "enabled": true 60 | } 61 | }, 62 | "javascript": { 63 | "formatter": { 64 | "quoteStyle": "single", 65 | "trailingCommas": "all", 66 | "semicolons": "asNeeded" 67 | } 68 | }, 69 | "overrides": [ 70 | { 71 | "include": [ 72 | "tsconfig.json", 73 | "tsconfig.*.json" 74 | ], 75 | "json": { 76 | "parser": { 77 | "allowComments": true, 78 | "allowTrailingCommas": true 79 | } 80 | } 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | VITE_DISABLE_DIALOG=false 2 | VITE_ENVIRONMENT="development" 3 | VITE_DIALOG_HOST="https://exp.porto.sh/dialog" 4 | VITE_SERVER_URL="http://localhost:6900" 5 | 6 | CLOUDFLARE_API_TOKEN="" 7 | CLOUDFLARE_ACCOUNT_ID="" -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | anvil.dump.json -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | EXP-0003 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 |

EXP-0003

37 |

Application Subscriptions

38 |
39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "type": "module", 5 | "imports": { 6 | "#*": "./src/*", 7 | "#package.json": "./package.json", 8 | "#wrangler.json": "./wrangler.json" 9 | }, 10 | "scripts": { 11 | "dev": "vite --port 6901 --open", 12 | "build": "vite build", 13 | "deploy": "vite build && wrangler --config='wrangler.json' deploy", 14 | "typecheck": "tsc --noEmit --project tsconfig.json" 15 | }, 16 | "dependencies": { 17 | "@tanstack/react-query": "^5.75.1", 18 | "ox": "catalog:", 19 | "porto": "catalog:", 20 | "react": "19.1.0", 21 | "react-dom": "19.1.0", 22 | "wagmi": "catalog:" 23 | }, 24 | "devDependencies": { 25 | "@tanstack/react-query-devtools": "^5.75.1", 26 | "@types/node": "catalog:", 27 | "@types/react": "19.1.2", 28 | "@types/react-dom": "19.1.3", 29 | "@vitejs/plugin-react": "4.4.1", 30 | "globals": "^16.0.0", 31 | "typed-query-selector": "^2.12.0", 32 | "typescript": "catalog:", 33 | "vite": "^6.2.7", 34 | "wrangler": "catalog:" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/public/exp-0003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ithacaxyz/exp-0003/394324cbb3c8aeb04b7ce5e4a714c594c580c71c/client/public/exp-0003.png -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useChainId, 3 | useConnect, 4 | useAccount, 5 | useSendCalls, 6 | useDisconnect, 7 | useConnectors, 8 | useCallsStatus, 9 | } from 'wagmi' 10 | import * as React from 'react' 11 | import { Hooks } from 'porto/wagmi' 12 | import { useMutation } from '@tanstack/react-query' 13 | import { type Errors, type Hex, Json, Value } from 'ox' 14 | 15 | import { 16 | useDebug, 17 | useBalance, 18 | nukeEverything, 19 | useNukeEverything, 20 | } from '#hooks.ts' 21 | import { exp1Config } from '#contracts.ts' 22 | import { porto, wagmiConfig } from '#config.ts' 23 | import { StringFormatter } from '#utilities.ts' 24 | import { SERVER_URL, permissions } from '#constants.ts' 25 | 26 | export function App() { 27 | useNukeEverything() 28 | const balance = useBalance() 29 | 30 | return ( 31 |
32 | 33 |
34 |
35 | State 36 | 37 |
38 |
39 | 40 | Events 41 | 42 | 43 |
44 |
45 | 46 |
47 | 48 |
49 | 50 |
51 | {balance ? ( 52 | 53 | ) : ( 54 |
55 | 56 |
57 | )} 58 |
59 | 60 |
61 | ) 62 | } 63 | 64 | function DebugLink() { 65 | const { address } = useAccount() 66 | const connectors = useConnectors() 67 | const disconnect = useDisconnect() 68 | 69 | const searchParams = new URLSearchParams({ 70 | pretty: 'true', 71 | ...(address ? { address } : {}), 72 | }) 73 | 74 | return ( 75 |
86 | 102 | DEBUG 103 | 104 | 128 |
129 | ) 130 | } 131 | 132 | function Connect() { 133 | const chainId = useChainId() 134 | const label = `_exp-0003-${Math.floor(Date.now() / 1_000)}` 135 | const [grantPermissions, setGrantPermissions] = React.useState(true) 136 | 137 | const { address } = useAccount() 138 | 139 | const connect = useConnect() 140 | const disconnect = useDisconnect() 141 | 142 | const [connector] = connect.connectors 143 | 144 | const allPermissions_ = Hooks.usePermissions() 145 | const latestPermissions = allPermissions_.data?.at(-1) 146 | 147 | const disconnectFromAll = async () => { 148 | await Promise.all( 149 | connect.connectors.map((c) => c.disconnect().catch(() => {})), 150 | ) 151 | await disconnect.disconnectAsync({ connector }) 152 | } 153 | 154 | const balance = useBalance() 155 | 156 | return ( 157 |
158 |
166 |

[client] wallet_connect

| 167 |

{connect.status}

168 |
169 |

170 | setGrantPermissions((x) => !x)} 174 | /> 175 | Grant Permissions 176 |

177 | 178 | {connector && ( 179 |
180 | 200 | 220 | 231 |
232 | )} 233 |

{connect.error?.message}

234 | {address &&

Account: {address}

} 235 | 236 |

Balance: {balance ?? 0}

237 | 238 | {address && latestPermissions && ( 239 |
243 | 244 | Permissions: 245 | {StringFormatter.truncateHexString({ 246 | address: latestPermissions?.key.publicKey, 247 | length: 12, 248 | })} 249 | 250 |
{Json.stringify(latestPermissions, undefined, 2)}
251 |
252 | )} 253 |
254 | ) 255 | } 256 | 257 | interface Key { 258 | type: 'p256' 259 | expiry: number 260 | publicKey: Hex.Hex 261 | role: 'session' | 'admin' 262 | } 263 | 264 | function RequestKey() { 265 | const chainId = useChainId() 266 | const { address } = useAccount() 267 | 268 | // const { refetch } = useDebug({ enabled: !!address, address }) 269 | 270 | const requestKeyMutation = useMutation({ 271 | mutationFn: async () => { 272 | if (!address) return 273 | const searchParams = new URLSearchParams({ 274 | expiry: permissions({ chainId }).expiry.toString(), 275 | }) 276 | const response = await fetch( 277 | `${SERVER_URL}/keys/${address.toLowerCase()}?${searchParams.toString()}`, 278 | ) 279 | const result = await Json.parse(await response.text()) 280 | await wagmiConfig.storage?.setItem( 281 | `${address.toLowerCase()}-keys`, 282 | Json.stringify(result), 283 | ) 284 | return result 285 | }, 286 | }) 287 | 288 | return ( 289 |
290 |

[server] Request Key from Server (GET /keys/:address)

291 | 300 | {requestKeyMutation.data ? ( 301 |
302 | 303 | {StringFormatter.truncateHexString({ 304 | address: requestKeyMutation.data?.publicKey, 305 | length: 12, 306 | })}{' '} 307 | - expires:{' '} 308 | {new Date(requestKeyMutation.data.expiry * 1_000).toLocaleString()}{' '} 309 | (local time) 310 | 311 |
{Json.stringify(requestKeyMutation.data, undefined, 2)}
312 |
313 | ) : null} 314 |
315 | ) 316 | } 317 | 318 | function GrantPermissions() { 319 | const chainId = useChainId() 320 | const { address } = useAccount() 321 | const grantPermissions = Hooks.useGrantPermissions() 322 | return ( 323 |
324 |

[client] Grant Permissions to Server (grantPermissions)

325 |
{ 327 | event.preventDefault() 328 | if (!address) return 329 | 330 | const key = Json.parse( 331 | (await wagmiConfig.storage?.getItem( 332 | `${address.toLowerCase()}-keys`, 333 | )) || '{}', 334 | ) as Key 335 | 336 | // if `expry` is present in both `key` and `permissions`, pick the lower value 337 | const expiry = Math.min(key.expiry, permissions({ chainId }).expiry) 338 | 339 | grantPermissions.mutate({ 340 | key, 341 | expiry, 342 | address, 343 | permissions: permissions({ chainId }).permissions, 344 | }) 345 | }} 346 | > 347 | 356 | {grantPermissions.status === 'error' && ( 357 |

{grantPermissions.error?.message}

358 | )} 359 |
360 | {grantPermissions.data ? ( 361 |
362 | 363 | Permissions:{' '} 364 | {StringFormatter.truncateHexString({ 365 | address: grantPermissions.data?.key.publicKey, 366 | length: 12, 367 | })} 368 | 369 |
{Json.stringify(grantPermissions.data, undefined, 2)}
370 |
371 | ) : null} 372 |
373 | ) 374 | } 375 | 376 | function Fund() { 377 | const chainId = useChainId() 378 | const { address, chain } = useAccount() 379 | const { data, error, isPending, sendCalls } = useSendCalls() 380 | const { 381 | data: txHashData, 382 | isLoading: isConfirming, 383 | isSuccess: isConfirmed, 384 | } = useCallsStatus({ 385 | id: data?.id as unknown as string, 386 | query: { 387 | enabled: !!data?.id, 388 | refetchInterval: ({ state }) => { 389 | if (state.data?.status === 'success') return false 390 | return 1_000 391 | }, 392 | }, 393 | }) 394 | 395 | const blockExplorer = chain?.blockExplorers?.default?.url 396 | const transactionLink = (hash: string) => 397 | blockExplorer ? `${blockExplorer}/tx/${hash}` : hash 398 | 399 | const balance = useBalance() 400 | 401 | const [transactions, setTransactions] = React.useState>(new Set()) 402 | React.useEffect(() => { 403 | if (!txHashData?.id) return 404 | const hash = txHashData.receipts?.at(0)?.transactionHash 405 | if (!hash) return 406 | setTransactions((prev) => new Set([...prev, hash])) 407 | }, [txHashData?.id, txHashData?.receipts]) 408 | 409 | return ( 410 |
411 |

[client] Get Funded to Mint [balance: {balance}]

412 |
{ 414 | event.preventDefault() 415 | sendCalls({ 416 | calls: [ 417 | { 418 | functionName: 'mint', 419 | abi: exp1Config.abi, 420 | to: exp1Config.address[chainId], 421 | args: [address!, Value.fromEther('100')], 422 | }, 423 | ], 424 | }) 425 | }} 426 | > 427 | 434 |
435 | 451 |

{isConfirming && 'Waiting for confirmation...'}

452 |

{isConfirmed && 'Transaction confirmed.'}

453 | {error && ( 454 |
455 | Error: {(error as Errors.BaseError).shortMessage || error.message} 456 |
457 | )} 458 |
459 | ) 460 | } 461 | 462 | function Mint() { 463 | const chainId = useChainId() 464 | const { address, chain } = useAccount() 465 | const { data, error, isPending, sendCalls } = useSendCalls() 466 | const { 467 | data: txHashData, 468 | isLoading: isConfirming, 469 | isSuccess: isConfirmed, 470 | } = useCallsStatus({ 471 | id: data?.id as unknown as string, 472 | query: { 473 | enabled: !!data?.id, 474 | refetchInterval: ({ state }) => { 475 | if (state.data?.status === 'success') return false 476 | return 1_000 477 | }, 478 | }, 479 | }) 480 | 481 | const blockExplorer = chain?.blockExplorers?.default?.url 482 | const transactionLink = (hash: string) => 483 | blockExplorer ? `${blockExplorer}/tx/${hash}` : hash 484 | 485 | const balance = useBalance() 486 | 487 | const [transactions, setTransactions] = React.useState>(new Set()) 488 | React.useEffect(() => { 489 | if (!txHashData?.id) return 490 | const hash = txHashData.receipts?.at(0)?.transactionHash 491 | if (!hash) return 492 | setTransactions((prev) => new Set([...prev, hash])) 493 | }, [txHashData?.id, txHashData?.receipts]) 494 | 495 | return ( 496 |
497 |

[client] Mint EXP [balance: {balance}]

498 |
{ 500 | event.preventDefault() 501 | sendCalls({ 502 | calls: [ 503 | { 504 | functionName: 'mint', 505 | abi: exp1Config.abi, 506 | to: exp1Config.address[chainId], 507 | args: [address!, Value.fromEther('100')], 508 | }, 509 | ], 510 | }) 511 | }} 512 | > 513 | 520 |
521 |
    522 | {Array.from(transactions).map((tx) => ( 523 |
  • 524 | 529 | {tx} 530 | 531 |
  • 532 | ))} 533 |
534 |

{isConfirming && 'Waiting for confirmation...'}

535 |

{isConfirmed && 'Transaction confirmed.'}

536 | {error && ( 537 |
538 | Error: {(error as Errors.BaseError).shortMessage || error.message} 539 |
540 | )} 541 |
542 | ) 543 | } 544 | 545 | function DemoScheduler() { 546 | const chainId = useChainId() 547 | const { address } = useAccount() 548 | const { data: debugData } = useDebug({ address, enabled: !!address }) 549 | 550 | const scheduleTransactionMutation = useMutation({ 551 | mutationFn: async ({ 552 | count = 6, 553 | action, 554 | schedule, 555 | }: { 556 | count?: number 557 | action: string 558 | schedule: string 559 | }) => { 560 | if (!address) return 561 | 562 | const { expiry } = permissions({ chainId }) 563 | 564 | if (expiry < Math.floor(Date.now() / 1_000)) { 565 | throw new Error('Key expired') 566 | } 567 | 568 | const searchParams = new URLSearchParams({ 569 | address: address.toLowerCase(), 570 | }) 571 | const url = `${SERVER_URL}/schedule?${searchParams.toString()}` 572 | const response = await fetch(url, { 573 | method: 'POST', 574 | body: Json.stringify({ action, schedule }), 575 | }) 576 | 577 | return { ...Json.parse(await response.text()), count } 578 | }, 579 | onSuccess: (data) => { 580 | console.info('scheduleTransactionMutation onSuccess', data) 581 | startWorkflowMutation.mutate({ count: data.count }) 582 | }, 583 | }) 584 | 585 | const startWorkflowMutation = useMutation({ 586 | mutationFn: async ({ count }: { count: number }) => { 587 | if (!address) return 588 | console.info('startWorkflowMutation', count) 589 | 590 | const response = await fetch( 591 | `${SERVER_URL}/workflow/${address.toLowerCase()}?count=${count}`, 592 | { method: 'POST' }, 593 | ) 594 | return Json.parse(await response.text()) 595 | }, 596 | }) 597 | 598 | const isPending = 599 | scheduleTransactionMutation.status === 'pending' || 600 | startWorkflowMutation.status === 'pending' 601 | 602 | const error = scheduleTransactionMutation.error || startWorkflowMutation.error 603 | 604 | return ( 605 |
606 |
607 |

[server] Schedule Transactions

608 |

609 | | active schedules: {debugData?.schedules?.length || 0} | 610 |

611 | {startWorkflowMutation.status !== 'idle' && ( 612 | 621 | {startWorkflowMutation.status} 622 | 623 | )} 624 |
625 |

626 | (wallet_prepareCalls {'->'} wallet_sendPreparedCalls) 627 |

628 |
{ 630 | event.preventDefault() 631 | 632 | const formData = new FormData(event.target as HTMLFormElement) 633 | 634 | const action = 635 | (formData.get('action') as string) ?? 'approve-transfer' 636 | const count = Number(formData.get('count') as string) || 6 637 | 638 | const schedule = '*/10 * * * * *' 639 | 640 | scheduleTransactionMutation.mutate({ action, schedule, count }) 641 | }} 642 | > 643 |

Approve & Transfer 1 EXP

644 |

once every 10 seconds

645 |
653 | 654 | Total Transactions 655 | 656 | 665 | 666 | 673 |
674 |
675 | {error && ( 676 |
677 |           {error instanceof Error
678 |             ? error.message
679 |             : Json.stringify(error, null, 2)}
680 |           
681 | Try again in a few seconds 682 |
683 | )} 684 |
    685 | {debugData 686 | ? debugData?.transactions?.toReversed()?.map((transaction) => { 687 | return ( 688 |
  • 689 |

    690 | 🔑 PUBLIC KEY:{' '} 691 | {StringFormatter.truncateHexString({ 692 | address: transaction.public_key, 693 | length: 6, 694 | })}{' '} 695 | | TYPE: {transaction.role} 696 |

    697 | 🔗 TX HASH: 698 | 699 |
  • 700 | ) 701 | }) 702 | : null} 703 |
704 |
705 | ) 706 | } 707 | 708 | function TxHash({ id }: { id: string }) { 709 | const { chain } = useAccount() 710 | const callStatus = useCallsStatus({ id }) 711 | 712 | const hash = callStatus.data?.receipts?.at(0)?.transactionHash 713 | if (!hash || callStatus.status === 'pending') return pending... 714 | 715 | const blockExplorer = chain?.blockExplorers?.default?.url 716 | const transactionLink = (hash: string) => 717 | blockExplorer ? `${blockExplorer}/tx/${hash}` : hash 718 | return ( 719 | 720 | {StringFormatter.truncateHexString({ 721 | length: 12, 722 | address: hash, 723 | })} 724 | 725 | ) 726 | } 727 | 728 | function State() { 729 | const state = React.useSyncExternalStore( 730 | // @ts-ignore 731 | porto._internal.store.subscribe, 732 | // @ts-ignore 733 | () => porto._internal.store.getState(), 734 | // @ts-ignore 735 | () => porto._internal.store.getState(), 736 | ) 737 | 738 | return ( 739 |
740 |

State

741 | {state.accounts.length === 0 ? ( 742 |

Disconnected

743 | ) : ( 744 | <> 745 |

Address: {state?.accounts?.[0]?.address}

746 |

Chain ID: {state?.chainId}

747 |
748 | Keys:{' '} 749 |
{Json.stringify(state.accounts?.[0]?.keys, null, 2)}
750 |
751 | 752 | )} 753 |
754 | ) 755 | } 756 | 757 | function Events() { 758 | const [responses, setResponses] = React.useState>({}) 759 | 760 | React.useEffect(() => { 761 | const handleResponse = (event: string) => (response: unknown) => 762 | setResponses((responses) => ({ 763 | ...responses, 764 | [event]: response, 765 | })) 766 | 767 | const handleAccountsChanged = handleResponse('accountsChanged') 768 | const handleChainChanged = handleResponse('chainChanged') 769 | const handleConnect = handleResponse('connect') 770 | const handleDisconnect = handleResponse('disconnect') 771 | const handleMessage = handleResponse('message') 772 | 773 | porto.provider.on('accountsChanged', handleAccountsChanged) 774 | porto.provider.on('chainChanged', handleChainChanged) 775 | porto.provider.on('connect', handleConnect) 776 | porto.provider.on('disconnect', handleDisconnect) 777 | porto.provider.on('message', handleMessage) 778 | return () => { 779 | porto.provider.removeListener('accountsChanged', handleAccountsChanged) 780 | porto.provider.removeListener('chainChanged', handleChainChanged) 781 | porto.provider.removeListener('connect', handleConnect) 782 | porto.provider.removeListener('disconnect', handleDisconnect) 783 | porto.provider.removeListener('message', handleMessage) 784 | } 785 | }, []) 786 | return ( 787 |
788 |

Events

789 |
{Json.stringify(responses, null, 2)}
790 |
791 | ) 792 | } 793 | -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- 1 | import { Porto } from 'porto' 2 | import { baseSepolia } from 'porto/Chains' 3 | import { porto as portoConnector } from 'porto/wagmi' 4 | import { http, createConfig, createStorage } from 'wagmi' 5 | import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' 6 | 7 | export const porto = Porto.create() 8 | 9 | export const wagmiConfig = createConfig({ 10 | chains: [baseSepolia], 11 | connectors: [portoConnector()], 12 | multiInjectedProviderDiscovery: false, 13 | storage: createStorage({ storage: window.localStorage }), 14 | transports: { 15 | [baseSepolia.id]: http(), 16 | }, 17 | }) 18 | 19 | export const queryClient: QueryClient = new QueryClient({ 20 | defaultOptions: { 21 | queries: { 22 | gcTime: 1_000 * 60 * 60, // 1 hour 23 | refetchOnReconnect: () => !queryClient.isMutating(), 24 | }, 25 | }, 26 | /** 27 | * https://tkdodo.eu/blog/react-query-error-handling#putting-it-all-together 28 | * note: only runs in development mode. Production unaffected. 29 | */ 30 | queryCache: new QueryCache({ 31 | onError: (error, query) => { 32 | if (import.meta.env.MODE !== 'development') return 33 | if (query.state.data !== undefined) { 34 | console.error(error) 35 | } 36 | }, 37 | }), 38 | mutationCache: new MutationCache({ 39 | onSettled: () => { 40 | if (queryClient.isMutating() === 1) { 41 | return queryClient.invalidateQueries() 42 | } 43 | }, 44 | }), 45 | }) 46 | 47 | declare module 'wagmi' { 48 | interface Register { 49 | config: typeof wagmiConfig 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/src/constants.ts: -------------------------------------------------------------------------------- 1 | import { Hex, Value } from 'ox' 2 | 3 | import { exp1Config } from '#contracts.ts' 4 | 5 | export const SERVER_URL = import.meta.env.DEV 6 | ? 'http://localhost:6900' 7 | : 'https://exp-0003-server.evm.workers.dev' 8 | 9 | export const permissions = ({ chainId }: { chainId: number }) => 10 | ({ 11 | expiry: Math.floor(Date.now() / 1_000) + 60 * 60 * 24 * 30, // 1 month 12 | permissions: { 13 | calls: [ 14 | { 15 | signature: 'approve(address,uint256)', 16 | to: exp1Config.address[chainId as keyof typeof exp1Config.address], 17 | }, 18 | { 19 | signature: 'transfer(address,uint256)', 20 | to: exp1Config.address[chainId as keyof typeof exp1Config.address], 21 | }, 22 | ], 23 | spend: [ 24 | { 25 | period: 'minute', 26 | limit: Hex.fromNumber(Value.fromEther('1000')), 27 | token: exp1Config.address[chainId as keyof typeof exp1Config.address], 28 | }, 29 | ], 30 | }, 31 | }) as const 32 | -------------------------------------------------------------------------------- /client/src/contracts.ts: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 2 | // exp1 3 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 4 | 5 | /** 6 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0x074C9c3273F31651a9dae896C1A1d68E868b6998) 7 | * - 8 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x29f45fc3ed1d0ffafb5e2af9cc6c3ab1555cd5a2) 9 | */ 10 | 11 | export const exp1Abi = [ 12 | { 13 | inputs: [ 14 | { internalType: 'string', name: 'name_', type: 'string' }, 15 | { internalType: 'string', name: 'symbol_', type: 'string' }, 16 | { internalType: 'uint256', name: 'scalar_', type: 'uint256' }, 17 | ], 18 | stateMutability: 'nonpayable', 19 | type: 'constructor', 20 | }, 21 | { stateMutability: 'payable', type: 'fallback' }, 22 | { stateMutability: 'payable', type: 'receive' }, 23 | { 24 | inputs: [], 25 | name: 'DOMAIN_SEPARATOR', 26 | outputs: [{ internalType: 'bytes32', name: 'result', type: 'bytes32' }], 27 | stateMutability: 'view', 28 | type: 'function', 29 | }, 30 | { 31 | inputs: [ 32 | { internalType: 'address', name: 'owner', type: 'address' }, 33 | { internalType: 'address', name: 'spender', type: 'address' }, 34 | ], 35 | name: 'allowance', 36 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 37 | stateMutability: 'view', 38 | type: 'function', 39 | }, 40 | { 41 | inputs: [ 42 | { internalType: 'address', name: 'spender', type: 'address' }, 43 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 44 | ], 45 | name: 'approve', 46 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 47 | stateMutability: 'nonpayable', 48 | type: 'function', 49 | }, 50 | { 51 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 52 | name: 'balanceOf', 53 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 54 | stateMutability: 'view', 55 | type: 'function', 56 | }, 57 | { 58 | inputs: [], 59 | name: 'decimals', 60 | outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], 61 | stateMutability: 'view', 62 | type: 'function', 63 | }, 64 | { 65 | inputs: [ 66 | { internalType: 'address', name: 'recipient', type: 'address' }, 67 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 68 | ], 69 | name: 'mint', 70 | outputs: [], 71 | stateMutability: 'nonpayable', 72 | type: 'function', 73 | }, 74 | { 75 | inputs: [], 76 | name: 'name', 77 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 78 | stateMutability: 'view', 79 | type: 'function', 80 | }, 81 | { 82 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 83 | name: 'nonces', 84 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 85 | stateMutability: 'view', 86 | type: 'function', 87 | }, 88 | { 89 | inputs: [ 90 | { internalType: 'address', name: 'owner', type: 'address' }, 91 | { internalType: 'address', name: 'spender', type: 'address' }, 92 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 93 | { internalType: 'uint256', name: 'deadline', type: 'uint256' }, 94 | { internalType: 'uint8', name: 'v', type: 'uint8' }, 95 | { internalType: 'bytes32', name: 'r', type: 'bytes32' }, 96 | { internalType: 'bytes32', name: 's', type: 'bytes32' }, 97 | ], 98 | name: 'permit', 99 | outputs: [], 100 | stateMutability: 'nonpayable', 101 | type: 'function', 102 | }, 103 | { 104 | inputs: [ 105 | { internalType: 'address', name: 'target', type: 'address' }, 106 | { internalType: 'address', name: 'recipient', type: 'address' }, 107 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 108 | ], 109 | name: 'swap', 110 | outputs: [], 111 | stateMutability: 'nonpayable', 112 | type: 'function', 113 | }, 114 | { 115 | inputs: [], 116 | name: 'symbol', 117 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 118 | stateMutability: 'view', 119 | type: 'function', 120 | }, 121 | { 122 | inputs: [], 123 | name: 'totalSupply', 124 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 125 | stateMutability: 'view', 126 | type: 'function', 127 | }, 128 | { 129 | inputs: [ 130 | { internalType: 'address', name: 'to', type: 'address' }, 131 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 132 | ], 133 | name: 'transfer', 134 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 135 | stateMutability: 'nonpayable', 136 | type: 'function', 137 | }, 138 | { 139 | inputs: [ 140 | { internalType: 'address', name: 'from', type: 'address' }, 141 | { internalType: 'address', name: 'to', type: 'address' }, 142 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 143 | ], 144 | name: 'transferFrom', 145 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 146 | stateMutability: 'nonpayable', 147 | type: 'function', 148 | }, 149 | { 150 | anonymous: false, 151 | inputs: [ 152 | { 153 | indexed: true, 154 | internalType: 'address', 155 | name: 'owner', 156 | type: 'address', 157 | }, 158 | { 159 | indexed: true, 160 | internalType: 'address', 161 | name: 'spender', 162 | type: 'address', 163 | }, 164 | { 165 | indexed: false, 166 | internalType: 'uint256', 167 | name: 'amount', 168 | type: 'uint256', 169 | }, 170 | ], 171 | name: 'Approval', 172 | type: 'event', 173 | }, 174 | { 175 | anonymous: false, 176 | inputs: [ 177 | { indexed: true, internalType: 'address', name: 'from', type: 'address' }, 178 | { indexed: true, internalType: 'address', name: 'to', type: 'address' }, 179 | { 180 | indexed: false, 181 | internalType: 'uint256', 182 | name: 'amount', 183 | type: 'uint256', 184 | }, 185 | ], 186 | name: 'Transfer', 187 | type: 'event', 188 | }, 189 | { inputs: [], name: 'AllowanceOverflow', type: 'error' }, 190 | { inputs: [], name: 'AllowanceUnderflow', type: 'error' }, 191 | { inputs: [], name: 'InsufficientAllowance', type: 'error' }, 192 | { inputs: [], name: 'InsufficientBalance', type: 'error' }, 193 | { inputs: [], name: 'InvalidPermit', type: 'error' }, 194 | { inputs: [], name: 'Permit2AllowanceIsFixedAtInfinity', type: 'error' }, 195 | { inputs: [], name: 'PermitExpired', type: 'error' }, 196 | { inputs: [], name: 'TotalSupplyOverflow', type: 'error' }, 197 | ] as const 198 | 199 | /** 200 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0x074C9c3273F31651a9dae896C1A1d68E868b6998) 201 | * - 202 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x29f45fc3ed1d0ffafb5e2af9cc6c3ab1555cd5a2) 203 | */ 204 | export const exp1Address = { 205 | 28404: '0x29F45fc3eD1d0ffaFb5e2af9Cc6C3AB1555cd5a2', 206 | 84532: '0x29F45fc3eD1d0ffaFb5e2af9Cc6C3AB1555cd5a2', 207 | } as const 208 | 209 | /** 210 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0x074C9c3273F31651a9dae896C1A1d68E868b6998) 211 | * - 212 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x29f45fc3ed1d0ffafb5e2af9cc6c3ab1555cd5a2) 213 | */ 214 | export const exp1Config = { abi: exp1Abi, address: exp1Address } as const 215 | 216 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 217 | // exp2 218 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 219 | 220 | /** 221 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0xFcc74F42621D03Fd234d5f40931D8B82923E4D29) 222 | * - 223 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x62a9d6de963a5590f6fba5119e937f167677bfe7) 224 | */ 225 | export const exp2Abi = [ 226 | { 227 | inputs: [ 228 | { internalType: 'string', name: 'name_', type: 'string' }, 229 | { internalType: 'string', name: 'symbol_', type: 'string' }, 230 | { internalType: 'uint256', name: 'scalar_', type: 'uint256' }, 231 | ], 232 | stateMutability: 'nonpayable', 233 | type: 'constructor', 234 | }, 235 | { stateMutability: 'payable', type: 'fallback' }, 236 | { stateMutability: 'payable', type: 'receive' }, 237 | { 238 | inputs: [], 239 | name: 'DOMAIN_SEPARATOR', 240 | outputs: [{ internalType: 'bytes32', name: 'result', type: 'bytes32' }], 241 | stateMutability: 'view', 242 | type: 'function', 243 | }, 244 | { 245 | inputs: [ 246 | { internalType: 'address', name: 'owner', type: 'address' }, 247 | { internalType: 'address', name: 'spender', type: 'address' }, 248 | ], 249 | name: 'allowance', 250 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 251 | stateMutability: 'view', 252 | type: 'function', 253 | }, 254 | { 255 | inputs: [ 256 | { internalType: 'address', name: 'spender', type: 'address' }, 257 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 258 | ], 259 | name: 'approve', 260 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 261 | stateMutability: 'nonpayable', 262 | type: 'function', 263 | }, 264 | { 265 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 266 | name: 'balanceOf', 267 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 268 | stateMutability: 'view', 269 | type: 'function', 270 | }, 271 | { 272 | inputs: [], 273 | name: 'decimals', 274 | outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], 275 | stateMutability: 'view', 276 | type: 'function', 277 | }, 278 | { 279 | inputs: [ 280 | { internalType: 'address', name: 'recipient', type: 'address' }, 281 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 282 | ], 283 | name: 'mint', 284 | outputs: [], 285 | stateMutability: 'nonpayable', 286 | type: 'function', 287 | }, 288 | { 289 | inputs: [], 290 | name: 'name', 291 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 292 | stateMutability: 'view', 293 | type: 'function', 294 | }, 295 | { 296 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 297 | name: 'nonces', 298 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 299 | stateMutability: 'view', 300 | type: 'function', 301 | }, 302 | { 303 | inputs: [ 304 | { internalType: 'address', name: 'owner', type: 'address' }, 305 | { internalType: 'address', name: 'spender', type: 'address' }, 306 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 307 | { internalType: 'uint256', name: 'deadline', type: 'uint256' }, 308 | { internalType: 'uint8', name: 'v', type: 'uint8' }, 309 | { internalType: 'bytes32', name: 'r', type: 'bytes32' }, 310 | { internalType: 'bytes32', name: 's', type: 'bytes32' }, 311 | ], 312 | name: 'permit', 313 | outputs: [], 314 | stateMutability: 'nonpayable', 315 | type: 'function', 316 | }, 317 | { 318 | inputs: [ 319 | { internalType: 'address', name: 'target', type: 'address' }, 320 | { internalType: 'address', name: 'recipient', type: 'address' }, 321 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 322 | ], 323 | name: 'swap', 324 | outputs: [], 325 | stateMutability: 'nonpayable', 326 | type: 'function', 327 | }, 328 | { 329 | inputs: [], 330 | name: 'symbol', 331 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 332 | stateMutability: 'view', 333 | type: 'function', 334 | }, 335 | { 336 | inputs: [], 337 | name: 'totalSupply', 338 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 339 | stateMutability: 'view', 340 | type: 'function', 341 | }, 342 | { 343 | inputs: [ 344 | { internalType: 'address', name: 'to', type: 'address' }, 345 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 346 | ], 347 | name: 'transfer', 348 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 349 | stateMutability: 'nonpayable', 350 | type: 'function', 351 | }, 352 | { 353 | inputs: [ 354 | { internalType: 'address', name: 'from', type: 'address' }, 355 | { internalType: 'address', name: 'to', type: 'address' }, 356 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 357 | ], 358 | name: 'transferFrom', 359 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 360 | stateMutability: 'nonpayable', 361 | type: 'function', 362 | }, 363 | { 364 | anonymous: false, 365 | inputs: [ 366 | { 367 | indexed: true, 368 | internalType: 'address', 369 | name: 'owner', 370 | type: 'address', 371 | }, 372 | { 373 | indexed: true, 374 | internalType: 'address', 375 | name: 'spender', 376 | type: 'address', 377 | }, 378 | { 379 | indexed: false, 380 | internalType: 'uint256', 381 | name: 'amount', 382 | type: 'uint256', 383 | }, 384 | ], 385 | name: 'Approval', 386 | type: 'event', 387 | }, 388 | { 389 | anonymous: false, 390 | inputs: [ 391 | { indexed: true, internalType: 'address', name: 'from', type: 'address' }, 392 | { indexed: true, internalType: 'address', name: 'to', type: 'address' }, 393 | { 394 | indexed: false, 395 | internalType: 'uint256', 396 | name: 'amount', 397 | type: 'uint256', 398 | }, 399 | ], 400 | name: 'Transfer', 401 | type: 'event', 402 | }, 403 | { inputs: [], name: 'AllowanceOverflow', type: 'error' }, 404 | { inputs: [], name: 'AllowanceUnderflow', type: 'error' }, 405 | { inputs: [], name: 'InsufficientAllowance', type: 'error' }, 406 | { inputs: [], name: 'InsufficientBalance', type: 'error' }, 407 | { inputs: [], name: 'InvalidPermit', type: 'error' }, 408 | { inputs: [], name: 'Permit2AllowanceIsFixedAtInfinity', type: 'error' }, 409 | { inputs: [], name: 'PermitExpired', type: 'error' }, 410 | { inputs: [], name: 'TotalSupplyOverflow', type: 'error' }, 411 | ] as const 412 | 413 | /** 414 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0xFcc74F42621D03Fd234d5f40931D8B82923E4D29) 415 | * - 416 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x62a9d6de963a5590f6fba5119e937f167677bfe7) 417 | */ 418 | export const exp2Address = { 419 | 28404: '0x502fF46e72C47b8c3183DB8919700377EED66d2E', 420 | 84532: '0x62a9d6DE963a5590f6fbA5119e937F167677bfE7', 421 | } as const 422 | 423 | /** 424 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0xFcc74F42621D03Fd234d5f40931D8B82923E4D29) 425 | * - 426 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x62a9d6de963a5590f6fba5119e937f167677bfe7) 427 | */ 428 | export const exp2Config = { abi: exp2Abi, address: exp2Address } as const 429 | -------------------------------------------------------------------------------- /client/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { queryClient } from './config.ts' 3 | import { SERVER_URL } from './constants.ts' 4 | import { exp1Config } from './contracts.ts' 5 | import { useQuery } from '@tanstack/react-query' 6 | import { Address, type Hex, Json, Value } from 'ox' 7 | import { useAccount, useChainId, useReadContract } from 'wagmi' 8 | 9 | export function useBalance() { 10 | const chainId = useChainId() 11 | const { address } = useAccount() 12 | const { data: balance } = useReadContract({ 13 | args: [address!], 14 | abi: exp1Config.abi, 15 | functionName: 'balanceOf', 16 | address: exp1Config.address[chainId], 17 | query: { enabled: !!address, refetchInterval: 2_000 }, 18 | }) 19 | 20 | return `${Number(Value.format(balance ?? 0n, 18)).toFixed(2)} EXP` 21 | } 22 | 23 | export interface DebugData { 24 | transactions: Array<{ 25 | id: number 26 | role: 'session' | 'admin' 27 | created_at: string 28 | address: Address.Address 29 | hash: Address.Address 30 | public_key: Hex.Hex 31 | }> 32 | key: { 33 | account: Address.Address 34 | privateKey: Address.Address 35 | } 36 | schedules: Array<{ 37 | id: number 38 | created_at: string 39 | address: Address.Address 40 | schedule: string 41 | action: string 42 | calls: string 43 | }> 44 | } 45 | 46 | export function useDebug({ 47 | address, 48 | enabled = false, 49 | }: { 50 | address?: Address.Address 51 | enabled?: boolean 52 | }) { 53 | const { address: _address = address } = useAccount() 54 | 55 | return useQuery({ 56 | queryKey: ['debug', address], 57 | refetchInterval: (_) => 5_000, 58 | enabled: !!address && Address.validate(address) && enabled, 59 | queryFn: async () => { 60 | const response = await fetch( 61 | `${SERVER_URL}/debug?address=${address?.toLowerCase()}`, 62 | ) 63 | const result = await Json.parse(await response.text()) 64 | return result as DebugData 65 | }, 66 | }) 67 | } 68 | 69 | export function useNukeEverything() { 70 | React.useEffect(() => { 71 | // on `d` press 72 | window.addEventListener('keydown', (event) => { 73 | if (event.key === 'd') nukeEverything() 74 | }) 75 | }, []) 76 | } 77 | 78 | export function nukeEverything() { 79 | if (import.meta.env.MODE !== 'development') return 80 | // clear everything 81 | return fetch(`${SERVER_URL}/debug/nuke/everything`) 82 | .then(() => { 83 | queryClient.clear() 84 | queryClient.resetQueries() 85 | queryClient.removeQueries() 86 | queryClient.invalidateQueries() 87 | queryClient.unmount() 88 | window.localStorage.clear() 89 | window.sessionStorage.clear() 90 | }) 91 | .catch(() => {}) 92 | } 93 | -------------------------------------------------------------------------------- /client/src/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: light dark; 3 | background-color: #111111; 4 | color: rgba(255, 255, 255, 0.87); 5 | } 6 | 7 | *, 8 | *::before, 9 | *::after { 10 | scrollbar-width: none; 11 | box-sizing: border-box; 12 | } 13 | 14 | html, 15 | body, 16 | #root { 17 | margin: 0; 18 | padding: 0; 19 | width: 100%; 20 | height: 100%; 21 | min-width: 100%; 22 | min-height: 100%; 23 | font-family: monospace; 24 | } 25 | 26 | main > *:not(hr) { 27 | padding: 0.3em 1.25em; 28 | } 29 | 30 | header { 31 | gap: 2rem; 32 | width: 100%; 33 | margin: auto; 34 | padding: 0.5em; 35 | & > h1 { 36 | margin: 0; 37 | font-size: 3rem; 38 | text-align: center; 39 | } 40 | & > h3 { 41 | margin: 0; 42 | font-size: 1.5rem; 43 | text-align: center; 44 | } 45 | } 46 | 47 | hr { 48 | border-color: hsla(0, 0%, 98%, 0.525); 49 | } 50 | 51 | *::selection { 52 | color: hsl(0, 0%, 0%); 53 | background-color: hsl(290, 100%, 90%); 54 | } 55 | 56 | @media (prefers-color-scheme: light) { 57 | :root { 58 | background-color: #f8f8f8; 59 | color: #181818; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import './main.css' 2 | import { App } from './App.tsx' 3 | import { StrictMode } from 'react' 4 | import { WagmiProvider } from 'wagmi' 5 | import { createRoot } from 'react-dom/client' 6 | import { queryClient, wagmiConfig } from './config.ts' 7 | import { QueryClientProvider } from '@tanstack/react-query' 8 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 9 | 10 | const root = document.querySelector('div#root') 11 | if (!root) throw new Error('Root not found') 12 | 13 | createRoot(root).render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | , 22 | ) 23 | -------------------------------------------------------------------------------- /client/src/utilities.ts: -------------------------------------------------------------------------------- 1 | export namespace ValueFormatter { 2 | const numberIntl = new Intl.NumberFormat('en-US', { 3 | maximumSignificantDigits: 6, 4 | }) 5 | 6 | export function format(num: number) { 7 | return numberIntl.format(num) 8 | } 9 | } 10 | 11 | export namespace StringFormatter { 12 | export function truncateHexString({ 13 | address, 14 | length = 6, 15 | }: { 16 | address: string 17 | length?: number 18 | }) { 19 | return length > 0 20 | ? `${address.slice(0, length)}...${address.slice(-length)}` 21 | : address 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_SERVER_URL: string 5 | readonly VITE_DIALOG_HOST: string 6 | readonly VITE_ENVIRONMENT: 'development' | 'production' 7 | } 8 | 9 | interface ImportMeta { 10 | readonly env: ImportMetaEnv 11 | } 12 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ESNext", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "types": [ 20 | "typed-query-selector/strict" 21 | ], 22 | /* Linting */ 23 | "strict": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "resolvePackageJsonExports": true, 28 | "resolvePackageJsonImports": true 29 | }, 30 | "include": [ 31 | "src" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "vite.config.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }) 7 | -------------------------------------------------------------------------------- /client/wrangler.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://esm.sh/wrangler/config-schema.json", 3 | "minify": true, 4 | "keep_vars": true, 5 | "workers_dev": true, 6 | "name": "exp-0003-client", 7 | "compatibility_date": "2025-05-27", 8 | "compatibility_flags": [ 9 | "nodejs_als", 10 | "nodejs_compat" 11 | ], 12 | "dev": { 13 | "port": 6901 14 | }, 15 | "placement": { 16 | "mode": "smart" 17 | }, 18 | "observability": { 19 | "enabled": true 20 | }, 21 | "assets": { 22 | "directory": "./dist" 23 | }, 24 | "vars": { 25 | "VITE_DISABLE_DIALOG": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exp-0003", 3 | "private": true, 4 | "type": "module", 5 | "repository": "github:ithacaxyz/exp-0003", 6 | "scripts": { 7 | "dev": "pnpm --filter='*' dev", 8 | "build": "pnpm --filter='*' build", 9 | "deploy": "pnpm --filter='*' deploy", 10 | "format": "biome format --write .", 11 | "lint": "biome lint --write .", 12 | "check": "biome check --write .", 13 | "typecheck": "pnpm --filter='*' typecheck", 14 | "up": "pnpm dlx taze@latest --recursive --update major --write" 15 | }, 16 | "devDependencies": { 17 | "@biomejs/biome": "^1.9.4", 18 | "@types/node": "catalog:", 19 | "tsx": "^4.19.4", 20 | "typescript": "catalog:", 21 | "wrangler": "catalog:" 22 | }, 23 | "packageManager": "pnpm@10.11.1", 24 | "license": "MIT" 25 | } 26 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - client 3 | - server 4 | catalog: 5 | ox: ^0.7.2 6 | porto: ^0.0.29 7 | wagmi: ^2.15.4 8 | wrangler: ^4.18.0 9 | typescript: ^5.8.3 10 | '@types/node': ^22.15.29 11 | -------------------------------------------------------------------------------- /server/.dev.vars.example: -------------------------------------------------------------------------------- 1 | PORT=6900 2 | ENVIRONMENT="development" 3 | 4 | ADMIN_USERNAME="admin" 5 | ADMIN_PASSWORD="admin" 6 | 7 | # 'verbose' / 'normal' / unset 8 | LOGGING="verbose" 9 | -------------------------------------------------------------------------------- /server/env.d.ts: -------------------------------------------------------------------------------- 1 | interface Environment { 2 | readonly PORT: string 3 | readonly ENVIRONMENT: 'development' | 'production' 4 | readonly ADMIN_USERNAME: string 5 | readonly ADMIN_PASSWORD: string 6 | readonly LOGGING: 'verbose' | 'normal' | '' 7 | } 8 | 9 | declare namespace NodeJS { 10 | interface ProcessEnv extends Environment {} 11 | } 12 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "private": true, 4 | "type": "module", 5 | "imports": { 6 | "#*": "./src/*", 7 | "#package.json": "./package.json", 8 | "#wrangler.json": "./wrangler.json" 9 | }, 10 | "scripts": { 11 | "dev": "wrangler --config='wrangler.json' dev", 12 | "preview": "wrangler --config='wrangler.json' dev --remote", 13 | "build": "wrangler --config='wrangler.json' deploy --outdir='dist' --keep-vars --var ENVIRONMENT:production --dry-run", 14 | "deploy": "wrangler deploy --config='wrangler.json' --var ENVIRONMENT:production", 15 | "db:create": "wrangler --config='wrangler.json' d1 create exp-0003", 16 | "db:delete": "wrangler --config='wrangler.json' d1 delete exp-0003 --skip-confirmation", 17 | "db:bootstrap": "wrangler --config='wrangler.json' d1 execute exp-0003 --file='schema.sql' --local", 18 | "db:bootstrap:remote": "wrangler --config='wrangler.json' d1 execute exp-0003 --file='schema.sql' --remote", 19 | "typecheck": "tsc --noEmit --project tsconfig.json" 20 | }, 21 | "dependencies": { 22 | "@hono/cloudflare-access": "^0.3.0", 23 | "hono": "^4.7.8", 24 | "ox": "catalog:", 25 | "porto": "catalog:", 26 | "viem": "^2.28.3" 27 | }, 28 | "devDependencies": { 29 | "@cloudflare/workers-types": "^4.20250601.0", 30 | "@types/node": "catalog:", 31 | "typescript": "catalog:", 32 | "wrangler": "catalog:" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/schema.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys = OFF; 2 | 3 | DROP TABLE IF EXISTS "keypairs"; 4 | DROP TABLE IF EXISTS "schedules"; 5 | DROP TABLE IF EXISTS "transactions"; 6 | 7 | CREATE TABLE IF NOT EXISTS "keypairs" ( 8 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 9 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | address TEXT UNIQUE NOT NULL, 11 | public_key TEXT NOT NULL, 12 | private_key TEXT NOT NULL, 13 | role TEXT NOT NULL, -- session or admin 14 | type TEXT NOT NULL, -- p256 15 | expiry INTEGER NOT NULL 16 | ); 17 | 18 | CREATE TABLE IF NOT EXISTS "transactions" ( 19 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 20 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 | address TEXT NOT NULL, 22 | hash TEXT NOT NULL, 23 | role TEXT NOT NULL, -- session or admin 24 | public_key TEXT NOT NULL 25 | ); 26 | 27 | CREATE TABLE IF NOT EXISTS "schedules" ( 28 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 29 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 30 | address TEXT NOT NULL, 31 | schedule TEXT NOT NULL, 32 | action TEXT NOT NULL, 33 | calls TEXT NOT NULL 34 | ); 35 | -------------------------------------------------------------------------------- /server/scripts/d1-gui.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eou pipefail 4 | 5 | # in case of multiple sqlite files, use the latest (most recent) one 6 | sqlite_path=$(/usr/bin/find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name "*.sqlite" -print | tail -n 1) 7 | 8 | pnpm dlx @outerbase/studio "$sqlite_path" 9 | -------------------------------------------------------------------------------- /server/src/calls.ts: -------------------------------------------------------------------------------- 1 | import { AbiFunction, type Address, Value } from 'ox' 2 | 3 | import { exp1Config } from '#contracts.ts' 4 | 5 | export const actions = ['mint', 'approve-transfer'] 6 | 7 | export function buildActionCall({ 8 | action, 9 | account, 10 | }: { 11 | action: string 12 | account: Address.Address 13 | }) { 14 | if (action === 'mint') { 15 | return [ 16 | { 17 | to: exp1Config.address[84532], 18 | data: AbiFunction.encodeData( 19 | AbiFunction.fromAbi(exp1Config.abi, 'mint'), 20 | [account, Value.fromEther('1')], 21 | ), 22 | }, 23 | ] 24 | } 25 | 26 | if (action === 'approve-transfer') { 27 | return [ 28 | { 29 | to: exp1Config.address[84532], 30 | data: AbiFunction.encodeData( 31 | AbiFunction.fromAbi(exp1Config.abi, 'approve'), 32 | [account, Value.fromEther('1')], 33 | ), 34 | }, 35 | { 36 | to: exp1Config.address[84532], 37 | data: AbiFunction.encodeData( 38 | AbiFunction.fromAbi(exp1Config.abi, 'transfer'), 39 | ['0x0000000000000000000000000000000000000000', Value.fromEther('1')], 40 | ), 41 | }, 42 | ] 43 | } 44 | 45 | return [ 46 | { to: '0x0000000000000000000000000000000000000000', value: '0x0' }, 47 | { to: '0x0000000000000000000000000000000000000000', value: '0x0' }, 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | import { http } from 'viem' 2 | import { Porto } from 'porto' 3 | import { baseSepolia } from 'porto/Chains' 4 | 5 | export type TPorto = ReturnType 6 | 7 | export const getPorto = () => 8 | Porto.create({ 9 | chains: [baseSepolia], 10 | transports: { 11 | [baseSepolia.id]: http(), 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /server/src/contracts.ts: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 2 | // exp1 3 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 4 | 5 | /** 6 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0x074C9c3273F31651a9dae896C1A1d68E868b6998) 7 | * - 8 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x29f45fc3ed1d0ffafb5e2af9cc6c3ab1555cd5a2) 9 | */ 10 | 11 | export const exp1Abi = [ 12 | { 13 | inputs: [ 14 | { internalType: 'string', name: 'name_', type: 'string' }, 15 | { internalType: 'string', name: 'symbol_', type: 'string' }, 16 | { internalType: 'uint256', name: 'scalar_', type: 'uint256' }, 17 | ], 18 | stateMutability: 'nonpayable', 19 | type: 'constructor', 20 | }, 21 | { stateMutability: 'payable', type: 'fallback' }, 22 | { stateMutability: 'payable', type: 'receive' }, 23 | { 24 | inputs: [], 25 | name: 'DOMAIN_SEPARATOR', 26 | outputs: [{ internalType: 'bytes32', name: 'result', type: 'bytes32' }], 27 | stateMutability: 'view', 28 | type: 'function', 29 | }, 30 | { 31 | inputs: [ 32 | { internalType: 'address', name: 'owner', type: 'address' }, 33 | { internalType: 'address', name: 'spender', type: 'address' }, 34 | ], 35 | name: 'allowance', 36 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 37 | stateMutability: 'view', 38 | type: 'function', 39 | }, 40 | { 41 | inputs: [ 42 | { internalType: 'address', name: 'spender', type: 'address' }, 43 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 44 | ], 45 | name: 'approve', 46 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 47 | stateMutability: 'nonpayable', 48 | type: 'function', 49 | }, 50 | { 51 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 52 | name: 'balanceOf', 53 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 54 | stateMutability: 'view', 55 | type: 'function', 56 | }, 57 | { 58 | inputs: [], 59 | name: 'decimals', 60 | outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], 61 | stateMutability: 'view', 62 | type: 'function', 63 | }, 64 | { 65 | inputs: [ 66 | { internalType: 'address', name: 'recipient', type: 'address' }, 67 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 68 | ], 69 | name: 'mint', 70 | outputs: [], 71 | stateMutability: 'nonpayable', 72 | type: 'function', 73 | }, 74 | { 75 | inputs: [], 76 | name: 'name', 77 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 78 | stateMutability: 'view', 79 | type: 'function', 80 | }, 81 | { 82 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 83 | name: 'nonces', 84 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 85 | stateMutability: 'view', 86 | type: 'function', 87 | }, 88 | { 89 | inputs: [ 90 | { internalType: 'address', name: 'owner', type: 'address' }, 91 | { internalType: 'address', name: 'spender', type: 'address' }, 92 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 93 | { internalType: 'uint256', name: 'deadline', type: 'uint256' }, 94 | { internalType: 'uint8', name: 'v', type: 'uint8' }, 95 | { internalType: 'bytes32', name: 'r', type: 'bytes32' }, 96 | { internalType: 'bytes32', name: 's', type: 'bytes32' }, 97 | ], 98 | name: 'permit', 99 | outputs: [], 100 | stateMutability: 'nonpayable', 101 | type: 'function', 102 | }, 103 | { 104 | inputs: [ 105 | { internalType: 'address', name: 'target', type: 'address' }, 106 | { internalType: 'address', name: 'recipient', type: 'address' }, 107 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 108 | ], 109 | name: 'swap', 110 | outputs: [], 111 | stateMutability: 'nonpayable', 112 | type: 'function', 113 | }, 114 | { 115 | inputs: [], 116 | name: 'symbol', 117 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 118 | stateMutability: 'view', 119 | type: 'function', 120 | }, 121 | { 122 | inputs: [], 123 | name: 'totalSupply', 124 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 125 | stateMutability: 'view', 126 | type: 'function', 127 | }, 128 | { 129 | inputs: [ 130 | { internalType: 'address', name: 'to', type: 'address' }, 131 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 132 | ], 133 | name: 'transfer', 134 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 135 | stateMutability: 'nonpayable', 136 | type: 'function', 137 | }, 138 | { 139 | inputs: [ 140 | { internalType: 'address', name: 'from', type: 'address' }, 141 | { internalType: 'address', name: 'to', type: 'address' }, 142 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 143 | ], 144 | name: 'transferFrom', 145 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 146 | stateMutability: 'nonpayable', 147 | type: 'function', 148 | }, 149 | { 150 | anonymous: false, 151 | inputs: [ 152 | { 153 | indexed: true, 154 | internalType: 'address', 155 | name: 'owner', 156 | type: 'address', 157 | }, 158 | { 159 | indexed: true, 160 | internalType: 'address', 161 | name: 'spender', 162 | type: 'address', 163 | }, 164 | { 165 | indexed: false, 166 | internalType: 'uint256', 167 | name: 'amount', 168 | type: 'uint256', 169 | }, 170 | ], 171 | name: 'Approval', 172 | type: 'event', 173 | }, 174 | { 175 | anonymous: false, 176 | inputs: [ 177 | { indexed: true, internalType: 'address', name: 'from', type: 'address' }, 178 | { indexed: true, internalType: 'address', name: 'to', type: 'address' }, 179 | { 180 | indexed: false, 181 | internalType: 'uint256', 182 | name: 'amount', 183 | type: 'uint256', 184 | }, 185 | ], 186 | name: 'Transfer', 187 | type: 'event', 188 | }, 189 | { inputs: [], name: 'AllowanceOverflow', type: 'error' }, 190 | { inputs: [], name: 'AllowanceUnderflow', type: 'error' }, 191 | { inputs: [], name: 'InsufficientAllowance', type: 'error' }, 192 | { inputs: [], name: 'InsufficientBalance', type: 'error' }, 193 | { inputs: [], name: 'InvalidPermit', type: 'error' }, 194 | { inputs: [], name: 'Permit2AllowanceIsFixedAtInfinity', type: 'error' }, 195 | { inputs: [], name: 'PermitExpired', type: 'error' }, 196 | { inputs: [], name: 'TotalSupplyOverflow', type: 'error' }, 197 | ] as const 198 | 199 | /** 200 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0x074C9c3273F31651a9dae896C1A1d68E868b6998) 201 | * - 202 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x29f45fc3ed1d0ffafb5e2af9cc6c3ab1555cd5a2) 203 | */ 204 | export const exp1Address = { 205 | 28404: '0x29F45fc3eD1d0ffaFb5e2af9Cc6C3AB1555cd5a2', 206 | 84532: '0x29F45fc3eD1d0ffaFb5e2af9Cc6C3AB1555cd5a2', 207 | } as const 208 | 209 | /** 210 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0x074C9c3273F31651a9dae896C1A1d68E868b6998) 211 | * - 212 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x29f45fc3ed1d0ffafb5e2af9cc6c3ab1555cd5a2) 213 | */ 214 | export const exp1Config = { abi: exp1Abi, address: exp1Address } as const 215 | 216 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 217 | // exp2 218 | ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 219 | 220 | /** 221 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0xFcc74F42621D03Fd234d5f40931D8B82923E4D29) 222 | * - 223 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x62a9d6de963a5590f6fba5119e937f167677bfe7) 224 | */ 225 | export const exp2Abi = [ 226 | { 227 | inputs: [ 228 | { internalType: 'string', name: 'name_', type: 'string' }, 229 | { internalType: 'string', name: 'symbol_', type: 'string' }, 230 | { internalType: 'uint256', name: 'scalar_', type: 'uint256' }, 231 | ], 232 | stateMutability: 'nonpayable', 233 | type: 'constructor', 234 | }, 235 | { stateMutability: 'payable', type: 'fallback' }, 236 | { stateMutability: 'payable', type: 'receive' }, 237 | { 238 | inputs: [], 239 | name: 'DOMAIN_SEPARATOR', 240 | outputs: [{ internalType: 'bytes32', name: 'result', type: 'bytes32' }], 241 | stateMutability: 'view', 242 | type: 'function', 243 | }, 244 | { 245 | inputs: [ 246 | { internalType: 'address', name: 'owner', type: 'address' }, 247 | { internalType: 'address', name: 'spender', type: 'address' }, 248 | ], 249 | name: 'allowance', 250 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 251 | stateMutability: 'view', 252 | type: 'function', 253 | }, 254 | { 255 | inputs: [ 256 | { internalType: 'address', name: 'spender', type: 'address' }, 257 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 258 | ], 259 | name: 'approve', 260 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 261 | stateMutability: 'nonpayable', 262 | type: 'function', 263 | }, 264 | { 265 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 266 | name: 'balanceOf', 267 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 268 | stateMutability: 'view', 269 | type: 'function', 270 | }, 271 | { 272 | inputs: [], 273 | name: 'decimals', 274 | outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], 275 | stateMutability: 'view', 276 | type: 'function', 277 | }, 278 | { 279 | inputs: [ 280 | { internalType: 'address', name: 'recipient', type: 'address' }, 281 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 282 | ], 283 | name: 'mint', 284 | outputs: [], 285 | stateMutability: 'nonpayable', 286 | type: 'function', 287 | }, 288 | { 289 | inputs: [], 290 | name: 'name', 291 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 292 | stateMutability: 'view', 293 | type: 'function', 294 | }, 295 | { 296 | inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], 297 | name: 'nonces', 298 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 299 | stateMutability: 'view', 300 | type: 'function', 301 | }, 302 | { 303 | inputs: [ 304 | { internalType: 'address', name: 'owner', type: 'address' }, 305 | { internalType: 'address', name: 'spender', type: 'address' }, 306 | { internalType: 'uint256', name: 'value', type: 'uint256' }, 307 | { internalType: 'uint256', name: 'deadline', type: 'uint256' }, 308 | { internalType: 'uint8', name: 'v', type: 'uint8' }, 309 | { internalType: 'bytes32', name: 'r', type: 'bytes32' }, 310 | { internalType: 'bytes32', name: 's', type: 'bytes32' }, 311 | ], 312 | name: 'permit', 313 | outputs: [], 314 | stateMutability: 'nonpayable', 315 | type: 'function', 316 | }, 317 | { 318 | inputs: [ 319 | { internalType: 'address', name: 'target', type: 'address' }, 320 | { internalType: 'address', name: 'recipient', type: 'address' }, 321 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 322 | ], 323 | name: 'swap', 324 | outputs: [], 325 | stateMutability: 'nonpayable', 326 | type: 'function', 327 | }, 328 | { 329 | inputs: [], 330 | name: 'symbol', 331 | outputs: [{ internalType: 'string', name: '', type: 'string' }], 332 | stateMutability: 'view', 333 | type: 'function', 334 | }, 335 | { 336 | inputs: [], 337 | name: 'totalSupply', 338 | outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], 339 | stateMutability: 'view', 340 | type: 'function', 341 | }, 342 | { 343 | inputs: [ 344 | { internalType: 'address', name: 'to', type: 'address' }, 345 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 346 | ], 347 | name: 'transfer', 348 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 349 | stateMutability: 'nonpayable', 350 | type: 'function', 351 | }, 352 | { 353 | inputs: [ 354 | { internalType: 'address', name: 'from', type: 'address' }, 355 | { internalType: 'address', name: 'to', type: 'address' }, 356 | { internalType: 'uint256', name: 'amount', type: 'uint256' }, 357 | ], 358 | name: 'transferFrom', 359 | outputs: [{ internalType: 'bool', name: '', type: 'bool' }], 360 | stateMutability: 'nonpayable', 361 | type: 'function', 362 | }, 363 | { 364 | anonymous: false, 365 | inputs: [ 366 | { 367 | indexed: true, 368 | internalType: 'address', 369 | name: 'owner', 370 | type: 'address', 371 | }, 372 | { 373 | indexed: true, 374 | internalType: 'address', 375 | name: 'spender', 376 | type: 'address', 377 | }, 378 | { 379 | indexed: false, 380 | internalType: 'uint256', 381 | name: 'amount', 382 | type: 'uint256', 383 | }, 384 | ], 385 | name: 'Approval', 386 | type: 'event', 387 | }, 388 | { 389 | anonymous: false, 390 | inputs: [ 391 | { indexed: true, internalType: 'address', name: 'from', type: 'address' }, 392 | { indexed: true, internalType: 'address', name: 'to', type: 'address' }, 393 | { 394 | indexed: false, 395 | internalType: 'uint256', 396 | name: 'amount', 397 | type: 'uint256', 398 | }, 399 | ], 400 | name: 'Transfer', 401 | type: 'event', 402 | }, 403 | { inputs: [], name: 'AllowanceOverflow', type: 'error' }, 404 | { inputs: [], name: 'AllowanceUnderflow', type: 'error' }, 405 | { inputs: [], name: 'InsufficientAllowance', type: 'error' }, 406 | { inputs: [], name: 'InsufficientBalance', type: 'error' }, 407 | { inputs: [], name: 'InvalidPermit', type: 'error' }, 408 | { inputs: [], name: 'Permit2AllowanceIsFixedAtInfinity', type: 'error' }, 409 | { inputs: [], name: 'PermitExpired', type: 'error' }, 410 | { inputs: [], name: 'TotalSupplyOverflow', type: 'error' }, 411 | ] as const 412 | 413 | /** 414 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0xFcc74F42621D03Fd234d5f40931D8B82923E4D29) 415 | * - 416 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x62a9d6de963a5590f6fba5119e937f167677bfe7) 417 | */ 418 | export const exp2Address = { 419 | 28404: '0x502fF46e72C47b8c3183DB8919700377EED66d2E', 420 | 84532: '0x62a9d6DE963a5590f6fbA5119e937F167677bfE7', 421 | } as const 422 | 423 | /** 424 | * - [__View Contract on Base Basescan__](https://basescan.org/address/0xFcc74F42621D03Fd234d5f40931D8B82923E4D29) 425 | * - 426 | * - [__View Contract on Base Sepolia Basescan__](https://sepolia.basescan.org/address/0x62a9d6de963a5590f6fba5119e937f167677bfe7) 427 | */ 428 | export const exp2Config = { abi: exp2Abi, address: exp2Address } as const 429 | -------------------------------------------------------------------------------- /server/src/debug.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { showRoutes } from 'hono/dev' 3 | import { basicAuth } from 'hono/basic-auth' 4 | import { HTTPException } from 'hono/http-exception' 5 | import { getConnInfo } from 'hono/cloudflare-workers' 6 | 7 | import type { Env } from '#types.ts' 8 | import { ServerKeyPair } from '#keys.ts' 9 | 10 | export const debugApp = new Hono<{ Bindings: Env }>() 11 | 12 | // debugApp.use( 13 | // '/nuke/*', 14 | // basicAuth({ 15 | // verifyUser: (username, password, context) => { 16 | // if (context.env.ENVIRONMENT === 'development') return true 17 | // return ( 18 | // username === context.env.ADMIN_USERNAME && 19 | // password === context.env.ADMIN_PASSWORD 20 | // ) 21 | // }, 22 | // }), 23 | // ) 24 | 25 | /** 26 | * Debug stored keys, schedules and transactions 27 | * If `address` is provided, returns the values for the given address 28 | * Otherwise, returns all keys, schedules & transactions 29 | */ 30 | debugApp.get('/', async (context) => { 31 | if (context.env.ENVIRONMENT === 'development') { 32 | const verbose = context.req.query('verbose') 33 | if (verbose) { 34 | showRoutes(debugApp, { 35 | colorize: context.env.ENVIRONMENT === 'development', 36 | }) 37 | } 38 | } 39 | const { remote } = getConnInfo(context) 40 | const address = context.req.query('address') 41 | 42 | if (address) { 43 | const key = await ServerKeyPair.getFromStore({ address }) 44 | const statements = [ 45 | context.env.DB.prepare( 46 | /* sql */ `SELECT * FROM transactions WHERE address = ?;`, 47 | ).bind(address.toLowerCase()), 48 | context.env.DB.prepare( 49 | /* sql */ `SELECT * FROM schedules WHERE address = ?;`, 50 | ).bind(address.toLowerCase()), 51 | ] 52 | const [transactions, schedules] = await context.env.DB.batch(statements) 53 | return context.json({ 54 | remote, 55 | keys: key ? [key] : [], 56 | schedules: schedules?.results, 57 | transactions: transactions?.results, 58 | }) 59 | } 60 | 61 | const keys = await ServerKeyPair['~listFromStore']() 62 | const statements = [ 63 | context.env.DB.prepare(`SELECT * FROM transactions;`), 64 | context.env.DB.prepare(`SELECT * FROM schedules;`), 65 | ] 66 | const [transactions, schedules] = await context.env.DB.batch(statements) 67 | return context.json({ 68 | remote, 69 | keys, 70 | schedules: schedules?.results, 71 | transactions: transactions?.results, 72 | }) 73 | }) 74 | 75 | debugApp.get('/nuke', async (context) => { 76 | const { address } = context.req.query() 77 | if (!address) { 78 | return context.json({ error: 'address is required' }, 400) 79 | } 80 | 81 | try { 82 | context.executionCtx.waitUntil( 83 | Promise.all([ 84 | ServerKeyPair.deleteFromStore({ address }), 85 | context.env.DB.prepare(`DELETE FROM schedules WHERE address = ?;`) 86 | .bind(address.toLowerCase()) 87 | .all(), 88 | context.env.DB.prepare(`DELETE FROM transactions WHERE address = ?;`) 89 | .bind(address.toLowerCase()) 90 | .all(), 91 | ]), 92 | ) 93 | 94 | return context.json({ success: true }) 95 | } catch (error) { 96 | console.error(error) 97 | throw new HTTPException(500, { message: `/debug/nuke failed` }) 98 | } 99 | }) 100 | 101 | // nuke all keys, schedules and transactions 102 | debugApp.get('/nuke/everything', async (context) => { 103 | try { 104 | context.executionCtx.waitUntil( 105 | Promise.all([ 106 | context.env.DB.prepare(`DELETE FROM keypairs;`).all(), 107 | context.env.DB.prepare(`DELETE FROM transactions;`).all(), 108 | context.env.DB.prepare(`DELETE FROM schedules;`).all(), 109 | ]), 110 | ) 111 | return context.json({ success: true }) 112 | } catch (error) { 113 | console.error(error) 114 | throw new HTTPException(500, { message: `/debug/nuke/everything failed` }) 115 | } 116 | }) 117 | -------------------------------------------------------------------------------- /server/src/keys.ts: -------------------------------------------------------------------------------- 1 | import { P256, PublicKey } from 'ox' 2 | import { env } from 'cloudflare:workers' 3 | 4 | import type { KeyPair, Env } from '#types.ts' 5 | 6 | type GeneratedKeyPair = Omit 7 | 8 | export const ServerKeyPair = { 9 | generateAndStore: async ({ 10 | address, 11 | role = 'session', 12 | expiry = Math.floor(Date.now() / 1_000) + 60 * 2, // 2 minutes by default 13 | }: { 14 | address: string 15 | expiry?: number 16 | role?: 'session' | 'admin' 17 | }): Promise => { 18 | const privateKey = P256.randomPrivateKey() 19 | const publicKey = PublicKey.toHex(P256.getPublicKey({ privateKey }), { 20 | includePrefix: false, 21 | }) 22 | 23 | /** 24 | * you can have a setup where an address can have multiple keys 25 | * we are just doing 1 per address in this demo for simplicity 26 | */ 27 | const deleteStatement = env.DB.prepare( 28 | /* sql */ 29 | `DELETE FROM keypairs WHERE address = ?;`, 30 | ).bind(address.toLowerCase()) 31 | 32 | const insertStatement = env.DB.prepare( 33 | /* sql */ 34 | `INSERT INTO keypairs 35 | (address, public_key, private_key, role, type, expiry) 36 | VALUES (?, ?, ?, ?, ?, ?);`, 37 | ).bind(address.toLowerCase(), publicKey, privateKey, role, 'p256', expiry) 38 | 39 | const [, insertQuery] = await env.DB.batch([ 40 | deleteStatement, 41 | insertStatement, 42 | ]) 43 | 44 | if (!insertQuery?.success) { 45 | console.error(`Failed to insert key pair for address: ${address}`, { 46 | error: insertQuery?.error, 47 | }) 48 | } 49 | 50 | return { 51 | public_key: publicKey, 52 | role, 53 | expiry, 54 | address, 55 | type: 'p256', 56 | } as const 57 | }, 58 | getFromStore: async ({ address }: { address: string }) => { 59 | const queryResult = await env.DB.prepare( 60 | /* sql */ `SELECT * FROM keypairs WHERE address = ?;`, 61 | ) 62 | .bind(address.toLowerCase()) 63 | .first() 64 | 65 | if (queryResult) return queryResult 66 | 67 | console.info(`no keypair found for address: ${address}`) 68 | return undefined 69 | }, 70 | 71 | deleteFromStore: async ({ address }: { address: string }) => { 72 | const deleteKeypair = env.DB.prepare( 73 | /* sql */ 74 | `DELETE FROM keypairs WHERE address = ?;`, 75 | ).bind(address.toLowerCase()) 76 | 77 | const deleteSchedule = env.DB.prepare( 78 | /* sql */ 79 | `DELETE FROM schedules WHERE address = ?;`, 80 | ).bind(address.toLowerCase()) 81 | 82 | return await env.DB.batch([ 83 | deleteKeypair, 84 | deleteSchedule, 85 | ]) 86 | }, 87 | 88 | deleteAllFromStore: async () => 89 | await env.DB.prepare(/* sql */ `DELETE FROM keypairs;`).run(), 90 | 91 | '~listFromStore': async () => { 92 | const queryResult = await env.DB.prepare( 93 | /* sql */ `SELECT * FROM keypairs;`, 94 | ).all() 95 | 96 | if (queryResult.success) return queryResult.results 97 | 98 | console.error(queryResult.error) 99 | return [] 100 | }, 101 | } 102 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { cors } from 'hono/cors' 3 | import crypto from 'node:crypto' 4 | import { Address, Json } from 'ox' 5 | import { logger } from 'hono/logger' 6 | import { env } from 'cloudflare:workers' 7 | import { requestId } from 'hono/request-id' 8 | import { prettyJSON } from 'hono/pretty-json' 9 | import { HTTPException } from 'hono/http-exception' 10 | import { getConnInfo } from 'hono/cloudflare-workers' 11 | 12 | import { debugApp } from '#debug.ts' 13 | import type { Env } from '#types.ts' 14 | import { ServerKeyPair } from '#keys.ts' 15 | import wranglerJSON from '#wrangler.json' 16 | import { Exp3Workflow } from '#workflow.ts' 17 | import { actions, buildActionCall } from '#calls.ts' 18 | 19 | const app = new Hono<{ Bindings: Env }>() 20 | 21 | app.use(logger()) 22 | app.use(prettyJSON({ space: 2 })) // append `?pretty` to any request to get prettified JSON 23 | app.use('*', requestId({ headerName: `${wranglerJSON.name}-Request-Id` })) 24 | app.use( 25 | '*', 26 | cors({ origin: '*', allowMethods: ['GET', 'OPTIONS', 'POST', 'HEAD'] }), 27 | ) 28 | 29 | app.onError((error, context) => { 30 | const { remote } = getConnInfo(context) 31 | const requestId = context.get('requestId') 32 | console.error( 33 | [ 34 | `[${requestId}]`, 35 | `-[${remote.address}]`, 36 | `-[${context.req.url}]:\n`, 37 | `${error.message}`, 38 | ].join(''), 39 | ) 40 | if (error instanceof HTTPException) return error.getResponse() 41 | return context.json({ remote, error: error.message, requestId }, 500) 42 | }) 43 | 44 | app.notFound((context) => { 45 | throw new HTTPException(404, { 46 | cause: context.error, 47 | message: `${context.req.url} is not a valid path.`, 48 | }) 49 | }) 50 | 51 | app.get('/keys/:address', async (context) => { 52 | const { address } = context.req.param() 53 | const { expiry } = context.req.query() 54 | 55 | if (!address || !Address.validate(address)) { 56 | return context.json({ error: 'Invalid address' }, 400) 57 | } 58 | 59 | // check for existing key 60 | const storedKey = await ServerKeyPair.getFromStore({ 61 | address, 62 | }) 63 | 64 | const expired = 65 | storedKey?.expiry && storedKey.expiry < Math.floor(Date.now() / 1_000) 66 | 67 | if (!expired && storedKey) { 68 | return context.json({ 69 | type: storedKey.type, 70 | publicKey: storedKey.public_key, 71 | expiry: storedKey.expiry, 72 | role: storedKey.role, 73 | }) 74 | } 75 | 76 | const keyPair = await ServerKeyPair.generateAndStore({ 77 | address, 78 | expiry: expiry ? Number(expiry) : undefined, 79 | }) 80 | 81 | const { public_key, role, type } = keyPair 82 | return context.json({ type, publicKey: public_key, expiry, role }) 83 | }) 84 | 85 | /** 86 | * Schedules transactions to be executed at a later time 87 | * The transaction are sent by the key owner 88 | */ 89 | app.post('/schedule', async (context) => { 90 | const account = context.req.query('address') 91 | if (!account || !Address.validate(account)) { 92 | throw new HTTPException(400, { message: 'Invalid address' }) 93 | } 94 | 95 | const { action, schedule } = await context.req.json<{ 96 | action: string 97 | schedule: string 98 | }>() 99 | 100 | if (!action || !actions.includes(action)) { 101 | throw new HTTPException(400, { message: 'Invalid action' }) 102 | } 103 | 104 | const storedKey = await ServerKeyPair.getFromStore({ 105 | address: account.toLowerCase(), 106 | }) 107 | 108 | if (!storedKey) { 109 | throw new HTTPException(400, { 110 | message: 111 | 'Key not found. Request a new key and grant permissions if the problem persists', 112 | }) 113 | } 114 | 115 | if (storedKey?.expiry && storedKey?.expiry < Math.floor(Date.now() / 1_000)) { 116 | await ServerKeyPair.deleteFromStore({ 117 | address: account.toLowerCase(), 118 | }) 119 | throw new HTTPException(400, { message: 'Key expired and deleted' }) 120 | } 121 | 122 | const calls = buildActionCall({ action, account }) 123 | 124 | const insertSchedule = await context.env.DB.prepare( 125 | /* sql */ ` 126 | INSERT INTO schedules ( address, schedule, action, calls ) VALUES ( ?, ?, ?, ? )`, 127 | ) 128 | .bind(account.toLowerCase(), schedule, action, Json.stringify(calls)) 129 | .all() 130 | 131 | if (!insertSchedule.success) { 132 | console.info('insertSchedule error', insertSchedule) 133 | throw new HTTPException(500, { message: insertSchedule.error }) 134 | } 135 | 136 | console.info('insertSchedule success', insertSchedule.success) 137 | 138 | return context.json({ 139 | calls, 140 | action, 141 | schedule, 142 | address: account.toLowerCase(), 143 | }) 144 | }) 145 | 146 | app.post('/workflow/:address', async (context) => { 147 | const { address } = context.req.param() 148 | const { count = 6 } = context.req.query() 149 | 150 | if (!Address.validate(address)) { 151 | throw new HTTPException(400, { message: 'Invalid address' }) 152 | } 153 | 154 | if (!count || Number(count) < 1 || Number(count) > 10) { 155 | throw new HTTPException(400, { 156 | message: `Count must be between 1 and 10. Received: ${count}`, 157 | }) 158 | } 159 | 160 | const keyPair = await ServerKeyPair.getFromStore({ address }) 161 | 162 | if (!keyPair) return context.json({ error: 'Key not found' }, 404) 163 | 164 | if (keyPair.expiry && keyPair.expiry < Math.floor(Date.now() / 1_000)) { 165 | await ServerKeyPair.deleteFromStore({ address }) 166 | return context.json({ error: 'Key expired and deleted' }, 400) 167 | } 168 | 169 | console.info({ 170 | id: crypto.randomUUID(), 171 | params: { 172 | keyPair, 173 | count: Number(count || 6), 174 | }, 175 | }) 176 | const instance = await env.EXP3_WORKFLOW.create({ 177 | id: crypto.randomUUID(), 178 | params: { 179 | keyPair, 180 | count: Number(count || 6), 181 | }, 182 | }) 183 | 184 | console.info('Exp3Workflow instance created', instance.id) 185 | 186 | return context.json({ id: instance.id, details: await instance.status() }) 187 | }) 188 | 189 | app.route('/debug', debugApp) 190 | 191 | export { Exp3Workflow } 192 | 193 | export default app satisfies ExportedHandler 194 | -------------------------------------------------------------------------------- /server/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Address, Hex } from 'ox' 2 | 3 | import type { Params as WorkflowParams } from '#workflow.ts' 4 | 5 | export interface Env extends Environment { 6 | DB: D1Database 7 | EXP3_WORKFLOW: Workflow 8 | } 9 | 10 | interface BaseAttributes { 11 | id: number 12 | created_at: string 13 | } 14 | 15 | export type Transaction = Pretty< 16 | BaseAttributes & { 17 | address: string 18 | hash: Hex.Hex 19 | public_key: Hex.Hex 20 | role: 'session' | 'admin' 21 | } 22 | > 23 | 24 | export type Schedule = Pretty< 25 | BaseAttributes & { 26 | address: Address.Address 27 | schedule: string 28 | action: string 29 | calls: string 30 | } 31 | > 32 | 33 | export type KeyPair = Pretty< 34 | BaseAttributes & { 35 | address: string 36 | public_key: Hex.Hex 37 | private_key: Hex.Hex 38 | expiry: number 39 | type: 'p256' 40 | role: 'session' | 'admin' 41 | } 42 | > 43 | 44 | // https://totaltypescript.com/concepts/the-prettify-helper 45 | export type Pretty = { [K in keyof T]: T[K] } & {} 46 | -------------------------------------------------------------------------------- /server/src/workflow.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type WorkflowStep, 3 | WorkflowEntrypoint, 4 | type WorkflowEvent, 5 | } from 'cloudflare:workers' 6 | import { Chains } from 'porto' 7 | import { Hex, Json, P256, Signature } from 'ox' 8 | import { NonRetryableError } from 'cloudflare:workflows' 9 | 10 | import { getPorto } from '#config.ts' 11 | import type { Env, KeyPair, Schedule } from '#types.ts' 12 | 13 | export type Params = { 14 | count: number 15 | keyPair: KeyPair 16 | } 17 | 18 | export class Exp3Workflow extends WorkflowEntrypoint { 19 | constructor(ctx: ExecutionContext, env: Env) { 20 | super(ctx, env) 21 | } 22 | 23 | async run( 24 | event: Readonly>, 25 | step: WorkflowStep, 26 | ): Promise { 27 | const scheduleResult = await step.do( 28 | 'STEP_01: get schedule', 29 | { timeout: 3_000 }, 30 | async () => { 31 | if (!event.payload) throw new NonRetryableError('missing payload') 32 | 33 | const schedule = await this.env.DB.prepare( 34 | /* sql */ `SELECT * FROM schedules WHERE address = ?;`, 35 | ) 36 | .bind(event.payload.keyPair.address) 37 | .first() 38 | 39 | if (!schedule) throw new NonRetryableError('schedule not found') 40 | return schedule 41 | }, 42 | ) 43 | 44 | let transactionsProcessed = 0 45 | const targetCount = event.payload.count 46 | 47 | while (transactionsProcessed < targetCount) { 48 | try { 49 | const result = await step.do( 50 | `STEP_02: process transaction ${transactionsProcessed + 1}`, 51 | { 52 | timeout: 1_000 * 60 * 5, // 5 minutes 53 | retries: { 54 | backoff: 'constant', 55 | delay: '10 seconds', 56 | limit: 3, 57 | }, 58 | }, 59 | async () => { 60 | const { keyPair } = event.payload 61 | const { calls, address } = scheduleResult 62 | 63 | const porto = getPorto() 64 | 65 | const { digest, ...request } = await porto.provider.request({ 66 | method: 'wallet_prepareCalls', 67 | params: [ 68 | { 69 | key: { 70 | type: keyPair.type, 71 | publicKey: keyPair.public_key, 72 | }, 73 | from: address, 74 | calls: Json.parse(calls), 75 | chainId: Hex.fromNumber(Chains.baseSepolia.id), 76 | }, 77 | ], 78 | }) 79 | 80 | const signature = Signature.toHex( 81 | P256.sign({ 82 | payload: digest, 83 | privateKey: keyPair.private_key, 84 | }), 85 | ) 86 | 87 | const [sendPreparedCallsResult] = await porto.provider.request({ 88 | method: 'wallet_sendPreparedCalls', 89 | params: [ 90 | { 91 | ...request, 92 | signature, 93 | key: { 94 | type: keyPair.type, 95 | publicKey: keyPair.public_key, 96 | }, 97 | }, 98 | ], 99 | }) 100 | 101 | const bundleId = sendPreparedCallsResult?.id 102 | if (!bundleId) { 103 | console.error( 104 | `failed to send prepared calls for ${address}. No bundleId returned from wallet_sendPreparedCalls`, 105 | ) 106 | throw new Error('failed to send prepared calls') 107 | } 108 | 109 | const insertQuery = await this.env.DB.prepare( 110 | /* sql */ `INSERT INTO transactions (address, hash, role, public_key) VALUES (?, ?, ?, ?)`, 111 | ) 112 | .bind(address, bundleId, keyPair.role, keyPair.public_key) 113 | .run() 114 | 115 | if (!insertQuery.success) { 116 | throw new Error('failed to insert transaction') 117 | } 118 | 119 | console.info(`transaction inserted: ${bundleId}`) 120 | return { 121 | success: true, 122 | hash: bundleId, 123 | transactionNumber: transactionsProcessed + 1, 124 | } 125 | }, 126 | ) 127 | 128 | if (result.success) { 129 | transactionsProcessed++ 130 | console.info( 131 | `Transaction ${transactionsProcessed}/${targetCount} completed: ${result.hash}`, 132 | ) 133 | } 134 | } catch (error) { 135 | console.error( 136 | `Error processing transaction ${transactionsProcessed + 1}:`, 137 | error, 138 | ) 139 | 140 | if (error instanceof NonRetryableError) { 141 | throw error 142 | } 143 | 144 | // For retryable errors, the step.do will handle retries automatically 145 | // If we reach here after all retries are exhausted, we should fail 146 | throw new NonRetryableError( 147 | `Failed to process transaction after retries: ${error instanceof Error ? error.message : 'unknown error'}`, 148 | ) 149 | } 150 | 151 | // Add 10 second delay between transactions as per schedule 152 | if (transactionsProcessed < targetCount) { 153 | await step.sleep('delay between transactions', '10 seconds') 154 | } 155 | } 156 | 157 | // cleanup 158 | await step.do('STEP_03: clean up', async () => { 159 | const deleteScheduleStatement = this.env.DB.prepare( 160 | /* sql */ `DELETE FROM schedules WHERE address = ?;`, 161 | ).bind(event.payload.keyPair.address) 162 | 163 | await this.env.DB.batch([deleteScheduleStatement]) 164 | console.info( 165 | `Cleanup completed for address: ${event.payload.keyPair.address}`, 166 | ) 167 | }) 168 | 169 | console.info( 170 | `Workflow completed successfully. Processed ${transactionsProcessed}/${targetCount} transactions.`, 171 | ) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": "https://json.schemastore.org/tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": ".", 6 | "noEmit": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "lib": [ 12 | "ESNext" 13 | ], 14 | "target": "ESNext", 15 | "module": "ESNext", 16 | "skipLibCheck": true, 17 | "alwaysStrict": true, 18 | "esModuleInterop": true, 19 | "isolatedModules": true, 20 | "strictNullChecks": true, 21 | "resolveJsonModule": true, 22 | "verbatimModuleSyntax": true, 23 | "moduleResolution": "Bundler", 24 | "useDefineForClassFields": true, 25 | "allowArbitraryExtensions": true, 26 | "noUncheckedIndexedAccess": true, 27 | "resolvePackageJsonImports": true, 28 | "resolvePackageJsonExports": true, 29 | "useUnknownInCatchVariables": true, 30 | "allowImportingTsExtensions": true, 31 | "noFallthroughCasesInSwitch": true, 32 | "allowSyntheticDefaultImports": true, 33 | "forceConsistentCasingInFileNames": true, 34 | "noPropertyAccessFromIndexSignature": true, 35 | "types": [ 36 | "node", 37 | "@cloudflare/workers-types" 38 | ] 39 | }, 40 | "include": [ 41 | "src/**/*" 42 | ], 43 | "files": [ 44 | "env.d.ts", 45 | "wrangler.json", 46 | "package.json", 47 | "worker-configuration.d.ts" 48 | ], 49 | "exclude": [ 50 | "dist", 51 | "node_modules" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /server/wrangler.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://esm.sh/wrangler/config-schema.json", 3 | "minify": true, 4 | "keep_vars": true, 5 | "workers_dev": true, 6 | "main": "./src/main.ts", 7 | "name": "exp-0003-server", 8 | "compatibility_date": "2025-05-27", 9 | "compatibility_flags": [ 10 | "nodejs_als", 11 | "nodejs_compat", 12 | "nodejs_compat_populate_process_env" 13 | ], 14 | "dev": { 15 | "port": 6900 16 | }, 17 | "placement": { 18 | "mode": "smart" 19 | }, 20 | "observability": { 21 | "enabled": true 22 | }, 23 | "vars": { 24 | "ENVIRONMENT": "development" 25 | }, 26 | "d1_databases": [ 27 | { 28 | "binding": "DB", 29 | "database_name": "exp-0003", 30 | "database_id": "35a2ff47-d9f3-4cde-a0cf-a072b77551b1" 31 | } 32 | ], 33 | "workflows": [ 34 | { 35 | "name": "EXP3_WORKFLOW", 36 | "binding": "EXP3_WORKFLOW", 37 | "class_name": "Exp3Workflow" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": "https://json.schemastore.org/tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": ".", 6 | "noEmit": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "lib": [ 10 | "ESNext" 11 | ], 12 | "target": "ESNext", 13 | "module": "ESNext", 14 | "skipLibCheck": true, 15 | "alwaysStrict": true, 16 | "esModuleInterop": true, 17 | "isolatedModules": true, 18 | "strictNullChecks": true, 19 | "resolveJsonModule": true, 20 | "verbatimModuleSyntax": true, 21 | "moduleResolution": "Bundler", 22 | "useDefineForClassFields": true, 23 | "allowArbitraryExtensions": true, 24 | "noUncheckedIndexedAccess": true, 25 | "resolvePackageJsonImports": true, 26 | "resolvePackageJsonExports": true, 27 | "useUnknownInCatchVariables": true, 28 | "allowImportingTsExtensions": true, 29 | "noFallthroughCasesInSwitch": true, 30 | "allowSyntheticDefaultImports": true, 31 | "forceConsistentCasingInFileNames": true, 32 | "noPropertyAccessFromIndexSignature": true, 33 | "types": [ 34 | "node" 35 | ] 36 | }, 37 | "include": [ 38 | "**/*" 39 | ], 40 | "exclude": [ 41 | "dist", 42 | "node_modules" 43 | ] 44 | } 45 | --------------------------------------------------------------------------------