diff --git a/README.md b/README.md index 1509fe4b..b80aa025 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,138 @@ the next page, and page queries are pinned to one subgraph block snapshot. It preserves the historical `pageSize` behavior for compatibility, while `getAnnouncementsPageUsingSubgraph` is the typed cursor-based API. +### Streaming announcements for a user from a subgraph + +Use `scanAnnouncementsForUserUsingSubgraph` when you want to fetch one bounded +page at a time, scan it locally for a user, and surface matches incrementally. + +```ts +import { scanAnnouncementsForUserUsingSubgraph } from "@scopelift/stealth-address-sdk"; + +for await (const batch of scanAnnouncementsForUserUsingSubgraph({ + subgraphUrl: "https://your-subgraph.example/api", + fromBlock: 12345678, + toBlock: 12349999, + spendingPublicKey: "0xUserSpendingPublicKey", + viewingPrivateKey: "0xUserViewingPrivateKey", +})) { + console.log(batch.announcements); +} +``` + +Batches are sorted newest to oldest within each scanned page. The overall +stream follows subgraph cursor order, so consumers that need a single global +ordering should re-sort after accumulation. + +Each yielded batch includes: + +- `announcements`: the matches found in that scanned page +- `scannedCount`: the number of candidate announcements scanned in that page +- `nextCursor`: the resume token for the next older page, when another page exists +- `snapshotBlock`: the fixed subgraph snapshot block for the full bounded scan + +Persist `nextCursor` and `snapshotBlock` from any yielded batch if you want to +resume the scan later from the next older page. + +If you use `includeList` or `excludeList`, provide `clientParams` or call the +helper through `createStealthClient(...)` so the SDK can resolve transaction +senders for those filters. + +Historical scan work is pinned to one `snapshotBlock`. Complete the historical +scan against that fixed snapshot before you start live watching. + +### Watching live announcements for a user + +Use `watchAnnouncementsForUser` when you want to process live announcement +batches after historical catch-up is complete. + +```ts +import { + ERC5564_CONTRACT_ADDRESS, + VALID_SCHEME_ID, + createStealthClient, +} from "@scopelift/stealth-address-sdk"; + +const stealthClient = createStealthClient({ + chainId: 11155111, + rpcUrl: process.env.RPC_URL!, +}); + +const snapshotBlock = 12349999n; + +const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address: ERC5564_CONTRACT_ADDRESS, + args: { + schemeId: BigInt(VALID_SCHEME_ID.SCHEME_ID_1), + caller: "0xYourCallingContractAddress", + }, + fromBlock: snapshotBlock + 1n, + spendingPublicKey: "0xUserSpendingPublicKey", + viewingPrivateKey: "0xUserViewingPrivateKey", + handleLogsForUser: async (logs, meta) => { + console.log("live matches", logs, meta); + }, + onHeartbeat: meta => { + console.log("watch heartbeat", meta.observedBlock.toString()); + }, + onError: error => { + console.error("watch handler failed", error); + }, +}); +``` + +`handleLogsForUser` receives a second `meta` argument with: + +- `fromBlock` +- `observedBlock` +- `pollTimestamp` +- `rawLogCount` +- `relevantLogCount` + +If you also provide `onHeartbeat`, the SDK calls it whenever the watcher +observes a chain head while polling. That lets the app distinguish "watch is +alive but there were no matching logs" from "watch appears stalled" without +adding a separate head-polling loop. + +The SDK ignores `handleLogsForUser`'s return value, but it awaits any returned +promise before considering that batch processed. Watched batches are processed +one at a time in arrival order. If one batch takes a long time to finish, later +batches wait behind it. + +If watched batch processing fails: + +- the SDK reports that failure once through `onError` when provided +- if `onError` is omitted, the SDK logs the error to `console.error` +- the watcher stays alive for later batches +- the failed batch is not replayed automatically +- if `onError` also throws, that secondary failure is swallowed so the watcher + can continue +- if both `onError` and fallback logging fail, that reporting failure is + swallowed as a last resort so the watcher can continue processing later + batches + +Calling `unwatch()` stops future watched batches, but it does not cancel a batch +that is already in flight. + +### Recommended scan -> watch handoff + +The clean no-gap/no-dup handoff is: + +1. Fetch historical pages with `getAnnouncementsPageUsingSubgraph(...)` or + stream them with `scanAnnouncementsForUserUsingSubgraph(...)`. +2. Reuse the first page's `snapshotBlock` for every later historical page in + that catch-up sequence. +3. Persist and dedupe the historical matches locally. +4. Only after historical catch-up completes, start + `watchAnnouncementsForUser(...)` at `fromBlock: snapshotBlock + 1n`. + +That boundary keeps historical reads pinned to one frozen subgraph view and +starts live watching strictly after that snapshot, so the normal path avoids +gaps and avoids duplicate delivery. + +See the runnable React/TanStack composition example in +`examples/composeAnnouncementHistoryAndWatch`. + ## License [MIT](/LICENSE) License diff --git a/bun.lockb b/bun.lockb index 390a2fcc..b71b7d8d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/examples/composeAnnouncementHistoryAndWatch/.env.example b/examples/composeAnnouncementHistoryAndWatch/.env.example new file mode 100644 index 00000000..5f311e14 --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/.env.example @@ -0,0 +1,9 @@ +VITE_CHAIN_ID='11155111' +VITE_RPC_URL='https://your-rpc.example' +VITE_SUBGRAPH_URL='https://your-subgraph.example/api' +VITE_SPENDING_PUBLIC_KEY='0xYourSpendingPublicKey' +VITE_VIEWING_PRIVATE_KEY='0xYourViewingPrivateKey' +VITE_CALLER='0xYourCallingContractAddress' +VITE_FROM_BLOCK='12345678' +VITE_PAGE_SIZE='100' +VITE_POLLING_INTERVAL='1000' diff --git a/examples/composeAnnouncementHistoryAndWatch/README.md b/examples/composeAnnouncementHistoryAndWatch/README.md new file mode 100644 index 00000000..2d2fa9ba --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/README.md @@ -0,0 +1,18 @@ +# Compose Announcement History And Watch Example + +This example shows the recommended `scan -> watch` composition for a user inbox: + +- keep a merged inbox in memory for the current page session +- catch up paged history with `getAnnouncementsPageUsingSubgraph(...)` +- filter each page locally with `getAnnouncementsForUser(...)` +- only start `watchAnnouncementsForUser(...)` after catch-up completes +- start live watching from `snapshotBlock + 1n` +- use watch heartbeat metadata to show the latest observed block without a + separate head poll + +Run it with: + +```bash +bun install +bun run dev +``` diff --git a/examples/composeAnnouncementHistoryAndWatch/bun.lock b/examples/composeAnnouncementHistoryAndWatch/bun.lock new file mode 100644 index 00000000..230f4c31 --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/bun.lock @@ -0,0 +1,232 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "example-compose-announcement-history-and-watch", + "dependencies": { + "@tanstack/react-query": "^5.59.20", + "@types/react": "^18.2.61", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "latest", + }, + "devDependencies": { + "typescript": "^5.3.3", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + + "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.96.1", "", {}, "sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.96.1", "", { "dependencies": { "@tanstack/query-core": "5.96.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001784", "", {}, "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.330", "", {}, "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + } +} diff --git a/examples/composeAnnouncementHistoryAndWatch/index.html b/examples/composeAnnouncementHistoryAndWatch/index.html new file mode 100644 index 00000000..9e9ab12d --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/index.html @@ -0,0 +1,12 @@ + + + + + + Compose Announcement History And Watch Example + + +
+ + + diff --git a/examples/composeAnnouncementHistoryAndWatch/index.tsx b/examples/composeAnnouncementHistoryAndWatch/index.tsx new file mode 100644 index 00000000..87a45e8f --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/index.tsx @@ -0,0 +1,709 @@ +import { + ERC5564_CONTRACT_ADDRESS, + VALID_SCHEME_ID, + createStealthClient +} from '@scopelift/stealth-address-sdk'; +import { + QueryClient, + QueryClientProvider, + useInfiniteQuery +} from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom/client'; + +const DEFAULT_CHAIN_ID = import.meta.env.VITE_CHAIN_ID ?? '11155111'; +const DEFAULT_RPC_URL = import.meta.env.VITE_RPC_URL ?? ''; +const DEFAULT_SUBGRAPH_URL = import.meta.env.VITE_SUBGRAPH_URL ?? ''; +const DEFAULT_SPENDING_PUBLIC_KEY = + import.meta.env.VITE_SPENDING_PUBLIC_KEY ?? ''; +const DEFAULT_VIEWING_PRIVATE_KEY = + import.meta.env.VITE_VIEWING_PRIVATE_KEY ?? ''; +const DEFAULT_CALLER = import.meta.env.VITE_CALLER ?? ''; +const DEFAULT_FROM_BLOCK = import.meta.env.VITE_FROM_BLOCK ?? ''; +const DEFAULT_PAGE_SIZE = import.meta.env.VITE_PAGE_SIZE ?? '100'; +const DEFAULT_POLLING_INTERVAL = + import.meta.env.VITE_POLLING_INTERVAL ?? '1000'; + +const queryClient = new QueryClient(); + +type ExampleChainId = Parameters[0]['chainId']; + +type AppliedConfig = { + caller?: `0x${string}`; + chainId: ExampleChainId; + erc5564Address: `0x${string}`; + fromBlock?: number; + pageSize: number; + pollingInterval: number; + rpcUrl: string; + spendingPublicKey: `0x${string}`; + subgraphUrl: string; + viewingPrivateKey: `0x${string}`; +}; + +type PersistedAnnouncement = { + blockNumber: string; + caller: string; + id: string; + logIndex: number; + schemeId: string; + source: 'history' | 'live'; + stealthAddress: string; + transactionHash: string; + transactionIndex: number; +}; + +type HistoryPageParam = + | { + cursor: string; + snapshotBlock: bigint; + } + | undefined; + +function parseRequiredNumber(value: string, fieldName: string): number { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isNaN(parsed)) { + throw new Error(`${fieldName} must be a whole number`); + } + + return parsed; +} + +function parseOptionalNumber( + value: string, + fieldName: string +): number | undefined { + if (!value.trim()) { + return undefined; + } + + return parseRequiredNumber(value, fieldName); +} + +function createInboxKey(config: AppliedConfig): string { + return [ + 'stealth-address-sdk', + 'announcement-inbox', + config.chainId, + config.subgraphUrl, + config.spendingPublicKey, + config.caller ?? 'all-callers', + config.fromBlock ?? 'earliest' + ].join('::'); +} + +function toPersistedAnnouncement( + announcement: Awaited< + ReturnType< + ReturnType['getAnnouncementsForUser'] + > + >[number], + source: PersistedAnnouncement['source'] +): PersistedAnnouncement { + return { + blockNumber: announcement.blockNumber?.toString() ?? '0', + caller: announcement.caller, + id: `${announcement.transactionHash ?? 'missing'}:${ + announcement.logIndex ?? 0 + }`, + logIndex: announcement.logIndex ?? 0, + schemeId: announcement.schemeId.toString(), + source, + stealthAddress: announcement.stealthAddress, + transactionHash: announcement.transactionHash ?? 'missing', + transactionIndex: announcement.transactionIndex ?? 0 + }; +} + +function comparePersistedAnnouncements( + left: PersistedAnnouncement, + right: PersistedAnnouncement +): number { + const leftBlockNumber = BigInt(left.blockNumber); + const rightBlockNumber = BigInt(right.blockNumber); + + if (leftBlockNumber !== rightBlockNumber) { + return leftBlockNumber > rightBlockNumber ? -1 : 1; + } + + if (left.transactionIndex !== right.transactionIndex) { + return left.transactionIndex > right.transactionIndex ? -1 : 1; + } + + if (left.logIndex !== right.logIndex) { + return left.logIndex > right.logIndex ? -1 : 1; + } + + return 0; +} + +function mergeAnnouncements( + current: PersistedAnnouncement[], + incoming: PersistedAnnouncement[] +): PersistedAnnouncement[] { + const merged = new Map( + current.map(announcement => [announcement.id, announcement]) + ); + + for (const announcement of incoming) { + merged.set(announcement.id, announcement); + } + + return [...merged.values()].sort(comparePersistedAnnouncements); +} + +function getAppliedConfig({ + caller, + chainId, + fromBlock, + pageSize, + pollingInterval, + rpcUrl, + spendingPublicKey, + subgraphUrl, + viewingPrivateKey +}: { + caller: string; + chainId: string; + fromBlock: string; + pageSize: string; + pollingInterval: string; + rpcUrl: string; + spendingPublicKey: string; + subgraphUrl: string; + viewingPrivateKey: string; +}): AppliedConfig { + if (!rpcUrl.trim()) { + throw new Error('RPC URL is required'); + } + + if (!subgraphUrl.trim()) { + throw new Error('Subgraph URL is required'); + } + + if (!spendingPublicKey.trim()) { + throw new Error('Spending public key is required'); + } + + if (!viewingPrivateKey.trim()) { + throw new Error('Viewing private key is required'); + } + + return { + caller: caller.trim() ? (caller.trim() as `0x${string}`) : undefined, + chainId: parseRequiredNumber(chainId, 'Chain ID') as ExampleChainId, + erc5564Address: ERC5564_CONTRACT_ADDRESS, + fromBlock: parseOptionalNumber(fromBlock, 'From block'), + pageSize: parseRequiredNumber(pageSize, 'Page size'), + pollingInterval: parseRequiredNumber(pollingInterval, 'Polling interval'), + rpcUrl: rpcUrl.trim(), + spendingPublicKey: spendingPublicKey.trim() as `0x${string}`, + subgraphUrl: subgraphUrl.trim(), + viewingPrivateKey: viewingPrivateKey.trim() as `0x${string}` + }; +} + +function InboxExample() { + const [chainId, setChainId] = useState(DEFAULT_CHAIN_ID); + const [rpcUrl, setRpcUrl] = useState(DEFAULT_RPC_URL); + const [subgraphUrl, setSubgraphUrl] = useState(DEFAULT_SUBGRAPH_URL); + const [spendingPublicKey, setSpendingPublicKey] = useState( + DEFAULT_SPENDING_PUBLIC_KEY + ); + const [viewingPrivateKey, setViewingPrivateKey] = useState( + DEFAULT_VIEWING_PRIVATE_KEY + ); + const [caller, setCaller] = useState(DEFAULT_CALLER); + const [fromBlock, setFromBlock] = useState(DEFAULT_FROM_BLOCK); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [pollingInterval, setPollingInterval] = useState( + DEFAULT_POLLING_INTERVAL + ); + const [appliedConfig, setAppliedConfig] = useState( + null + ); + const [configError, setConfigError] = useState(); + const [liveError, setLiveError] = useState(); + const [sessionAnnouncements, setSessionAnnouncements] = useState< + PersistedAnnouncement[] + >([]); + const [sessionSnapshotBlock, setSessionSnapshotBlock] = useState< + bigint | undefined + >(); + const [sessionLiveFromBlock, setSessionLiveFromBlock] = useState< + bigint | undefined + >(); + const [latestObservedBlock, setLatestObservedBlock] = useState< + bigint | undefined + >(); + const [lastHeartbeatTimestamp, setLastHeartbeatTimestamp] = useState< + number | undefined + >(); + + const inboxKey = appliedConfig + ? createInboxKey(appliedConfig) + : 'stealth-address-sdk::announcement-inbox::idle'; + + const historyQuery = useInfiniteQuery({ + enabled: appliedConfig !== null, + initialPageParam: undefined as HistoryPageParam, + queryKey: ['announcement-history', inboxKey] as const, + queryFn: async ({ pageParam }) => { + if (!appliedConfig) { + throw new Error('Configuration is required before opening the inbox'); + } + + const client = createStealthClient({ + chainId: appliedConfig.chainId, + rpcUrl: appliedConfig.rpcUrl + }); + + const announcementArgs = { + schemeId: BigInt(VALID_SCHEME_ID.SCHEME_ID_1), + ...(appliedConfig.caller ? { caller: appliedConfig.caller } : {}) + }; + + const page = pageParam + ? await client.getAnnouncementsPageUsingSubgraph({ + ...announcementArgs, + cursor: pageParam.cursor, + fromBlock: appliedConfig.fromBlock, + pageSize: appliedConfig.pageSize, + snapshotBlock: pageParam.snapshotBlock, + subgraphUrl: appliedConfig.subgraphUrl + }) + : await client.getAnnouncementsPageUsingSubgraph({ + ...announcementArgs, + fromBlock: appliedConfig.fromBlock, + pageSize: appliedConfig.pageSize, + subgraphUrl: appliedConfig.subgraphUrl + }); + + const relevantAnnouncements = await client.getAnnouncementsForUser({ + announcements: page.announcements, + spendingPublicKey: appliedConfig.spendingPublicKey, + viewingPrivateKey: appliedConfig.viewingPrivateKey + }); + + return { + announcements: relevantAnnouncements.map(announcement => + toPersistedAnnouncement(announcement, 'history') + ), + nextCursor: page.nextCursor, + scannedCount: page.announcements.length, + snapshotBlock: page.snapshotBlock + }; + }, + getNextPageParam: lastPage => + lastPage.nextCursor + ? { + cursor: lastPage.nextCursor, + snapshotBlock: lastPage.snapshotBlock + } + : undefined, + retry: false, + staleTime: Number.POSITIVE_INFINITY + }); + + useEffect(() => { + if ( + appliedConfig === null || + historyQuery.status !== 'success' || + historyQuery.isFetchingNextPage || + !historyQuery.hasNextPage + ) { + return; + } + + void historyQuery.fetchNextPage(); + }, [ + appliedConfig, + historyQuery.fetchNextPage, + historyQuery.hasNextPage, + historyQuery.isFetchingNextPage, + historyQuery.status + ]); + + useEffect(() => { + if (appliedConfig === null || historyQuery.status !== 'success') { + return; + } + + const historicalAnnouncements = historyQuery.data.pages.flatMap( + page => page.announcements + ); + + setSessionAnnouncements(current => + mergeAnnouncements(current, historicalAnnouncements) + ); + setSessionSnapshotBlock(historyQuery.data.pages[0]?.snapshotBlock); + }, [appliedConfig, historyQuery.data, historyQuery.status]); + + useEffect(() => { + if ( + appliedConfig === null || + historyQuery.status !== 'success' || + historyQuery.hasNextPage || + historyQuery.isFetchingNextPage + ) { + return; + } + + const snapshotBlock = historyQuery.data.pages[0]?.snapshotBlock; + if (snapshotBlock === undefined) { + return; + } + + const liveFromBlock = snapshotBlock + 1n; + const client = createStealthClient({ + chainId: appliedConfig.chainId, + rpcUrl: appliedConfig.rpcUrl + }); + const announcementArgs = { + schemeId: BigInt(VALID_SCHEME_ID.SCHEME_ID_1), + ...(appliedConfig.caller ? { caller: appliedConfig.caller } : {}) + }; + + let unwatch: undefined | (() => void); + let cancelled = false; + + setLiveError(undefined); + + void (async () => { + try { + unwatch = await client.watchAnnouncementsForUser({ + ERC5564Address: appliedConfig.erc5564Address, + args: announcementArgs, + fromBlock: liveFromBlock, + handleLogsForUser: async logs => { + if (cancelled || logs.length === 0) { + return; + } + + const liveAnnouncements = logs.map(announcement => + toPersistedAnnouncement(announcement, 'live') + ); + + setSessionAnnouncements(current => + mergeAnnouncements(current, liveAnnouncements) + ); + }, + onHeartbeat: meta => { + if (cancelled) { + return; + } + + setLatestObservedBlock(meta.observedBlock); + setLastHeartbeatTimestamp(meta.pollTimestamp); + }, + onError: error => { + if (!cancelled) { + setLiveError(error.message); + } + }, + pollOptions: { + pollingInterval: appliedConfig.pollingInterval + }, + spendingPublicKey: appliedConfig.spendingPublicKey, + viewingPrivateKey: appliedConfig.viewingPrivateKey + }); + + setSessionSnapshotBlock(snapshotBlock); + setSessionLiveFromBlock(liveFromBlock); + } catch (error) { + if (!cancelled) { + setLiveError( + error instanceof Error + ? error.message + : 'Failed to start live watch' + ); + } + } + })(); + + return () => { + cancelled = true; + unwatch?.(); + }; + }, [ + appliedConfig, + historyQuery.data, + historyQuery.hasNextPage, + historyQuery.isFetchingNextPage, + historyQuery.status + ]); + + const openInbox = () => { + try { + const nextConfig = getAppliedConfig({ + caller, + chainId, + fromBlock, + pageSize, + pollingInterval, + rpcUrl, + spendingPublicKey, + subgraphUrl, + viewingPrivateKey + }); + const nextInboxKey = createInboxKey(nextConfig); + + queryClient.removeQueries({ + queryKey: ['announcement-history', nextInboxKey] + }); + setAppliedConfig(nextConfig); + setConfigError(undefined); + setLiveError(undefined); + setSessionAnnouncements([]); + setSessionSnapshotBlock(undefined); + setSessionLiveFromBlock(undefined); + setLatestObservedBlock(undefined); + setLastHeartbeatTimestamp(undefined); + } catch (error) { + setConfigError( + error instanceof Error ? error.message : 'Failed to apply settings' + ); + } + }; + + const resetSessionInbox = () => { + if (appliedConfig !== null) { + queryClient.removeQueries({ + queryKey: ['announcement-history', createInboxKey(appliedConfig)] + }); + } + + setAppliedConfig(null); + setConfigError(undefined); + setLiveError(undefined); + setSessionAnnouncements([]); + setSessionSnapshotBlock(undefined); + setSessionLiveFromBlock(undefined); + setLatestObservedBlock(undefined); + setLastHeartbeatTimestamp(undefined); + }; + + let syncState = 'Idle'; + if (appliedConfig !== null) { + if ( + historyQuery.status === 'pending' || + historyQuery.isFetchingNextPage || + historyQuery.hasNextPage + ) { + syncState = 'Catching up'; + } else if (historyQuery.status === 'error') { + syncState = 'Failed'; + } else { + syncState = 'Live'; + } + } + + const scannedCount = + historyQuery.data?.pages.reduce( + (count, page) => count + page.scannedCount, + 0 + ) ?? 0; + const blocksSinceSnapshot = + latestObservedBlock !== undefined && sessionSnapshotBlock !== undefined + ? latestObservedBlock - sessionSnapshotBlock + : undefined; + const blocksSinceLiveWatchStart = + latestObservedBlock !== undefined && sessionLiveFromBlock !== undefined + ? latestObservedBlock - sessionLiveFromBlock + : undefined; + + return ( +
+

