diff --git a/.github/workflows/test-200ms-demo.yml b/.github/workflows/test-200ms-demo.yml new file mode 100644 index 000000000..d768c47a5 --- /dev/null +++ b/.github/workflows/test-200ms-demo.yml @@ -0,0 +1,42 @@ +name: 200ms demo + +on: + pull_request: + paths: + - "200ms-demo/**" + - ".github/workflows/test-200ms-demo.yml" + push: + branches: [master] + paths: + - "200ms-demo/**" + - ".github/workflows/test-200ms-demo.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: 200ms-demo + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: 200ms-demo/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Test exact rate math + run: npm run test:math + + - name: Build the hosted path + run: npm run build:pages diff --git a/200ms-demo/.gitignore b/200ms-demo/.gitignore new file mode 100644 index 000000000..ae9fd706f --- /dev/null +++ b/200ms-demo/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.DS_Store +*.local +npm-debug.log* diff --git a/200ms-demo/PHASE1.md b/200ms-demo/PHASE1.md new file mode 100644 index 000000000..ff6552716 --- /dev/null +++ b/200ms-demo/PHASE1.md @@ -0,0 +1,68 @@ +# Phase 1 live-chain report + +Verified against Base Vibenet on September 10, 2026. + +## Final passing configuration + +- Chain ID: `84538453` +- Sender implementation: `CanonicalHighRatePayerAccount` + (`0x813002ffdd25c81cef79781702176d453af0fa57` at test time) +- Bootstrap transaction: ordered nonce key `0` +- Streaming transactions: nonce-free `nonceKeyMax`, 15-second validity +- Cadence: self-correcting 200ms schedule +- Gas limit: sequenced estimate + nonce-free surcharge + 30% headroom +- Receipt reconciliation: independent watcher with exact signed-byte rebroadcast + +## 50-transfer gate + +| Metric | Result | +| --- | ---: | +| Transfers | 50 | +| Recipient delta | 0.833300 USDV | +| Broadcast duration | 9.922s | +| Accepted throughput | 5.04 tx/s | +| Confirmation duration | 10.824s | +| Confirmed throughput | 4.62 tx/s | +| Mean gas per transfer | 50,017 | +| Sampled base fee | 1 gwei | +| Cost per transfer | 0.000050017 ETH | +| 0.1 ETH runway | 1,999 transfers | +| Runway at 5 tx/s | 6.66 minutes | + +## Five-minute streamer/funding soak + +The corrected hot loop then ran for five minutes with `npm run soak`: + +| Metric | Result | +| --- | ---: | +| Transfers | 1,500 | +| Recipient delta | 24.999000 USDV | +| Broadcast duration | 299.915s | +| Accepted throughput | 5.00 tx/s | +| Confirmation duration | 303.017s | +| Confirmed throughput | 4.95 tx/s | +| Mean gas per transfer | 50,040 | +| Automatic ETH top-ups | 1 | + +Two absent transactions were recovered by exact signed-byte rebroadcast during +the soak. There were no unrecovered nonce failures, and every transfer was +included in the final authoritative recipient balance delta. + +## Rejected designs discovered by the gate + +1. **Unchecked ordered-nonce bursts:** a lower sequence could disappear after + the RPC returned its hash. Later sequences then filled the sender signature + pool and produced `sender EIP-8130 signature limit reached`. +2. **Receipt work inside the queue:** throughput fell to 2.65 tx/s. +3. **Visibility acknowledgement through the account RPC proxy:** consistent but + effectively receipt-paced at 1.39 tx/s. +4. **Account configuration lock:** the current execution client rejected the + lock change as `unknown account-change op in the enshrined apply path`. +5. **60-second nonce-free validity:** the live admission window is 20 seconds; + the working path uses 15 seconds. + +Run the gate again with: + +```bash +npm run spike +``` diff --git a/200ms-demo/README.md b/200ms-demo/README.md new file mode 100644 index 000000000..e93c772ab --- /dev/null +++ b/200ms-demo/README.md @@ -0,0 +1,57 @@ +# 200ms demo + +A single-page React app that streams Vibenet USDV through real EIP-8130 native +account-abstraction transactions at approximately five transfers per second. +It uses a locally generated devnet key: no wallet connection, extension, or +signing prompt. + +Live preview: + +The workflow committed to `base/demos` validates this directory but does not +deploy to the Base organization GitHub Pages site. + +## Run + +```bash +npm install +npm run dev +``` + +Open the printed local Vite URL. A fresh browser profile takes roughly 12–20 +seconds to receive both faucet drips because Vibenet enforces a shared ten-second +IP cooldown. + +## Verify the live transaction path + +```bash +npm run spike +``` + +The spike creates a disposable high-rate account, funds it, deploys it, and +sends 50 real USDV transfers. See [PHASE1.md](./PHASE1.md) for the passing +September 10, 2026 measurements and the designs rejected during testing. + +The five-minute funding/throughput soak is available as `npm run soak`. + +## Architecture + +- `src/chain/bootstrap.ts` — health, live contracts, persisted account, + cooldown-aware funding, deployment proof, and one-time gas calibration. +- `src/chain/streamer.ts` — exact rational accumulator, 200ms scheduler, + nonce-free signing, pending backpressure, receipt watcher, rebroadcast, + funding daemon, and reset detection. +- `src/hooks/useStream.ts` — requestAnimationFrame sampling over the mutable + streamer snapshot. +- `src/ui/` — counter, real balance cards, rate selector, and explorer-linked + transaction ticker. + +## Important limitations + +- **Devnet only.** The private signing key is stored in localStorage. Do not + point this storage model at production assets. +- **USDV, not USDC.** USDV is Vibenet's faucet-backed six-decimal test USD token. +- The high-rate account implementation restricts outbound ETH execution. The + app only calls the USDV token and uses ETH for transaction fees. +- There is no mock fallback. If Vibenet is down, the app says so. +- Browsers call Vibenet directly. Visitors behind the same NAT or corporate + egress can still contend for the faucet's IP cooldown. diff --git a/200ms-demo/index.html b/200ms-demo/index.html new file mode 100644 index 000000000..2c9ff14b4 --- /dev/null +++ b/200ms-demo/index.html @@ -0,0 +1,14 @@ + + + + + + + + 200ms demo + + +
+ + + diff --git a/200ms-demo/package-lock.json b/200ms-demo/package-lock.json new file mode 100644 index 000000000..ea518f2fd --- /dev/null +++ b/200ms-demo/package-lock.json @@ -0,0 +1,1273 @@ +{ + "name": "200ms-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "200ms-demo", + "version": "0.1.0", + "dependencies": { + "@vibenet/aa": "file:./vendor/aa", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vibenet/aa": { + "resolved": "vendor/aa", + "link": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "vendor/aa": { + "name": "@vibenet/aa" + } + } +} diff --git a/200ms-demo/package.json b/200ms-demo/package.json new file mode 100644 index 000000000..ad6db2351 --- /dev/null +++ b/200ms-demo/package.json @@ -0,0 +1,27 @@ +{ + "name": "200ms-demo", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "build:pages": "tsc --noEmit && vite build --mode pages", + "preview": "vite preview", + "spike": "node scripts/spike.mjs", + "soak": "SPIKE_TRANSFERS=1500 SPIKE_AUTO_TOPUP=1 node scripts/spike.mjs", + "test:math": "node --experimental-strip-types scripts/test-rate-math.ts" + }, + "dependencies": { + "@vibenet/aa": "file:./vendor/aa", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.0" + } +} diff --git a/200ms-demo/scripts/spike.mjs b/200ms-demo/scripts/spike.mjs new file mode 100644 index 000000000..b45d5341d --- /dev/null +++ b/200ms-demo/scripts/spike.mjs @@ -0,0 +1,550 @@ +import { performance } from 'node:perf_hooks' + +import { + allPhasesSucceeded, + canonicalAuthenticators, + createPublicClient, + decodeAbiParameters, + encodeFunctionData, + encodeWalletCalls, + estimateGas, + generatePrivateKey, + getTransactionCount, + getTransactionReceipt, + http, + key, + keccak256, + newSmartAccount, + nonceFreeCost, + nonceKeyExistingCost, + nonceKeyMax, + privateKeyToAccount, + toHex, + vibenetDevnetDeployment, +} from '../vendor/aa/index.js' + +const CHAIN_ID = 84_538_453 +const API_URL = 'https://api.vibes.base.org' +const RPC_URL = 'https://rpc.vibes.base.org' +const TRANSFER_COUNT = Number(process.env.SPIKE_TRANSFERS ?? 50) +const TRANSFER_AMOUNT = BigInt(process.env.SPIKE_AMOUNT_UNITS ?? 16_666) +const AUTO_TOP_UP = process.env.SPIKE_AUTO_TOPUP === '1' +const PRIORITY_FEE = 1_000_000n +const REQUEST_TIMEOUT_MS = 15_000 +const RECEIPT_TIMEOUT_MS = 90_000 + +const chain = { + id: CHAIN_ID, + name: 'Base Vibenet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [RPC_URL] } }, +} + +const client = createPublicClient({ chain, transport: http(RPC_URL) }) + +const erc20Abi = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, +] + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const short = (value) => `${value.slice(0, 8)}…${value.slice(-6)}` +const formatUnits = (value, decimals = 6) => { + const negative = value < 0n + const absolute = negative ? -value : value + const base = 10n ** BigInt(decimals) + const whole = absolute / base + const fraction = (absolute % base).toString().padStart(decimals, '0').replace(/0+$/, '') + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}` +} + +async function request(url, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { ...options, signal: controller.signal }) + const body = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(body?.error ?? body?.message ?? `${response.status} ${response.statusText}`) + } + return body + } finally { + clearTimeout(timer) + } +} + +let rpcId = 0 +async function rpc(method, params = []) { + const body = await request(RPC_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }), + }) + if (body?.error) { + const error = new Error(body.error.message ?? `${method} failed`) + error.code = body.error.code + error.data = body.error.data + throw error + } + return body?.result +} + +async function post(path, body) { + return request(`${API_URL}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +async function waitFor(name, read, predicate, timeoutMs = 60_000, intervalMs = 350) { + const started = performance.now() + let last + while (performance.now() - started < timeoutMs) { + try { + last = await read() + if (predicate(last)) return last + } catch (error) { + last = error + } + await sleep(intervalMs) + } + throw new Error(`Timed out waiting for ${name}${last instanceof Error ? `: ${last.message}` : ''}`) +} + +async function readCode(address) { + return rpc('eth_getCode', [address, 'latest']) +} + +async function readEthBalance(address) { + return BigInt(await rpc('eth_getBalance', [address, 'latest'])) +} + +async function readTokenBalance(token, address, blockTag = 'latest') { + const data = encodeFunctionData({ abi: erc20Abi, functionName: 'balanceOf', args: [address] }) + const raw = await rpc('eth_call', [{ to: token, data }, blockTag]) + return decodeAbiParameters([{ type: 'uint256' }], raw)[0] +} + +async function waitForReceipt(hash, timeoutMs = RECEIPT_TIMEOUT_MS) { + return waitFor( + `receipt ${hash}`, + () => getTransactionReceipt(client, { hash }).catch(() => null), + Boolean, + timeoutMs, + 250, + ) +} + +async function waitForReceipts(items, timeoutMs = RECEIPT_TIMEOUT_MS) { + const pending = new Map(items.map((item) => [item.hash.toLowerCase(), { + ...item, + lastRebroadcastAt: item.sentAt, + }])) + const receipts = new Map() + const started = performance.now() + + while (pending.size > 0 && performance.now() - started < timeoutMs) { + const batch = [...pending.values()] + .sort((left, right) => left.sentAt - right.sentAt) + .slice(0, 10) + + await Promise.all(batch.map(async (item) => { + const receipt = await getTransactionReceipt(client, { hash: item.hash }).catch(() => null) + if (receipt) { + assertReceiptSucceeded(item.hash, receipt) + receipts.set(item.hash.toLowerCase(), receipt) + pending.delete(item.hash.toLowerCase()) + return + } + + const now = performance.now() + if (now - item.lastRebroadcastAt < 1_250) return + item.lastRebroadcastAt = now + try { + await rpc('eth_sendRawTransaction', [item.raw]) + console.log(` rebroadcast sequence ${item.sequence} (${short(item.hash)})`) + } catch (error) { + const known = await rpc('eth_getTransactionByHash', [item.hash]).catch(() => null) + if (!known && !/already known|nonce too low/i.test(error.message)) { + console.warn(` rebroadcast sequence ${item.sequence} returned: ${error.message}`) + } + } + })) + + if (pending.size > 0) await sleep(250) + } + + if (pending.size > 0) { + const missing = [...pending.values()].map((item) => `${item.sequence}:${item.hash}`).join(', ') + throw new Error(`Timed out waiting for ${pending.size} receipt(s): ${missing}`) + } + + return items.map((item) => receipts.get(item.hash.toLowerCase())) +} + +async function reconcileInFlight(pending, receipts, { rebroadcastAfterMs = 900 } = {}) { + const batch = [...pending.values()] + .sort((left, right) => left.sentAt - right.sentAt) + .slice(0, 10) + + await Promise.all(batch.map(async (item) => { + const receipt = await getTransactionReceipt(client, { hash: item.hash }).catch(() => null) + if (receipt) { + assertReceiptSucceeded(item.hash, receipt) + receipts.set(item.hash.toLowerCase(), receipt) + pending.delete(item.hash.toLowerCase()) + return + } + + const now = performance.now() + const known = await rpc('eth_getTransactionByHash', [item.hash]).catch(() => null) + if (known) { + item.lastSeenAt = now + return + } + if (now - item.lastRebroadcastAt < rebroadcastAfterMs) return + item.lastRebroadcastAt = now + try { + await rpc('eth_sendRawTransaction', [item.raw]) + console.log(` rebroadcast sequence ${item.sequence} (${short(item.hash)})`) + } catch (error) { + const foundAfterError = await rpc('eth_getTransactionByHash', [item.hash]).catch(() => null) + if (!foundAfterError && !/already known|nonce too low/i.test(error.message)) { + console.warn(` rebroadcast sequence ${item.sequence} returned: ${error.message}`) + } + } + })) +} + +async function broadcastAccepted(raw, expectedHash, { timeoutMs = 30_000 } = {}) { + const deadline = performance.now() + timeoutMs + let attempt = 0 + let lastError + + while (performance.now() < deadline) { + attempt += 1 + try { + const returnedHash = await rpc('eth_sendRawTransaction', [raw]) + if (returnedHash.toLowerCase() !== expectedHash.toLowerCase()) { + throw new Error(`RPC returned unexpected hash ${returnedHash}; expected ${expectedHash}`) + } + } catch (error) { + lastError = error + if (!/already known|nonce too low|signature limit reached|nonce too high|nonce mismatch/i.test(error.message) && attempt >= 3) { + throw error + } + } + + return expectedHash + } + + throw new Error(`Timed out broadcasting ${expectedHash}${lastError ? `: ${lastError.message}` : ''}`) +} + +function assertReceiptSucceeded(hash, receipt) { + const topLevelFailed = receipt.status === '0x0' || receipt.status === 0n || receipt.status === 0 + const fields = receipt.eip8130 ?? receipt + if (topLevelFailed || !allPhasesSucceeded(fields)) { + throw new Error(`Transaction ${hash} reverted: ${JSON.stringify(fields.phaseStatuses ?? [])}`) + } +} + +async function currentFees() { + const block = await rpc('eth_getBlockByNumber', ['latest', false]) + const baseFee = BigInt(block?.baseFeePerGas ?? 1_000_000_000n) + return { + baseFee, + priorityFee: PRIORITY_FEE, + maxFeePerGas: baseFee * 2n + PRIORITY_FEE, + } +} + +async function faucetDrip(path, address, balanceReader, before, cooldownSeconds) { + for (let attempt = 1; attempt <= 4; attempt += 1) { + try { + const response = await post(path, { address }) + const after = await waitFor( + `${path} balance increase`, + balanceReader, + (balance) => balance > before, + ) + return { response, after } + } catch (error) { + if (attempt === 4) throw error + const retryMs = (cooldownSeconds + 1) * 1000 + console.warn(` ${path} attempt ${attempt} failed (${error.message}); retrying in ${retryMs / 1000}s`) + await sleep(retryMs) + } + } +} + +async function main() { + if (!Number.isInteger(TRANSFER_COUNT) || TRANSFER_COUNT < 1) { + throw new Error('SPIKE_TRANSFERS must be a positive integer') + } + + console.log('200ms demo Phase 1 spike') + console.log('====================================') + + const [health, contracts, faucetStatus, chainIdHex, genesis] = await Promise.all([ + request(`${API_URL}/api/vibenet/chain-health`), + request(`${API_URL}/api/vibenet/contracts`), + request(`${API_URL}/api/vibenet/faucet/status`), + rpc('eth_chainId'), + rpc('eth_getBlockByNumber', ['0x0', false]), + ]) + + if (!health.healthy) throw new Error(`Vibenet is unhealthy: ${health.reason ?? health.detail ?? 'unknown reason'}`) + if (Number(BigInt(chainIdHex)) !== CHAIN_ID) throw new Error(`Unexpected chain id ${chainIdHex}`) + + const candidates = [ + contracts?.eip8130?.CanonicalHighRatePayerAccount, + vibenetDevnetDeployment?.accounts?.defaultHighRate, + ] + .filter((value, index, values) => typeof value === 'string' && values.indexOf(value) === index) + + let implementation + for (const candidate of candidates) { + if ((await readCode(candidate)) !== '0x') { + implementation = candidate + break + } + } + if (!implementation) throw new Error('No live EIP-8130 high-rate account implementation found') + + const usdv = contracts.usdv ?? faucetStatus.usdv_address + if (!usdv || (await readCode(usdv)) === '0x') throw new Error('No live USDV contract found') + + const signer = privateKeyToAccount(generatePrivateKey()) + const account = newSmartAccount({ signer, implementation, salt: generatePrivateKey() }) + const recipient = privateKeyToAccount(generatePrivateKey()).address + + console.log(`chain: ${Number(BigInt(chainIdHex))}`) + console.log(`head: ${health.head}`) + console.log(`genesis: ${short(genesis.hash)}`) + console.log(`implementation: ${implementation} (high-rate)`) + console.log(`USDV: ${usdv}`) + console.log(`sender: ${account.address}`) + console.log(`recipient: ${recipient}`) + + const ethBefore = await readEthBalance(account.address) + console.log(`\nFunding sender with ETH (before ${formatUnits(ethBefore, 18)} ETH)…`) + const ethDrip = await faucetDrip( + '/api/vibenet/faucet/drip', + account.address, + () => readEthBalance(account.address), + ethBefore, + faucetStatus.ip_cooldown_secs ?? 10, + ) + console.log(` landed ${ethDrip.response.tx_hash ?? '(hash unavailable)'}; balance ${formatUnits(ethDrip.after, 18)} ETH`) + + const cooldownMs = (Number(faucetStatus.ip_cooldown_secs ?? 10) + 1) * 1000 + console.log(`Waiting ${cooldownMs / 1000}s for the shared faucet cooldown…`) + await sleep(cooldownMs) + + const usdvBefore = await readTokenBalance(usdv, account.address) + console.log(`Funding sender with USDV (before ${formatUnits(usdvBefore)} USDV)…`) + const usdvDrip = await faucetDrip( + '/api/vibenet/faucet/drip-usdv', + account.address, + () => readTokenBalance(usdv, account.address), + usdvBefore, + faucetStatus.ip_cooldown_secs ?? 10, + ) + console.log(` landed ${usdvDrip.response.tx_hash ?? '(hash unavailable)'}; balance ${formatUnits(usdvDrip.after)} USDV`) + + const transferData = (amount) => encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient, amount], + }) + const phases = (amount) => [[{ to: usdv, value: 0n, data: transferData(amount) }]] + const actor = key.k1(signer.address) + const fees = await currentFees() + let sequence = await getTransactionCount(client, { address: account.address, nonceKey: 0n }) + + console.log(`\nDeploying account with a 1-unit USDV proof transfer at sequence ${sequence}…`) + const createEstimate = await estimateGas(client, { + sender: account.address, + senderActorId: actor.actorId, + senderAuthAuthenticator: canonicalAuthenticators.k1, + accountChanges: [account.createChange], + calls: phases(1n), + nonceKey: 0n, + nonceSequence: Number(sequence), + }) + const createGas = (createEstimate * 13n + 9n) / 10n + const createRaw = await account.signTransaction({ + chainId: CHAIN_ID, + accountChanges: [account.createChange], + calls: encodeWalletCalls({ account: account.address, calls: phases(1n) }), + nonceKey: 0n, + nonceSequence: sequence, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.priorityFee, + gas: createGas, + }) + const expectedCreateHash = keccak256(createRaw) + const createHash = await rpc('eth_sendRawTransaction', [createRaw]) + if (createHash.toLowerCase() !== expectedCreateHash.toLowerCase()) { + throw new Error(`RPC returned unexpected create hash ${createHash}; expected ${expectedCreateHash}`) + } + const createReceipt = await waitForReceipt(createHash) + assertReceiptSucceeded(createHash, createReceipt) + await waitFor('account bytecode', () => readCode(account.address), (code) => code !== '0x', 15_000, 250) + console.log(` deployed in block ${Number(BigInt(createReceipt.blockNumber))}; gas ${Number(BigInt(createReceipt.gasUsed))}`) + sequence += 1n + + const recipientBaseline = await readTokenBalance(usdv, recipient) + const plainEstimate = await estimateGas(client, { + sender: account.address, + senderActorId: actor.actorId, + senderAuthAuthenticator: canonicalAuthenticators.k1, + accountChanges: [], + calls: phases(TRANSFER_AMOUNT), + nonceKey: 0n, + nonceSequence: Number(sequence), + }) + const nonceFreeEstimate = plainEstimate + nonceFreeCost - nonceKeyExistingCost + const gasLimit = (nonceFreeEstimate * 13n + 9n) / 10n + console.log(`\nSequenced transfer estimate: ${plainEstimate} gas`) + console.log(`Nonce-free adjusted estimate: ${nonceFreeEstimate} gas; session limit: ${gasLimit}`) + console.log(`Broadcasting ${TRANSFER_COUNT} nonce-free transfers of ${formatUnits(TRANSFER_AMOUNT)} USDV…`) + + const sent = [] + const pending = new Map() + const earlyReceipts = new Map() + let sendingComplete = false + let topUpCount = 0 + const watcher = (async () => { + while (!sendingComplete || pending.size > 0) { + await reconcileInFlight(pending, earlyReceipts, { rebroadcastAfterMs: 700 }) + await sleep(180) + } + })() + const fundingWatcher = AUTO_TOP_UP ? (async () => { + while (!sendingComplete || pending.size > 0) { + const before = await readEthBalance(account.address) + if (before < 30_000_000_000_000_000n) { + const response = await post('/api/vibenet/faucet/drip', { address: account.address }) + await waitFor('automatic ETH top-up', () => readEthBalance(account.address), (balance) => balance > before) + topUpCount += 1 + console.log(` automatic ETH top-up ${topUpCount} landed (${short(response.tx_hash)})`) + } + await sleep(5_000) + } + })() : Promise.resolve() + const broadcastStarted = performance.now() + try { + for (let index = 0; index < TRANSFER_COUNT; index += 1) { + const scheduledAt = broadcastStarted + index * 200 + const beforeSendDelay = scheduledAt - performance.now() + if (beforeSendDelay > 0) await sleep(beforeSendDelay) + while (pending.size > 12) await sleep(80) + + const raw = await account.signTransaction({ + chainId: CHAIN_ID, + accountChanges: [], + calls: encodeWalletCalls({ account: account.address, calls: phases(TRANSFER_AMOUNT) }), + metadata: toHex(`200ms-demo-spike:${index}:${Date.now()}`), + nonceKey: nonceKeyMax, + validBefore: BigInt(Date.now() + 15_000 + index), + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.priorityFee, + gas: gasLimit, + }) + const expectedHash = keccak256(raw) + const sentAt = performance.now() + const hash = await broadcastAccepted(raw, expectedHash) + const item = { hash, raw, sequence: BigInt(index), sentAt, lastRebroadcastAt: sentAt } + sent.push(item) + pending.set(hash.toLowerCase(), item) + if ((index + 1) % 10 === 0 || index === TRANSFER_COUNT - 1) { + console.log(` accepted ${index + 1}/${TRANSFER_COUNT}`) + } + } + } finally { + sendingComplete = true + } + const broadcastEnded = performance.now() + + console.log('Waiting for all receipts…') + await Promise.race([ + Promise.all([watcher, fundingWatcher]), + sleep(RECEIPT_TIMEOUT_MS).then(() => { throw new Error('Receipt watcher timed out') }), + ]) + if (pending.size > 0) { + const missing = [...pending.values()].map((item) => `${item.sequence}:${item.hash}`).join(', ') + throw new Error(`Timed out waiting for ${pending.size} receipt(s): ${missing}`) + } + const receipts = sent.map((item) => earlyReceipts.get(item.hash.toLowerCase())) + const confirmedAt = performance.now() + + const recipientAfter = await waitFor( + 'recipient USDV delta', + () => readTokenBalance(usdv, recipient), + (balance) => balance - recipientBaseline === BigInt(TRANSFER_COUNT) * TRANSFER_AMOUNT, + 30_000, + 300, + ) + const actualDelta = recipientAfter - recipientBaseline + const expectedDelta = BigInt(TRANSFER_COUNT) * TRANSFER_AMOUNT + if (actualDelta !== expectedDelta) { + throw new Error(`Recipient delta mismatch: expected ${expectedDelta}, received ${actualDelta}`) + } + + const gasUsed = receipts.map((receipt) => BigInt(receipt.gasUsed)) + const totalGas = gasUsed.reduce((sum, value) => sum + value, 0n) + const meanGas = totalGas / BigInt(gasUsed.length) + const broadcastSeconds = (broadcastEnded - broadcastStarted) / 1000 + const confirmationSeconds = (confirmedAt - broadcastStarted) / 1000 + const acceptedTps = TRANSFER_COUNT / broadcastSeconds + const confirmedTps = TRANSFER_COUNT / confirmationSeconds + const costPerTransfer = meanGas * fees.baseFee + const transfersPerDrip = BigInt(faucetStatus.drip_wei) / costPerTransfer + const runwaySecondsAtFiveTps = Number(transfersPerDrip) / 5 + + console.log('\nPHASE 1 REPORT') + console.log('==============') + console.log(`transfers: ${TRANSFER_COUNT}`) + console.log(`recipient delta: ${formatUnits(actualDelta)} USDV`) + console.log(`broadcast duration: ${broadcastSeconds.toFixed(3)}s`) + console.log(`accepted throughput: ${acceptedTps.toFixed(2)} tx/s`) + console.log(`confirmation duration: ${confirmationSeconds.toFixed(3)}s`) + console.log(`confirmed throughput: ${confirmedTps.toFixed(2)} tx/s`) + console.log(`mean gas/transfer: ${meanGas}`) + console.log(`base fee sampled: ${fees.baseFee} wei`) + console.log(`cost/transfer: ${formatUnits(costPerTransfer, 18)} ETH`) + console.log(`0.1 ETH runway: ${transfersPerDrip} transfers`) + console.log(`runway at 5 tx/s: ${(runwaySecondsAtFiveTps / 60).toFixed(2)} minutes`) + console.log(`automatic ETH top-ups: ${topUpCount}`) + + if (acceptedTps < 3) { + throw new Error(`Phase 1 gate failed: accepted throughput ${acceptedTps.toFixed(2)} tx/s is below 3 tx/s`) + } + if (AUTO_TOP_UP && TRANSFER_COUNT >= 1_400 && topUpCount < 1) { + throw new Error('Soak gate failed: expected at least one automatic ETH top-up') + } + console.log('\nPASS: high-rate nonce-free broadcasting clears the Phase 1 throughput gate.') +} + +main().catch((error) => { + console.error(`\nFAIL: ${error.stack ?? error.message ?? error}`) + process.exitCode = 1 +}) diff --git a/200ms-demo/scripts/test-rate-math.ts b/200ms-demo/scripts/test-rate-math.ts new file mode 100644 index 000000000..34c56d7cd --- /dev/null +++ b/200ms-demo/scripts/test-rate-math.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' + +import type { StreamRate } from '../src/chain/config.ts' +import { RationalAccumulator } from '../src/chain/rationalAccumulator.ts' + +const monthly: StreamRate = { + id: 'monthly', + amountUnits: 10_000_000n, + periodMicros: 2_628_000_000_000n, + amountLabel: '10 USDV', + periodLabel: 'month', + shortLabel: '10 / month', +} + +const accumulator = new RationalAccumulator() +let total = 0n +for (let index = 0; index < 13_140_000; index += 1) { + total += accumulator.accrue(monthly, 200_000n) +} +assert.equal(total, 10_000_000n, '10 USDV/month must accrue exactly over the full period') +assert.deepEqual(accumulator.fraction(), { numerator: 0n, denominator: 1n }) + +const first: StreamRate = { ...monthly, amountUnits: 5_000_000n, periodMicros: 60_000_000n } +const second: StreamRate = { ...monthly, amountUnits: 100_000_000n, periodMicros: 3_600_000_000n } +const changed = new RationalAccumulator() +const firstHalf = changed.accrue(first, 30_000_000n) +const secondHalf = changed.accrue(second, 1_800_000_000n) +assert.equal(firstHalf + secondHalf, 52_500_000n, 'rate changes must preserve the exact accrued value') +assert.deepEqual(changed.fraction(), { numerator: 0n, denominator: 1n }) + +console.log('Exact rational accumulator checks passed.') diff --git a/200ms-demo/src/App.tsx b/200ms-demo/src/App.tsx new file mode 100644 index 000000000..e55f867e2 --- /dev/null +++ b/200ms-demo/src/App.tsx @@ -0,0 +1,227 @@ +import { useEffect, useRef, useState } from 'react' + +import { bootstrap, type BootstrapProgress, type StreamRuntime } from './chain/bootstrap' +import { RATE_PRESETS, type StreamRate } from './chain/config' +import { formatDuration, formatUnits } from './chain/format' +import { MoneyStreamer } from './chain/streamer' +import { useChainHead } from './hooks/useChainHead' +import { useStream } from './hooks/useStream' +import { AccountCard } from './ui/AccountCard' +import { RateSelector } from './ui/RateSelector' +import { StreamCounter } from './ui/StreamCounter' +import { TxTicker } from './ui/TxTicker' + +const progressOrder: BootstrapProgress['stage'][] = ['health', 'account', 'eth', 'usdv', 'deploy', 'gas', 'ready'] +const progressLabels: Record = { + health: 'Network healthy', + account: 'Local account created', + eth: 'Gas funded', + usdv: 'USDV funded', + deploy: 'Account deployed', + gas: 'Stream calibrated', + ready: 'Ready', +} + +function Header({ phase }: { phase?: string }) { + const { head, cadenceMs } = useChainHead() + const live = head !== null + return ( +
+
200ms demo
+
+ Vibenet + block {head?.toLocaleString() ?? '—'} + {cadenceMs ? `${Math.round(cadenceMs)}ms` : 'measuring'} + {phase === 'error' ? 'issue' : live ? 'live' : 'connecting'} +
+
+ ) +} + +function BootstrapView({ progress, error, onRetry }: { + progress: BootstrapProgress + error: string | null + onRetry: () => void +}) { + const activeIndex = progressOrder.indexOf(progress.stage) + return ( +
+
+
+
+
+ ) +} + +export default function App() { + const [runtime, setRuntime] = useState(null) + const [streamer, setStreamer] = useState(null) + const [rate, setRate] = useState(RATE_PRESETS[0]) + const [bootstrapKey, setBootstrapKey] = useState(0) + const [progress, setProgress] = useState({ stage: 'health', label: 'Checking Vibenet health…' }) + const [bootstrapError, setBootstrapError] = useState(null) + const [recovering, setRecovering] = useState(false) + const forceRotate = useRef(false) + const resumeAfterReset = useRef(false) + + useEffect(() => { + let cancelled = false + setBootstrapError(null) + setProgress(forceRotate.current + ? { stage: 'account', label: 'Network reset — starting over…' } + : { stage: 'health', label: 'Checking Vibenet health…' }) + void bootstrap((next) => { if (!cancelled) setProgress(next) }, { forceRotate: forceRotate.current }) + .then((nextRuntime) => { + if (cancelled) return + forceRotate.current = false + setRuntime(nextRuntime) + setRecovering(false) + }) + .catch((error) => { + if (!cancelled) setBootstrapError(error instanceof Error ? error.message : 'Bootstrap failed') + }) + return () => { cancelled = true } + }, [bootstrapKey]) + + useEffect(() => { + if (!runtime) { + setStreamer(null) + return + } + const next = new MoneyStreamer(runtime, rate) + setStreamer(next) + if (resumeAfterReset.current) { + resumeAfterReset.current = false + queueMicrotask(() => next.start()) + } + return () => next.destroy() + }, [runtime]) + + useEffect(() => { streamer?.setRate(rate) }, [rate, streamer]) + const snapshot = useStream(streamer) + + useEffect(() => { + if (!snapshot || snapshot.errorCode !== 'NETWORK_RESET' || recovering) return + resumeAfterReset.current = snapshot.startedAt !== null + forceRotate.current = true + setRecovering(true) + setRuntime(null) + const timer = window.setTimeout(() => setBootstrapKey((value) => value + 1), 1_200) + return () => window.clearTimeout(timer) + }, [snapshot, recovering]) + + const retry = () => { + setRuntime(null) + setBootstrapKey((value) => value + 1) + } + + if (!runtime || !streamer || !snapshot) { + return + } + + const streamedBalance = snapshot.recipientTokenBalance > runtime.recipientBaseline + ? snapshot.recipientTokenBalance - runtime.recipientBaseline + : 0n + const approximateTransfers = snapshot.senderEthBalance / 50_017_000_000_000n + const runwayMinutes = Number(approximateTransfers) / 5 / 60 + const actionLabel = snapshot.phase === 'streaming' + ? 'Stop stream' + : snapshot.phase === 'paused' + ? 'Resume stream' + : snapshot.phase === 'stopping' + ? 'Stopping…' + : 'Start stream' + + const handleAction = () => { + if (snapshot.phase === 'streaming') streamer.stop() + else if (snapshot.phase === 'paused') streamer.resume() + else if (snapshot.phase !== 'stopping') streamer.start() + } + + const summary = snapshot.phase === 'stopped' + ? `${snapshot.confirmedCount} confirmed · ${formatDuration(snapshot.elapsedActiveMs)} · ${rate.amountLabel} / ${rate.periodLabel}` + : null + + return ( +
+
+
+
+ +
+ {snapshot.confirmedCount} confirmed + {snapshot.transferCount} transfers + {formatDuration(snapshot.elapsedActiveMs)} + {runwayMinutes.toFixed(1)}m gas runway +
+ {summary ?
{summary}
: null} +
+ +
+ + + +
+ +
+ + +
+ {snapshot.error ?? snapshot.notice ?? (snapshot.phase === 'streaming' + ? 'Streaming real nonce-free EIP-8130 transfers' + : 'No wallet · no extension · no signing prompts')} +
+
+ + +
+
+ USDV is Vibenet's faucet-backed test USD stablecoin. Keys are local devnet-only bearer material. +
+
+ ) +} diff --git a/200ms-demo/src/chain/account.ts b/200ms-demo/src/chain/account.ts new file mode 100644 index 000000000..76a38ae16 --- /dev/null +++ b/200ms-demo/src/chain/account.ts @@ -0,0 +1,176 @@ +import { + generatePrivateKey, + newSmartAccount, + privateKeyToAccount, + type Address, + type Hex, + type LocalAccount, + type NewSmartAccountReturnType, + vibenetDevnetDeployment, +} from '@vibenet/aa' + +import type { LiveContracts } from './config' +import { readCode } from './rpc' + +const ACCOUNT_STORAGE_KEY = 'base.200ms-demo.account.v2' +const LEASE_STORAGE_KEY = 'base.200ms-demo.lease.v1' + +type StoredAccount = { + version: 2 + genesisHash: Hex + implementation: Address + token: Address + privateKey: Hex + salt: Hex + address: Address + recipient: Address + lastHead: number +} + +export type StreamAccount = { + account: NewSmartAccountReturnType + signer: LocalAccount + recipient: Address + genesisHash: Hex + implementation: Address + rotated: boolean + stored: StoredAccount +} + +function loadStored(): StoredAccount | null { + try { + const parsed = JSON.parse(localStorage.getItem(ACCOUNT_STORAGE_KEY) ?? 'null') as StoredAccount | null + return parsed?.version === 2 ? parsed : null + } catch { + return null + } +} + +export function saveStoredHead(stored: StoredAccount, head: number) { + stored.lastHead = head + try { + localStorage.setItem(ACCOUNT_STORAGE_KEY, JSON.stringify(stored)) + } catch { + // The in-memory account remains usable when storage is unavailable. + } +} + +function persist(stored: StoredAccount) { + try { + localStorage.setItem(ACCOUNT_STORAGE_KEY, JSON.stringify(stored)) + } catch { + // Private browsing can deny storage; this page session still works. + } +} + +export async function resolveHighRateImplementation(contracts: LiveContracts): Promise
{ + const candidates = [ + contracts.eip8130?.CanonicalHighRatePayerAccount, + vibenetDevnetDeployment.accounts.defaultHighRate, + ].filter((candidate): candidate is Address => typeof candidate === 'string' && /^0x[0-9a-fA-F]{40}$/.test(candidate)) + + for (const candidate of candidates) { + if ((await readCode(candidate)) !== '0x') return candidate + } + return null +} + +export function resolveUsdv(contracts: LiveContracts, faucetToken: Address): Address { + return (contracts.usdv ?? faucetToken) as Address +} + +export function getOrCreateAccount(parameters: { + genesisHash: Hex + implementation: Address + token: Address + head: number + forceRotate?: boolean +}): StreamAccount { + const { genesisHash, implementation, token, head, forceRotate = false } = parameters + let stored = loadStored() + const mismatch = !stored + || stored.genesisHash.toLowerCase() !== genesisHash.toLowerCase() + || stored.implementation.toLowerCase() !== implementation.toLowerCase() + || stored.token.toLowerCase() !== token.toLowerCase() + || stored.lastHead > head + 8 + + const rotated = forceRotate || mismatch + if (rotated || !stored) { + const privateKey = generatePrivateKey() + const salt = generatePrivateKey() + const signer = privateKeyToAccount(privateKey) + const account = newSmartAccount({ signer, implementation, salt }) + stored = { + version: 2, + genesisHash, + implementation, + token, + privateKey, + salt, + address: account.address, + recipient: privateKeyToAccount(generatePrivateKey()).address, + lastHead: head, + } + persist(stored) + return { account, signer, recipient: stored.recipient, genesisHash, implementation, rotated, stored } + } + + const signer = privateKeyToAccount(stored.privateKey) + let account = newSmartAccount({ signer, implementation, salt: stored.salt }) + if (account.address.toLowerCase() !== stored.address.toLowerCase()) { + stored.privateKey = generatePrivateKey() + stored.salt = generatePrivateKey() + const replacementSigner = privateKeyToAccount(stored.privateKey) + account = newSmartAccount({ signer: replacementSigner, implementation, salt: stored.salt }) + stored.address = account.address + stored.recipient = privateKeyToAccount(generatePrivateKey()).address + stored.lastHead = head + persist(stored) + return { + account, + signer: replacementSigner, + recipient: stored.recipient, + genesisHash, + implementation, + rotated: true, + stored, + } + } + + stored.lastHead = head + persist(stored) + return { account, signer, recipient: stored.recipient, genesisHash, implementation, rotated, stored } +} + +export class StreamLease { + private readonly owner = crypto.randomUUID() + private timer: number | null = null + + acquire(): boolean { + const now = Date.now() + try { + const current = JSON.parse(localStorage.getItem(LEASE_STORAGE_KEY) ?? 'null') as { owner: string; expiresAt: number } | null + if (current && current.owner !== this.owner && current.expiresAt > now) return false + this.write(now + 5_000) + this.timer = window.setInterval(() => this.write(Date.now() + 5_000), 2_000) + return true + } catch { + return true + } + } + + release() { + if (this.timer !== null) window.clearInterval(this.timer) + this.timer = null + try { + const current = JSON.parse(localStorage.getItem(LEASE_STORAGE_KEY) ?? 'null') as { owner: string } | null + if (current?.owner === this.owner) localStorage.removeItem(LEASE_STORAGE_KEY) + } catch { + // Nothing to release. + } + } + + private write(expiresAt: number) { + localStorage.setItem(LEASE_STORAGE_KEY, JSON.stringify({ owner: this.owner, expiresAt })) + } +} diff --git a/200ms-demo/src/chain/bootstrap.ts b/200ms-demo/src/chain/bootstrap.ts new file mode 100644 index 000000000..39850f4fe --- /dev/null +++ b/200ms-demo/src/chain/bootstrap.ts @@ -0,0 +1,252 @@ +import { + allPhasesSucceeded, + canonicalAuthenticators, + encodeFunctionData, + encodeWalletCalls, + estimateGas, + getTransactionCount, + key, + keccak256, + nonceFreeCost, + nonceKeyExistingCost, + type Address, + type Hex, +} from '@vibenet/aa' + +import { getOrCreateAccount, resolveHighRateImplementation, resolveUsdv, saveStoredHead, type StreamAccount } from './account' +import { + CHAIN_ID, + erc20Abi, + MIN_ETH_BOOTSTRAP, + PRIORITY_FEE, + type ChainHealth, + type FaucetStatus, + type LiveContracts, +} from './config' +import { FaucetQueue } from './faucet' +import { + getChainHealth, + getContracts, + getFaucetStatus, + isRecoverableBroadcastError, + publicClient, + readCode, + readFreshBalances, + readGenesisHash, + readLatestBlock, + readReceipt, + readTokenBalance, + readTransaction, + sendRaw, +} from './rpc' + +export type BootstrapStage = 'health' | 'account' | 'eth' | 'usdv' | 'deploy' | 'gas' | 'ready' + +export type BootstrapProgress = { + stage: BootstrapStage + label: string + detail?: string +} + +export type StreamRuntime = { + identity: StreamAccount + token: Address + gasLimit: bigint + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + recipientBaseline: bigint + faucet: FaucetQueue + faucetStatus: FaucetStatus + contracts: LiveContracts + health: ChainHealth + senderEthBalance: bigint + senderTokenBalance: bigint + recipientTokenBalance: bigint +} + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) + +function transferData(recipient: Address, amount: bigint): Hex { + return encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [recipient, amount] }) +} + +function transferPhases(token: Address, recipient: Address, amount: bigint) { + return [[{ to: token, value: 0n, data: transferData(recipient, amount) }]] as const +} + +function assertReceiptSucceeded(hash: Hex, receipt: Record) { + const fields = receipt.eip8130 ?? receipt + if (receipt.status === '0x0' || !allPhasesSucceeded(fields)) { + const error = new Error(`Bootstrap transaction reverted (${hash})`) + Object.assign(error, { hash, receipt }) + throw error + } +} + +async function submitAndWait(raw: Hex, timeoutMs = 60_000) { + const hash = keccak256(raw) + const started = performance.now() + let lastBroadcastAt = 0 + + while (performance.now() - started < timeoutMs) { + const receipt = await readReceipt(hash) + if (receipt) { + assertReceiptSucceeded(hash, receipt) + return { hash, receipt } + } + + const transaction = await readTransaction(hash).catch(() => null) + const now = performance.now() + if (!transaction && now - lastBroadcastAt > 800) { + lastBroadcastAt = now + try { + const returnedHash = await sendRaw(raw) + if (returnedHash.toLowerCase() !== hash.toLowerCase()) { + throw new Error(`RPC returned ${returnedHash}; expected ${hash}`) + } + } catch (error) { + if (!isRecoverableBroadcastError(error)) throw error + } + } + await sleep(250) + } + throw new Error(`Timed out waiting for bootstrap transaction ${hash}`) +} + +async function waitForCode(address: Address) { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + if ((await readCode(address)) !== '0x') return + await sleep(250) + } + throw new Error('The account transaction confirmed, but account bytecode did not become readable') +} + +async function runBootstrap( + onProgress: (progress: BootstrapProgress) => void, + forceRotate: boolean, +): Promise { + onProgress({ stage: 'health', label: 'Checking Vibenet health…' }) + const [health, contracts, faucetStatus, genesisHash] = await Promise.all([ + getChainHealth(), + getContracts(), + getFaucetStatus(), + readGenesisHash(), + ]) + if (!health.healthy) { + throw new Error(`Vibenet is unavailable: ${health.reason ?? health.detail ?? 'chain health check failed'}`) + } + if (faucetStatus.chain_id !== CHAIN_ID) throw new Error(`Unexpected faucet chain ${faucetStatus.chain_id}`) + + const implementation = await resolveHighRateImplementation(contracts) + if (!implementation) throw new Error('Vibenet has no live high-rate EIP-8130 account implementation') + const token = resolveUsdv(contracts, faucetStatus.usdv_address) + if ((await readCode(token)) === '0x') throw new Error('Vibenet has no live USDV contract') + + onProgress({ stage: 'account', label: 'Creating a local account…', detail: 'No wallet or extension' }) + const identity = getOrCreateAccount({ + genesisHash, + implementation, + token, + head: health.head, + forceRotate, + }) + const faucet = new FaucetQueue(faucetStatus, token) + + onProgress({ stage: 'eth', label: 'Funding transaction gas…', detail: '0.1 test ETH from the Vibenet faucet' }) + await faucet.ensureEth(identity.account.address, MIN_ETH_BOOTSTRAP) + + onProgress({ stage: 'usdv', label: 'Funding the stream…', detail: '1,000 Vibenet USDV' }) + await faucet.ensureUsdv(identity.account.address, 1n) + + const latestBlock = await readLatestBlock() + const baseFee = BigInt(latestBlock.baseFeePerGas ?? 1_000_000_000n) + const maxFeePerGas = baseFee * 2n + PRIORITY_FEE + let sequence = await getTransactionCount(publicClient, { address: identity.account.address, nonceKey: 0n }) + const deployed = (await readCode(identity.account.address)) !== '0x' + let recipientBaseline = await readTokenBalance(token, identity.recipient) + + if (!deployed) { + onProgress({ stage: 'deploy', label: 'Deploying the native account…', detail: 'First transfer proves the full path' }) + const phases = transferPhases(token, identity.recipient, 1n) + const actor = key.k1(identity.signer.address) + const createEstimate = await estimateGas(publicClient, { + sender: identity.account.address, + senderActorId: actor.actorId, + senderAuthAuthenticator: canonicalAuthenticators.k1, + accountChanges: [identity.account.createChange], + calls: phases, + nonceKey: 0n, + nonceSequence: Number(sequence), + }) + const createGas = (createEstimate * 13n + 9n) / 10n + const raw = await identity.account.signTransaction({ + chainId: CHAIN_ID, + accountChanges: [identity.account.createChange], + calls: encodeWalletCalls({ account: identity.account.address, calls: phases }), + nonceKey: 0n, + nonceSequence: sequence, + maxFeePerGas, + maxPriorityFeePerGas: PRIORITY_FEE, + gas: createGas, + }) + await submitAndWait(raw) + await waitForCode(identity.account.address) + const expectedRecipientBalance = recipientBaseline + 1n + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const balance = await readTokenBalance(token, identity.recipient).catch(() => recipientBaseline) + if (balance >= expectedRecipientBalance) { + recipientBaseline = balance + break + } + await sleep(250) + } + sequence += 1n + } + + onProgress({ stage: 'gas', label: 'Measuring the stream path…', detail: 'Calibrating gas once for this chain epoch' }) + const actor = key.k1(identity.signer.address) + const samplePhases = transferPhases(token, identity.recipient, 16_666n) + const sequencedEstimate = await estimateGas(publicClient, { + sender: identity.account.address, + senderActorId: actor.actorId, + senderAuthAuthenticator: canonicalAuthenticators.k1, + accountChanges: [], + calls: samplePhases, + nonceKey: 0n, + nonceSequence: Number(sequence), + }) + const nonceFreeEstimate = sequencedEstimate + nonceFreeCost - nonceKeyExistingCost + const gasLimit = (nonceFreeEstimate * 13n + 9n) / 10n + const balances = await readFreshBalances(token, identity.account.address, identity.recipient) + recipientBaseline = balances.recipientToken > recipientBaseline ? balances.recipientToken : recipientBaseline + saveStoredHead(identity.stored, balances.blockNumber) + + onProgress({ stage: 'ready', label: 'Ready to stream', detail: 'Real transfers, no wallet prompts' }) + return { + identity, + token, + gasLimit, + maxFeePerGas, + maxPriorityFeePerGas: PRIORITY_FEE, + recipientBaseline, + faucet, + faucetStatus, + contracts, + health, + senderEthBalance: balances.eth, + senderTokenBalance: balances.senderToken, + recipientTokenBalance: balances.recipientToken, + } +} + +export async function bootstrap( + onProgress: (progress: BootstrapProgress) => void, + options: { forceRotate?: boolean } = {}, +): Promise { + if (navigator.locks?.request) { + return navigator.locks.request('base-200ms-demo-bootstrap', () => runBootstrap(onProgress, Boolean(options.forceRotate))) + } + return runBootstrap(onProgress, Boolean(options.forceRotate)) +} diff --git a/200ms-demo/src/chain/config.ts b/200ms-demo/src/chain/config.ts new file mode 100644 index 000000000..3c7156f2a --- /dev/null +++ b/200ms-demo/src/chain/config.ts @@ -0,0 +1,108 @@ +import type { Address } from '@vibenet/aa' + +export const CHAIN_ID = 84_538_453 +export const NETWORK_NAME = 'Base Vibenet' +export const API_URL = 'https://api.vibes.base.org' +export const EXECUTION_RPC = 'https://rpc.vibes.base.org' +export const ACCOUNT_RPC = `${API_URL}/api/vibenet/account/rpc` +export const EXPLORER_URL = 'https://chain.base.org/vibenet/explorer' + +export const TOKEN_DECIMALS = 6 +export const TICK_MS = 200 +export const WATCH_MS = 400 +export const HEAD_POLL_MS = 1_000 +export const FUNDING_POLL_MS = 5_000 +export const MAX_PENDING = 12 +export const MAX_TICKER_ROWS = 40 +export const PENDING_UNKNOWN_MS = 30_000 +export const NONCE_FREE_VALIDITY_MS = 15_000 +export const PRIORITY_FEE = 1_000_000n +export const MIN_ETH_BOOTSTRAP = 5_000_000_000_000_000n +export const ETH_TOP_UP_THRESHOLD = 30_000_000_000_000_000n +export const USDV_TOP_UP_THRESHOLD = 50_000_000n + +export const erc20Abi = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, +] as const + +export type StreamRate = { + id: string + amountUnits: bigint + periodMicros: bigint + amountLabel: string + periodLabel: string + shortLabel: string +} + +export const RATE_PRESETS: StreamRate[] = [ + { + id: '5-minute', + amountUnits: 5_000_000n, + periodMicros: 60_000_000n, + amountLabel: '5 USDV', + periodLabel: 'minute', + shortLabel: '5 / min', + }, + { + id: '60-minute', + amountUnits: 60_000_000n, + periodMicros: 60_000_000n, + amountLabel: '60 USDV', + periodLabel: 'minute', + shortLabel: '60 / min', + }, + { + id: '100-hour', + amountUnits: 100_000_000n, + periodMicros: 3_600_000_000n, + amountLabel: '100 USDV', + periodLabel: 'hour', + shortLabel: '100 / hr', + }, +] + +export type LiveContracts = { + eip8130?: { + DefaultAccount?: Address + CanonicalHighRatePayerAccount?: Address + } + usdv?: Address + [key: string]: unknown +} + +export type ChainHealth = { + healthy: boolean + reason?: string | null + detail?: string | null + head: number + headAgeSecs: number + faucetBacklog?: number + stuckSecs?: number +} + +export type FaucetStatus = { + address: Address + chain_id: number + drip_wei: string + balance_wei: string + ip_cooldown_secs: number + addr_cooldown_secs: number + usdv_address: Address + usdv_drip_units: string +} diff --git a/200ms-demo/src/chain/faucet.ts b/200ms-demo/src/chain/faucet.ts new file mode 100644 index 000000000..b6a30dcae --- /dev/null +++ b/200ms-demo/src/chain/faucet.ts @@ -0,0 +1,90 @@ +import type { Address, Hex } from '@vibenet/aa' + +import { MIN_ETH_BOOTSTRAP, USDV_TOP_UP_THRESHOLD, type FaucetStatus } from './config' +import { postFaucet, readEthBalance, readTokenBalance } from './rpc' + +const LAST_DRIP_KEY = 'base.200ms-demo.faucet.last.v1' +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) + +export type FaucetEvent = { + kind: 'eth' | 'usdv' + hash: Hex +} + +export class FaucetQueue { + private tail = Promise.resolve() + private nextAllowedAt = 0 + + constructor( + readonly status: FaucetStatus, + readonly token: Address, + private readonly onEvent?: (event: FaucetEvent) => void, + ) { + try { + const stored = Number(localStorage.getItem(LAST_DRIP_KEY) ?? 0) + this.nextAllowedAt = Number.isFinite(stored) ? stored : 0 + } catch { + this.nextAllowedAt = 0 + } + } + + ensureEth(address: Address, minimum = MIN_ETH_BOOTSTRAP): Promise { + return this.enqueue(async () => { + const before = await readEthBalance(address) + if (before >= minimum) return null + const response = await this.drip('/drip', address) + await this.waitForIncrease(() => readEthBalance(address), before, 'ETH faucet balance') + this.onEvent?.({ kind: 'eth', hash: response.tx_hash }) + return response.tx_hash + }) + } + + ensureUsdv(address: Address, minimum = USDV_TOP_UP_THRESHOLD): Promise { + return this.enqueue(async () => { + const before = await readTokenBalance(this.token, address) + if (before >= minimum) return null + const response = await this.drip('/drip-usdv', address) + await this.waitForIncrease(() => readTokenBalance(this.token, address), before, 'USDV faucet balance') + this.onEvent?.({ kind: 'usdv', hash: response.tx_hash }) + return response.tx_hash + }) + } + + private enqueue(task: () => Promise): Promise { + const result = this.tail.then(task, task) + this.tail = result.then(() => undefined, () => undefined) + return result + } + + private async drip(path: '/drip' | '/drip-usdv', address: Address) { + const cooldownMs = (Math.max(this.status.ip_cooldown_secs, this.status.addr_cooldown_secs) + 1) * 1_000 + for (let attempt = 0; attempt < 4; attempt += 1) { + const waitMs = this.nextAllowedAt - Date.now() + if (waitMs > 0) await sleep(waitMs) + try { + const response = await postFaucet(path, address) + this.nextAllowedAt = Date.now() + cooldownMs + try { localStorage.setItem(LAST_DRIP_KEY, String(this.nextAllowedAt)) } catch { /* session-only cooldown */ } + return response + } catch (error) { + this.nextAllowedAt = Date.now() + cooldownMs + try { localStorage.setItem(LAST_DRIP_KEY, String(this.nextAllowedAt)) } catch { /* session-only cooldown */ } + if (attempt === 3) throw error + } + } + throw new Error('Faucet retries exhausted') + } + + private async waitForIncrease(read: () => Promise, before: bigint, label: string) { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + try { + if ((await read()) > before) return + } catch { + // A lagging replica is retried on the next poll. + } + await sleep(350) + } + throw new Error(`Timed out waiting for ${label}`) + } +} diff --git a/200ms-demo/src/chain/format.ts b/200ms-demo/src/chain/format.ts new file mode 100644 index 000000000..9077e86af --- /dev/null +++ b/200ms-demo/src/chain/format.ts @@ -0,0 +1,44 @@ +import { TOKEN_DECIMALS } from './config' + +export function formatUnits(value: bigint, decimals = TOKEN_DECIMALS, maxFraction = decimals): string { + const negative = value < 0n + const absolute = negative ? -value : value + const base = 10n ** BigInt(decimals) + const whole = absolute / base + const rawFraction = (absolute % base).toString().padStart(decimals, '0') + const fraction = rawFraction.slice(0, maxFraction).replace(/0+$/, '') + return `${negative ? '-' : ''}${whole.toLocaleString()}${fraction ? `.${fraction}` : ''}` +} + +export function formatFixedUnits(value: bigint, decimals = TOKEN_DECIMALS): string { + const negative = value < 0n + const absolute = negative ? -value : value + const base = 10n ** BigInt(decimals) + const whole = absolute / base + const fraction = (absolute % base).toString().padStart(decimals, '0') + return `${negative ? '-' : ''}${whole.toLocaleString()}.${fraction}` +} + +export function formatEth(value: bigint): string { + return formatUnits(value, 18, 4) +} + +export function shortAddress(value: string, leading = 6, trailing = 4): string { + if (value.length <= leading + trailing + 1) return value + return `${value.slice(0, leading)}…${value.slice(-trailing)}` +} + +export function formatDuration(milliseconds: number): string { + const seconds = Math.max(0, Math.floor(milliseconds / 1_000)) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + return `${minutes}m ${seconds % 60}s` +} + +export function parseDecimalUnits(value: string, decimals = TOKEN_DECIMALS): bigint { + const normalized = value.trim() + if (!/^\d+(?:\.\d*)?$/.test(normalized)) throw new Error('Enter a positive number') + const [whole, fraction = ''] = normalized.split('.') + const padded = fraction.padEnd(decimals, '0').slice(0, decimals) + return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(padded || '0') +} diff --git a/200ms-demo/src/chain/rationalAccumulator.ts b/200ms-demo/src/chain/rationalAccumulator.ts new file mode 100644 index 000000000..dde6b1616 --- /dev/null +++ b/200ms-demo/src/chain/rationalAccumulator.ts @@ -0,0 +1,40 @@ +import type { StreamRate } from './config' + +function gcd(left: bigint, right: bigint): bigint { + let a = left < 0n ? -left : left + let b = right < 0n ? -right : right + while (b !== 0n) [a, b] = [b, a % b] + return a || 1n +} + +/** Carries sub-base-unit value exactly across ticks and rate changes. */ +export class RationalAccumulator { + private numerator = 0n + private denominator = 1n + + accrue(rate: StreamRate, elapsedMicros: bigint): bigint { + if (elapsedMicros <= 0n) return 0n + const incrementNumerator = rate.amountUnits * elapsedMicros + const incrementDenominator = rate.periodMicros + const combinedNumerator = this.numerator * incrementDenominator + + incrementNumerator * this.denominator + const combinedDenominator = this.denominator * incrementDenominator + const wholeUnits = combinedNumerator / combinedDenominator + let remainder = combinedNumerator % combinedDenominator + let denominator = combinedDenominator + if (remainder !== 0n) { + const divisor = gcd(remainder, denominator) + remainder /= divisor + denominator /= divisor + } else { + denominator = 1n + } + this.numerator = remainder + this.denominator = denominator + return wholeUnits + } + + fraction() { + return { numerator: this.numerator, denominator: this.denominator } + } +} diff --git a/200ms-demo/src/chain/rpc.ts b/200ms-demo/src/chain/rpc.ts new file mode 100644 index 000000000..71e01bf02 --- /dev/null +++ b/200ms-demo/src/chain/rpc.ts @@ -0,0 +1,177 @@ +import { + createPublicClient, + decodeAbiParameters, + encodeFunctionData, + getTransactionReceipt, + http, + type Address, + type Hex, +} from '@vibenet/aa' + +import { + ACCOUNT_RPC, + API_URL, + CHAIN_ID, + erc20Abi, + EXECUTION_RPC, + NETWORK_NAME, + type ChainHealth, + type FaucetStatus, + type LiveContracts, +} from './config' + +const chain = { + id: CHAIN_ID, + name: NETWORK_NAME, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [EXECUTION_RPC] } }, +} + +export const publicClient = createPublicClient({ chain, transport: http(EXECUTION_RPC) }) + +export class RpcError extends Error { + readonly code?: number + readonly data?: unknown + readonly method: string + readonly endpoint: string + + constructor(message: string, options: { code?: number; data?: unknown; method: string; endpoint: string }) { + super(message) + this.name = 'RpcError' + this.code = options.code + this.data = options.data + this.method = options.method + this.endpoint = options.endpoint + } +} + +export async function fetchJson(url: string, init: RequestInit = {}, timeoutMs = 12_000): Promise { + const controller = new AbortController() + const timer = window.setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { ...init, signal: controller.signal, cache: 'no-store' }) + const body = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(body?.error ?? body?.message ?? `${response.status} ${response.statusText}`) + } + return body as T + } finally { + window.clearTimeout(timer) + } +} + +let rpcId = 0 +export async function rpc( + method: string, + params: unknown[] = [], + endpoint = EXECUTION_RPC, +): Promise { + const body = await fetchJson<{ result?: T; error?: { code?: number; message?: string; data?: unknown } }>( + endpoint, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }), + }, + ) + if (body.error) { + throw new RpcError(body.error.message ?? `${method} failed`, { + code: body.error.code, + data: body.error.data, + method, + endpoint, + }) + } + return body.result as T +} + +export const getChainHealth = () => fetchJson(`${API_URL}/api/vibenet/chain-health`) +export const getContracts = () => fetchJson(`${API_URL}/api/vibenet/contracts`) +export const getFaucetStatus = () => fetchJson(`${API_URL}/api/vibenet/faucet/status`) + +export async function postFaucet(path: '/drip' | '/drip-usdv', address: Address) { + return fetchJson<{ tx_hash: Hex; to: Address; usdv_address?: Address }>( + `${API_URL}/api/vibenet/faucet${path}`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ address }), + }, + ) +} + +export async function readCode(address: Address): Promise { + return rpc('eth_getCode', [address, 'latest']) +} + +export async function readGenesisHash(): Promise { + const block = await rpc<{ hash: Hex }>('eth_getBlockByNumber', ['0x0', false]) + if (!block?.hash) throw new Error('Vibenet returned no genesis hash') + return block.hash +} + +export async function readHead(): Promise { + return Number(BigInt(await rpc('eth_blockNumber'))) +} + +export async function readLatestBlock(): Promise<{ number: Hex; hash: Hex; baseFeePerGas?: Hex }> { + return rpc('eth_getBlockByNumber', ['latest', false]) +} + +export async function readEthBalance(address: Address, blockTag: Hex | 'latest' = 'latest'): Promise { + return BigInt(await rpc('eth_getBalance', [address, blockTag])) +} + +export async function readTokenBalance( + token: Address, + address: Address, + blockTag: Hex | 'latest' = 'latest', +): Promise { + const data = encodeFunctionData({ abi: erc20Abi, functionName: 'balanceOf', args: [address] }) + const raw = await rpc('eth_call', [{ to: token, data }, blockTag]) + return decodeAbiParameters([{ type: 'uint256' }], raw)[0] as bigint +} + +export async function readFreshBalances(token: Address, sender: Address, recipient: Address) { + const blockNumber = await rpc('eth_blockNumber') + try { + const [eth, senderToken, recipientToken] = await Promise.all([ + readEthBalance(sender, blockNumber), + readTokenBalance(token, sender, blockNumber), + readTokenBalance(token, recipient, blockNumber), + ]) + return { blockNumber: Number(BigInt(blockNumber)), eth, senderToken, recipientToken } + } catch { + const [eth, senderToken, recipientToken] = await Promise.all([ + readEthBalance(sender), + readTokenBalance(token, sender), + readTokenBalance(token, recipient), + ]) + return { blockNumber: Number(BigInt(blockNumber)), eth, senderToken, recipientToken } + } +} + +export async function readReceipt(hash: Hex) { + return getTransactionReceipt(publicClient, { hash }).catch(() => null) +} + +export async function readTransaction(hash: Hex) { + return rpc | null>('eth_getTransactionByHash', [hash]) +} + +export async function sendRaw(raw: Hex): Promise { + try { + return await rpc('eth_sendRawTransaction', [raw], EXECUTION_RPC) + } catch (primaryError) { + try { + return await rpc('eth_sendRawTransaction', [raw], ACCOUNT_RPC) + } catch { + throw primaryError + } + } +} + +export function isRecoverableBroadcastError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /already known|nonce too low|nonce too high|nonce mismatch|signature limit reached|timeout|network|fetch/i.test(message) +} diff --git a/200ms-demo/src/chain/streamer.ts b/200ms-demo/src/chain/streamer.ts new file mode 100644 index 000000000..141b456ef --- /dev/null +++ b/200ms-demo/src/chain/streamer.ts @@ -0,0 +1,554 @@ +import { + allPhasesSucceeded, + encodeFunctionData, + encodeWalletCalls, + keccak256, + nonceKeyMax, + toHex, + type Hex, +} from '@vibenet/aa' + +import { StreamLease } from './account' +import { + CHAIN_ID, + erc20Abi, + ETH_TOP_UP_THRESHOLD, + FUNDING_POLL_MS, + MAX_PENDING, + MAX_TICKER_ROWS, + NONCE_FREE_VALIDITY_MS, + PENDING_UNKNOWN_MS, + TICK_MS, + USDV_TOP_UP_THRESHOLD, + WATCH_MS, + type StreamRate, +} from './config' +import type { StreamRuntime } from './bootstrap' +import { RationalAccumulator } from './rationalAccumulator' +import { + getChainHealth, + isRecoverableBroadcastError, + readCode, + readFreshBalances, + readLatestBlock, + readReceipt, + readTransaction, + sendRaw, +} from './rpc' + +export type StreamPhase = 'idle' | 'streaming' | 'paused' | 'stopping' | 'stopped' | 'error' +export type TransactionStatus = 'pending' | 'confirmed' | 'reverted' | 'unknown' + +export type StreamTransaction = { + hash: Hex + amount: bigint + status: TransactionStatus + sentAt: number + blockNumber?: number + latencyMs?: number + message?: string +} + +type PendingTransaction = StreamTransaction & { + raw: Hex + validBefore: number + lastBroadcastAt: number +} + +export type StreamSnapshot = { + version: number + phase: StreamPhase + rate: StreamRate + confirmedUnits: bigint + pendingUnits: bigint + displayPendingUnits: bigint + owedUnits: bigint + senderEthBalance: bigint + senderTokenBalance: bigint + recipientTokenBalance: bigint + transferCount: number + confirmedCount: number + revertedCount: number + elapsedActiveMs: number + startedAt: number | null + head: number + cadenceMs: number | null + transactions: StreamTransaction[] + notice: string | null + error: string | null + errorCode: 'NETWORK_RESET' | 'LEASE_HELD' | 'STREAM_ERROR' | null +} + +type Listener = () => void + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) +const minBigInt = (left: bigint, right: bigint) => left < right ? left : right + +function receiptSucceeded(receipt: Record) { + return receipt.status !== '0x0' && allPhasesSucceeded(receipt.eip8130 ?? receipt) +} + +export class MoneyStreamer { + private snapshot: StreamSnapshot + private readonly listeners = new Set() + private readonly pending = new Map() + private readonly lease = new StreamLease() + private tickTimer: number | null = null + private watchTimer: number | null = null + private fundingTimer: number | null = null + private lastTickAt: number | null = null + private nextTickAt = 0 + private readonly accumulator = new RationalAccumulator() + private sending = false + private destroyed = false + private watcherRunning = false + private fundingRunning = false + private receiptConfirmedUnits = 0n + private lastHeadSeenAt = performance.now() + private lastIntegrityCheckAt = 0 + private transactionOrdinal = 0 + private readonly sessionId = crypto.randomUUID() + + constructor(readonly runtime: StreamRuntime, initialRate: StreamRate) { + this.snapshot = { + version: 0, + phase: 'idle', + rate: initialRate, + confirmedUnits: 0n, + pendingUnits: 0n, + displayPendingUnits: 0n, + owedUnits: 0n, + senderEthBalance: runtime.senderEthBalance, + senderTokenBalance: runtime.senderTokenBalance, + recipientTokenBalance: runtime.recipientTokenBalance, + transferCount: 0, + confirmedCount: 0, + revertedCount: 0, + elapsedActiveMs: 0, + startedAt: null, + head: runtime.health.head, + cadenceMs: null, + transactions: [], + notice: null, + error: null, + errorCode: null, + } + document.addEventListener('visibilitychange', this.handleVisibility) + this.scheduleWatcher(0) + this.scheduleFunding(FUNDING_POLL_MS) + } + + getSnapshot = () => this.snapshot + + subscribe = (listener: Listener) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + setRate(rate: StreamRate) { + if (this.snapshot.phase === 'streaming') this.accrue(performance.now()) + this.patch({ rate, notice: this.snapshot.phase === 'streaming' ? `Rate changed to ${rate.shortLabel}` : null }) + } + + start() { + if (this.destroyed) return + if (this.snapshot.phase === 'streaming') return + if (!this.lease.acquire()) { + this.fail('Another tab is already streaming from this account.', 'LEASE_HELD') + return + } + const now = performance.now() + this.lastTickAt = now + this.nextTickAt = now + TICK_MS + this.patch({ + phase: 'streaming', + startedAt: this.snapshot.startedAt ?? Date.now(), + notice: null, + error: null, + errorCode: null, + }) + this.scheduleTick() + } + + pause(reason = 'Stream paused') { + if (this.snapshot.phase !== 'streaming') return + this.accrue(performance.now()) + this.clearTick() + this.lastTickAt = null + this.lease.release() + this.patch({ phase: 'paused', notice: reason }) + } + + resume() { + if (this.snapshot.phase !== 'paused') return + this.start() + } + + stop() { + if (!['streaming', 'paused'].includes(this.snapshot.phase)) return + if (this.snapshot.phase === 'streaming') this.accrue(performance.now()) + this.clearTick() + this.lastTickAt = null + this.patch({ + phase: this.pending.size > 0 || this.sending ? 'stopping' : 'stopped', + notice: this.pending.size > 0 ? 'Waiting for final confirmations…' : 'Stream stopped', + }) + if (this.pending.size === 0 && !this.sending) this.lease.release() + } + + destroy() { + this.destroyed = true + this.clearTick() + if (this.watchTimer !== null) window.clearTimeout(this.watchTimer) + if (this.fundingTimer !== null) window.clearTimeout(this.fundingTimer) + this.lease.release() + document.removeEventListener('visibilitychange', this.handleVisibility) + this.listeners.clear() + } + + private readonly handleVisibility = () => { + if (document.hidden && this.snapshot.phase === 'streaming') { + this.pause('Paused — this tab was hidden') + } + } + + private scheduleTick() { + this.clearTick() + if (this.snapshot.phase !== 'streaming') return + const delay = Math.max(0, this.nextTickAt - performance.now()) + this.tickTimer = window.setTimeout(() => { + const now = performance.now() + this.accrue(now) + do this.nextTickAt += TICK_MS + while (this.nextTickAt <= now) + if (this.pending.size < MAX_PENDING) void this.pump() + else this.patch({ notice: 'Network backlog — accrued value is being held safely' }) + this.scheduleTick() + }, delay) + } + + private clearTick() { + if (this.tickTimer !== null) window.clearTimeout(this.tickTimer) + this.tickTimer = null + } + + private accrue(now: number) { + if (this.lastTickAt === null) return + const elapsedMs = Math.max(0, now - this.lastTickAt) + const elapsedMicros = BigInt(Math.round(elapsedMs * 1_000)) + this.lastTickAt = now + if (elapsedMicros === 0n) return + + const wholeUnits = this.accumulator.accrue(this.snapshot.rate, elapsedMicros) + + this.patch({ + owedUnits: this.snapshot.owedUnits + wholeUnits, + elapsedActiveMs: this.snapshot.elapsedActiveMs + elapsedMs, + }, false) + } + + private async pump() { + if (this.sending || this.snapshot.phase !== 'streaming' || this.snapshot.owedUnits < 1n) return + this.sending = true + const reserved = [...this.pending.values()].reduce((sum, transaction) => sum + transaction.amount, 0n) + const available = this.snapshot.senderTokenBalance > reserved + ? this.snapshot.senderTokenBalance - reserved + : 0n + const amount = minBigInt(this.snapshot.owedUnits, available) + if (amount < 1n) { + this.sending = false + this.patch({ notice: 'Waiting for a USDV top-up…' }) + return + } + + this.patch({ owedUnits: this.snapshot.owedUnits - amount }, false) + const ordinal = this.transactionOrdinal++ + const validBefore = Date.now() + NONCE_FREE_VALIDITY_MS + const calls = [[{ + to: this.runtime.token, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [this.runtime.identity.recipient, amount], + }), + }]] as const + + try { + const raw = await this.runtime.identity.account.signTransaction({ + chainId: CHAIN_ID, + accountChanges: [], + calls: encodeWalletCalls({ account: this.runtime.identity.account.address, calls }), + metadata: toHex(`200ms-demo:${this.sessionId}:${ordinal}:${validBefore}`), + nonceKey: nonceKeyMax, + validBefore: BigInt(validBefore), + maxFeePerGas: this.runtime.maxFeePerGas, + maxPriorityFeePerGas: this.runtime.maxPriorityFeePerGas, + gas: this.runtime.gasLimit, + }) + const hash = keccak256(raw) + const acceptedHash = await this.broadcast(raw, hash) + const sentAt = Date.now() + const transaction: PendingTransaction = { + hash: acceptedHash, + raw, + amount, + status: 'pending', + sentAt, + validBefore, + lastBroadcastAt: performance.now(), + } + this.pending.set(hash.toLowerCase(), transaction) + this.patch({ + transferCount: this.snapshot.transferCount + 1, + notice: null, + transactions: [transaction, ...this.snapshot.transactions].slice(0, MAX_TICKER_ROWS), + }) + } catch (error) { + this.patch({ + owedUnits: this.snapshot.owedUnits + amount, + notice: error instanceof Error ? `Send delayed: ${error.message}` : 'Send delayed', + }) + } finally { + this.sending = false + } + } + + private async broadcast(raw: Hex, expectedHash: Hex): Promise { + let lastError: unknown + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + const returnedHash = await sendRaw(raw) + if (returnedHash.toLowerCase() !== expectedHash.toLowerCase()) { + throw new Error(`RPC returned an unexpected transaction hash`) + } + return expectedHash + } catch (error) { + lastError = error + const [transaction, receipt] = await Promise.all([ + readTransaction(expectedHash).catch(() => null), + readReceipt(expectedHash), + ]) + if (transaction || receipt) return expectedHash + if (!isRecoverableBroadcastError(error) && attempt >= 1) throw error + await sleep(80 + attempt * 100) + } + } + throw lastError instanceof Error ? lastError : new Error('Transaction broadcast failed') + } + + private scheduleWatcher(delay = WATCH_MS) { + if (this.destroyed) return + if (this.watchTimer !== null) window.clearTimeout(this.watchTimer) + this.watchTimer = window.setTimeout(() => void this.watch(), delay) + } + + private async watch() { + if (this.watcherRunning || this.destroyed) return this.scheduleWatcher() + this.watcherRunning = true + try { + const balances = await readFreshBalances( + this.runtime.token, + this.runtime.identity.account.address, + this.runtime.identity.recipient, + ) + if (balances.blockNumber + 5 < this.snapshot.head) { + this.fail('Vibenet reset detected — creating a new stream account.', 'NETWORK_RESET') + return + } + if (balances.blockNumber > this.snapshot.head) { + const now = performance.now() + const blockDelta = balances.blockNumber - this.snapshot.head + const cadence = blockDelta > 0 ? (now - this.lastHeadSeenAt) / blockDelta : this.snapshot.cadenceMs + this.lastHeadSeenAt = now + this.snapshot = { ...this.snapshot, head: balances.blockNumber, cadenceMs: cadence } + } + + const balanceConfirmedUnits = balances.recipientToken > this.runtime.recipientBaseline + ? balances.recipientToken - this.runtime.recipientBaseline + : 0n + if (balances.recipientToken < this.snapshot.recipientTokenBalance && this.snapshot.confirmedUnits > 0n) { + this.fail('Recipient balance rolled back — Vibenet reset detected.', 'NETWORK_RESET') + return + } + this.snapshot = { + ...this.snapshot, + confirmedUnits: balanceConfirmedUnits > this.receiptConfirmedUnits + ? balanceConfirmedUnits + : this.receiptConfirmedUnits, + senderEthBalance: balances.eth, + senderTokenBalance: balances.senderToken, + recipientTokenBalance: balances.recipientToken, + } + + const oldest = [...this.pending.values()] + .sort((left, right) => left.sentAt - right.sentAt) + .slice(0, 10) + for (const transaction of oldest) await this.reconcile(transaction) + + if (performance.now() - this.lastIntegrityCheckAt > 10_000) { + this.lastIntegrityCheckAt = performance.now() + const [implementationCode, tokenCode, health, latestBlock] = await Promise.all([ + readCode(this.runtime.identity.implementation), + readCode(this.runtime.token), + getChainHealth(), + readLatestBlock(), + ]) + if (implementationCode === '0x' || tokenCode === '0x' || !health.healthy) { + this.fail('Vibenet reset or maintenance detected — rebuilding the stream.', 'NETWORK_RESET') + return + } + const baseFee = BigInt(latestBlock.baseFeePerGas ?? 1_000_000_000n) + this.runtime.maxFeePerGas = baseFee * 2n + this.runtime.maxPriorityFeePerGas + } + + this.recomputePending() + if (this.snapshot.phase === 'stopping' && this.pending.size === 0 && !this.sending) { + this.lease.release() + this.patch({ phase: 'stopped', notice: 'Stream stopped' }) + } else { + this.emit() + } + } catch (error) { + this.patch({ notice: error instanceof Error ? `Watcher retrying: ${error.message}` : 'Watcher retrying' }) + } finally { + this.watcherRunning = false + this.scheduleWatcher() + } + } + + private async reconcile(transaction: PendingTransaction) { + const receipt = await readReceipt(transaction.hash) + if (receipt) { + const succeeded = receiptSucceeded(receipt) + const blockNumber = Number(BigInt(receipt.blockNumber)) + const latencyMs = Date.now() - transaction.sentAt + this.pending.delete(transaction.hash.toLowerCase()) + if (!succeeded) { + this.snapshot = { + ...this.snapshot, + owedUnits: this.snapshot.owedUnits + transaction.amount, + revertedCount: this.snapshot.revertedCount + 1, + transactions: this.replaceTransaction(transaction.hash, { + status: 'reverted', + blockNumber, + latencyMs, + message: 'Call phase reverted; value returned to the accumulator', + }), + } + } else { + this.receiptConfirmedUnits += transaction.amount + this.snapshot = { + ...this.snapshot, + confirmedUnits: this.receiptConfirmedUnits > this.snapshot.confirmedUnits + ? this.receiptConfirmedUnits + : this.snapshot.confirmedUnits, + confirmedCount: this.snapshot.confirmedCount + 1, + transactions: this.replaceTransaction(transaction.hash, { + status: 'confirmed', + blockNumber, + latencyMs, + }), + } + } + return + } + + const now = performance.now() + const chainTransaction = await readTransaction(transaction.hash).catch(() => null) + if (!chainTransaction && Date.now() < transaction.validBefore - 500 && now - transaction.lastBroadcastAt > 700) { + transaction.lastBroadcastAt = now + try { + await sendRaw(transaction.raw) + } catch (error) { + if (!isRecoverableBroadcastError(error)) { + transaction.message = error instanceof Error ? error.message : 'Rebroadcast failed' + } + } + } + + if (Date.now() - transaction.sentAt > PENDING_UNKNOWN_MS) { + this.pending.delete(transaction.hash.toLowerCase()) + this.snapshot = { + ...this.snapshot, + transactions: this.replaceTransaction(transaction.hash, { + status: 'unknown', + message: 'No receipt after 30 seconds; balance reads remain authoritative', + }), + } + } + } + + private replaceTransaction(hash: Hex, update: Partial) { + return this.snapshot.transactions.map((transaction) => + transaction.hash.toLowerCase() === hash.toLowerCase() + ? { ...transaction, ...update } + : transaction, + ) + } + + private recomputePending() { + const values = [...this.pending.values()] + const pendingUnits = values.reduce((sum, transaction) => sum + transaction.amount, 0n) + const latestPending = values.sort((left, right) => right.sentAt - left.sentAt)[0] + this.snapshot = { + ...this.snapshot, + pendingUnits, + displayPendingUnits: latestPending?.amount ?? 0n, + } + } + + private scheduleFunding(delay = FUNDING_POLL_MS) { + if (this.destroyed) return + if (this.fundingTimer !== null) window.clearTimeout(this.fundingTimer) + this.fundingTimer = window.setTimeout(() => void this.fund(), delay) + } + + private async fund() { + if (this.fundingRunning || this.destroyed) return this.scheduleFunding() + this.fundingRunning = true + try { + if (this.snapshot.senderEthBalance < ETH_TOP_UP_THRESHOLD) { + this.patch({ notice: 'Topping up gas automatically…' }) + await this.runtime.faucet.ensureEth(this.runtime.identity.account.address, ETH_TOP_UP_THRESHOLD) + } + if (this.snapshot.senderTokenBalance < USDV_TOP_UP_THRESHOLD) { + this.patch({ notice: 'Topping up USDV automatically…' }) + await this.runtime.faucet.ensureUsdv(this.runtime.identity.account.address, USDV_TOP_UP_THRESHOLD) + } + } catch (error) { + this.patch({ notice: error instanceof Error ? `Top-up retrying: ${error.message}` : 'Top-up retrying' }) + } finally { + this.fundingRunning = false + this.scheduleFunding() + } + } + + private fail(message: string, code: StreamSnapshot['errorCode']) { + this.clearTick() + this.lease.release() + this.patch({ phase: 'error', error: message, errorCode: code, notice: null }) + } + + private patch(update: Partial, emit = true) { + this.snapshot = { ...this.snapshot, ...update } + if (emit) this.emit() + } + + private emit() { + this.recomputePendingWithoutEmit() + this.snapshot = { ...this.snapshot, version: this.snapshot.version + 1 } + for (const listener of this.listeners) listener() + } + + private recomputePendingWithoutEmit() { + const values = [...this.pending.values()] + const pendingUnits = values.reduce((sum, transaction) => sum + transaction.amount, 0n) + const latestPending = values.sort((left, right) => right.sentAt - left.sentAt)[0] + this.snapshot = { + ...this.snapshot, + pendingUnits, + displayPendingUnits: latestPending?.amount ?? 0n, + } + } +} diff --git a/200ms-demo/src/hooks/useChainHead.ts b/200ms-demo/src/hooks/useChainHead.ts new file mode 100644 index 000000000..afaa31ecd --- /dev/null +++ b/200ms-demo/src/hooks/useChainHead.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from 'react' + +import { HEAD_POLL_MS } from '../chain/config' +import { readHead } from '../chain/rpc' + +export function useChainHead() { + const [head, setHead] = useState(null) + const [cadenceMs, setCadenceMs] = useState(null) + const previous = useRef<{ head: number; at: number } | null>(null) + + useEffect(() => { + let stopped = false + let timer = 0 + const poll = async () => { + try { + const next = await readHead() + const now = performance.now() + if (!stopped) { + const last = previous.current + if (last && next > last.head) setCadenceMs((now - last.at) / (next - last.head)) + previous.current = { head: next, at: now } + setHead(next) + } + } catch { + // The page-level health state handles prolonged failures. + } finally { + if (!stopped) timer = window.setTimeout(poll, HEAD_POLL_MS) + } + } + void poll() + return () => { + stopped = true + window.clearTimeout(timer) + } + }, []) + + return { head, cadenceMs } +} diff --git a/200ms-demo/src/hooks/useStream.ts b/200ms-demo/src/hooks/useStream.ts new file mode 100644 index 000000000..98c1fb820 --- /dev/null +++ b/200ms-demo/src/hooks/useStream.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef, useState } from 'react' + +import { MoneyStreamer, type StreamSnapshot } from '../chain/streamer' + +export function useStream(streamer: MoneyStreamer | null): StreamSnapshot | null { + const [snapshot, setSnapshot] = useState(() => streamer?.getSnapshot() ?? null) + const dirty = useRef(true) + + useEffect(() => { + if (!streamer) { + setSnapshot(null) + return + } + setSnapshot(streamer.getSnapshot()) + dirty.current = true + const unsubscribe = streamer.subscribe(() => { dirty.current = true }) + let frame = 0 + const sample = () => { + if (dirty.current) { + dirty.current = false + setSnapshot(streamer.getSnapshot()) + } + frame = requestAnimationFrame(sample) + } + frame = requestAnimationFrame(sample) + return () => { + unsubscribe() + cancelAnimationFrame(frame) + } + }, [streamer]) + + return snapshot +} diff --git a/200ms-demo/src/main.tsx b/200ms-demo/src/main.tsx new file mode 100644 index 000000000..b716d6a48 --- /dev/null +++ b/200ms-demo/src/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from 'react-dom/client' + +import App from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render() diff --git a/200ms-demo/src/styles.css b/200ms-demo/src/styles.css new file mode 100644 index 000000000..f20c8359c --- /dev/null +++ b/200ms-demo/src/styles.css @@ -0,0 +1,226 @@ +:root { + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #0a0b0d; + background: #eef0f3; + font-synthesis: none; + text-rendering: geometricPrecision; + --blue: #0000ff; + --white: #ffffff; + --gray-10: #eef0f3; + --gray-15: #dee1e7; + --gray-30: #b1b7c3; + --gray-50: #717886; + --gray-60: #5b616e; + --gray-80: #32353d; + --black: #0a0b0d; + --green: #66c800; + --red: #fc401f; + --yellow: #ffd12f; + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); +} + +* { box-sizing: border-box; } +html, body, #root { width: 100%; height: 100%; margin: 0; } +body { overflow: hidden; } +button, input, select { font: inherit; } +button { -webkit-tap-highlight-color: transparent; } + +.app-shell, .state-page { + width: 100%; + height: 100dvh; + min-height: 620px; + background: var(--white); + display: grid; + grid-template-rows: 52px minmax(0, 1fr) 30px; + overflow: hidden; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--gray-15); + padding: 0 24px; +} + +.demo-name { + color: var(--black); + font-size: 16px; + font-weight: 700; + letter-spacing: -.025em; +} +.network-readout { + display: flex; + align-items: center; + gap: 14px; + color: var(--gray-60); + font: 600 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .08em; + text-transform: uppercase; +} +.network-readout > span + span::before { content: "·"; margin-right: 14px; color: var(--gray-30); } +.live-state { color: var(--gray-60); } +.live-state i { width: 7px; height: 7px; background: var(--gray-30); display: inline-block; border-radius: 50%; margin-right: 6px; } +.live-state.is-live { color: var(--green); } +.live-state.is-live i { background: var(--green); } + +.stream-layout { + width: min(1120px, calc(100% - 48px)); + min-height: 0; + margin: 0 auto; + display: grid; + grid-template-rows: 1.25fr .8fr auto minmax(150px, .9fr); + padding: 16px 0 12px; + gap: 12px; +} + +.hero-section { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 0; } +.stream-counter { position: relative; text-align: center; min-width: min(760px, 90vw); } +.counter-value { + font-size: clamp(56px, 8vw, 112px); + line-height: .86; + letter-spacing: -.065em; + font-weight: 560; + font-variant-numeric: tabular-nums; +} +.pending-value { + min-height: 18px; + margin-top: 10px; + color: var(--gray-30); + font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .08em; + text-transform: uppercase; + opacity: 0; + transition: opacity 140ms var(--ease-out); +} +.pending-value.is-visible { opacity: 1; } +.stream-counter.is-stopping .pending-value { color: var(--blue); } +.stream-counter.is-stopped .pending-value { color: var(--gray-50); } +.counter-label, .eyebrow { + color: var(--gray-50); + font: 600 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .2em; + text-transform: uppercase; +} +.counter-label { margin-top: 6px; } +.stream-meta { display: flex; gap: 16px; margin-top: 16px; color: var(--gray-60); font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .08em; } +.stream-meta span + span::before { content: "·"; margin-right: 16px; color: var(--gray-30); } +.summary-line { color: var(--blue); margin-top: 8px; font-size: 12px; font-weight: 650; } + +.accounts-flow { display: grid; grid-template-columns: 1fr 160px 1fr; align-items: stretch; min-height: 0; } +.account-card { border: 1px solid var(--gray-15); padding: 15px 18px; display: flex; flex-direction: column; justify-content: center; min-width: 0; } +.account-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; } +.account-aux { color: var(--gray-50); font: 500 9px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.address-link { color: var(--blue); text-decoration: none; font: 600 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.account-balance { margin-top: 8px; font-size: clamp(24px, 3vw, 40px); line-height: 1; letter-spacing: -.045em; font-variant-numeric: tabular-nums; } +.account-balance span { color: var(--gray-50); font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; } +.gas-balance { color: var(--gray-60); margin-top: 8px; font: 500 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } +.flow-rail { display: flex; align-items: center; justify-content: center; gap: 6px; } +.flow-rail i { display: block; width: 10px; height: 10px; background: var(--gray-15); } +.flow-rail.is-flowing i { background: var(--blue); animation: flow-pulse 1.6s linear infinite; } +.flow-rail.is-flowing i:nth-child(2) { animation-delay: .2s; } +.flow-rail.is-flowing i:nth-child(3) { animation-delay: .4s; } +.flow-rail.is-flowing i:nth-child(4) { animation-delay: .6s; } +.flow-rail.is-flowing i:nth-child(5) { animation-delay: .8s; } +.flow-rail.is-flowing i:nth-child(6) { animation-delay: 1s; } +.flow-rail.is-flowing i:nth-child(7) { animation-delay: 1.2s; } +.flow-rail.is-flowing i:nth-child(8) { animation-delay: 1.4s; } +@keyframes flow-pulse { 0%, 35% { background: var(--gray-15); } 36%, 55% { background: var(--blue); } 56%, 100% { background: var(--gray-15); } } + +.controls { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 10px; position: relative; } +.rate-options { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.rate-button, .stream-button, .secondary-button, .apply-rate { + border: 1px solid var(--gray-15); + background: var(--white); + color: var(--black); + min-height: 42px; + cursor: pointer; + font: 650 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .06em; + text-transform: uppercase; + transition: transform 120ms var(--ease-out), background-color 160ms ease, color 160ms ease, border-color 160ms ease; +} +.rate-button:hover { border-color: var(--blue); color: var(--blue); } +.rate-button.is-selected { background: var(--blue); border-color: var(--blue); color: var(--white); } +.stream-button { background: var(--blue); border-color: var(--blue); color: var(--white); } +.stream-button.is-stop { background: var(--black); border-color: var(--black); } +.rate-button:active, .stream-button:active, .secondary-button:active, .apply-rate:active { transform: scale(.98); } +button:disabled { cursor: not-allowed; color: var(--gray-30); background: var(--gray-10); border-color: var(--gray-15); } +.status-note { grid-column: 1 / -1; min-height: 16px; color: var(--gray-60); text-align: center; font: 600 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } +.status-note.is-error, .field-error { color: var(--red); } +.custom-rate { position: absolute; left: 0; top: 50px; z-index: 4; width: min(520px, calc(100vw - 48px)); padding: 12px; background: var(--white); border: 1px solid var(--blue); display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; animation: pop-in 180ms var(--ease-out); transform-origin: top right; } +@keyframes pop-in { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } } +.custom-rate label span { display: block; margin-bottom: 5px; color: var(--gray-50); font: 600 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .08em; } +.custom-rate input, .custom-rate select { width: 100%; height: 36px; border: 1px solid var(--gray-15); background: var(--white); padding: 0 10px; outline: none; } +.custom-rate input:focus, .custom-rate select:focus { border-color: var(--blue); } +.apply-rate { min-height: 36px; align-self: end; background: var(--blue); color: var(--white); border-color: var(--blue); } +.field-error { grid-column: 1 / -1; font-size: 11px; } + +.ticker { border-top: 1px solid var(--gray-15); min-height: 0; overflow: hidden; } +.ticker-head, .ticker-row { display: grid; grid-template-columns: 1.3fr .8fr .8fr .55fr .55fr; gap: 10px; align-items: center; } +.ticker-head { height: 30px; color: var(--gray-50); font: 600 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .1em; text-transform: uppercase; border-bottom: 1px solid var(--gray-15); } +.ticker-body { min-height: 0; max-height: calc(100% - 30px); overflow: hidden; } +.ticker-row { min-height: 29px; color: var(--gray-80); border-bottom: 1px solid var(--gray-10); text-decoration: none; font: 550 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; animation: row-in 180ms var(--ease-out); } +@keyframes row-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } +.ticker-row:hover { background: var(--gray-10); } +.tx-hash { color: var(--blue); } +.tx-status { text-transform: uppercase; font-size: 9px; } +.tx-status i { width: 6px; height: 6px; display: inline-block; margin-right: 6px; border-radius: 50%; background: var(--gray-30); } +.tx-status.is-confirmed { color: var(--green); } +.tx-status.is-confirmed i { background: var(--green); } +.tx-status.is-reverted { color: var(--red); } +.tx-status.is-reverted i { background: var(--red); } +.tx-status.is-pending { color: var(--gray-50); } +.tx-status.is-pending i { background: var(--yellow); } +.ticker-empty { display: grid; place-items: center; height: 100px; color: var(--gray-50); font: 500 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } + +footer { border-top: 1px solid var(--gray-15); display: grid; place-items: center; color: var(--gray-50); font: 500 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .04em; } + +.state-page { grid-template-rows: 52px minmax(0, 1fr); } +.bootstrap-card { align-self: center; justify-self: center; width: min(540px, calc(100% - 40px)); border: 1px solid var(--gray-15); padding: 34px; } +.state-mark { width: 34px; height: 34px; background: var(--blue); margin-bottom: 26px; } +.bootstrap-card h1 { margin: 10px 0 12px; font-size: clamp(34px, 5vw, 54px); line-height: .94; letter-spacing: -.05em; } +.state-copy { min-height: 42px; color: var(--gray-60); font-size: 14px; line-height: 1.5; } +.progress-list { margin: 24px 0; display: grid; gap: 10px; } +.progress-row { display: flex; align-items: center; gap: 10px; color: var(--gray-30); font: 600 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: .06em; } +.progress-row i { width: 8px; height: 8px; border-radius: 50%; background: var(--gray-15); } +.progress-row.is-active { color: var(--blue); } +.progress-row.is-active i { background: var(--blue); } +.progress-row.is-done { color: var(--gray-80); } +.progress-row.is-done i { background: var(--green); } +.progress-line { height: 3px; background: var(--gray-15); overflow: hidden; margin-top: 20px; } +.progress-line i { display: block; height: 100%; background: var(--blue); transition: width 220ms var(--ease-out); } +.fine-print { color: var(--gray-50); font-size: 10px; line-height: 1.5; margin: 14px 0 0; } +.secondary-button { padding: 0 18px; border-color: var(--black); margin: 12px 0; } + +@media (max-width: 760px) { + .app-shell { min-height: 600px; grid-template-rows: 44px minmax(0, 1fr) 24px; } + .site-header { padding: 0 14px; } + .demo-name { font-size: 14px; } + .network-readout { gap: 8px; font-size: 8px; } + .network-readout > span + span::before { margin-right: 8px; } + .network-readout span:nth-child(2) { display: none; } + .stream-layout { width: calc(100% - 24px); grid-template-rows: 1fr .85fr auto 1fr; padding: 10px 0 8px; gap: 8px; } + .counter-value { font-size: clamp(48px, 15vw, 72px); } + .stream-meta { gap: 8px; font-size: 8px; flex-wrap: wrap; justify-content: center; } + .stream-meta span + span::before { margin-right: 8px; } + .accounts-flow { grid-template-columns: 1fr 44px 1fr; } + .account-card { padding: 10px; } + .account-aux, .gas-balance { display: none; } + .account-balance { font-size: 23px; } + .address-link { font-size: 9px; } + .flow-rail { gap: 3px; } + .flow-rail i { width: 4px; height: 8px; } + .controls { grid-template-columns: 1fr; gap: 6px; } + .rate-options { gap: 4px; } + .rate-button, .stream-button { min-height: 36px; font-size: 9px; } + .status-note { display: none; } + .ticker-head, .ticker-row { grid-template-columns: 1.2fr .8fr .8fr .55fr; } + .ticker-head span:last-child, .ticker-row span:last-child { display: none; } + .ticker-row { font-size: 8px; min-height: 25px; } + footer { font-size: 7px; text-align: center; padding: 0 8px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} diff --git a/200ms-demo/src/ui/AccountCard.tsx b/200ms-demo/src/ui/AccountCard.tsx new file mode 100644 index 000000000..661e0245f --- /dev/null +++ b/200ms-demo/src/ui/AccountCard.tsx @@ -0,0 +1,31 @@ +import { formatEth, formatUnits, shortAddress } from '../chain/format' + +type Props = { + label: string + address: string + tokenBalance: bigint + ethBalance?: bigint + auxiliary?: string +} + +export function AccountCard({ label, address, tokenBalance, ethBalance, auxiliary }: Props) { + return ( +
+
+ {label} + {auxiliary ? {auxiliary} : null} +
+ + {shortAddress(address, 10, 8)} + +
{formatUnits(tokenBalance, 6, 4)} USDV
+ {ethBalance !== undefined ?
{formatEth(ethBalance)} ETH gas
: null} +
+ ) +} diff --git a/200ms-demo/src/ui/RateSelector.tsx b/200ms-demo/src/ui/RateSelector.tsx new file mode 100644 index 000000000..0f5d5f4ea --- /dev/null +++ b/200ms-demo/src/ui/RateSelector.tsx @@ -0,0 +1,88 @@ +import { useMemo, useState } from 'react' + +import { RATE_PRESETS, type StreamRate } from '../chain/config' +import { parseDecimalUnits } from '../chain/format' + +type Props = { + value: StreamRate + onChange: (rate: StreamRate) => void + disabled?: boolean +} + +const periods = { + minute: { label: 'minute', micros: 60_000_000n }, + hour: { label: 'hour', micros: 3_600_000_000n }, + day: { label: 'day', micros: 86_400_000_000n }, + month: { label: 'month', micros: 2_628_000_000_000n }, +} as const + +export function RateSelector({ value, onChange, disabled }: Props) { + const [customOpen, setCustomOpen] = useState(value.id === 'custom') + const [amount, setAmount] = useState('10') + const [period, setPeriod] = useState('month') + const [error, setError] = useState(null) + const customSummary = useMemo(() => `${amount || '0'} / ${period}`, [amount, period]) + + const applyCustom = () => { + try { + const amountUnits = parseDecimalUnits(amount) + if (amountUnits < 1n) throw new Error('The rate must accrue at least one USDV base unit') + const selection = periods[period] + onChange({ + id: 'custom', + amountUnits, + periodMicros: selection.micros, + amountLabel: `${amount} USDV`, + periodLabel: selection.label, + shortLabel: customSummary, + }) + setError(null) + setCustomOpen(false) + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Invalid rate') + } + } + + return ( +
+
+ {RATE_PRESETS.map((rate) => ( + + ))} + +
+ {customOpen ? ( +
+ + + + {error ?
{error}
: null} +
+ ) : null} +
+ ) +} diff --git a/200ms-demo/src/ui/StreamCounter.tsx b/200ms-demo/src/ui/StreamCounter.tsx new file mode 100644 index 000000000..9ffcb1936 --- /dev/null +++ b/200ms-demo/src/ui/StreamCounter.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react' + +import { formatFixedUnits } from '../chain/format' +import type { StreamPhase } from '../chain/streamer' + +type Props = { + confirmed: bigint + pending: bigint + phase: StreamPhase +} + +const VISUAL_SCALE = 1_000_000n +const ALPHA_SCALE = 1_000_000n + +export function StreamCounter({ confirmed, pending, phase }: Props) { + const [displayed, setDisplayed] = useState(confirmed) + const displayedScaled = useRef(confirmed * VISUAL_SCALE) + const targetScaled = useRef(confirmed * VISUAL_SCALE) + const phaseRef = useRef(phase) + const finalSettleAt = useRef(null) + const lastRendered = useRef(confirmed) + + useEffect(() => { + const target = confirmed * VISUAL_SCALE + targetScaled.current = target + if (target < displayedScaled.current || matchMedia('(prefers-reduced-motion: reduce)').matches) { + displayedScaled.current = target + lastRendered.current = confirmed + setDisplayed(confirmed) + } else if (phaseRef.current === 'stopped') { + finalSettleAt.current = performance.now() + 220 + } + }, [confirmed]) + + useEffect(() => { + phaseRef.current = phase + if (phase === 'stopped') finalSettleAt.current = performance.now() + 220 + else finalSettleAt.current = null + }, [phase]) + + useEffect(() => { + let frame = 0 + let previousFrame = performance.now() + const animate = (now: number) => { + const elapsed = Math.min(50, Math.max(0, now - previousFrame)) + previousFrame = now + const target = targetScaled.current + let current = displayedScaled.current + const difference = target - current + + if (difference > 0n) { + const currentPhase = phaseRef.current + const timeConstant = currentPhase === 'streaming' + ? 200 + : currentPhase === 'stopping' + ? 85 + : currentPhase === 'stopped' + ? 65 + : 110 + const alpha = 1 - Math.exp(-elapsed / timeConstant) + let step = (difference * BigInt(Math.max(1, Math.round(alpha * Number(ALPHA_SCALE))))) / ALPHA_SCALE + if (step < 1n) step = 1n + current = current + step > target ? target : current + step + + if (currentPhase === 'stopped' && finalSettleAt.current !== null && now >= finalSettleAt.current) { + current = target + finalSettleAt.current = null + } + displayedScaled.current = current + const rendered = current / VISUAL_SCALE + if (rendered !== lastRendered.current) { + lastRendered.current = rendered + setDisplayed(rendered) + } + } + + frame = requestAnimationFrame(animate) + } + frame = requestAnimationFrame(animate) + return () => cancelAnimationFrame(frame) + }, []) + + const pendingLabel = phase === 'stopping' + ? 'settling onchain' + : phase === 'stopped' + ? 'final onchain balance' + : 'pending confirmation' + + return ( +
0n ? `, ${formatFixedUnits(pending)} pending` : ''}`} + > + +
0n || phase === 'stopped' ? 'is-visible' : ''}`}> + {pending > 0n ? `+${formatFixedUnits(pending)} · ${pendingLabel}` : pendingLabel} +
+
USDV streamed
+
+ ) +} diff --git a/200ms-demo/src/ui/TxTicker.tsx b/200ms-demo/src/ui/TxTicker.tsx new file mode 100644 index 000000000..4d7f2d358 --- /dev/null +++ b/200ms-demo/src/ui/TxTicker.tsx @@ -0,0 +1,49 @@ +import type { StreamTransaction } from '../chain/streamer' +import { formatFixedUnits, shortAddress } from '../chain/format' + +type Props = { + transactions: StreamTransaction[] +} + +const statusLabel = { + pending: 'pending', + confirmed: 'confirmed', + reverted: 'reverted', + unknown: 'unknown', +} + +export function TxTicker({ transactions }: Props) { + return ( +
+
+ Transaction + Amount + Status + Block + Latency +
+