├── .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 |
--------------------------------------------------------------------------------
/.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 | 
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------