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`.
+
+
+
+
+
+
+ Open inbox
+
+
+ Clear session
+
+
+
+
+
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.
+
+
+
+
+
+ {isScanning ? 'Scanning...' : 'Start scan'}
+
+
+
+
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