History + Live Inbox Composition

+

+ This example keeps an in-memory inbox for the current page session, + catches up historical pages against one pinned `snapshotBlock`, then + starts live watching from `snapshotBlock + 1n`. +

+ +
+ + + + + + + + + +
+ +
+ + +
+ +
+
Sync state: {syncState}
+
History pages loaded: {historyQuery.data?.pages.length ?? 0}
+
Candidate announcements scanned: {scannedCount}
+
Session inbox size: {sessionAnnouncements.length}
+
+ Latest observed block:{' '} + {latestObservedBlock !== undefined + ? latestObservedBlock.toString() + : 'pending'} +
+
+ Last watch heartbeat:{' '} + {lastHeartbeatTimestamp !== undefined + ? new Date(lastHeartbeatTimestamp).toLocaleTimeString() + : 'pending'} +
+
+ Snapshot block:{' '} + {sessionSnapshotBlock !== undefined + ? sessionSnapshotBlock.toString() + : 'pending'} +
+
+ Live watch from block:{' '} + {sessionLiveFromBlock !== undefined + ? sessionLiveFromBlock.toString() + : 'pending'} +
+
+ Blocks since snapshot:{' '} + {blocksSinceSnapshot !== undefined + ? blocksSinceSnapshot.toString() + : 'pending'} +
+
+ Blocks since live watch start:{' '} + {blocksSinceLiveWatchStart !== undefined + ? blocksSinceLiveWatchStart.toString() + : 'pending'} +
+
+ Catch-up runs against one pinned snapshot. Live watching only starts + after the terminal history page and begins at `snapshotBlock + 1n`. +
+ {configError ?
Error: {configError}
: null} + {historyQuery.error ? ( +
Error: {historyQuery.error.message}
+ ) : null} + {liveError ?
Live watch error: {liveError}
: null} +
+ +
    + {sessionAnnouncements.map(announcement => ( +
  • +
    Source: {announcement.source}
    +
    Block: {announcement.blockNumber}
    +
    Tx: {announcement.transactionHash}
    +
    Caller: {announcement.caller}
    +
    Stealth address: {announcement.stealthAddress}
    +
  • + ))} +
+
+ ); +} + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Root element not found'); +} + +ReactDOM.createRoot(rootElement).render( + + + +); diff --git a/examples/composeAnnouncementHistoryAndWatch/package.json b/examples/composeAnnouncementHistoryAndWatch/package.json new file mode 100644 index 00000000..0e09fd8b --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/package.json @@ -0,0 +1,20 @@ +{ + "name": "example-compose-announcement-history-and-watch", + "private": true, + "type": "module", + "scripts": { + "dev": "vite" + }, + "dependencies": { + "@tanstack/react-query": "^5.59.20", + "@types/react": "^18.2.61", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "latest" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/examples/composeAnnouncementHistoryAndWatch/tsconfig.json b/examples/composeAnnouncementHistoryAndWatch/tsconfig.json new file mode 100644 index 00000000..f64314f6 --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "ignoreDeprecations": "5.0", + "skipLibCheck": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@scopelift/stealth-address-sdk": ["../../src/index.ts"] + } + }, + "include": ["."] +} diff --git a/examples/composeAnnouncementHistoryAndWatch/vite.config.ts b/examples/composeAnnouncementHistoryAndWatch/vite.config.ts new file mode 100644 index 00000000..e3d94614 --- /dev/null +++ b/examples/composeAnnouncementHistoryAndWatch/vite.config.ts @@ -0,0 +1,18 @@ +import { URL, fileURLToPath } from 'node:url'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@scopelift/stealth-address-sdk': fileURLToPath( + new URL('../../src/index.ts', import.meta.url) + ) + } + }, + server: { + host: '127.0.0.1', + port: 4174 + } +}); diff --git a/examples/scanAnnouncementsForUserUsingSubgraph/README.md b/examples/scanAnnouncementsForUserUsingSubgraph/README.md new file mode 100644 index 00000000..66c949bb --- /dev/null +++ b/examples/scanAnnouncementsForUserUsingSubgraph/README.md @@ -0,0 +1,8 @@ +# Scan Announcements For User Using Subgraph Example + +This example streams historical announcement matches from a pinned +`snapshotBlock`. + +Use it when you want incremental historical catch-up. Once that catch-up is +complete, start `watchAnnouncementsForUser(...)` from `snapshotBlock + 1n` for +live delivery. diff --git a/examples/scanAnnouncementsForUserUsingSubgraph/index.html b/examples/scanAnnouncementsForUserUsingSubgraph/index.html new file mode 100644 index 00000000..d5521273 --- /dev/null +++ b/examples/scanAnnouncementsForUserUsingSubgraph/index.html @@ -0,0 +1,13 @@ + + + + + + Scan Announcements For User Using Subgraph Example + + +

Scan Announcements For User Using Subgraph Example

+
+ + + diff --git a/examples/scanAnnouncementsForUserUsingSubgraph/index.tsx b/examples/scanAnnouncementsForUserUsingSubgraph/index.tsx new file mode 100644 index 00000000..eb3bc3be --- /dev/null +++ b/examples/scanAnnouncementsForUserUsingSubgraph/index.tsx @@ -0,0 +1,265 @@ +import { + type AnnouncementLog, + scanAnnouncementsForUserUsingSubgraph +} from '@scopelift/stealth-address-sdk'; +import { startTransition, useState } from 'react'; +import ReactDOM from 'react-dom/client'; + +const DEFAULT_SUBGRAPH_URL = + import.meta.env.VITE_SUBGRAPH_URL ?? + 'https://subgraph.satsuma-prod.com/760e79467576/scopelift/stealth-address-erc-mainnet'; +const DEFAULT_FROM_BLOCK = import.meta.env.VITE_FROM_BLOCK ?? ''; +const DEFAULT_TO_BLOCK = import.meta.env.VITE_TO_BLOCK ?? ''; +const DEFAULT_SPENDING_PUBLIC_KEY = + import.meta.env.VITE_SPENDING_PUBLIC_KEY ?? ''; +const DEFAULT_VIEWING_PRIVATE_KEY = + import.meta.env.VITE_VIEWING_PRIVATE_KEY ?? ''; +const DEFAULT_PAGE_SIZE = import.meta.env.VITE_PAGE_SIZE ?? '250'; + +type ScanConfig = { + subgraphUrl: string; + spendingPublicKey: `0x${string}`; + viewingPrivateKey: `0x${string}`; + fromBlock?: number; + toBlock?: number; + pageSize?: number; +}; + +function parseOptionalNumber( + value: string, + fieldName: string +): number | undefined { + if (!value.trim()) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + throw new Error(`${fieldName} must be a whole number`); + } + + return parsed; +} + +function getScanConfig({ + fromBlock, + pageSize, + spendingPublicKey, + subgraphUrl, + toBlock, + viewingPrivateKey +}: { + fromBlock: string; + pageSize: string; + spendingPublicKey: string; + subgraphUrl: string; + toBlock: string; + viewingPrivateKey: string; +}): ScanConfig { + if (!subgraphUrl.trim()) { + throw new Error('Subgraph URL is required'); + } + + if (!spendingPublicKey.trim()) { + throw new Error('Spending public key is required'); + } + + if (!viewingPrivateKey.trim()) { + throw new Error('Viewing private key is required'); + } + + return { + fromBlock: parseOptionalNumber(fromBlock, 'From block'), + pageSize: parseOptionalNumber(pageSize, 'Page size'), + spendingPublicKey: spendingPublicKey.trim() as `0x${string}`, + subgraphUrl: subgraphUrl.trim(), + toBlock: parseOptionalNumber(toBlock, 'To block'), + viewingPrivateKey: viewingPrivateKey.trim() as `0x${string}` + }; +} + +function waitForNextPaint(): Promise { + return new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); +} + +const Example = () => { + const [subgraphUrl, setSubgraphUrl] = useState(DEFAULT_SUBGRAPH_URL); + const [spendingPublicKey, setSpendingPublicKey] = useState( + DEFAULT_SPENDING_PUBLIC_KEY + ); + const [viewingPrivateKey, setViewingPrivateKey] = useState( + DEFAULT_VIEWING_PRIVATE_KEY + ); + const [fromBlock, setFromBlock] = useState(DEFAULT_FROM_BLOCK); + const [toBlock, setToBlock] = useState(DEFAULT_TO_BLOCK); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [announcements, setAnnouncements] = useState([]); + const [isScanning, setIsScanning] = useState(false); + const [isFinished, setIsFinished] = useState(false); + const [scannedCount, setScannedCount] = useState(0); + const [batchCount, setBatchCount] = useState(0); + const [lastCursor, setLastCursor] = useState(); + const [snapshotBlock, setSnapshotBlock] = useState(); + const [error, setError] = useState(); + + const startScan = async () => { + setAnnouncements([]); + setError(undefined); + setIsFinished(false); + setIsScanning(true); + setScannedCount(0); + setBatchCount(0); + setLastCursor(undefined); + setSnapshotBlock(undefined); + + try { + const config = getScanConfig({ + fromBlock, + pageSize, + spendingPublicKey, + subgraphUrl, + toBlock, + viewingPrivateKey + }); + + for await (const batch of scanAnnouncementsForUserUsingSubgraph(config)) { + startTransition(() => { + setAnnouncements(current => current.concat(batch.announcements)); + setScannedCount(current => current + batch.scannedCount); + setBatchCount(current => current + 1); + setLastCursor(batch.nextCursor); + setSnapshotBlock(batch.snapshotBlock.toString()); + }); + + await waitForNextPaint(); + } + + setIsFinished(true); + } catch (scanError) { + const message = + scanError instanceof Error ? scanError.message : 'Scan failed'; + setError(message); + } finally { + setIsScanning(false); + } + }; + + return ( +
+

Streaming Subgraph Scan

+

+ Enter the recipient scan keys and bounds, then stream matches page by + page. Each page is sorted newest to oldest internally. +

+ +
+ + + + + + +
+ + + +
+
Pages scanned: {batchCount}
+
Candidates scanned: {scannedCount}
+
User matches: {announcements.length}
+
+ Incremental updates appear once the scan spans more than one page. Use + a page size smaller than the candidate count to see streaming in + action. +
+
Latest cursor: {lastCursor ?? 'none'}
+
Snapshot block: {snapshotBlock ?? 'pending'}
+ {isFinished ?
Finished paged scan.
: null} + {error ?
Error: {error}
: null} +
+ +
    + {announcements.map(announcement => ( +
  • +
    + Block: {announcement.blockNumber?.toString() ?? 'unknown'} +
    +
    Tx: {announcement.transactionHash ?? 'missing'}
    +
    Stealth address: {announcement.stealthAddress}
    +
  • + ))} +
+
+ ); +}; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + +); diff --git a/examples/scanAnnouncementsForUserUsingSubgraph/package.json b/examples/scanAnnouncementsForUserUsingSubgraph/package.json new file mode 100644 index 00000000..f998a06f --- /dev/null +++ b/examples/scanAnnouncementsForUserUsingSubgraph/package.json @@ -0,0 +1,19 @@ +{ + "name": "example-scan-announcements-for-user-using-subgraph", + "private": true, + "type": "module", + "scripts": { + "dev": "vite" + }, + "dependencies": { + "@types/react": "^18.2.61", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "latest" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/examples/scanAnnouncementsForUserUsingSubgraph/tsconfig.json b/examples/scanAnnouncementsForUserUsingSubgraph/tsconfig.json new file mode 100644 index 00000000..c452cf75 --- /dev/null +++ b/examples/scanAnnouncementsForUserUsingSubgraph/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@scopelift/stealth-address-sdk": ["../../src/index.ts"] + } + }, + "include": ["."] +} diff --git a/examples/scanAnnouncementsForUserUsingSubgraph/vite.config.ts b/examples/scanAnnouncementsForUserUsingSubgraph/vite.config.ts new file mode 100644 index 00000000..697fca30 --- /dev/null +++ b/examples/scanAnnouncementsForUserUsingSubgraph/vite.config.ts @@ -0,0 +1,18 @@ +import { URL, fileURLToPath } from 'node:url'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@scopelift/stealth-address-sdk': fileURLToPath( + new URL('../../src/index.ts', import.meta.url) + ) + } + }, + server: { + host: '127.0.0.1', + port: 4173 + } +}); diff --git a/examples/watchAnnouncementsForUser/.env.example b/examples/watchAnnouncementsForUser/.env.example index bd751226..ecf749e1 100644 --- a/examples/watchAnnouncementsForUser/.env.example +++ b/examples/watchAnnouncementsForUser/.env.example @@ -1 +1,6 @@ -RPC_URL='Your rpc url' \ No newline at end of file +CHAIN_ID='84532' +RPC_URL='https://your-rpc.example' +SPENDING_PUBLIC_KEY='0xYourSpendingPublicKey' +VIEWING_PRIVATE_KEY='0xYourViewingPrivateKey' +CALLER='0xYourCallingContractAddress' +FROM_BLOCK='12345678' diff --git a/examples/watchAnnouncementsForUser/README.md b/examples/watchAnnouncementsForUser/README.md index c9363030..c360f53f 100644 --- a/examples/watchAnnouncementsForUser/README.md +++ b/examples/watchAnnouncementsForUser/README.md @@ -1 +1,11 @@ # Watch Announcements For User Example + +This example shows the live watch primitive only. + +For the normal no-gap/no-dup flow, complete historical catch-up first and then +start watching from `snapshotBlock + 1n`. + +Run the script and leave it running while you wait for live announcements. +It logs both live batches and watcher heartbeat metadata. + +Press `Ctrl+C` to stop watching. diff --git a/examples/watchAnnouncementsForUser/index.ts b/examples/watchAnnouncementsForUser/index.ts index 7886b0f1..4c3cfef3 100644 --- a/examples/watchAnnouncementsForUser/index.ts +++ b/examples/watchAnnouncementsForUser/index.ts @@ -4,14 +4,23 @@ import { createStealthClient } from '@scopelift/stealth-address-sdk'; -// Initialize your environment variables or configuration -const chainId = 11155111; // Example chain ID +const chainId = Number.parseInt(process.env.CHAIN_ID ?? '84532', 10); const rpcUrl = process.env.RPC_URL; // Your Ethereum RPC URL if (!rpcUrl) throw new Error('Missing RPC_URL environment variable'); -// User's keys and stealth address details -const spendingPublicKey = '0xUserSpendingPublicKey'; -const viewingPrivateKey = '0xUserViewingPrivateKey'; +const spendingPublicKey = process.env.SPENDING_PUBLIC_KEY as `0x${string}`; +if (!spendingPublicKey) { + throw new Error('Missing SPENDING_PUBLIC_KEY environment variable'); +} + +const viewingPrivateKey = process.env.VIEWING_PRIVATE_KEY as `0x${string}`; +if (!viewingPrivateKey) { + throw new Error('Missing VIEWING_PRIVATE_KEY environment variable'); +} + +const caller = process.env.CALLER as `0x${string}` | undefined; +const fromBlockEnv = process.env.FROM_BLOCK; +const fromBlock = fromBlockEnv ? BigInt(fromBlockEnv) : undefined; // The contract address of the ERC5564Announcer on your target blockchain // You can use the provided ERC5564_CONTRACT_ADDRESS get the singleton contract address for a valid chain ID @@ -24,15 +33,41 @@ const stealthClient = createStealthClient({ chainId, rpcUrl }); const unwatch = await stealthClient.watchAnnouncementsForUser({ ERC5564Address, args: { - schemeId: BigInt(VALID_SCHEME_ID.SCHEME_ID_1), // Your scheme ID - caller: '0xYourCallingContractAddress' // Use the address of your calling contract if applicable + schemeId: BigInt(VALID_SCHEME_ID.SCHEME_ID_1), + ...(caller ? { caller } : {}) }, + ...(fromBlock === undefined ? {} : { fromBlock }), spendingPublicKey, viewingPrivateKey, - handleLogsForUser: logs => { + handleLogsForUser: async (logs, meta) => { + console.log('watch batch', { + observedBlock: meta.observedBlock.toString(), + rawLogCount: meta.rawLogCount, + relevantLogCount: meta.relevantLogCount + }); console.log(logs); - } // Your callback function to handle incoming logs + }, + onHeartbeat: meta => { + console.log('watch heartbeat', { + observedBlock: meta.observedBlock.toString(), + pollTimestamp: new Date(meta.pollTimestamp).toISOString() + }); + }, + onError: (error: Error) => { + console.error('watchAnnouncementsForUser failed to process a batch', error); + } }); -// Stop watching for announcements -unwatch(); +console.log('Watching for announcements. Press Ctrl+C to stop.'); + +await new Promise(resolve => { + const stopWatching = () => { + unwatch(); + process.off('SIGINT', stopWatching); + process.off('SIGTERM', stopWatching); + resolve(); + }; + + process.once('SIGINT', stopWatching); + process.once('SIGTERM', stopWatching); +}); diff --git a/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.test.ts b/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.test.ts index b7d4c3ab..0333f237 100644 --- a/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.test.ts +++ b/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.test.ts @@ -1,5 +1,5 @@ import * as BunTest from 'bun:test'; -import type { Account, PublicClient } from 'viem'; +import type { Account } from 'viem'; import type { AnnouncementLog } from '..'; import { getViewTagFromMetadata } from '../../..'; import { VALID_SCHEME_ID, generateStealthAddress } from '../../../utils/crypto'; @@ -92,173 +92,146 @@ describe('getAnnouncementsForUser', () => { }); }); - test( - 'filters announcements correctly for the user', - async () => { - // Fetch announcements for the specific user - const results = await stealthClient.getAnnouncementsForUser({ - announcements, - spendingPublicKey, - viewingPrivateKey - }); + test('filters announcements correctly for the user', async () => { + // Fetch announcements for the specific user + const results = await stealthClient.getAnnouncementsForUser({ + announcements, + spendingPublicKey, + viewingPrivateKey + }); - expect(results[0].stealthAddress).toEqual(stealthAddress); + expect(results[0].stealthAddress).toEqual(stealthAddress); - // Now change the spending public key and check that the results are empty - const results2 = await stealthClient.getAnnouncementsForUser({ - announcements, - spendingPublicKey: '0x', - viewingPrivateKey - }); + // Now change the spending public key and check that the results are empty + const results2 = await stealthClient.getAnnouncementsForUser({ + announcements, + spendingPublicKey: '0x', + viewingPrivateKey + }); - expect(results2.length).toBe(0); + expect(results2.length).toBe(0); - // Now change the viewing private key and check that the results are empty - const results3 = await stealthClient.getAnnouncementsForUser({ - announcements, - spendingPublicKey, - viewingPrivateKey: '0x' - }); + // Now change the viewing private key and check that the results are empty + const results3 = await stealthClient.getAnnouncementsForUser({ + announcements, + spendingPublicKey, + viewingPrivateKey: '0x' + }); - expect(results3.length).toBe(0); - }, - { timeout: GET_ANNOUNCEMENTS_FOR_USER_TEST_TIMEOUT } - ); + expect(results3.length).toBe(0); + }); - test( - 'handles include and exclude lists correctly', - async () => { - if (!account) throw new Error('No account found'); + test('handles include and exclude lists correctly', async () => { + if (!account) throw new Error('No account found'); - // Just an example: the 'from' address of the announcement to use for filtering - const fromAddressToTest = account.address; - const someOtherAddress = '0xD945323b7E5071598868989838414e679F29C0AB'; + // Just an example: the 'from' address of the announcement to use for filtering + const fromAddressToTest = account.address; + const someOtherAddress = '0xD945323b7E5071598868989838414e679F29C0AB'; - // Test with an exclude list that should filter out the announcement - const excludeListResults = await stealthClient.getAnnouncementsForUser({ - announcements, - spendingPublicKey, - viewingPrivateKey, - excludeList: [fromAddressToTest] - }); + // Test with an exclude list that should filter out the announcement + const excludeListResults = await stealthClient.getAnnouncementsForUser({ + announcements, + spendingPublicKey, + viewingPrivateKey, + excludeList: [fromAddressToTest] + }); - expect(excludeListResults.length).toBe(0); + expect(excludeListResults.length).toBe(0); - // Test with an exclude list that doesn't have this from address - const excludeListResults2 = await stealthClient.getAnnouncementsForUser({ - announcements, - spendingPublicKey, - viewingPrivateKey, - excludeList: [someOtherAddress] - }); + // Test with an exclude list that doesn't have this from address + const excludeListResults2 = await stealthClient.getAnnouncementsForUser({ + announcements, + spendingPublicKey, + viewingPrivateKey, + excludeList: [someOtherAddress] + }); + + expect(excludeListResults2[0].stealthAddress).toEqual(stealthAddress); + + // Test with an include list that should only include the announcement + const includeListResults = await stealthClient.getAnnouncementsForUser({ + announcements, + spendingPublicKey, + viewingPrivateKey, + includeList: [fromAddressToTest] + }); + + expect(includeListResults[0].stealthAddress).toEqual(stealthAddress); - expect(excludeListResults2[0].stealthAddress).toEqual(stealthAddress); + // Test with an include list that doesn't have this from address + const includeListResults2 = await stealthClient.getAnnouncementsForUser({ + announcements, + spendingPublicKey, + viewingPrivateKey, + includeList: [someOtherAddress] + }); - // Test with an include list that should only include the announcement - const includeListResults = await stealthClient.getAnnouncementsForUser({ + expect(includeListResults2.length).toBe(0); + + // Test with both an include and exclude list, which should exclude the announcement + const includeAndExcludeListResults = + await stealthClient.getAnnouncementsForUser({ announcements, spendingPublicKey, viewingPrivateKey, - includeList: [fromAddressToTest] + includeList: [fromAddressToTest], + excludeList: [fromAddressToTest] }); - expect(includeListResults[0].stealthAddress).toEqual(stealthAddress); + expect(includeAndExcludeListResults.length).toBe(0); + }); - // Test with an include list that doesn't have this from address - const includeListResults2 = await stealthClient.getAnnouncementsForUser({ - announcements, + test('efficiently processes a large number of announcements', async () => { + // Generate a large set of mock announcements using the first announcement from above + const largeAnnouncements = Array.from( + { length: PROCESS_LARGE_NUMBER_OF_ANNOUNCEMENTS_NUM }, + () => announcements[0] + ); + + const results = await stealthClient.getAnnouncementsForUser({ + announcements: largeAnnouncements, + spendingPublicKey, + viewingPrivateKey + }); + + // Verify the function handles large data sets correctly + expect(results).toHaveLength(PROCESS_LARGE_NUMBER_OF_ANNOUNCEMENTS_NUM); + }); + + test('throws TransactionHashRequiredError when transactionHash is null', async () => { + const announcementWithoutHash: AnnouncementLog = { + ...announcements[0], + transactionHash: null + }; + + expect( + processAnnouncement(announcementWithoutHash, { spendingPublicKey, viewingPrivateKey, - includeList: [someOtherAddress] - }); + publicClient: walletClient, + excludeList: new Set([]), + includeList: new Set([]) + }) + ).rejects.toBeInstanceOf(TransactionHashRequiredError); + }); - expect(includeListResults2.length).toBe(0); - - // Test with both an include and exclude list, which should exclude the announcement - const includeAndExcludeListResults = - await stealthClient.getAnnouncementsForUser({ - announcements, - spendingPublicKey, - viewingPrivateKey, - includeList: [fromAddressToTest], - excludeList: [fromAddressToTest] - }); - - expect(includeAndExcludeListResults.length).toBe(0); - }, - { timeout: GET_ANNOUNCEMENTS_FOR_USER_TEST_TIMEOUT } - ); - - test( - 'efficiently processes a large number of announcements', - async () => { - // Generate a large set of mock announcements using the first announcement from above - const largeAnnouncements = Array.from( - { length: PROCESS_LARGE_NUMBER_OF_ANNOUNCEMENTS_NUM }, - () => announcements[0] - ); - - const results = await stealthClient.getAnnouncementsForUser({ - announcements: largeAnnouncements, - spendingPublicKey, - viewingPrivateKey - }); + test('throws FromValueNotFoundError when the "from" value is not found', async () => { + const invalidHash = '0xinvalidhash'; - // Verify the function handles large data sets correctly - expect(results).toHaveLength(PROCESS_LARGE_NUMBER_OF_ANNOUNCEMENTS_NUM); - }, - { timeout: GET_ANNOUNCEMENTS_FOR_USER_TEST_TIMEOUT } - ); - - test( - 'throws TransactionHashRequiredError when transactionHash is null', - async () => { - const announcementWithoutHash: AnnouncementLog = { - ...announcements[0], - transactionHash: null - }; - - expect( - processAnnouncement( - announcementWithoutHash, - walletClient as PublicClient, - { - spendingPublicKey, - viewingPrivateKey, - excludeList: new Set([]), - includeList: new Set([]) - } - ) - ).rejects.toBeInstanceOf(TransactionHashRequiredError); - }, - { timeout: GET_ANNOUNCEMENTS_FOR_USER_TEST_TIMEOUT } - ); - - test( - 'throws FromValueNotFoundError when the "from" value is not found', - async () => { - const invalidHash = '0xinvalidhash'; - - expect( - getTransactionFrom({ - publicClient: walletClient as PublicClient, - hash: invalidHash - }) - ).rejects.toBeInstanceOf(FromValueNotFoundError); - }, - { timeout: GET_ANNOUNCEMENTS_FOR_USER_TEST_TIMEOUT } - ); - - test( - 'throws error if view tag does not start with 0x', - () => { - const metadata = 'invalidmetadata'; - expect(() => getViewTagFromMetadata(metadata as HexString)).toThrow( - 'Invalid metadata format' - ); - }, - { timeout: GET_ANNOUNCEMENTS_FOR_USER_TEST_TIMEOUT } - ); + expect( + getTransactionFrom({ + publicClient: walletClient, + hash: invalidHash + }) + ).rejects.toBeInstanceOf(FromValueNotFoundError); + }); + + test('throws error if view tag does not start with 0x', () => { + const metadata = 'invalidmetadata'; + expect(() => getViewTagFromMetadata(metadata as HexString)).toThrow( + 'Invalid metadata format' + ); + }); }); describe('getAnnouncementsForUser error class tests', () => { diff --git a/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.ts b/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.ts index 23b73856..774305e3 100644 --- a/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.ts +++ b/src/lib/actions/getAnnouncementsForUser/getAnnouncementsForUser.ts @@ -1,4 +1,4 @@ -import { type PublicClient, getAddress } from 'viem'; +import { getAddress } from 'viem'; import { type EthAddress, checkStealthAddress, @@ -7,14 +7,70 @@ import { import { handleViemPublicClient } from '../../stealthClient/createStealthClient'; import type { AnnouncementLog } from '../getAnnouncements/types'; import { + type AnnouncementTransactionLookupClient, + type FilterAnnouncementsForUserBatchParams, FromValueNotFoundError, type GetAnnouncementsForUserParams, type GetAnnouncementsForUserReturnType, + type NormalizedAnnouncementAddressFilters, type ProcessAnnouncementParams, type ProcessAnnouncementReturnType, TransactionHashRequiredError } from './types'; +export function normalizeAnnouncementAddressFilters({ + excludeList = [], + includeList = [] +}: Pick< + GetAnnouncementsForUserParams, + 'excludeList' | 'includeList' +>): NormalizedAnnouncementAddressFilters { + return { + excludeList: new Set( + [...new Set(excludeList).values()].map(address => getAddress(address)) + ), + includeList: new Set( + [...new Set(includeList).values()].map(address => getAddress(address)) + ) + }; +} + +export function announcementFiltersRequireTransactionLookup({ + excludeList, + includeList +}: NormalizedAnnouncementAddressFilters): boolean { + return excludeList.size > 0 || includeList.size > 0; +} + +export async function filterAnnouncementsForUserBatch({ + announcements, + excludeList, + includeList, + publicClient, + spendingPublicKey, + viewingPrivateKey +}: FilterAnnouncementsForUserBatchParams): Promise { + const processedAnnouncements = await Promise.allSettled( + announcements.map(announcement => + processAnnouncement(announcement, { + excludeList, + includeList, + publicClient, + spendingPublicKey, + viewingPrivateKey + }) + ) + ); + + return processedAnnouncements.reduce( + (acc, result) => + result.status === 'fulfilled' && result.value !== null + ? acc.concat(result.value) + : acc, + [] + ); +} + /** * @description Fetches and processes a list of announcements to determine which are relevant for the user. * Filters announcements based on the `checkStealthAddress` function and optional exclude/include lists, @@ -36,39 +92,24 @@ async function getAnnouncementsForUser({ excludeList = [], includeList = [] }: GetAnnouncementsForUserParams): Promise { - const publicClient = handleViemPublicClient(clientParams); - - // Validate excludeList and includeList - const _excludeList = new Set( - [...new Set(excludeList).values()].map(address => getAddress(address)) - ); - const _includeList = new Set( - [...new Set(includeList).values()].map(address => getAddress(address)) - ); - - const processedAnnouncements = await Promise.allSettled( - announcements.map(announcement => - processAnnouncement(announcement, publicClient, { - spendingPublicKey, - viewingPrivateKey, - clientParams, - excludeList: _excludeList, - includeList: _includeList - }) - ) - ); - - const relevantAnnouncements = processedAnnouncements.reduce< - AnnouncementLog[] - >( - (acc, result) => - result.status === 'fulfilled' && result.value !== null - ? acc.concat(result.value) - : acc, - [] - ); - - return relevantAnnouncements; + const normalizedAddressFilters = normalizeAnnouncementAddressFilters({ + excludeList, + includeList + }); + const publicClient = announcementFiltersRequireTransactionLookup( + normalizedAddressFilters + ) + ? handleViemPublicClient(clientParams) + : undefined; + + return filterAnnouncementsForUserBatch({ + announcements, + excludeList: normalizedAddressFilters.excludeList, + includeList: normalizedAddressFilters.includeList, + publicClient, + spendingPublicKey, + viewingPrivateKey + }); } /** @@ -86,12 +127,12 @@ async function getAnnouncementsForUser({ */ export async function processAnnouncement( announcement: AnnouncementLog, - publicClient: PublicClient, { - spendingPublicKey, - viewingPrivateKey, excludeList, - includeList + includeList, + publicClient, + spendingPublicKey, + viewingPrivateKey }: ProcessAnnouncementParams ): Promise { const { @@ -150,9 +191,14 @@ async function shouldIncludeAnnouncement({ hash: `0x${string}`; excludeList: Set; includeList: Set; - publicClient: PublicClient; + publicClient?: AnnouncementTransactionLookupClient; }): Promise { if (excludeList.size === 0 && includeList.size === 0) return true; // No filters applied, include announcement + if (!publicClient) { + throw new Error( + 'publicClient or chainId and rpcUrl must be provided when includeList or excludeList is used' + ); + } const from = await getTransactionFrom({ hash, publicClient }); @@ -177,7 +223,7 @@ export async function getTransactionFrom({ publicClient, hash }: { - publicClient: PublicClient; + publicClient: AnnouncementTransactionLookupClient; hash: `0x${string}`; }): Promise<`0x${string}`> { try { diff --git a/src/lib/actions/getAnnouncementsForUser/types.ts b/src/lib/actions/getAnnouncementsForUser/types.ts index d558353c..187bf807 100644 --- a/src/lib/actions/getAnnouncementsForUser/types.ts +++ b/src/lib/actions/getAnnouncementsForUser/types.ts @@ -1,7 +1,13 @@ +import type { PublicClient } from 'viem'; import type { EthAddress } from '../../..'; import type { ClientParams } from '../../stealthClient/types'; import type { AnnouncementLog } from '../getAnnouncements/types'; +export type AnnouncementTransactionLookupClient = Pick< + PublicClient, + 'getTransaction' +>; + export type GetAnnouncementsForUserParams = { announcements: AnnouncementLog[]; spendingPublicKey: `0x${string}`; @@ -13,14 +19,24 @@ export type GetAnnouncementsForUserParams = { export type GetAnnouncementsForUserReturnType = AnnouncementLog[]; -export type ProcessAnnouncementParams = Omit< - GetAnnouncementsForUserParams, - 'announcements' | 'excludeList' | 'includeList' -> & { +export type NormalizedAnnouncementAddressFilters = { excludeList: Set; includeList: Set; }; +export type FilterAnnouncementsForUserBatchParams = { + announcements: AnnouncementLog[]; + publicClient?: AnnouncementTransactionLookupClient; + spendingPublicKey: `0x${string}`; + viewingPrivateKey: `0x${string}`; +} & NormalizedAnnouncementAddressFilters; + +export type ProcessAnnouncementParams = { + publicClient?: AnnouncementTransactionLookupClient; + spendingPublicKey: `0x${string}`; + viewingPrivateKey: `0x${string}`; +} & NormalizedAnnouncementAddressFilters; + export type ProcessAnnouncementReturnType = AnnouncementLog | null; export class FromValueNotFoundError extends Error { diff --git a/src/lib/actions/index.ts b/src/lib/actions/index.ts index 03b12dfd..2aa105cc 100644 --- a/src/lib/actions/index.ts +++ b/src/lib/actions/index.ts @@ -7,9 +7,11 @@ import getStealthMetaAddress from './getStealthMetaAddress/getStealthMetaAddress import prepareAnnounce from './prepareAnnounce/prepareAnnounce'; import prepareRegisterKeys from './prepareRegisterKeys/prepareRegisterKeys'; import prepareRegisterKeysOnBehalf from './prepareRegisterKeysOnBehalf/prepareRegisterKeysOnBehalf'; +import scanAnnouncementsForUserUsingSubgraph from './scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph'; import watchAnnouncementsForUser from './watchAnnouncementsForUser/watchAnnouncementsForUser'; export { default as getAnnouncements } from './getAnnouncements/getAnnouncements'; export { default as getAnnouncementsForUser } from './getAnnouncementsForUser/getAnnouncementsForUser'; +export { default as scanAnnouncementsForUserUsingSubgraph } from './scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph'; export { default as getAnnouncementsPageUsingSubgraph } from './getAnnouncementsUsingSubgraph/getAnnouncementsPageUsingSubgraph'; export { default as getAnnouncementsUsingSubgraph } from './getAnnouncementsUsingSubgraph/getAnnouncementsUsingSubgraph'; export { default as getStealthMetaAddress } from './getStealthMetaAddress/getStealthMetaAddress'; @@ -32,6 +34,13 @@ export type { GetAnnouncementsForUserParams, GetAnnouncementsForUserReturnType } from './getAnnouncementsForUser/types'; +export type { + ScanAnnouncementsForUserUsingSubgraphBatch, + ScanAnnouncementsForUserUsingSubgraphInitialParams, + ScanAnnouncementsForUserUsingSubgraphNextParams, + ScanAnnouncementsForUserUsingSubgraphParams, + ScanAnnouncementsForUserUsingSubgraphReturnType +} from './scanAnnouncementsForUserUsingSubgraph/types'; export type { GetAnnouncementsPageUsingSubgraphParams, GetAnnouncementsPageUsingSubgraphReturnType, @@ -39,7 +48,12 @@ export type { GetAnnouncementsUsingSubgraphReturnType } from './getAnnouncementsUsingSubgraph/types'; export type { + WatchAnnouncementsForUserBatchMeta, + WatchAnnouncementsForUserErrorHandler, + WatchAnnouncementsForUserHeartbeatHandler, + WatchAnnouncementsForUserHandler, WatchAnnouncementsForUserParams, + WatchAnnouncementsForUserPollMeta, WatchAnnouncementsForUserReturnType } from './watchAnnouncementsForUser/types'; export type { @@ -58,6 +72,7 @@ export type { export const actions: StealthActions = { getAnnouncements, getAnnouncementsForUser, + scanAnnouncementsForUserUsingSubgraph, getAnnouncementsPageUsingSubgraph, getAnnouncementsUsingSubgraph, getStealthMetaAddress, diff --git a/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph.test.ts b/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph.test.ts new file mode 100644 index 00000000..e56af69c --- /dev/null +++ b/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph.test.ts @@ -0,0 +1,710 @@ +import { describe, expect, test } from 'bun:test'; +import { ERC5564_CONTRACT_ADDRESS } from '../../../config'; +import { VALID_SCHEME_ID, generateStealthAddress } from '../../../utils/crypto'; +import setupTestStealthKeys from '../../helpers/test/setupTestStealthKeys'; +import type { AnnouncementLog } from '../getAnnouncements/types'; +import getAnnouncementsForUser from '../getAnnouncementsForUser/getAnnouncementsForUser'; +import type { GetAnnouncementsPageUsingSubgraphParams } from '../getAnnouncementsUsingSubgraph/types'; +import scanAnnouncementsForUserUsingSubgraph, { + compareAnnouncementsByChainRecency, + scanAnnouncementsForUserUsingSubgraphWithPageFetcher, + sortAnnouncementsByChainRecency +} from './scanAnnouncementsForUserUsingSubgraph'; +import { ScanAnnouncementsForUserUsingSubgraphError } from './types'; + +const SCHEME_ID = VALID_SCHEME_ID.SCHEME_ID_1; + +const allowedFrom = '0x00000000000000000000000000000000000000AA'; +const secondAllowedFrom = '0x00000000000000000000000000000000000000BB'; +const blockedFrom = '0x00000000000000000000000000000000000000CC'; +const REAL_SUBGRAPH_RETRY_ATTEMPTS = 4; +const REAL_SUBGRAPH_RETRY_DELAY_MS = 2_000; +const REAL_SUBGRAPH_TEST_TIMEOUT_MS = 30_000; +const DEFAULT_BASE_SEPOLIA_PUBLIC_SUBGRAPH_URL = + 'https://api.goldsky.com/api/public/project_cmmiaq2g3rzcq01wv00814i31/subgraphs/stealth-address-erc-base-sepolia/v0.0.1/gn'; + +const userKeys = setupTestStealthKeys(SCHEME_ID); +const alternateKeys = setupTestStealthKeys(SCHEME_ID); + +function hex64(value: number): `0x${string}` { + return `0x${value.toString(16).padStart(64, '0')}`; +} + +function makeAnnouncement({ + blockNumber, + from, + isForUser, + logIndex, + sequence, + transactionIndex +}: { + blockNumber: number; + from: `0x${string}`; + isForUser: boolean; + logIndex: number; + sequence: number; + transactionIndex: number; +}): AnnouncementLog { + const { stealthAddress, ephemeralPublicKey, viewTag } = + generateStealthAddress({ + stealthMetaAddressURI: isForUser + ? userKeys.stealthMetaAddressURI + : alternateKeys.stealthMetaAddressURI, + schemeId: SCHEME_ID + }); + + const announcement: AnnouncementLog = { + address: ERC5564_CONTRACT_ADDRESS, + blockHash: hex64(sequence + 10_000), + blockNumber: BigInt(blockNumber), + caller: from, + data: '0x', + ephemeralPubKey: ephemeralPublicKey, + logIndex, + metadata: viewTag, + removed: false, + schemeId: BigInt(SCHEME_ID), + stealthAddress, + topics: [], + transactionHash: hex64(sequence), + transactionIndex + }; + + return announcement; +} + +function createPublicClientForAnnouncements( + announcements: AnnouncementLog[], + fromByHash?: Record<`0x${string}`, `0x${string}`> +) { + const lookup = fromByHash + ? new Map<`0x${string}`, `0x${string}`>( + Object.entries(fromByHash) as Array<[`0x${string}`, `0x${string}`]> + ) + : new Map<`0x${string}`, `0x${string}`>( + announcements.map(announcement => { + if (!announcement.transactionHash) { + throw new Error( + 'Expected mock announcements to include transaction hashes' + ); + } + + return [announcement.transactionHash, announcement.caller]; + }) + ); + + return { + getTransaction: async ({ hash }: { hash: `0x${string}` }) => { + const from = lookup.get(hash); + if (!from) { + throw new Error(`Missing mock sender for ${hash}`); + } + + return { + from + }; + } + }; +} + +function createPageFetcher( + pages: Array<{ + announcements: AnnouncementLog[]; + nextCursor?: string; + snapshotBlock: bigint; + }> +) { + const calls: GetAnnouncementsPageUsingSubgraphParams[] = []; + + return { + calls, + fetcher: async (params: GetAnnouncementsPageUsingSubgraphParams) => { + calls.push(params); + const nextPage = pages.shift(); + if (!nextPage) { + throw new Error('Unexpected page fetch'); + } + + return nextPage; + } + }; +} + +async function collectAll( + generator: AsyncGenerator +): Promise { + const values: T[] = []; + + for await (const value of generator) { + values.push(value); + } + + return values; +} + +const sleep = (ms: number): Promise => + new Promise(resolve => setTimeout(resolve, ms)); + +const isRateLimitedSubgraphError = (error: unknown): boolean => { + const visited = new Set(); + let current = error; + + while (current && !visited.has(current)) { + visited.add(current); + + if (current instanceof Error) { + const message = current.message.toLowerCase(); + if ( + message.includes('429') || + message.includes('too many requests') || + message.includes('retry-after') + ) { + return true; + } + + current = + 'originalError' in current + ? (current as { originalError?: unknown }).originalError + : undefined; + continue; + } + + current = undefined; + } + + return false; +}; + +const withRealSubgraphRetry = async (fn: () => Promise): Promise => { + let attempt = 0; + + while (true) { + try { + return await fn(); + } catch (error) { + attempt += 1; + if ( + attempt >= REAL_SUBGRAPH_RETRY_ATTEMPTS || + !isRateLimitedSubgraphError(error) + ) { + throw error; + } + + await sleep(REAL_SUBGRAPH_RETRY_DELAY_MS * attempt); + } + } +}; + +const buildBaseSepoliaSubgraphUrlCandidates = (): string[] => { + const explicitUrl = process.env.SUBGRAPH_URL_BASE_SEPOLIA; + if (explicitUrl) { + return [explicitUrl, DEFAULT_BASE_SEPOLIA_PUBLIC_SUBGRAPH_URL]; + } + + const subgraphUrlPrefix = process.env.SUBGRAPH_URL_PREFIX; + const subgraphName = process.env.SUBGRAPH_NAME_BASE_SEPOLIA; + if (!subgraphUrlPrefix || !subgraphName) { + return []; + } + + if ( + subgraphName.startsWith('http://') || + subgraphName.startsWith('https://') + ) { + return [subgraphName, DEFAULT_BASE_SEPOLIA_PUBLIC_SUBGRAPH_URL]; + } + + const normalizedPrefix = subgraphUrlPrefix.replace(/\/+$/, ''); + const normalizedSubgraphName = subgraphName.replace(/^\/+/, ''); + const baseUrl = `${normalizedPrefix}/${normalizedSubgraphName}`; + const candidates = [baseUrl]; + + if (!baseUrl.endsWith('/api') && !baseUrl.endsWith('/gn')) { + candidates.push(`${baseUrl}/api`); + } + + candidates.push(DEFAULT_BASE_SEPOLIA_PUBLIC_SUBGRAPH_URL); + + return [...new Set(candidates)]; +}; + +const baseSepoliaSubgraphUrlCandidates = + buildBaseSepoliaSubgraphUrlCandidates(); +const isCi = process.env.CI === 'true'; +const hasBaseSepoliaSubgraph = + baseSepoliaSubgraphUrlCandidates.length > 0 || isCi; +const describeRealBaseSepolia = hasBaseSepoliaSubgraph + ? describe + : describe.skip; + +describe('scanAnnouncementsForUserUsingSubgraph', () => { + test('yields one scanned batch per page without speculative prefetch', async () => { + const pageOne = [ + makeAnnouncement({ + blockNumber: 101, + from: allowedFrom, + isForUser: false, + logIndex: 0, + sequence: 1, + transactionIndex: 0 + }), + makeAnnouncement({ + blockNumber: 100, + from: allowedFrom, + isForUser: true, + logIndex: 1, + sequence: 2, + transactionIndex: 1 + }) + ]; + const pageTwo = [ + makeAnnouncement({ + blockNumber: 99, + from: secondAllowedFrom, + isForUser: true, + logIndex: 0, + sequence: 3, + transactionIndex: 0 + }) + ]; + const { calls, fetcher } = createPageFetcher([ + { + announcements: pageOne, + nextCursor: 'cursor-1', + snapshotBlock: 500n + }, + { + announcements: pageTwo, + nextCursor: undefined, + snapshotBlock: 500n + } + ]); + + const iterator = scanAnnouncementsForUserUsingSubgraphWithPageFetcher( + { + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: userKeys.viewingPrivateKey + }, + fetcher + ); + + expect(calls).toHaveLength(0); + + const firstBatch = await iterator.next(); + + expect(calls).toHaveLength(1); + expect(firstBatch.value).toEqual({ + announcements: [pageOne[1]], + nextCursor: 'cursor-1', + scannedCount: 2, + snapshotBlock: 500n + }); + + await iterator.return(undefined); + expect(calls).toHaveLength(1); + }); + + test('resumes from a provided cursor and snapshot block', async () => { + const page = [ + makeAnnouncement({ + blockNumber: 90, + from: allowedFrom, + isForUser: true, + logIndex: 0, + sequence: 4, + transactionIndex: 0 + }) + ]; + const { calls, fetcher } = createPageFetcher([ + { + announcements: page, + nextCursor: undefined, + snapshotBlock: 700n + } + ]); + + const iterator = scanAnnouncementsForUserUsingSubgraphWithPageFetcher( + { + cursor: 'resume-cursor', + snapshotBlock: 700n, + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: userKeys.viewingPrivateKey + }, + fetcher + ); + + const batch = await iterator.next(); + + expect(calls[0]).toMatchObject({ + cursor: 'resume-cursor', + snapshotBlock: 700n + }); + expect(batch.value?.snapshotBlock).toBe(700n); + }); + + test('yields empty batches when a page has zero matching announcements', async () => { + const { fetcher } = createPageFetcher([ + { + announcements: [ + makeAnnouncement({ + blockNumber: 88, + from: blockedFrom, + isForUser: false, + logIndex: 0, + sequence: 5, + transactionIndex: 0 + }) + ], + nextCursor: undefined, + snapshotBlock: 800n + } + ]); + + const batches = await collectAll( + scanAnnouncementsForUserUsingSubgraphWithPageFetcher( + { + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: userKeys.viewingPrivateKey + }, + fetcher + ) + ); + + expect(batches).toEqual([ + { + announcements: [], + nextCursor: undefined, + scannedCount: 1, + snapshotBlock: 800n + } + ]); + }); + + test('matches the eager user scan when pages already satisfy the bounded scan order', async () => { + const pageOne = [ + makeAnnouncement({ + blockNumber: 120, + from: allowedFrom, + isForUser: true, + logIndex: 0, + sequence: 6, + transactionIndex: 0 + }), + makeAnnouncement({ + blockNumber: 118, + from: secondAllowedFrom, + isForUser: false, + logIndex: 1, + sequence: 7, + transactionIndex: 0 + }) + ]; + const pageTwo = [ + makeAnnouncement({ + blockNumber: 117, + from: secondAllowedFrom, + isForUser: true, + logIndex: 0, + sequence: 8, + transactionIndex: 0 + }), + makeAnnouncement({ + blockNumber: 116, + from: blockedFrom, + isForUser: true, + logIndex: 0, + sequence: 9, + transactionIndex: 0 + }) + ]; + const flattenedCandidates = [...pageOne, ...pageTwo]; + const { fetcher } = createPageFetcher([ + { + announcements: pageOne, + nextCursor: 'cursor-2', + snapshotBlock: 900n + }, + { + announcements: pageTwo, + nextCursor: undefined, + snapshotBlock: 900n + } + ]); + + const streamingBatches = await collectAll( + scanAnnouncementsForUserUsingSubgraphWithPageFetcher( + { + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: userKeys.viewingPrivateKey + }, + fetcher + ) + ); + const eagerAnnouncements = await getAnnouncementsForUser({ + announcements: flattenedCandidates, + spendingPublicKey: userKeys.spendingPublicKey, + viewingPrivateKey: userKeys.viewingPrivateKey + }); + + expect(streamingBatches.flatMap(batch => batch.announcements)).toEqual( + eagerAnnouncements + ); + }); + + test('applies include and exclude lists using the shared filtering internals', async () => { + const matchingAnnouncements = [ + makeAnnouncement({ + blockNumber: 110, + from: allowedFrom, + isForUser: true, + logIndex: 1, + sequence: 10, + transactionIndex: 0 + }), + makeAnnouncement({ + blockNumber: 109, + from: blockedFrom, + isForUser: true, + logIndex: 0, + sequence: 11, + transactionIndex: 0 + }) + ]; + const publicClient = createPublicClientForAnnouncements( + matchingAnnouncements + ); + const { fetcher } = createPageFetcher([ + { + announcements: matchingAnnouncements, + nextCursor: undefined, + snapshotBlock: 901n + } + ]); + + const batches = await collectAll( + scanAnnouncementsForUserUsingSubgraphWithPageFetcher( + { + clientParams: { + publicClient: publicClient as never + }, + excludeList: [blockedFrom], + includeList: [allowedFrom], + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: userKeys.viewingPrivateKey + }, + fetcher + ) + ); + + expect(batches).toEqual([ + { + announcements: [matchingAnnouncements[0]], + nextCursor: undefined, + scannedCount: 2, + snapshotBlock: 901n + } + ]); + }); + + test('rejects invalid initial and resume parameter combinations', async () => { + const iterator = scanAnnouncementsForUserUsingSubgraphWithPageFetcher({ + cursor: 'cursor-only', + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: userKeys.viewingPrivateKey + } as never); + + expect(iterator.next()).rejects.toThrow( + 'cursor and snapshotBlock must either both be omitted for the initial page or both be provided for subsequent pages' + ); + }); + + test('continues scanning when later pages are newer than earlier pages in subgraph id order', async () => { + const firstPage = [ + makeAnnouncement({ + blockNumber: 100, + from: allowedFrom, + isForUser: true, + logIndex: 0, + sequence: 12, + transactionIndex: 0 + }) + ]; + const secondPage = [ + makeAnnouncement({ + blockNumber: 101, + from: secondAllowedFrom, + isForUser: true, + logIndex: 0, + sequence: 13, + transactionIndex: 0 + }) + ]; + const { fetcher } = createPageFetcher([ + { + announcements: firstPage, + nextCursor: 'cursor-3', + snapshotBlock: 902n + }, + { + announcements: secondPage, + nextCursor: undefined, + snapshotBlock: 902n + } + ]); + const iterator = scanAnnouncementsForUserUsingSubgraphWithPageFetcher( + { + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: userKeys.viewingPrivateKey + }, + fetcher + ); + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { + announcements: firstPage, + nextCursor: 'cursor-3', + scannedCount: 1, + snapshotBlock: 902n + } + }); + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { + announcements: secondPage, + nextCursor: undefined, + scannedCount: 1, + snapshotBlock: 902n + } + }); + }); + + test('sorts announcements by block number, transaction index, then log index', () => { + const announcements = [ + makeAnnouncement({ + blockNumber: 100, + from: allowedFrom, + isForUser: true, + logIndex: 1, + sequence: 14, + transactionIndex: 0 + }), + makeAnnouncement({ + blockNumber: 101, + from: allowedFrom, + isForUser: true, + logIndex: 0, + sequence: 15, + transactionIndex: 0 + }), + makeAnnouncement({ + blockNumber: 100, + from: allowedFrom, + isForUser: true, + logIndex: 0, + sequence: 16, + transactionIndex: 1 + }) + ]; + + const sorted = sortAnnouncementsByChainRecency(announcements); + + expect(sorted).toEqual([ + announcements[1], + announcements[2], + announcements[0] + ]); + expect(compareAnnouncementsByChainRecency(sorted[0], sorted[1])).toBe(-1); + expect(compareAnnouncementsByChainRecency(sorted[1], sorted[2])).toBe(-1); + }); + + test('exposes the wrapped scan error with the original error attached', () => { + const originalError = new Error('boom'); + const error = new ScanAnnouncementsForUserUsingSubgraphError( + 'Failed to scan announcements from the subgraph', + originalError + ); + + expect(error.name).toBe('ScanAnnouncementsForUserUsingSubgraphError'); + expect(error.message).toBe( + 'Failed to scan announcements from the subgraph' + ); + expect(error.originalError).toBe(originalError); + }); +}); + +describeRealBaseSepolia( + 'scanAnnouncementsForUserUsingSubgraph with real subgraph', + () => { + test( + 'streams multiple pages from Base Sepolia without throwing', + async () => { + if (baseSepoliaSubgraphUrlCandidates.length === 0) { + throw new Error( + 'SUBGRAPH_URL_BASE_SEPOLIA or SUBGRAPH_URL_PREFIX + SUBGRAPH_NAME_BASE_SEPOLIA is required to run the real streaming integration test' + ); + } + + const result = await withRealSubgraphRetry(async () => { + let lastError: Error | undefined; + + for (const subgraphUrl of baseSepoliaSubgraphUrlCandidates) { + try { + const batches = await collectAll( + scanAnnouncementsForUserUsingSubgraph({ + pageSize: 50, + spendingPublicKey: userKeys.spendingPublicKey, + subgraphUrl, + viewingPrivateKey: userKeys.viewingPrivateKey + }) + ); + + return { + batches, + subgraphUrl + }; + } catch (error) { + lastError = error as Error; + } + } + + if (isCi) { + console.warn( + `Skipping Base Sepolia real streaming assertion because every configured endpoint failed externally${ + lastError ? `: ${lastError.message}` : '' + }` + ); + return undefined; + } + + throw ( + lastError ?? new Error('No Base Sepolia subgraph URL succeeded') + ); + }); + + if (!result) { + return; + } + + expect(result.batches.length).toBeGreaterThan(1); + expect(result.batches[0]?.nextCursor).toBeDefined(); + expect( + result.batches.every( + batch => batch.snapshotBlock === result.batches[0]?.snapshotBlock + ) + ).toBe(true); + expect( + result.batches.reduce((total, batch) => total + batch.scannedCount, 0) + ).toBeGreaterThan(50); + }, + { timeout: REAL_SUBGRAPH_TEST_TIMEOUT_MS } + ); + } +); diff --git a/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph.ts b/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph.ts new file mode 100644 index 00000000..2968bf7f --- /dev/null +++ b/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/scanAnnouncementsForUserUsingSubgraph.ts @@ -0,0 +1,227 @@ +import { handleViemPublicClient } from '../../stealthClient/createStealthClient'; +import type { AnnouncementLog } from '../getAnnouncements/types'; +import { + announcementFiltersRequireTransactionLookup, + filterAnnouncementsForUserBatch, + normalizeAnnouncementAddressFilters +} from '../getAnnouncementsForUser/getAnnouncementsForUser'; +import getAnnouncementsPageUsingSubgraph from '../getAnnouncementsUsingSubgraph/getAnnouncementsPageUsingSubgraph'; +import type { + GetAnnouncementsPageUsingSubgraphParams, + GetAnnouncementsPageUsingSubgraphReturnType +} from '../getAnnouncementsUsingSubgraph/types'; +import { + type ScanAnnouncementsForUserUsingSubgraphBatch, + ScanAnnouncementsForUserUsingSubgraphError, + type ScanAnnouncementsForUserUsingSubgraphParams, + type ScanAnnouncementsForUserUsingSubgraphReturnType +} from './types'; + +type GetAnnouncementsPageUsingSubgraphFn = ( + params: GetAnnouncementsPageUsingSubgraphParams +) => Promise; + +function assertValidScanParams({ + cursor, + snapshotBlock +}: Pick< + ScanAnnouncementsForUserUsingSubgraphParams, + 'cursor' | 'snapshotBlock' +>): void { + if ((cursor === undefined) !== (snapshotBlock === undefined)) { + throw new Error( + 'cursor and snapshotBlock must either both be omitted for the initial page or both be provided for subsequent pages' + ); + } +} + +export function compareAnnouncementsByChainRecency( + left: AnnouncementLog, + right: AnnouncementLog +): number { + const leftBlockNumber = left.blockNumber; + const rightBlockNumber = right.blockNumber; + + if (leftBlockNumber === null || rightBlockNumber === null) { + throw new Error( + 'Subgraph pages must include blockNumber for per-page recency sorting' + ); + } + + if (leftBlockNumber !== rightBlockNumber) { + return leftBlockNumber > rightBlockNumber ? -1 : 1; + } + + const leftTransactionIndex = left.transactionIndex; + const rightTransactionIndex = right.transactionIndex; + + if (leftTransactionIndex === null || rightTransactionIndex === null) { + throw new Error( + 'Subgraph pages must include transactionIndex for per-page recency sorting' + ); + } + + if (leftTransactionIndex !== rightTransactionIndex) { + return leftTransactionIndex > rightTransactionIndex ? -1 : 1; + } + + const leftLogIndex = left.logIndex; + const rightLogIndex = right.logIndex; + + if (leftLogIndex === null || rightLogIndex === null) { + throw new Error( + 'Subgraph pages must include logIndex for per-page recency sorting' + ); + } + + if (leftLogIndex !== rightLogIndex) { + return leftLogIndex > rightLogIndex ? -1 : 1; + } + + return 0; +} + +/** + * Compares two announcements by on-chain recency for per-page sorting. + * + * Announcements are ordered by descending `blockNumber`, then descending + * `transactionIndex`, then descending `logIndex`. + * + * This comparator is intended for sorting announcements within a single fetched + * page. It does not imply that separately paginated subgraph pages are globally + * ordered by chain recency. + * + * Throws when the announcement data is missing any chain-position field needed + * to perform the comparison. + */ +export function sortAnnouncementsByChainRecency( + announcements: AnnouncementLog[] +): AnnouncementLog[] { + return [...announcements].sort(compareAnnouncementsByChainRecency); +} + +function assertStrictlyDescendingChainRecency( + announcements: AnnouncementLog[] +): void { + for (let index = 1; index < announcements.length; index += 1) { + if ( + compareAnnouncementsByChainRecency( + announcements[index - 1], + announcements[index] + ) >= 0 + ) { + throw new Error( + 'Subgraph pages must preserve strict chain recency within the requested block range' + ); + } + } +} + +function buildPageParams({ + caller, + cursor, + fromBlock, + pageSize, + schemeId, + snapshotBlock, + subgraphUrl, + toBlock +}: ScanAnnouncementsForUserUsingSubgraphParams): GetAnnouncementsPageUsingSubgraphParams { + const sharedParams = { + caller, + fromBlock, + pageSize, + schemeId, + subgraphUrl, + toBlock + }; + + if (cursor === undefined && snapshotBlock === undefined) { + return sharedParams; + } + + return { + ...sharedParams, + cursor, + snapshotBlock + }; +} + +export async function* scanAnnouncementsForUserUsingSubgraphWithPageFetcher( + params: ScanAnnouncementsForUserUsingSubgraphParams, + getPage: GetAnnouncementsPageUsingSubgraphFn = getAnnouncementsPageUsingSubgraph +): ScanAnnouncementsForUserUsingSubgraphReturnType { + const { + clientParams, + includeList, + excludeList, + spendingPublicKey, + viewingPrivateKey + } = params; + + assertValidScanParams(params); + + const normalizedAddressFilters = normalizeAnnouncementAddressFilters({ + excludeList, + includeList + }); + const publicClient = announcementFiltersRequireTransactionLookup( + normalizedAddressFilters + ) + ? handleViemPublicClient(clientParams) + : undefined; + + let pageParams = buildPageParams(params); + + try { + while (true) { + const page = await getPage(pageParams); + const sortedPageAnnouncements = sortAnnouncementsByChainRecency( + page.announcements + ); + + assertStrictlyDescendingChainRecency(sortedPageAnnouncements); + + const announcements = await filterAnnouncementsForUserBatch({ + announcements: sortedPageAnnouncements, + excludeList: normalizedAddressFilters.excludeList, + includeList: normalizedAddressFilters.includeList, + publicClient, + spendingPublicKey, + viewingPrivateKey + }); + + const batch: ScanAnnouncementsForUserUsingSubgraphBatch = { + announcements, + nextCursor: page.nextCursor, + scannedCount: sortedPageAnnouncements.length, + snapshotBlock: page.snapshotBlock + }; + + yield batch; + + if (!page.nextCursor) { + return; + } + + pageParams = { + ...pageParams, + cursor: page.nextCursor, + snapshotBlock: page.snapshotBlock + }; + } + } catch (error) { + throw new ScanAnnouncementsForUserUsingSubgraphError( + 'Failed to scan announcements from the subgraph', + error + ); + } +} + +function scanAnnouncementsForUserUsingSubgraph( + params: ScanAnnouncementsForUserUsingSubgraphParams +): ScanAnnouncementsForUserUsingSubgraphReturnType { + return scanAnnouncementsForUserUsingSubgraphWithPageFetcher(params); +} + +export default scanAnnouncementsForUserUsingSubgraph; diff --git a/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/types.ts b/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/types.ts new file mode 100644 index 00000000..46b9ad86 --- /dev/null +++ b/src/lib/actions/scanAnnouncementsForUserUsingSubgraph/types.ts @@ -0,0 +1,66 @@ +import type { EthAddress } from '../../..'; +import type { ClientParams } from '../../stealthClient/types'; +import type { AnnouncementLog } from '../getAnnouncements/types'; + +type ScanAnnouncementsForUserUsingSubgraphBaseParams = { + /** The URL of the subgraph to fetch announcements from */ + subgraphUrl: string; + /** (Optional) The lower inclusive block bound for the scan */ + fromBlock?: bigint | number; + /** (Optional) The upper inclusive block bound for the scan */ + toBlock?: bigint | number; + /** (Optional) The scheme ID to filter by */ + schemeId?: bigint | number; + /** (Optional) The caller address to filter by */ + caller?: EthAddress; + /** (Optional) The number of results to fetch per page; defaults to 999 */ + pageSize?: number; + spendingPublicKey: `0x${string}`; + viewingPrivateKey: `0x${string}`; + clientParams?: ClientParams; + excludeList?: EthAddress[]; + includeList?: EthAddress[]; +}; + +export type ScanAnnouncementsForUserUsingSubgraphInitialParams = + ScanAnnouncementsForUserUsingSubgraphBaseParams & { + /** The initial scan must omit the cursor so the scan starts from the newest page */ + cursor?: undefined; + /** The initial scan may omit the snapshot block; the SDK resolves and returns it */ + snapshotBlock?: undefined; + }; + +export type ScanAnnouncementsForUserUsingSubgraphNextParams = + ScanAnnouncementsForUserUsingSubgraphBaseParams & { + /** The exclusive prior-page announcement id used with the query's `id desc` ordering */ + cursor: string; + /** The fixed subgraph block returned by the initial page and reused for the rest of the scan */ + snapshotBlock: bigint | number; + }; + +export type ScanAnnouncementsForUserUsingSubgraphParams = + | ScanAnnouncementsForUserUsingSubgraphInitialParams + | ScanAnnouncementsForUserUsingSubgraphNextParams; + +export type ScanAnnouncementsForUserUsingSubgraphBatch = { + announcements: AnnouncementLog[]; + scannedCount: number; + nextCursor?: string; + snapshotBlock: bigint; +}; + +export type ScanAnnouncementsForUserUsingSubgraphReturnType = AsyncGenerator< + ScanAnnouncementsForUserUsingSubgraphBatch, + void, + unknown +>; + +export class ScanAnnouncementsForUserUsingSubgraphError extends Error { + constructor( + message: string, + public readonly originalError?: unknown + ) { + super(message); + this.name = 'ScanAnnouncementsForUserUsingSubgraphError'; + } +} diff --git a/src/lib/actions/watchAnnouncementsForUser/types.ts b/src/lib/actions/watchAnnouncementsForUser/types.ts index eba900a6..32707f0f 100644 --- a/src/lib/actions/watchAnnouncementsForUser/types.ts +++ b/src/lib/actions/watchAnnouncementsForUser/types.ts @@ -12,13 +12,44 @@ import type { export type WatchAnnouncementsForUserPollingOptions = GetPollOptions; -export type WatchAnnouncementsForUserParams = { +export type WatchAnnouncementsForUserPollMeta = { + fromBlock?: bigint | 'latest'; + observedBlock: bigint; + pollTimestamp: number; +}; + +export type WatchAnnouncementsForUserBatchMeta = + WatchAnnouncementsForUserPollMeta & { + rawLogCount: number; + relevantLogCount: number; + }; + +export type WatchAnnouncementsForUserHandler = ( + logs: AnnouncementLog[], + meta: WatchAnnouncementsForUserBatchMeta +) => T | Promise; + +export type WatchAnnouncementsForUserErrorHandler = ( + error: Error +) => void | Promise; + +export type WatchAnnouncementsForUserHeartbeatHandler = ( + meta: WatchAnnouncementsForUserPollMeta +) => void | Promise; + +export type WatchAnnouncementsForUserParams = { /** The address of the ERC5564 contract. */ ERC5564Address: EthAddress; /** The arguments to filter the announcements. */ args: AnnouncementArgs; + /** Optional lower inclusive block bound for the live watch. */ + fromBlock?: bigint | 'latest'; /** The callback function to handle the filtered announcement logs. */ - handleLogsForUser: (logs: AnnouncementLog[]) => T; + handleLogsForUser: WatchAnnouncementsForUserHandler; + /** Optional error handler for asynchronous watch processing failures. */ + onError?: WatchAnnouncementsForUserErrorHandler; + /** Optional callback invoked whenever the watcher observes a chain head while polling. */ + onHeartbeat?: WatchAnnouncementsForUserHeartbeatHandler; /** Optional polling options */ pollOptions?: WatchAnnouncementsForUserPollingOptions; } & Omit; diff --git a/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.test.ts b/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.test.ts index 01aa2228..3a9b6ef0 100644 --- a/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.test.ts +++ b/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.test.ts @@ -1,8 +1,9 @@ import * as BunTest from 'bun:test'; -import type { Address } from 'viem'; +import { type Address, getAddress } from 'viem'; import { type AnnouncementLog, ERC5564AnnouncerAbi, + ERC5564_CONTRACT_ADDRESS, VALID_SCHEME_ID, generateStealthAddress } from '../../..'; @@ -11,9 +12,15 @@ import setupTestStealthKeys from '../../helpers/test/setupTestStealthKeys'; import setupTestWallet from '../../helpers/test/setupTestWallet'; import type { SuperWalletClient } from '../../helpers/types'; import type { StealthActions } from '../../stealthClient/types'; +import { + createWatchedAnnouncementsQueue, + processWatchedAnnouncementsBatch, + startWatchHeartbeat +} from './watchAnnouncementsForUser'; const NUM_ANNOUNCEMENTS = 3; -const WATCH_POLLING_INTERVAL = 1000; +const WATCH_POLLING_INTERVAL = 250; +const WAIT_TIMEOUT_MS = 8_000; const WATCH_TEST_TIMEOUT = 15000; const { afterAll, beforeAll, describe, expect, test } = BunTest; @@ -30,6 +37,44 @@ type WriteAnnounceArgs = { viewTag: `0x${string}`; }; +const sleep = async (ms: number) => + await new Promise(resolve => setTimeout(resolve, ms)); + +const createDeferred = () => { + let resolve!: () => void; + const promise = new Promise(innerResolve => { + resolve = innerResolve; + }); + + return { + promise, + resolve + }; +}; + +const waitForCondition = async ( + condition: () => boolean, + timeoutMs = WAIT_TIMEOUT_MS +) => { + const startedAt = Date.now(); + + while (!condition()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('Timed out waiting for watcher state'); + } + + await sleep(50); + } +}; + +const incrementLastCharOfHexString = (hexStr: `0x${string}`) => { + const lastChar = hexStr.slice(-1); + const base = '0123456789abcdef'; + const index = base.indexOf(lastChar.toLowerCase()); + const newLastChar = index === 15 ? '0' : base[index + 1]; + return `0x${hexStr.slice(2, -1) + newLastChar}` as `0x${string}`; +}; + const announce = async ({ walletClient, ERC5564Address, @@ -41,7 +86,6 @@ const announce = async ({ }) => { if (!walletClient.account) throw new Error('No account found'); - // Write to the announcement contract const hash = await walletClient.writeContract({ address: ERC5564Address, functionName: 'announce', @@ -56,12 +100,14 @@ const announce = async ({ account: walletClient.account }); - // Wait for the transaction receipt - await walletClient.waitForTransactionReceipt({ + const receipt = await walletClient.waitForTransactionReceipt({ hash }); - return hash; + return { + blockNumber: receipt.blockNumber, + hash + }; }; describe('watchAnnouncementsForUser', () => { @@ -69,146 +115,1110 @@ describe('watchAnnouncementsForUser', () => { let walletClient: SuperWalletClient; let ERC5564Address: Address; - // Set up keys const schemeId = VALID_SCHEME_ID.SCHEME_ID_1; const schemeIdBigInt = BigInt(schemeId); const { spendingPublicKey, viewingPrivateKey, stealthMetaAddressURI } = setupTestStealthKeys(schemeId); - // Track the new announcements to see if they are being watched - const newAnnouncements: AnnouncementLog[] = []; - let unwatch: () => void; + beforeAll(async () => { + ({ stealthClient, ERC5564Address } = await setupTestEnv()); + walletClient = await setupTestWallet(); + }); - const waitForAnnouncementCount = async ( - expectedCount: number, - timeout = WATCH_TEST_TIMEOUT - ) => { - const startTime = Date.now(); + afterAll(() => { + // No shared watcher cleanup. + }); - while (Date.now() - startTime < timeout) { - if (newAnnouncements.length === expectedCount) return; + test('awaits async handlers and still delivers relevant announcements', async () => { + const watchedAnnouncements: AnnouncementLog[] = []; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: async logs => { + await sleep(25); - await new Promise(resolve => setTimeout(resolve, 50)); + for (const log of logs) { + watchedAnnouncements.push(log); + } + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + const { stealthAddress, ephemeralPublicKey, viewTag } = + generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + for (let index = 0; index < NUM_ANNOUNCEMENTS; index += 1) { + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress, + ephemeralPublicKey, + viewTag + } + }); + } + + await waitForCondition( + () => watchedAnnouncements.length === NUM_ANNOUNCEMENTS + ); + + expect(watchedAnnouncements).toHaveLength(NUM_ANNOUNCEMENTS); + } finally { + unwatch(); } + }); - throw new Error( - `Timed out waiting for ${expectedCount} announcements. Received ${newAnnouncements.length}.` - ); - }; + test('reports rejected handlers once and keeps watching later announcements', async () => { + const handledAnnouncements: AnnouncementLog[] = []; + const handlerErrors: string[] = []; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + let handlerCalls = 0; - const waitForAnnouncementStability = async ( - expectedCount: number, - duration = WATCH_POLLING_INTERVAL * 2 - ) => { - const initialCount = newAnnouncements.length; + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: async logs => { + if (logs.length === 0) { + return; + } - await new Promise(resolve => setTimeout(resolve, duration)); + handlerCalls += 1; - expect(initialCount).toEqual(expectedCount); - expect(newAnnouncements.length).toEqual(expectedCount); - }; + if (handlerCalls === 1) { + throw new Error('intentional handler failure'); + } - beforeAll(async () => { - // Set up the testing environment - ({ stealthClient, ERC5564Address } = await setupTestEnv()); - walletClient = await setupTestWallet(); + handledAnnouncements.push(...logs); + }, + onError: async error => { + await sleep(25); + handlerErrors.push(error.message); + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + const firstAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: firstAnnouncement.stealthAddress, + ephemeralPublicKey: firstAnnouncement.ephemeralPublicKey, + viewTag: firstAnnouncement.viewTag + } + }); - // Set up watching announcements for a user - unwatch = await stealthClient.watchAnnouncementsForUser({ + await waitForCondition(() => handlerErrors.length === 1); + + const secondAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + const secondReceipt = await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: secondAnnouncement.stealthAddress, + ephemeralPublicKey: secondAnnouncement.ephemeralPublicKey, + viewTag: secondAnnouncement.viewTag + } + }); + + await waitForCondition(() => handledAnnouncements.length === 1); + + expect(handlerErrors).toEqual(['intentional handler failure']); + expect(handlerCalls).toEqual(2); + expect(handledAnnouncements[0]?.transactionHash).toEqual( + secondReceipt.hash + ); + } finally { + unwatch(); + } + }); + + test('logs handler failures when onError is omitted and keeps watching later announcements', async () => { + const originalConsoleError = console.error; + const loggedErrors: Error[] = []; + const handledAnnouncements: AnnouncementLog[] = []; + let handlerCalls = 0; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + + console.error = ((value: unknown) => { + if (value instanceof Error) { + loggedErrors.push(value); + } + }) as typeof console.error; + + const unwatch = await stealthClient.watchAnnouncementsForUser({ ERC5564Address, args: { schemeId: schemeIdBigInt, - caller: walletClient.account?.address // Watch announcements for the user, who is also the caller here as an example + caller: walletClient.account?.address }, - handleLogsForUser: logs => { - // Add the new announcements to the list - // Should be just one log for each call of the announce function - for (const log of logs) { - newAnnouncements.push(log); + fromBlock, + handleLogsForUser: async logs => { + if (logs.length === 0) { + return; } + + handlerCalls += 1; + + if (handlerCalls === 1) { + throw new Error('handler failed without onError'); + } + + handledAnnouncements.push(...logs); }, spendingPublicKey, viewingPrivateKey, pollOptions: { - pollingInterval: WATCH_POLLING_INTERVAL // Override the default polling interval for testing + pollingInterval: WATCH_POLLING_INTERVAL } }); - // Set up the stealth address to announce - const { stealthAddress, ephemeralPublicKey, viewTag } = - generateStealthAddress({ + try { + const firstAnnouncement = generateStealthAddress({ stealthMetaAddressURI, schemeId }); - // Sequentially announce NUM_ACCOUNCEMENT times - for (let i = 0; i < NUM_ANNOUNCEMENTS; i++) { await announce({ walletClient, ERC5564Address, args: { schemeId: schemeIdBigInt, - stealthAddress, - ephemeralPublicKey, - viewTag + stealthAddress: firstAnnouncement.stealthAddress, + ephemeralPublicKey: firstAnnouncement.ephemeralPublicKey, + viewTag: firstAnnouncement.viewTag + } + }); + + await waitForCondition(() => loggedErrors.length === 1); + + const secondAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + const secondReceipt = await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: secondAnnouncement.stealthAddress, + ephemeralPublicKey: secondAnnouncement.ephemeralPublicKey, + viewTag: secondAnnouncement.viewTag + } + }); + + await waitForCondition(() => handledAnnouncements.length === 1); + + expect(loggedErrors[0]?.message).toEqual( + 'handler failed without onError' + ); + expect(handledAnnouncements[0]?.transactionHash).toEqual( + secondReceipt.hash + ); + } finally { + console.error = originalConsoleError; + unwatch(); + } + }); + + test('swallows onError failures and keeps watching later announcements', async () => { + const originalConsoleError = console.error; + const loggedErrors: Error[] = []; + const handledAnnouncements: AnnouncementLog[] = []; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + let handlerCalls = 0; + + console.error = ((value: unknown) => { + if (value instanceof Error) { + loggedErrors.push(value); + } + }) as typeof console.error; + + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: async logs => { + if (logs.length === 0) { + return; + } + + handlerCalls += 1; + + if (handlerCalls === 1) { + throw new Error('primary handler failure'); } + + handledAnnouncements.push(...logs); + }, + onError: () => { + throw new Error('secondary onError failure'); + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + const firstAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId }); + + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: firstAnnouncement.stealthAddress, + ephemeralPublicKey: firstAnnouncement.ephemeralPublicKey, + viewTag: firstAnnouncement.viewTag + } + }); + + await waitForCondition(() => loggedErrors.length === 2); + + const secondAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + const secondReceipt = await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: secondAnnouncement.stealthAddress, + ephemeralPublicKey: secondAnnouncement.ephemeralPublicKey, + viewTag: secondAnnouncement.viewTag + } + }); + + await waitForCondition(() => handledAnnouncements.length === 1); + + expect(loggedErrors.map(error => error.message)).toEqual([ + 'primary handler failure', + 'secondary onError failure' + ]); + expect(handledAnnouncements[0]?.transactionHash).toEqual( + secondReceipt.hash + ); + } finally { + console.error = originalConsoleError; + unwatch(); } + }); + + test('recovers when fallback console logging throws', async () => { + const originalConsoleError = console.error; + const handledAnnouncements: AnnouncementLog[] = []; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + let consoleErrorCalls = 0; + let handlerCalls = 0; + + console.error = (() => { + consoleErrorCalls += 1; + throw new Error('console.error failed'); + }) as typeof console.error; + + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: async logs => { + if (logs.length === 0) { + return; + } + + handlerCalls += 1; + + if (handlerCalls === 1) { + throw new Error('handler failed before console fallback'); + } + + handledAnnouncements.push(...logs); + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + const firstAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: firstAnnouncement.stealthAddress, + ephemeralPublicKey: firstAnnouncement.ephemeralPublicKey, + viewTag: firstAnnouncement.viewTag + } + }); - await waitForAnnouncementCount(NUM_ANNOUNCEMENTS); + await waitForCondition(() => consoleErrorCalls === 1); + + const secondAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + const secondReceipt = await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: secondAnnouncement.stealthAddress, + ephemeralPublicKey: secondAnnouncement.ephemeralPublicKey, + viewTag: secondAnnouncement.viewTag + } + }); + + await waitForCondition(() => handledAnnouncements.length === 1); + + expect(consoleErrorCalls).toEqual(1); + expect(handledAnnouncements[0]?.transactionHash).toEqual( + secondReceipt.hash + ); + } finally { + console.error = originalConsoleError; + unwatch(); + } }); - afterAll(() => { - unwatch(); - }); - - test( - 'should watch announcements for a user', - () => { - // Check if the announcements were watched - // There should be NUM_ACCOUNCEMENTS announcements because there were NUM_ANNOUNCEMENTS calls to the announce function - expect(newAnnouncements.length).toEqual(NUM_ANNOUNCEMENTS); - }, - { timeout: WATCH_TEST_TIMEOUT } - ); - - test( - 'should correctly not update announcements for a user if announcement does not apply to user', - async () => { - // Announce again, but arbitrarily (just as an example/for testing) change the ephemeral public key, - // so that the announcement does not apply to the user, and is not watched + test('starts watching from the provided fromBlock boundary', async () => { + const olderAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + const olderReceipt = await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: olderAnnouncement.stealthAddress, + ephemeralPublicKey: olderAnnouncement.ephemeralPublicKey, + viewTag: olderAnnouncement.viewTag + } + }); + + const watchedAnnouncements: AnnouncementLog[] = []; + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock: olderReceipt.blockNumber + 1n, + handleLogsForUser: logs => { + watchedAnnouncements.push(...logs); + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + const newerAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + const newerReceipt = await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: newerAnnouncement.stealthAddress, + ephemeralPublicKey: newerAnnouncement.ephemeralPublicKey, + viewTag: newerAnnouncement.viewTag + } + }); + + await waitForCondition(() => watchedAnnouncements.length === 1); + + expect(watchedAnnouncements[0]?.transactionHash).toEqual( + newerReceipt.hash + ); + expect(watchedAnnouncements[0]?.transactionHash).not.toEqual( + olderReceipt.hash + ); + } finally { + unwatch(); + } + }); + + test('emits heartbeat metadata and batch metadata while watching', async () => { + const heartbeatMeta: Array<{ + fromBlock?: bigint | 'latest'; + observedBlock: bigint; + pollTimestamp: number; + }> = []; + const batchMeta: Array<{ + fromBlock?: bigint | 'latest'; + observedBlock: bigint; + pollTimestamp: number; + rawLogCount: number; + relevantLogCount: number; + }> = []; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: (logs, meta) => { + if (logs.length === 0) { + return; + } + + batchMeta.push(meta); + }, + onHeartbeat: meta => { + heartbeatMeta.push(meta); + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + await waitForCondition(() => heartbeatMeta.length > 0); + + expect(batchMeta).toHaveLength(0); + expect(heartbeatMeta[0]).toMatchObject({ + fromBlock, + observedBlock: expect.any(BigInt) + }); + expect(typeof heartbeatMeta[0]?.pollTimestamp).toBe('number'); + + const liveAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + const liveReceipt = await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: liveAnnouncement.stealthAddress, + ephemeralPublicKey: liveAnnouncement.ephemeralPublicKey, + viewTag: liveAnnouncement.viewTag + } + }); + + await waitForCondition(() => + batchMeta.some( + meta => + meta.observedBlock >= liveReceipt.blockNumber && + meta.rawLogCount >= 1 && + meta.relevantLogCount >= 1 + ) + ); + + const liveBatchMeta = batchMeta.find( + meta => + meta.observedBlock >= liveReceipt.blockNumber && + meta.rawLogCount >= 1 && + meta.relevantLogCount >= 1 + ); + + expect(liveBatchMeta).toBeDefined(); + expect(liveBatchMeta?.fromBlock).toEqual(fromBlock); + expect(liveBatchMeta?.observedBlock).toBeGreaterThanOrEqual( + liveReceipt.blockNumber + ); + } finally { + unwatch(); + } + }); + + test('serializes async handler batches without overlap', async () => { + const firstHandlerGate = createDeferred(); + const handlerOrder: string[] = []; + let activeHandlers = 0; + let handlerCalls = 0; + let overlapDetected = false; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: async logs => { + if (logs.length === 0) { + return; + } + + handlerCalls += 1; + activeHandlers += 1; + handlerOrder.push(`start-${handlerCalls}`); + + if (activeHandlers > 1) { + overlapDetected = true; + } + + if (handlerCalls === 1) { + await firstHandlerGate.promise; + } + + handlerOrder.push(`end-${handlerCalls}`); + activeHandlers -= 1; + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + const firstAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: firstAnnouncement.stealthAddress, + ephemeralPublicKey: firstAnnouncement.ephemeralPublicKey, + viewTag: firstAnnouncement.viewTag + } + }); + + await waitForCondition(() => handlerOrder.includes('start-1')); + + const secondAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: secondAnnouncement.stealthAddress, + ephemeralPublicKey: secondAnnouncement.ephemeralPublicKey, + viewTag: secondAnnouncement.viewTag + } + }); + + await sleep(WATCH_POLLING_INTERVAL * 2); + + expect(handlerCalls).toEqual(1); + expect(overlapDetected).toBeFalse(); + + firstHandlerGate.resolve(); + + await waitForCondition(() => handlerCalls === 2); + await waitForCondition(() => activeHandlers === 0); + + expect(handlerOrder).toEqual(['start-1', 'end-1', 'start-2', 'end-2']); + expect(overlapDetected).toBeFalse(); + } finally { + firstHandlerGate.resolve(); + unwatch(); + } + }); + + test('lets the in-flight batch finish after unwatch and stops later batches', async () => { + const firstHandlerGate = createDeferred(); + const firstHandlerStarted = createDeferred(); + const firstBatchFinished = createDeferred(); + const handledAnnouncements: AnnouncementLog[] = []; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + let sawLaterBatch = false; + let sawFirstBatch = false; + + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: async logs => { + if (logs.length === 0) { + return; + } + + if (handledAnnouncements.length === 0) { + firstHandlerStarted.resolve(); + await firstHandlerGate.promise; + } else { + sawLaterBatch = true; + } + + handledAnnouncements.push(...logs); + + if (!sawFirstBatch) { + sawFirstBatch = true; + firstBatchFinished.resolve(); + } + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { + const firstAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: firstAnnouncement.stealthAddress, + ephemeralPublicKey: firstAnnouncement.ephemeralPublicKey, + viewTag: firstAnnouncement.viewTag + } + }); + + await firstHandlerStarted.promise; + unwatch(); + firstHandlerGate.resolve(); + + await firstBatchFinished.promise; + + const secondAnnouncement = generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + + await announce({ + walletClient, + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + stealthAddress: secondAnnouncement.stealthAddress, + ephemeralPublicKey: secondAnnouncement.ephemeralPublicKey, + viewTag: secondAnnouncement.viewTag + } + }); + + await sleep(WATCH_POLLING_INTERVAL * 3); + + expect(handledAnnouncements.length).toBeGreaterThan(0); + expect(sawLaterBatch).toBeFalse(); + } finally { + firstHandlerGate.resolve(); + unwatch(); + } + }, 15_000); + + test('does not emit announcements that do not apply to the user', async () => { + const watchedAnnouncements: AnnouncementLog[] = []; + const fromBlock = (await walletClient.getBlockNumber()) + 1n; + const unwatch = await stealthClient.watchAnnouncementsForUser({ + ERC5564Address, + args: { + schemeId: schemeIdBigInt, + caller: walletClient.account?.address + }, + fromBlock, + handleLogsForUser: logs => { + watchedAnnouncements.push(...logs); + }, + spendingPublicKey, + viewingPrivateKey, + pollOptions: { + pollingInterval: WATCH_POLLING_INTERVAL + } + }); + + try { const { stealthAddress, ephemeralPublicKey, viewTag } = generateStealthAddress({ stealthMetaAddressURI, schemeId }); - const incrementLastCharOfHexString = (hexStr: `0x${string}`) => { - const lastChar = hexStr.slice(-1); - const base = '0123456789abcdef'; - const index = base.indexOf(lastChar.toLowerCase()); - const newLastChar = index === 15 ? '0' : base[index + 1]; // Roll over from 'f' to '0' - return `0x${hexStr.slice(2, -1) + newLastChar}` as `0x${string}`; - }; - - // Replace the last character of ephemeralPublicKey with a different character for testing - const newEphemeralPublicKey = - incrementLastCharOfHexString(ephemeralPublicKey); - - // Write to the announcement contract with an inaccurate ephemeral public key await announce({ walletClient, ERC5564Address, args: { - schemeId: BigInt(schemeId), - stealthAddress, - ephemeralPublicKey: newEphemeralPublicKey, + schemeId: schemeIdBigInt, + stealthAddress: getAddress( + incrementLastCharOfHexString( + stealthAddress.toLowerCase() as `0x${string}` + ) + ), + ephemeralPublicKey, viewTag } }); - await waitForAnnouncementStability(NUM_ANNOUNCEMENTS); - }, - { timeout: WATCH_TEST_TIMEOUT } - ); + await sleep(WATCH_POLLING_INTERVAL * 3); + + expect(watchedAnnouncements).toHaveLength(0); + } finally { + unwatch(); + } + }); +}); + +describe('createWatchedAnnouncementsQueue', () => { + test('recovers after an unexpected queued rejection and keeps draining later tasks', async () => { + const recoveredErrors: string[] = []; + const taskOrder: string[] = []; + const queue = createWatchedAnnouncementsQueue({ + onUnexpectedError: async error => { + recoveredErrors.push( + error instanceof Error ? error.message : 'unexpected queue failure' + ); + } + }); + + queue.enqueue(async () => { + taskOrder.push('first-start'); + throw new Error('unexpected queued failure'); + }); + + queue.enqueue(async () => { + taskOrder.push('second-start'); + }); + + await queue.onIdle(); + + expect(recoveredErrors).toEqual(['unexpected queued failure']); + expect(taskOrder).toEqual(['first-start', 'second-start']); + }); + + test('continues draining later tasks when onUnexpectedError throws', async () => { + const taskOrder: string[] = []; + const queue = createWatchedAnnouncementsQueue({ + onUnexpectedError: () => { + throw new Error('unexpected error handler failure'); + } + }); + + queue.enqueue(async () => { + taskOrder.push('first-start'); + throw new Error('unexpected queued failure'); + }); + + queue.enqueue(async () => { + taskOrder.push('second-start'); + }); + + await queue.onIdle(); + + expect(taskOrder).toEqual(['first-start', 'second-start']); + }); + + test('continues after an internal filtering failure from one queued batch', async () => { + const schemeId = VALID_SCHEME_ID.SCHEME_ID_1; + const schemeIdBigInt = BigInt(schemeId); + const caller = '0x00000000000000000000000000000000000000AA' as Address; + const { spendingPublicKey, viewingPrivateKey, stealthMetaAddressURI } = + setupTestStealthKeys(schemeId); + const { stealthAddress, ephemeralPublicKey, viewTag } = + generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + const reportedErrors: string[] = []; + const handledAnnouncements: AnnouncementLog[] = []; + let filterCalls = 0; + + const queue = createWatchedAnnouncementsQueue({ + onUnexpectedError: error => { + throw error; + } + }); + + const createRawLog = ( + transactionHash: `0x${string}` + ): Parameters< + typeof processWatchedAnnouncementsBatch + >[0]['logs'][number] => ({ + address: ERC5564_CONTRACT_ADDRESS as `0x${string}`, + blockHash: `0x${'1'.repeat(64)}` as `0x${string}`, + blockNumber: 1n, + data: '0x' as `0x${string}`, + logIndex: 0, + removed: false, + topics: [`0x${'4'.repeat(64)}` as `0x${string}`], + transactionHash, + transactionIndex: 0, + args: { + caller, + ephemeralPubKey: ephemeralPublicKey, + metadata: viewTag, + schemeId: schemeIdBigInt, + stealthAddress + } + }); + + const filterAnnouncementsForUser = async ({ + announcements + }: { + announcements: AnnouncementLog[]; + }) => { + filterCalls += 1; + + if (filterCalls === 1) { + throw new Error('synthetic filtering failure'); + } + + return announcements; + }; + + queue.enqueue(async () => { + await processWatchedAnnouncementsBatch({ + logs: [createRawLog(`0x${'2'.repeat(64)}`)], + spendingPublicKey, + viewingPrivateKey, + publicClient: {} as never, + handleLogsForUser: logs => { + handledAnnouncements.push(...logs); + }, + onError: error => { + reportedErrors.push(error.message); + }, + filterAnnouncementsForUser + }); + }); + + queue.enqueue(async () => { + await processWatchedAnnouncementsBatch({ + logs: [createRawLog(`0x${'3'.repeat(64)}`)], + spendingPublicKey, + viewingPrivateKey, + publicClient: {} as never, + handleLogsForUser: logs => { + handledAnnouncements.push(...logs); + }, + onError: error => { + reportedErrors.push(error.message); + }, + filterAnnouncementsForUser + }); + }); + + await queue.onIdle(); + + expect(reportedErrors).toEqual(['synthetic filtering failure']); + expect(handledAnnouncements).toHaveLength(1); + expect(handledAnnouncements[0]?.transactionHash).toEqual( + `0x${'3'.repeat(64)}` + ); + }); +}); + +describe('startWatchHeartbeat', () => { + test('emits heartbeat metadata across polling ticks and stops cleanly', async () => { + const heartbeatMeta: Array<{ + fromBlock?: bigint | 'latest'; + observedBlock: bigint; + pollTimestamp: number; + }> = []; + let observedBlock = 9n; + + const stopHeartbeat = startWatchHeartbeat({ + fromBlock: 7n, + onHeartbeat: meta => { + heartbeatMeta.push(meta); + }, + pollingInterval: 10, + publicClient: { + getBlockNumber: async () => { + observedBlock += 1n; + return observedBlock; + } + } as never + }); + + try { + await waitForCondition(() => heartbeatMeta.length >= 3, 2_000); + + expect(heartbeatMeta.slice(0, 3).map(meta => meta.observedBlock)).toEqual( + [10n, 11n, 12n] + ); + expect( + heartbeatMeta.slice(0, 3).every(meta => meta.fromBlock === 7n) + ).toBe(true); + expect( + heartbeatMeta + .slice(0, 3) + .every(meta => typeof meta.pollTimestamp === 'number') + ).toBe(true); + } finally { + stopHeartbeat(); + } + + const heartbeatCountAfterStop = heartbeatMeta.length; + + await sleep(40); + + expect(heartbeatMeta).toHaveLength(heartbeatCountAfterStop); + }); + + test('reports heartbeat failures through onError', async () => { + const reportedErrors: string[] = []; + + const stopHeartbeat = startWatchHeartbeat({ + onHeartbeat: () => { + throw new Error('heartbeat callback failed'); + }, + onError: error => { + reportedErrors.push(error.message); + }, + pollingInterval: 10, + publicClient: { + getBlockNumber: async () => 12n + } as never + }); + + try { + await waitForCondition(() => reportedErrors.length > 0, 2_000); + } finally { + stopHeartbeat(); + } + + expect(reportedErrors.length).toBeGreaterThan(0); + expect( + reportedErrors.every(error => error === 'heartbeat callback failed') + ).toBe(true); + }); +}); + +describe('processWatchedAnnouncementsBatch', () => { + test('passes watcher metadata to the batch handler', async () => { + const schemeId = VALID_SCHEME_ID.SCHEME_ID_1; + const schemeIdBigInt = BigInt(schemeId); + const caller = '0x00000000000000000000000000000000000000AA' as Address; + const fromBlock = 40n; + const { spendingPublicKey, viewingPrivateKey, stealthMetaAddressURI } = + setupTestStealthKeys(schemeId); + const { stealthAddress, ephemeralPublicKey, viewTag } = + generateStealthAddress({ + stealthMetaAddressURI, + schemeId + }); + const handledAnnouncements: AnnouncementLog[] = []; + let handledMeta: + | { + fromBlock?: bigint | 'latest'; + observedBlock: bigint; + pollTimestamp: number; + rawLogCount: number; + relevantLogCount: number; + } + | undefined; + + await processWatchedAnnouncementsBatch({ + logs: [ + { + address: ERC5564_CONTRACT_ADDRESS as `0x${string}`, + blockHash: `0x${'1'.repeat(64)}` as `0x${string}`, + blockNumber: 42n, + data: '0x' as `0x${string}`, + logIndex: 0, + removed: false, + topics: [`0x${'4'.repeat(64)}` as `0x${string}`], + transactionHash: `0x${'2'.repeat(64)}` as `0x${string}`, + transactionIndex: 0, + args: { + caller, + ephemeralPubKey: ephemeralPublicKey, + metadata: viewTag, + schemeId: schemeIdBigInt, + stealthAddress + } + } + ], + spendingPublicKey, + viewingPrivateKey, + publicClient: { + getBlockNumber: async () => 99n + } as never, + fromBlock, + handleLogsForUser: (logs, meta) => { + handledAnnouncements.push(...logs); + handledMeta = meta; + }, + filterAnnouncementsForUser: async ({ announcements }) => announcements + }); + + expect(handledAnnouncements).toHaveLength(1); + expect(handledMeta).toEqual({ + fromBlock, + observedBlock: 42n, + pollTimestamp: expect.any(Number), + rawLogCount: 1, + relevantLogCount: 1 + }); + }); }); diff --git a/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.ts b/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.ts index c3b5798b..8ad1b520 100644 --- a/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.ts +++ b/src/lib/actions/watchAnnouncementsForUser/watchAnnouncementsForUser.ts @@ -1,18 +1,260 @@ import type { + AnnouncementLog, + GetAnnouncementsForUserParams, + WatchAnnouncementsForUserBatchMeta, WatchAnnouncementsForUserParams, + WatchAnnouncementsForUserPollMeta, WatchAnnouncementsForUserReturnType } from '..'; import { ERC5564AnnouncerAbi, getAnnouncementsForUser } from '../..'; import { handleViemPublicClient } from '../../stealthClient/createStealthClient'; +function normalizeWatchError(error: unknown, fallbackMessage: string): Error { + if (error instanceof Error) { + return error; + } + + return new Error(fallbackMessage, { + cause: error + }); +} + +async function reportWatchError({ + error, + fallbackMessage, + onError +}: { + error: unknown; + fallbackMessage: string; + onError?: WatchAnnouncementsForUserParams['onError']; +}): Promise { + const normalizedError = normalizeWatchError(error, fallbackMessage); + const safeConsoleError = (loggedError: Error) => { + try { + console.error(loggedError); + } catch { + // Swallow logger failures so batch processing can continue. + } + }; + + if (!onError) { + safeConsoleError(normalizedError); + return; + } + + try { + await onError(normalizedError); + } catch (onErrorFailure) { + // Swallow secondary reporting failures so the live watch can continue. + safeConsoleError(normalizedError); + safeConsoleError( + normalizeWatchError( + onErrorFailure, + 'watchAnnouncementsForUser onError handler failed' + ) + ); + } +} + +const DEFAULT_WATCH_HEARTBEAT_INTERVAL_MS = 4_000; + +type WatchedAnnouncementsQueue = { + enqueue: (task: () => Promise) => void; + onIdle: () => Promise; +}; + +export function createWatchedAnnouncementsQueue({ + onUnexpectedError +}: { + onUnexpectedError: (error: unknown) => void | Promise; +}): WatchedAnnouncementsQueue { + let processingQueue = Promise.resolve(); + + return { + enqueue(task) { + processingQueue = processingQueue.then(task).catch(async error => { + try { + await onUnexpectedError(error); + } catch { + // Swallow queue-recovery failures so later batches can still run. + } + }); + }, + async onIdle() { + await processingQueue; + } + }; +} + +type WatchedAnnouncementEventLog = Omit< + AnnouncementLog, + 'caller' | 'ephemeralPubKey' | 'metadata' | 'schemeId' | 'stealthAddress' +> & { + args: Pick< + AnnouncementLog, + 'caller' | 'ephemeralPubKey' | 'metadata' | 'schemeId' | 'stealthAddress' + >; +}; + +const getObservedBlockFromLogs = ( + logs: WatchedAnnouncementEventLog[] +): bigint | undefined => + logs.reduce((maxObservedBlock, log) => { + if (log.blockNumber == null) { + return maxObservedBlock; + } + + const { blockNumber } = log; + + return maxObservedBlock === undefined || blockNumber > maxObservedBlock + ? blockNumber + : maxObservedBlock; + }, undefined); + +export async function processWatchedAnnouncementsBatch({ + logs, + spendingPublicKey, + viewingPrivateKey, + publicClient, + excludeList, + includeList, + fromBlock, + handleLogsForUser, + onError, + filterAnnouncementsForUser = getAnnouncementsForUser +}: { + logs: WatchedAnnouncementEventLog[]; + spendingPublicKey: WatchAnnouncementsForUserParams['spendingPublicKey']; + viewingPrivateKey: WatchAnnouncementsForUserParams['viewingPrivateKey']; + publicClient: ReturnType; + excludeList?: WatchAnnouncementsForUserParams['excludeList']; + includeList?: WatchAnnouncementsForUserParams['includeList']; + fromBlock?: WatchAnnouncementsForUserParams['fromBlock']; + handleLogsForUser: WatchAnnouncementsForUserParams['handleLogsForUser']; + onError?: WatchAnnouncementsForUserParams['onError']; + filterAnnouncementsForUser?: ( + params: GetAnnouncementsForUserParams + ) => Promise; +}): Promise { + const announcements: AnnouncementLog[] = logs.map(log => ({ + ...log, + caller: log.args.caller, + ephemeralPubKey: log.args.ephemeralPubKey, + metadata: log.args.metadata, + schemeId: log.args.schemeId, + stealthAddress: log.args.stealthAddress + })); + + let relevantAnnouncements: AnnouncementLog[]; + try { + relevantAnnouncements = await filterAnnouncementsForUser({ + announcements, + spendingPublicKey, + viewingPrivateKey, + clientParams: { publicClient }, + excludeList, + includeList + }); + } catch (error) { + await reportWatchError({ + error, + fallbackMessage: + 'watchAnnouncementsForUser failed while filtering watched announcements', + onError + }); + return; + } + + const observedBlock = + getObservedBlockFromLogs(logs) ?? (await publicClient.getBlockNumber()); + const batchMeta: WatchAnnouncementsForUserBatchMeta = { + fromBlock, + observedBlock, + pollTimestamp: Date.now(), + rawLogCount: logs.length, + relevantLogCount: relevantAnnouncements.length + }; + + try { + await handleLogsForUser(relevantAnnouncements, batchMeta); + } catch (error) { + await reportWatchError({ + error, + fallbackMessage: + 'watchAnnouncementsForUser handler failed while processing a batch', + onError + }); + } +} + +export function startWatchHeartbeat({ + fromBlock, + onError, + onHeartbeat, + pollingInterval, + publicClient +}: { + fromBlock?: WatchAnnouncementsForUserParams['fromBlock']; + onError?: WatchAnnouncementsForUserParams['onError']; + onHeartbeat?: WatchAnnouncementsForUserParams['onHeartbeat']; + pollingInterval?: number; + publicClient: ReturnType; +}) { + if (!onHeartbeat) { + return () => {}; + } + + let active = true; + let timeoutId: ReturnType | undefined; + const heartbeatInterval = + pollingInterval ?? DEFAULT_WATCH_HEARTBEAT_INTERVAL_MS; + + const emitHeartbeat = async () => { + try { + const heartbeatMeta: WatchAnnouncementsForUserPollMeta = { + fromBlock, + observedBlock: await publicClient.getBlockNumber(), + pollTimestamp: Date.now() + }; + + await onHeartbeat(heartbeatMeta); + } catch (error) { + await reportWatchError({ + error, + fallbackMessage: + 'watchAnnouncementsForUser heartbeat failed while observing the chain head', + onError + }); + } finally { + if (active) { + timeoutId = setTimeout(() => { + void emitHeartbeat(); + }, heartbeatInterval); + } + } + }; + + void emitHeartbeat(); + + return () => { + active = false; + + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; +} + /** * Watches for announcement events relevant to the user. * - * @template T - The return type of the handleLogsForUser callback function. * @property {EthAddress} ERC5564Address - The Ethereum address of the ERC5564 contract. * @property {AnnouncementArgs} args - Arguments to filter the announcements. - * @property {(logs: AnnouncementLog[]) => T} handleLogsForUser - Callback function to handle the filtered announcement logs. - * This function receives an array of AnnouncementLog and returns a generic value. + * @property {bigint | 'latest'} [fromBlock] - Optional lower inclusive block bound for the live watch. + * @property {(logs: AnnouncementLog[], meta: WatchAnnouncementsForUserBatchMeta) => unknown} handleLogsForUser - Callback function to handle the filtered announcement logs. + * The SDK ignores the return value, but awaits any returned promise before moving to the next batch. + * @property {(meta: WatchAnnouncementsForUserPollMeta) => void | Promise} [onHeartbeat] - Optional callback invoked whenever the watcher observes a chain head while polling. + * @property {(error: Error) => void | Promise} [onError] - Optional callback invoked when watched batch filtering or the consumer handler fails. * @property {WatchAnnouncementsForUserPollingOptions} [pollOptions] - Optional polling options to configure the behavior of watching announcements. * This includes configurations such as polling frequency. * @property {Omit} - Inherits all properties from GetAnnouncementsForUserParams except 'announcements'. @@ -25,43 +267,61 @@ async function watchAnnouncementsForUser({ ERC5564Address, clientParams, excludeList, + fromBlock, includeList, handleLogsForUser, + onHeartbeat, + onError, pollOptions }: WatchAnnouncementsForUserParams): Promise { const publicClient = handleViemPublicClient(clientParams); + const watchedAnnouncementsQueue = createWatchedAnnouncementsQueue({ + onUnexpectedError: async error => { + await reportWatchError({ + error, + fallbackMessage: + 'watchAnnouncementsForUser queue failed while processing a batch', + onError + }); + } + }); + const stopHeartbeat = startWatchHeartbeat({ + fromBlock, + onError, + onHeartbeat, + pollingInterval: pollOptions?.pollingInterval, + publicClient + }); - const unwatch = publicClient.watchContractEvent({ + const unwatchContractEvent = publicClient.watchContractEvent({ address: ERC5564Address, abi: ERC5564AnnouncerAbi, eventName: 'Announcement', args, - onLogs: async logs => { - const announcements = logs.map(log => ({ - ...log, - caller: log.args.caller, - ephemeralPubKey: log.args.ephemeralPubKey, - metadata: log.args.metadata, - schemeId: log.args.schemeId, - stealthAddress: log.args.stealthAddress - })); - - const relevantAnnouncements = await getAnnouncementsForUser({ - announcements, - spendingPublicKey, - viewingPrivateKey, - clientParams: { publicClient }, - excludeList, - includeList + ...(fromBlock === 'latest' ? {} : { fromBlock }), + onLogs: logs => { + watchedAnnouncementsQueue.enqueue(async () => { + await processWatchedAnnouncementsBatch({ + logs, + spendingPublicKey, + viewingPrivateKey, + publicClient, + excludeList, + includeList, + fromBlock, + handleLogsForUser, + onError + }); }); - - handleLogsForUser(relevantAnnouncements); }, strict: true, ...pollOptions }); - return unwatch; + return () => { + stopHeartbeat(); + unwatchContractEvent(); + }; } export default watchAnnouncementsForUser; diff --git a/src/lib/stealthClient/createStealthClient.test.ts b/src/lib/stealthClient/createStealthClient.test.ts index c0557ab7..380a2a82 100644 --- a/src/lib/stealthClient/createStealthClient.test.ts +++ b/src/lib/stealthClient/createStealthClient.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { http, createPublicClient } from 'viem'; import { foundry } from 'viem/chains'; +import { actions } from '../actions'; import { LOCAL_ENDPOINT } from '../helpers/test/setupTestEnv'; import type { VALID_CHAIN_IDS } from '../helpers/types'; import createStealthClient, { @@ -18,6 +19,105 @@ describe('createStealthClient', () => { }) ).toThrow(new Error('Invalid chainId: 9999')); }); + + test('wires subgraph helpers through the initialized client', async () => { + const originalGetAnnouncementsPageUsingSubgraph = + actions.getAnnouncementsPageUsingSubgraph; + const originalScanAnnouncementsForUserUsingSubgraph = + actions.scanAnnouncementsForUserUsingSubgraph; + const originalGetAnnouncementsUsingSubgraph = + actions.getAnnouncementsUsingSubgraph; + const pageResult = { + announcements: [], + nextCursor: undefined, + snapshotBlock: 123n + }; + const scanResult = { + announcements: [], + nextCursor: undefined, + scannedCount: 0, + snapshotBlock: 456n + }; + const announcementsUsingSubgraphResult: [] = []; + const pageCalls: unknown[] = []; + const scanCalls: unknown[] = []; + const announcementsUsingSubgraphCalls: unknown[] = []; + + actions.getAnnouncementsPageUsingSubgraph = params => { + pageCalls.push(params); + return Promise.resolve(pageResult); + }; + actions.scanAnnouncementsForUserUsingSubgraph = params => { + scanCalls.push(params); + + return (async function* () { + yield scanResult; + })(); + }; + actions.getAnnouncementsUsingSubgraph = params => { + announcementsUsingSubgraphCalls.push(params); + return Promise.resolve(announcementsUsingSubgraphResult); + }; + + try { + const client = createStealthClient({ + chainId: foundry.id as VALID_CHAIN_IDS, + rpcUrl: LOCAL_ENDPOINT + }); + + await expect( + client.getAnnouncementsPageUsingSubgraph({ + pageSize: 5, + subgraphUrl: 'https://example.com/subgraph' + }) + ).resolves.toEqual(pageResult); + + const iterator = client.scanAnnouncementsForUserUsingSubgraph({ + spendingPublicKey: '0x01', + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: '0x02' + }); + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: scanResult + }); + + await expect( + client.getAnnouncementsUsingSubgraph({ + subgraphUrl: 'https://example.com/subgraph' + }) + ).resolves.toEqual(announcementsUsingSubgraphResult); + + expect(pageCalls).toEqual([ + { + pageSize: 5, + subgraphUrl: 'https://example.com/subgraph' + } + ]); + expect(announcementsUsingSubgraphCalls).toEqual([ + { + subgraphUrl: 'https://example.com/subgraph' + } + ]); + expect(scanCalls).toHaveLength(1); + expect(scanCalls[0]).toMatchObject({ + clientParams: { + publicClient: expect.any(Object) + }, + spendingPublicKey: '0x01', + subgraphUrl: 'https://example.com/subgraph', + viewingPrivateKey: '0x02' + }); + } finally { + actions.getAnnouncementsPageUsingSubgraph = + originalGetAnnouncementsPageUsingSubgraph; + actions.scanAnnouncementsForUserUsingSubgraph = + originalScanAnnouncementsForUserUsingSubgraph; + actions.getAnnouncementsUsingSubgraph = + originalGetAnnouncementsUsingSubgraph; + } + }); }); describe('handleViemPublicClient', () => { diff --git a/src/lib/stealthClient/createStealthClient.ts b/src/lib/stealthClient/createStealthClient.ts index 6d307242..6238d5a7 100644 --- a/src/lib/stealthClient/createStealthClient.ts +++ b/src/lib/stealthClient/createStealthClient.ts @@ -46,6 +46,11 @@ function createStealthClient({ stealthActions.getAnnouncementsPageUsingSubgraph({ ...params }), + scanAnnouncementsForUserUsingSubgraph: params => + stealthActions.scanAnnouncementsForUserUsingSubgraph({ + clientParams: { publicClient }, + ...params + }), getAnnouncementsUsingSubgraph: params => stealthActions.getAnnouncementsUsingSubgraph({ ...params diff --git a/src/lib/stealthClient/types.ts b/src/lib/stealthClient/types.ts index 13cf7c26..a20d4842 100644 --- a/src/lib/stealthClient/types.ts +++ b/src/lib/stealthClient/types.ts @@ -15,6 +15,8 @@ import type { PrepareRegisterKeysOnBehalfReturnType, PrepareRegisterKeysParams, PrepareRegisterKeysReturnType, + ScanAnnouncementsForUserUsingSubgraphParams, + ScanAnnouncementsForUserUsingSubgraphReturnType, WatchAnnouncementsForUserParams, WatchAnnouncementsForUserReturnType } from '../actions/'; @@ -53,6 +55,21 @@ export type StealthActions = { subgraphUrl, toBlock }: GetAnnouncementsPageUsingSubgraphParams) => Promise; + scanAnnouncementsForUserUsingSubgraph: ({ + caller, + clientParams, + cursor, + excludeList, + fromBlock, + includeList, + pageSize, + schemeId, + snapshotBlock, + spendingPublicKey, + subgraphUrl, + toBlock, + viewingPrivateKey + }: ScanAnnouncementsForUserUsingSubgraphParams) => ScanAnnouncementsForUserUsingSubgraphReturnType; getAnnouncementsUsingSubgraph: ({ subgraphUrl, filter, @@ -73,7 +90,10 @@ export type StealthActions = { watchAnnouncementsForUser: ({ ERC5564Address, args, + fromBlock, handleLogsForUser, + onHeartbeat, + onError, spendingPublicKey, viewingPrivateKey, pollOptions