diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eb0130b..a9b09c3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,6 +54,28 @@ jobs:
- name: Verify only intended files would be published
run: pnpm pack:check
+ framework-types:
+ name: Framework package types
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm build
+ # These packages are published separately and their tests run under the
+ # root suite, but nothing type-checked them before, which let framework
+ # hooks drift away from the SDK's typed exports.
+ - run: pnpm --filter @wraith-protocol/sdk-react test:types
+ - run: pnpm --filter @wraith-protocol/sdk-vue test:types
+ - run: pnpm --filter @wraith-protocol/sdk-solid test:types
+ - run: pnpm --filter @wraith-protocol/sdk-svelte typecheck
+
compat:
name: Compatibility matrix (${{ matrix.name }})
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16f0135..f76a35b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,20 @@ All notable changes to the Wraith Protocol SDK will be documented in this file.
### Fixed
+- **Framework packages import the agent client from `@wraith-protocol/sdk-agent`**: `useWraith` (Vue, Svelte) and `createActivity` (Solid) still imported `Wraith`, `WraithAgent`, `Chain` and the agent config types from the package root after the agent client moved to its own package, so they failed type-checking and `sdk-vue`'s declaration build failed. The imports, tests, package dependencies, `tsup` externals and `tsconfig` paths now point at `@wraith-protocol/sdk-agent`. `pnpm test:compat` no longer lists those packages as known SSR import failures (it imports and renders them server-side instead), and a CI job type-checks every framework package so this cannot drift again.
+- **Consistent framework scanner cancellation** (issue #245): React, Vue, Svelte and Solid scan entry points now share the root-exported `ScanCancellationOptions` shape (`{ signal?: AbortSignal }`).
+ - React's `useScanner` aborts on unmount or when `chain`/`wallet` change; Vue's `useScanner` aborts on effect-scope disposal (component unmount) through `onScopeDispose`; Solid's `createScanner` aborts through `onCleanup` when its owner is disposed; Svelte's `useStellarAnnouncementScan` accepts a factory `signal`, exports `dispose()` and aborts on either. Every adapter also aborts the previous scan when a new one starts.
+ - Superseded scans can no longer publish: results are checked against the active scan and abort signal before the adapter writes its state, even when the underlying source ignores the signal (as the EVM and Solana fetchers currently do). Aborted scans reject with `AbortError` and leave the error state untouched.
+ - New tests cover unmount/scope/root disposal, caller signals, and late results from superseded scans for all four adapters. See [`docs/guides/framework-scanner-cancellation.mdx`](./docs/guides/framework-scanner-cancellation.mdx).
+- **`sdk-vue` scanner and activity keys are chain-discriminated** (issue #244): `useScanner` now takes a `ScannerFetchOptions` union (`EvmFetchOptions | StellarFetchOptions | SolanaFetchOptions`) instead of chain or URL strings, and its scan inputs pair each chain with that chain's `Announcement` and `StealthKeys` types. `useActivity` uses the same `ScannerScanInput` union, so an EVM announcement list can only be scanned with EVM keys.
+ - `scanAnnouncements`, `checkAddress` and `scanWithKeys` take one discriminated input object with chain-specific overloads; repeated `as any` casts are gone.
+ - Runtime guards (`assertScannerScanInput`, `assertScannerFetchOptions`, `assertScannerScanWithKeysInput`, `assertScannerCheckInput`) throw a descriptive `TypeError` for JavaScript consumers passing an unknown chain or mismatched key material.
+ - Compile-time tests in `packages/sdk-vue/test/scanner.test-d.ts` assert the mismatches fail to type-check; they run under `pnpm test:types`.
+- **`sdk-react` network hooks use SDK chain and Horizon types** (issue #243): `useScanner` options are typed (`chain: 'stellar'`, `wallet: string`, `signal?: AbortSignal`), the shared cache is read through a typed `globalThis` shape instead of `any`, the announcement stream receives `FetchAnnouncementsOptions` without a cast, and Horizon balances are read from `Horizon.HorizonApi.BalanceLine`.
+ - Caught values are narrowed with the root-exported `getUnknownErrorMessage()` / `isAbortError()` helpers instead of `catch (err: any)`.
+ - `useStellarName` now defaults to the `stellar` deployment (the previous `testnet` default did not match a deployment key) and only publishes errors for `Error` values.
+ - Fixed `IDBCache`'s in-memory fallback, whose `commit()` referenced IndexedDB-only fields and threw at runtime when `indexedDB` was unavailable.
+ - Compile-time tests in `packages/sdk-react/test/useScanner.test-d.ts` reject unsupported chains, non-string wallets, and malformed scan options under `pnpm test:types`.
- **Stellar `findStrictReceivePath` reads and validates real Horizon responses** (issue #236): the function now reads paths from Horizon's `_embedded.records` envelope instead of expecting a bare array, and sends the query parameters Horizon accepts (`source_assets` with `destination_asset_type` / `_code` / `_issuer`; the old `destination_asset` and dummy `source_account` made every live request fail with HTTP 400).
- Every record is validated before use: amounts, asset type / code / issuer, that the source and destination assets and `destination_amount` match the request, and a path of at most 5 hops. The body is read as a stream and abandoned past 256 KiB; responses over 100 records are rejected. The cheapest valid record is returned. An invalid `destAmount` now throws before the request, and the request uses the Horizon client's default timeouts (`RPCTimeoutError`).
- New `HorizonResponseError` (`WRAITH/NETWORK/HORIZON_RESPONSE`), exported from the package root, carries the URL and the rejected field. A non-2xx status now throws `RPCRequestError` instead of a plain `Error`.
@@ -55,6 +69,7 @@ All notable changes to the Wraith Protocol SDK will be documented in this file.
### Changed
+- **Vue scanner calls take options objects** (issue #244): `fetchAnnouncements()` now accepts `{ chain, network, url, signal }` instead of a chain or URL string, and `scanAnnouncements()`, `checkAddress()` and `scanWithKeys()` accept one discriminated input object (`{ chain, announcements, keys }`, `{ chain, ...keys, viewTag }`, `{ chain, keys, ...fetchOptions }`) instead of positional arguments. This is a breaking change for consumers on `0.1.x`; the old positional signatures could not reject mismatched key material.
- **Stellar Helper Errors Use the Taxonomy** (issue #235): for a non-2xx Horizon status, `buildMultisigStealthWithdraw()` throws `RPCRequestError` instead of a plain `Error`, and an account record without its sequence, thresholds or signers throws `HorizonResponseError`. `submitOfflineStellarTransaction()` reports the full `/transactions` URL on `RPCRequestError`, throws `HorizonResponseError` for a success body that is not a JSON object, and gives up after 60 s by default; pass `{ timeouts: { requestMs: 0 } }` for the old behaviour.
- **Stellar Horizon and RPC Clients Time Out by Default** (issue #202): an attempt now fails after 10 s without response headers or 30 s in total, then retries or fails over, instead of waiting indefinitely. Pass `timeouts: { connectMs: 0, requestMs: 0 }` for the old behaviour, or longer values for slow calls such as Horizon transaction submission.
- **Stellar Chain Module Cryptographic Audit Fixes**: Applied all findings from independent cryptographic audit (issue #55). Breaking changes:
diff --git a/README.md b/README.md
index a7fa1ff..37e029c 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,8 @@ Only `@stellar/stellar-sdk` and `@solana/web3.js` are optional peers, and each i
| `@wraith-protocol/sdk/vault` | Browser-only passphrase vault for short-lived keys |
> React Native support is documented in `docs/guides/react-native-setup.mdx` and the companion example at `examples/react-native-stellar`.
+>
+> The React, Vue, Svelte and Solid adapters share one scan cancellation contract. See [`docs/guides/framework-scanner-cancellation.mdx`](./docs/guides/framework-scanner-cancellation.mdx).
## Browser Vault
@@ -434,6 +436,8 @@ The nightly CI job (`slow-tests`) runs `pnpm test:fuzz` automatically at 02:00 U
Full protocol documentation, architecture details, and integration guides are available at [wraith-protocol/docs](https://github.com/wraith-protocol/docs).
+In-repo guides live under [`docs/`](./docs): framework scanner cancellation (`docs/guides/framework-scanner-cancellation.mdx`), React Native setup (`docs/guides/react-native-setup.mdx`), wallet adapters (`docs/wallet-adapters.md`) and per-chain notes under `docs/chains/`.
+
## License
MIT
diff --git a/docs/guides/framework-scanner-cancellation.mdx b/docs/guides/framework-scanner-cancellation.mdx
new file mode 100644
index 0000000..53f03ac
--- /dev/null
+++ b/docs/guides/framework-scanner-cancellation.mdx
@@ -0,0 +1,201 @@
+---
+title: Framework Scanner Cancellation
+---
+
+# Framework Scanner Cancellation
+
+The framework adapters — `@wraith-protocol/sdk-react`, `@wraith-protocol/sdk-vue`, `@wraith-protocol/sdk-svelte` and `@wraith-protocol/sdk-solid` — all drive the same chain scanners, and all of them accept the same cancellation contract. One `AbortController` cancels a scan regardless of framework, and a scan whose consumer has gone away can never publish its result.
+
+## The shared option shape
+
+```ts
+import type { ScanCancellationOptions } from '@wraith-protocol/sdk';
+
+interface ScanCancellationOptions {
+ /** Cancels an in-flight scan when aborted. */
+ signal?: AbortSignal;
+}
+```
+
+Every framework entry point that starts network work accepts this shape. The type is exported from the package root so adapters and applications can share it instead of redeclaring their own.
+
+## Per-framework lifecycle
+
+| Framework | Entry point | Aborts automatically on | Manual abort |
+| ------------ | ------------------------------ | ----------------------------------------- | -------------------------------------- |
+| `sdk-react` | `useScanner()` | unmount, `chain`/`wallet` change | hook `signal`, per-scan `signal` |
+| `sdk-vue` | `useScanner()` | effect-scope disposal (component unmount) | `dispose()`, fetch `signal` |
+| `sdk-svelte` | `useStellarAnnouncementScan()` | factory `signal` | `dispose()`, `scan({ signal })` |
+| `sdk-solid` | `createScanner()` | owner disposal (`onCleanup`) | `dispose()`, `scan(chain, { signal })` |
+
+### React
+
+```tsx
+import { useScanner } from '@wraith-protocol/sdk-react';
+
+function Payments({ wallet }: { wallet: string }) {
+ const { announcements, scanning, error, scan, commitScan } = useScanner({
+ chain: 'stellar',
+ wallet,
+ });
+
+ useEffect(() => {
+ scan()
+ .then(commitScan)
+ .catch((cause) => {
+ if (cause instanceof DOMException && cause.name === 'AbortError') return; // cancelled
+ throw cause;
+ });
+ }, [scan, commitScan]);
+
+ return ;
+}
+```
+
+The hook owns an internal controller: it aborts on unmount and whenever `chain` or `wallet` changes, so a scan started for the previous wallet cannot land on the next one. A `signal` on the hook options aborts the in-flight scan for every consumer sharing that controller, and `scan({ signal })` accepts a per-call signal as well.
+
+### Vue
+
+```vue
+
+```
+
+`useScanner` registers `onScopeDispose`, so disposal of the component (or any parent effect scope) aborts the in-flight request and `dispose()` does the same on demand. `setChain()` aborts before switching, because a late result from the previous chain must never be shown as the new chain's state.
+
+### Svelte
+
+```svelte
+
+```
+
+A Svelte store primitive has no lifecycle of its own, so the primitive takes the component's teardown signal and also exposes `dispose()` for imperative teardown.
+
+### Solid
+
+```tsx
+import { createScanner } from '@wraith-protocol/sdk-solid';
+
+function Feed() {
+ const scanner = createScanner();
+ const [announcements, setAnnouncements] = createSignal([]);
+
+ onMount(async () => setAnnouncements(await scanner.scan('stellar')));
+ return
;
+}
+```
+
+`createScanner` calls `onCleanup(dispose)` when it is created inside a Solid owner (component or `createRoot`), so disposal of the owner aborts the scan. Outside a root the cleanup is a no-op and `dispose()` is the explicit path.
+
+## Semantics
+
+- **Aborted scans reject with `AbortError`.** That is the standard `DOMException` name the underlying chain clients throw, and it is what `fetch` throws when its signal fires.
+- **An abort is not an error state.** Adapters leave their `error` store/ref untouched for cancellations, so UI that renders `error` does not flash a failure when a user navigates away.
+- **Superseded scans cannot publish.** Every adapter stamps each scan and checks the stamp, the disposal flag and the abort signal before writing state. This holds even when the source ignores the signal.
+- **State from a completed scan is preserved.** Aborting a newer scan never clears results a previous scan already published.
+
+## What each chain cancels
+
+| Chain | Cancellation |
+| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Stellar | The `signal` is passed into `FetchAnnouncementsOptions`, so in-flight Soroban RPC and Horizon requests are aborted (`src/chains/stellar/announcements.ts`). |
+| EVM | The core `fetchAnnouncements()` subgraph fetcher has no `signal` parameter yet; the Vue adapter races the promise against the signal, so an aborted fetch rejects immediately and cannot publish. The request itself completes in the background. |
+| Solana | Same as EVM: the adapter races the promise because `fetchAnnouncements()` takes no `signal` yet. |
+
+The guarantee callers depend on — an aborted or superseded scan never updates published state — is identical for all three chains.
+
+## Vue scanner options are a discriminated union
+
+`useScanner` models options and inputs as a union by chain, so mismatched key material is a compile-time error in TypeScript and a `TypeError` at runtime for JavaScript consumers.
+
+```ts
+type ScannerFetchOptions = EvmFetchOptions | StellarFetchOptions | SolanaFetchOptions;
+
+interface EvmFetchOptions extends ScanCancellationOptions {
+ chain: 'evm';
+ network?: string; // EVM deployment key, e.g. 'horizen'
+ url?: string; // subgraph URL override
+}
+
+interface StellarFetchOptions
+ extends Omit, ScanCancellationOptions {
+ chain: 'stellar';
+ network?: string; // Stellar deployment key, e.g. 'stellar'
+ url?: string; // Soroban RPC URL override
+}
+
+interface SolanaFetchOptions extends ScanCancellationOptions {
+ chain: 'solana';
+ network?: string;
+ url?: string;
+}
+```
+
+Scanning pairs each chain with its own announcements and keys:
+
+```ts
+scanner.scanAnnouncements({
+ chain: 'evm',
+ announcements: evmAnnouncements, // Announcement (EVM)
+ keys: evmKeys, // StealthKeys (EVM) — Stellar keys are a compile error here
+});
+```
+
+### Runtime guards
+
+JavaScript consumers (and `any`-typed call sites) get validated input:
+
+| Guard | Rejects |
+| -------------------------------- | --------------------------------------------------------------------------------------- |
+| `assertScannerChain` | anything other than `evm`, `stellar`, `solana` |
+| `assertScannerFetchOptions` | non-object options, unknown chains, non-`AbortSignal` signals, malformed Stellar ranges |
+| `assertScannerScanInput` | non-array announcements, missing or wrong-typed key material |
+| `assertScannerScanWithKeysInput` | fetch options plus mismatched or missing keys |
+| `assertScannerCheckInput` | malformed key material, a `viewTag` outside `0–255` |
+
+Every guard throws a `TypeError` that names the chain and the offending field, for example:
+
+```
+EVM scanner keys must provide a 0x-prefixed hex string "viewingKey".
+stellar scanner keys must provide a bigint "spendingScalar".
+Unsupported scanner chain "bitcoin". Expected "evm", "stellar" or "solana".
+```
+
+## Migrating from positional scanner arguments
+
+`@wraith-protocol/sdk-vue` 0.1.x took positional arguments. They could not express "these keys belong to this chain", so `scanWithKeys` guessed the key layout from the composable's active chain and mismatches failed inside the scanner.
+
+```ts
+// before
+await scanner.fetchAnnouncements('horizen');
+scanner.scanAnnouncements(announcements, viewingKey, spendingPubKey, spendingKey);
+scanner.checkAddress(ephemeralPubKey, viewingKey, spendingPubKey, viewTag);
+await scanner.scanWithKeys(keys);
+
+// after
+await scanner.fetchAnnouncements({ chain: 'evm', network: 'horizen' });
+scanner.scanAnnouncements({ chain: 'evm', announcements, keys });
+scanner.checkAddress({ chain: 'evm', ephemeralPubKey, viewingKey, spendingPubKey, viewTag });
+await scanner.scanWithKeys({ chain: 'evm', keys });
+```
+
+`useActivity().scanAnnouncements()` takes the same `{ chain, announcements, keys }` objects, so activity and scanner inputs cannot disagree about a chain.
diff --git a/etc/sdk.api.md b/etc/sdk.api.md
index 09c277b..272211b 100644
--- a/etc/sdk.api.md
+++ b/etc/sdk.api.md
@@ -188,6 +188,9 @@ export interface FreighterWalletWatcher {
// @public
export function getTracer(): Tracer;
+// @public
+export function getUnknownErrorMessage(err: unknown): string;
+
// @public
export class HorizonResponseError extends WraithNetworkError {
constructor(url: string, field: string, reason: string);
@@ -255,6 +258,9 @@ export class InvalidSignatureError extends WraithInputError {
describe(): string;
}
+// @public
+export function isAbortError(err: unknown): boolean;
+
// @public (undocumented)
export class KeyDerivationFailedError extends WraithCryptoError {
constructor(reason: string);
@@ -409,6 +415,11 @@ export interface ScanAllInput {
stellar?: StellarChainInput;
}
+// @public
+export interface ScanCancellationOptions {
+ signal?: AbortSignal;
+}
+
// @public
export function setTracer(tracer?: Tracer | null): void;
@@ -703,10 +714,10 @@ export abstract class WraithWalletError extends WraithError {
// Warnings were encountered during analysis:
//
-// dist/unified-BQ_-bZ0t.d.ts:166:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_2" needs to be exported by the entry point index.d.ts
-// dist/unified-BQ_-bZ0t.d.ts:171:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement$1" needs to be exported by the entry point index.d.ts
-// dist/unified-BQ_-bZ0t.d.ts:176:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_3" needs to be exported by the entry point index.d.ts
-// dist/unified-BQ_-bZ0t.d.ts:181:5 - (ae-forgotten-export) The symbol "MatchedStealthCell" needs to be exported by the entry point index.d.ts
+// dist/unified-BXqIrpNT.d.ts:179:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_2" needs to be exported by the entry point index.d.ts
+// dist/unified-BXqIrpNT.d.ts:184:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement$1" needs to be exported by the entry point index.d.ts
+// dist/unified-BXqIrpNT.d.ts:189:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_3" needs to be exported by the entry point index.d.ts
+// dist/unified-BXqIrpNT.d.ts:194:5 - (ae-forgotten-export) The symbol "MatchedStealthCell" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
diff --git a/packages/sdk-react/package.json b/packages/sdk-react/package.json
index a9b8ab1..ad2589f 100644
--- a/packages/sdk-react/package.json
+++ b/packages/sdk-react/package.json
@@ -23,7 +23,8 @@
"scripts": {
"build": "tsup",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "test:types": "tsc --noEmit -p tsconfig.json"
},
"peerDependencies": {
"@stellar/stellar-sdk": "^13.1.0",
diff --git a/packages/sdk-react/src/cache/idb.ts b/packages/sdk-react/src/cache/idb.ts
index 62a075e..b30d54e 100644
--- a/packages/sdk-react/src/cache/idb.ts
+++ b/packages/sdk-react/src/cache/idb.ts
@@ -55,33 +55,9 @@ class MemoryCache {
announcements: Announcement[],
lastScannedLedger?: number,
) {
- await this.readyCheck();
- if (this.fallback) {
- const e = await this.fallback.set(chain, wallet, announcements);
- if (lastScannedLedger) e.lastScannedLedger = lastScannedLedger;
- return e;
- }
- if (!this.db) return null;
- const id = makeId(chain, wallet);
- const entry: CacheEntry = {
- id,
- chain,
- wallet,
- announcements,
- lastAccess: Date.now(),
- lastUpdated: Date.now(),
- lastScannedLedger,
- };
- return new Promise((resolve, reject) => {
- const tx = this.db!.transaction(this.storeName, 'readwrite');
- const store = tx.objectStore(this.storeName);
- const req = store.put(entry);
- req.onsuccess = async () => {
- await this.enforceCap();
- resolve(entry);
- };
- req.onerror = () => reject(req.error);
- });
+ const entry = await this.set(chain, wallet, announcements);
+ if (lastScannedLedger != null) entry.lastScannedLedger = lastScannedLedger;
+ return entry;
}
async delete(chain: string, wallet: string) {
diff --git a/packages/sdk-react/src/hooks.ts b/packages/sdk-react/src/hooks.ts
index 2795eb6..1b4ee3c 100644
--- a/packages/sdk-react/src/hooks.ts
+++ b/packages/sdk-react/src/hooks.ts
@@ -11,6 +11,7 @@ import {
type BuildStealthPaymentOptions,
} from '@wraith-protocol/sdk/chains/stellar';
import { Horizon } from '@stellar/stellar-sdk';
+import { getUnknownErrorMessage } from '@wraith-protocol/sdk';
import { getPlatform, setPlatform, type Platform } from './platform';
export { getPlatform, setPlatform };
@@ -48,8 +49,8 @@ export function useStellarSendStealthPayment() {
try {
const result = await buildStealthPayment(options);
return result;
- } catch (err: any) {
- setError(err);
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error(getUnknownErrorMessage(err)));
throw err;
} finally {
setBuilding(false);
@@ -74,7 +75,7 @@ export function useStellarBalance(publicKey: string, rpcUrl = 'https://horizon.s
.loadAccount(publicKey)
.then((acc) => {
if (!mounted) return;
- const xlmBalance = acc.balances.find((b: any) => b.asset_type === 'native');
+ const xlmBalance = acc.balances.find((balance) => balance.asset_type === 'native');
setBalance(xlmBalance ? xlmBalance.balance : '0');
})
.catch(() => {
@@ -93,7 +94,7 @@ export function useStellarBalance(publicKey: string, rpcUrl = 'https://horizon.s
}
/** Hook to resolve a Wraith name on Stellar */
-export function useStellarName(name: string | undefined, chain = 'testnet') {
+export function useStellarName(name: string | undefined, chain = 'stellar') {
const [address, setAddress] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -113,8 +114,10 @@ export function useStellarName(name: string | undefined, chain = 'testnet') {
await new Promise((resolve) => setTimeout(resolve, 500));
if (!mounted) return;
setAddress(null); // Return null or resolved meta-address
- } catch (err: any) {
- if (mounted) setError(err);
+ } catch (err) {
+ if (mounted) {
+ setError(err instanceof Error ? err : new Error(getUnknownErrorMessage(err)));
+ }
} finally {
if (mounted) setLoading(false);
}
diff --git a/packages/sdk-react/src/hooks/useScanner.ts b/packages/sdk-react/src/hooks/useScanner.ts
index c8018a7..caf4521 100644
--- a/packages/sdk-react/src/hooks/useScanner.ts
+++ b/packages/sdk-react/src/hooks/useScanner.ts
@@ -4,17 +4,35 @@ import {
type FetchAnnouncementsOptions,
type Announcement,
} from '@wraith-protocol/sdk/chains/stellar';
+import {
+ getUnknownErrorMessage,
+ isAbortError,
+ type ScanCancellationOptions,
+} from '@wraith-protocol/sdk';
import IDBCache from '../cache/idb';
+/** Key under which the shared cache instance is stored on `globalThis`. */
+const CACHE_KEY = '__wraith_idb_cache' as const;
+
+type CacheGlobal = typeof globalThis & { __wraith_idb_cache?: IDBCache };
+
function getCache(): IDBCache {
- const key = '__wraith_idb_cache';
- const g = globalThis as any;
- if (!g[key]) g[key] = new IDBCache();
- return g[key] as IDBCache;
+ const global = globalThis as CacheGlobal;
+ global[CACHE_KEY] ??= new IDBCache();
+ return global[CACHE_KEY];
}
-export interface UseScannerOptions {
- chain?: string;
+function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+}
+
+export interface UseScannerOptions extends ScanCancellationOptions {
+ /**
+ * Stellar deployment key to scan. React's scanner reads Stellar
+ * announcements only, so the only accepted value is `stellar`.
+ */
+ chain?: 'stellar';
+ /** Wallet address used to namespace the announcement cache. */
wallet?: string;
}
@@ -28,24 +46,47 @@ export interface UseScannerResult {
}
export function useScanner(opts: UseScannerOptions = {}): UseScannerResult {
- const { chain = 'stellar', wallet = '' } = opts;
+ const { chain = 'stellar', wallet = '', signal: hookSignal } = opts;
const [announcements, setAnnouncements] = useState([]);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState(null);
const cache = useRef(getCache()).current;
const mountedRef = useRef(true);
+ const controllerRef = useRef(null);
+ const scanIdRef = useRef(0);
+
+ const abortActiveScan = useCallback(() => {
+ controllerRef.current?.abort();
+ controllerRef.current = null;
+ }, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
+ abortActiveScan();
};
- }, []);
+ }, [abortActiveScan]);
+
+ // A dependency change makes the in-flight scan stale: abort it so its
+ // remainder is never applied to the next chain or wallet's state.
+ useEffect(() => {
+ abortActiveScan();
+ }, [chain, wallet, abortActiveScan]);
+
+ useEffect(() => {
+ if (!hookSignal) return;
+ if (hookSignal.aborted) {
+ abortActiveScan();
+ return;
+ }
+ hookSignal.addEventListener('abort', abortActiveScan, { once: true });
+ return () => hookSignal.removeEventListener('abort', abortActiveScan);
+ }, [hookSignal, abortActiveScan]);
// Clear cache entry when wallet changes (not on chain reset — consumer calls clearCache for that)
useEffect(() => {
if (!wallet) return;
- const m = mountedRef;
(async () => {
try {
await cache.delete(chain, wallet);
@@ -53,20 +94,40 @@ export function useScanner(opts: UseScannerOptions = {}): UseScannerResult {
/* ignore */
}
})();
- }, [wallet]);
+ }, [chain, wallet, cache]);
const scan = useCallback(
async (options: FetchAnnouncementsOptions = {}) => {
+ abortActiveScan();
+
+ const controller = new AbortController();
+ controllerRef.current = controller;
+ const scanId = ++scanIdRef.current;
+
+ const externalSignal = options.signal;
+ const forwardAbort = () => controller.abort(externalSignal?.reason);
+ if (externalSignal) {
+ if (externalSignal.aborted) controller.abort(externalSignal.reason);
+ else externalSignal.addEventListener('abort', forwardAbort, { once: true });
+ }
+
+ const isCurrent = () => mountedRef.current && scanIdRef.current === scanId;
+ const throwIfStale = () => {
+ if (!isCurrent() || controller.signal.aborted) throw abortError();
+ };
+
setScanning(true);
setError(null);
try {
- let cachedEntry = null;
+ throwIfStale();
+
+ let cachedEntry: Awaited> = null;
if (wallet) {
try {
cachedEntry = await cache.get(chain, wallet);
- if (cachedEntry && cachedEntry.announcements?.length) {
+ if (cachedEntry && cachedEntry.announcements.length > 0) {
// Show cached data immediately while fresh data loads
- setAnnouncements(cachedEntry.announcements);
+ if (isCurrent()) setAnnouncements(cachedEntry.announcements);
}
} catch {
/* ignore cache read errors */
@@ -74,35 +135,46 @@ export function useScanner(opts: UseScannerOptions = {}): UseScannerResult {
}
// Resume from watermark if we have one
- let fetchOpts = options;
- if (cachedEntry?.lastScannedLedger != null && options.fromLedger == null) {
- fetchOpts = { ...options, fromLedger: cachedEntry.lastScannedLedger + 1 };
- }
+ const fetchOpts: FetchAnnouncementsOptions = {
+ ...options,
+ signal: controller.signal,
+ ...(cachedEntry?.lastScannedLedger != null && options.fromLedger == null
+ ? { fromLedger: cachedEntry.lastScannedLedger + 1 }
+ : {}),
+ };
// Fetch fresh announcements from the watermark onwards
const fresh: Announcement[] = [];
- for await (const ann of fetchAnnouncementsStream(chain, fetchOpts as any)) {
- fresh.push(ann as Announcement);
+ for await (const announcement of fetchAnnouncementsStream(chain, fetchOpts)) {
+ throwIfStale();
+ fresh.push(announcement);
}
+ throwIfStale();
// Merge cached + fresh
const merged =
- cachedEntry && cachedEntry.announcements?.length
+ cachedEntry && cachedEntry.announcements.length > 0
? [...cachedEntry.announcements, ...fresh]
: fresh;
- if (mountedRef.current) {
- setAnnouncements(merged);
- }
+ setAnnouncements(merged);
return merged;
- } catch (err: any) {
- if (mountedRef.current) setError(err);
- throw err;
+ } catch (err) {
+ if (isAbortError(err) || controller.signal.aborted || !isCurrent()) {
+ throw isAbortError(err) ? err : abortError();
+ }
+ const failure = err instanceof Error ? err : new Error(getUnknownErrorMessage(err));
+ if (isCurrent()) setError(failure);
+ throw failure;
} finally {
- if (mountedRef.current) setScanning(false);
+ externalSignal?.removeEventListener('abort', forwardAbort);
+ if (controllerRef.current === controller) {
+ controllerRef.current = null;
+ if (mountedRef.current) setScanning(false);
+ }
}
},
- [chain, wallet, cache],
+ [chain, wallet, cache, abortActiveScan],
);
const commitScan = useCallback(
diff --git a/packages/sdk-react/test/useScanner.test-d.ts b/packages/sdk-react/test/useScanner.test-d.ts
new file mode 100644
index 0000000..ab98bac
--- /dev/null
+++ b/packages/sdk-react/test/useScanner.test-d.ts
@@ -0,0 +1,39 @@
+import { describe, expectTypeOf, test } from 'vitest';
+import type { Announcement } from '@wraith-protocol/sdk/chains/stellar';
+import { useScanner, type UseScannerResult } from '../src/hooks/useScanner';
+
+describe('useScanner option types', () => {
+ test('accepts the Stellar deployment, a wallet, and a cancellation signal', () => {
+ expectTypeOf(
+ useScanner({
+ chain: 'stellar',
+ wallet: 'GABCDEF',
+ signal: new AbortController().signal,
+ }),
+ ).toEqualTypeOf();
+ });
+
+ test('rejects chains the React scanner cannot read', () => {
+ // @ts-expect-error - the React scanner only reads Stellar announcements
+ useScanner({ chain: 'evm' });
+ });
+
+ test('rejects non-string wallets', () => {
+ // @ts-expect-error - wallet must be a Stellar address string
+ useScanner({ wallet: 42 });
+ });
+
+ test('scan options use the shared cancellation shape', () => {
+ const result = null as unknown as UseScannerResult;
+
+ expectTypeOf(result.scan({ signal: new AbortController().signal })).toEqualTypeOf<
+ Promise
+ >();
+
+ // @ts-expect-error - fromLedger must be a number
+ void result.scan({ fromLedger: '10' });
+
+ // @ts-expect-error - signal must be an AbortSignal
+ void result.scan({ signal: 'stop' });
+ });
+});
diff --git a/packages/sdk-react/test/useScanner.test.tsx b/packages/sdk-react/test/useScanner.test.tsx
new file mode 100644
index 0000000..680b32d
--- /dev/null
+++ b/packages/sdk-react/test/useScanner.test.tsx
@@ -0,0 +1,226 @@
+// @vitest-environment jsdom
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Announcement, FetchAnnouncementsOptions } from '@wraith-protocol/sdk/chains/stellar';
+
+const fetchAnnouncementsStream = vi.hoisted(() => vi.fn());
+
+vi.mock('@wraith-protocol/sdk/chains/stellar', async () => {
+ const actual = await vi.importActual(
+ '@wraith-protocol/sdk/chains/stellar',
+ );
+ return { ...actual, fetchAnnouncementsStream };
+});
+
+import { useScanner } from '../src/hooks/useScanner';
+
+function announcement(stealthAddress: string): Announcement {
+ return {
+ schemeId: 1,
+ stealthAddress,
+ caller: 'GCALLER',
+ ephemeralPubKey: 'aa'.repeat(32),
+ metadata: '01',
+ };
+}
+
+function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+}
+
+/** Rejects as soon as the signal aborts, mirroring the SDK's fetch stream. */
+function rejectOnAbort(signal?: AbortSignal): Promise {
+ return new Promise((_, reject) => {
+ if (signal?.aborted) {
+ reject(abortError());
+ return;
+ }
+ signal?.addEventListener('abort', () => reject(abortError()), { once: true });
+ });
+}
+
+describe('useScanner cancellation', () => {
+ beforeEach(() => {
+ fetchAnnouncementsStream.mockReset();
+ });
+
+ it('publishes a completed scan', async () => {
+ fetchAnnouncementsStream.mockImplementation(async function* () {
+ yield announcement('GOK');
+ });
+ const { result, unmount } = renderHook(() => useScanner());
+
+ await act(async () => {
+ await result.current.scan();
+ });
+
+ expect(result.current.announcements).toEqual([announcement('GOK')]);
+ expect(result.current.scanning).toBe(false);
+ expect(result.current.error).toBeNull();
+ unmount();
+ });
+
+ it('aborts an in-flight scan on unmount and never publishes its result', async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const seen: AbortSignal[] = [];
+
+ fetchAnnouncementsStream.mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await Promise.race([gate, rejectOnAbort(options.signal)]);
+ yield announcement('GSTALE');
+ });
+
+ const { result, unmount } = renderHook(() => useScanner());
+
+ let pending!: Promise;
+ act(() => {
+ pending = result.current.scan().catch((err) => err);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.scanning).toBe(true);
+ expect(seen).toHaveLength(1);
+
+ unmount();
+ expect(seen[0].aborted).toBe(true);
+
+ release();
+ const outcome = await pending;
+ expect(outcome).toBeInstanceOf(DOMException);
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(result.current.announcements).toEqual([]);
+ });
+
+ it('discards a superseded scan even when the source ignores the abort', async () => {
+ const releases: Array<() => void> = [];
+ const seen: AbortSignal[] = [];
+
+ fetchAnnouncementsStream.mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ const index = releases.length;
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ releases.push(release);
+ if (options.signal) seen.push(options.signal);
+ // Deliberately ignores the signal: the hook's stale guard must still win.
+ await gate;
+ yield announcement(`G${index}`);
+ });
+
+ const { result, rerender, unmount } = renderHook(
+ ({ wallet }: { wallet: string }) => useScanner({ wallet }),
+ { initialProps: { wallet: 'A' } },
+ );
+
+ let first!: Promise;
+ act(() => {
+ first = result.current.scan().catch((err) => err);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ rerender({ wallet: 'B' });
+ expect(seen[0].aborted).toBe(true);
+
+ let second!: Promise;
+ act(() => {
+ second = result.current.scan();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ releases[1]();
+ await act(async () => {
+ await second;
+ });
+ expect(result.current.announcements).toEqual([announcement('G1')]);
+
+ releases[0]();
+ const outcome = await first;
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(result.current.announcements).toEqual([announcement('G1')]);
+ unmount();
+ });
+
+ it('aborts when the caller aborts the signal passed to scan()', async () => {
+ const seen: AbortSignal[] = [];
+ fetchAnnouncementsStream.mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await rejectOnAbort(options.signal);
+ yield announcement('GSTALE');
+ });
+
+ const { result } = renderHook(() => useScanner());
+ const controller = new AbortController();
+
+ let pending!: Promise;
+ act(() => {
+ pending = result.current.scan({ signal: controller.signal }).catch((err) => err);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const outcome = await act(async () => {
+ controller.abort();
+ return pending;
+ });
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0].aborted).toBe(true);
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(result.current.announcements).toEqual([]);
+ expect(result.current.scanning).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('aborts an in-flight scan when the hook-level signal aborts', async () => {
+ const seen: AbortSignal[] = [];
+ fetchAnnouncementsStream.mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await rejectOnAbort(options.signal);
+ yield announcement('GSTALE');
+ });
+
+ const controller = new AbortController();
+ const { result } = renderHook(() => useScanner({ signal: controller.signal }));
+
+ let pending!: Promise;
+ act(() => {
+ pending = result.current.scan().catch((err) => err);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const outcome = await act(async () => {
+ controller.abort();
+ return pending;
+ });
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0].aborted).toBe(true);
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(result.current.announcements).toEqual([]);
+ });
+});
diff --git a/packages/sdk-solid/package.json b/packages/sdk-solid/package.json
index a5231f6..27c231a 100644
--- a/packages/sdk-solid/package.json
+++ b/packages/sdk-solid/package.json
@@ -18,11 +18,13 @@
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
+ "test:types": "tsc --noEmit -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"clean": "rm -rf dist"
},
"dependencies": {
"@wraith-protocol/sdk": "workspace:*",
+ "@wraith-protocol/sdk-agent": "workspace:*",
"solid-js": "^1.9.0"
},
"peerDependencies": {
diff --git a/packages/sdk-solid/src/primitives/createActivity.ts b/packages/sdk-solid/src/primitives/createActivity.ts
index 048c186..b6ae2a8 100644
--- a/packages/sdk-solid/src/primitives/createActivity.ts
+++ b/packages/sdk-solid/src/primitives/createActivity.ts
@@ -1,6 +1,11 @@
import { createSignal } from 'solid-js';
-import { Wraith, WraithAgent, Chain } from '@wraith-protocol/sdk';
-import type { WraithConfig, AgentConfig, AgentInfo, ChatResponse } from '@wraith-protocol/sdk';
+import { Wraith, WraithAgent, Chain } from '@wraith-protocol/sdk-agent';
+import type {
+ WraithConfig,
+ AgentConfig,
+ AgentInfo,
+ ChatResponse,
+} from '@wraith-protocol/sdk-agent';
/**
* Solid primitive for managing Wraith agent activity (chat, balance, agent lifecycle).
diff --git a/packages/sdk-solid/src/primitives/createScanner.ts b/packages/sdk-solid/src/primitives/createScanner.ts
index dee3722..a1f5acd 100644
--- a/packages/sdk-solid/src/primitives/createScanner.ts
+++ b/packages/sdk-solid/src/primitives/createScanner.ts
@@ -1,4 +1,4 @@
-import { createSignal } from 'solid-js';
+import { createSignal, getOwner, onCleanup } from 'solid-js';
import {
fetchAnnouncementsStream,
scanAnnouncements as stellarScanAnnouncements,
@@ -6,42 +6,109 @@ import {
type Announcement,
type MatchedAnnouncement,
} from '@wraith-protocol/sdk/chains/stellar';
+import {
+ getUnknownErrorMessage,
+ isAbortError,
+ type ScanCancellationOptions,
+} from '@wraith-protocol/sdk';
+
+export interface CreateScannerOptions extends ScanCancellationOptions {}
+
+function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+}
/**
* Solid primitive for scanning Stellar stealth payment announcements.
*
* Handles fetch + scan lifecycle with reactive loading/error signals.
* All reactive values are returned as getter functions following Solid conventions.
+ *
+ * The primitive aborts its in-flight scan when the owning root or component is
+ * disposed. `scan` also accepts one `signal`, aborts any scan already in
+ * flight, and never publishes a superseded result.
*/
-export function createScanner() {
+export function createScanner(options: CreateScannerOptions = {}) {
const [announcements, setAnnouncements] = createSignal([]);
const [matched, setMatched] = createSignal([]);
const [scanning, setScanning] = createSignal(false);
const [error, setError] = createSignal(null);
+ let activeController: AbortController | null = null;
+ let scanId = 0;
+ let disposed = false;
+
+ function abortActive(): void {
+ activeController?.abort();
+ activeController = null;
+ }
+
+ function dispose(): void {
+ disposed = true;
+ abortActive();
+ }
+
+ // `onCleanup` is a no-op without an owner, so only register inside a root or
+ // component to avoid Solid's "cleanups created outside createRoot" warning.
+ if (getOwner()) onCleanup(dispose);
+
+ if (options.signal) {
+ if (options.signal.aborted) dispose();
+ else options.signal.addEventListener('abort', dispose, { once: true });
+ }
+
/**
* Fetch raw announcements from the Stellar network using the streaming RPC.
* Collects all pages into a local array then updates the signal.
*/
async function scan(
chain = 'stellar',
- opts?: FetchAnnouncementsOptions,
+ opts: FetchAnnouncementsOptions = {},
): Promise {
+ if (disposed) throw abortError();
+
+ abortActive();
+ const controller = new AbortController();
+ activeController = controller;
+ const currentScan = ++scanId;
+
+ const externalSignal = opts.signal;
+ const forwardAbort = () => controller.abort(externalSignal?.reason);
+ if (externalSignal) {
+ if (externalSignal.aborted) controller.abort(externalSignal.reason);
+ else externalSignal.addEventListener('abort', forwardAbort, { once: true });
+ }
+
+ const isCurrent = () => !disposed && currentScan === scanId && !controller.signal.aborted;
+
setScanning(true);
setError(null);
+
try {
const collected: Announcement[] = [];
- for await (const announcement of fetchAnnouncementsStream(chain, opts)) {
+ for await (const announcement of fetchAnnouncementsStream(chain, {
+ ...opts,
+ signal: controller.signal,
+ })) {
+ if (!isCurrent()) throw abortError();
collected.push(announcement);
}
+
+ if (!isCurrent()) throw abortError();
setAnnouncements(collected);
return collected;
- } catch (err) {
- const e = err instanceof Error ? err : new Error(String(err));
- setError(e);
- throw e;
+ } catch (cause) {
+ if (isAbortError(cause) || !isCurrent()) {
+ throw isAbortError(cause) ? cause : abortError();
+ }
+ setError(cause instanceof Error ? cause : new Error(getUnknownErrorMessage(cause)));
+ throw cause;
} finally {
- setScanning(false);
+ externalSignal?.removeEventListener('abort', forwardAbort);
+ if (activeController === controller) {
+ activeController = null;
+ if (!disposed) setScanning(false);
+ }
}
}
@@ -65,7 +132,7 @@ export function createScanner() {
setMatched(() => result);
return result;
} catch (err) {
- const e = err instanceof Error ? err : new Error(String(err));
+ const e = err instanceof Error ? err : new Error(getUnknownErrorMessage(err));
setError(e);
throw e;
}
@@ -95,5 +162,6 @@ export function createScanner() {
scan,
match,
scanAndMatch,
+ dispose,
};
}
diff --git a/packages/sdk-solid/test/createActivity.test.ts b/packages/sdk-solid/test/createActivity.test.ts
index b2c6db5..0b058e6 100644
--- a/packages/sdk-solid/test/createActivity.test.ts
+++ b/packages/sdk-solid/test/createActivity.test.ts
@@ -23,7 +23,7 @@ const mockClient = vi.hoisted(() => ({
listAgents: vi.fn().mockResolvedValue([mockAgentInfo]),
}));
-vi.mock('@wraith-protocol/sdk', () => ({
+vi.mock('@wraith-protocol/sdk-agent', () => ({
Wraith: vi.fn().mockImplementation(() => mockClient),
WraithAgent: vi.fn(),
Chain: {
@@ -34,7 +34,7 @@ vi.mock('@wraith-protocol/sdk', () => ({
}));
// Grab mocked references once (safe because vi.mock hoists before imports)
-import { Wraith as MockWraith } from '@wraith-protocol/sdk';
+import { Wraith as MockWraith } from '@wraith-protocol/sdk-agent';
describe('createActivity', () => {
beforeEach(() => {
diff --git a/packages/sdk-solid/test/createScanner.test.ts b/packages/sdk-solid/test/createScanner.test.ts
index d3ce883..31f50d5 100644
--- a/packages/sdk-solid/test/createScanner.test.ts
+++ b/packages/sdk-solid/test/createScanner.test.ts
@@ -1,4 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { createRoot } from 'solid-js';
+import {
+ fetchAnnouncementsStream,
+ type Announcement,
+ type FetchAnnouncementsOptions,
+} from '@wraith-protocol/sdk/chains/stellar';
import { createScanner } from '../src/primitives/createScanner';
const mockAnnouncements = vi.hoisted(() => [
@@ -99,4 +105,133 @@ describe('createScanner', () => {
expect(primitive.error()!.message).toBe('network error');
expect(primitive.scanning()).toBe(false);
});
+
+ describe('cancellation', () => {
+ const announcement: Announcement = {
+ schemeId: 1,
+ stealthAddress: 'GSTEALTH',
+ caller: 'GCALLER',
+ ephemeralPubKey: 'aa'.repeat(32),
+ metadata: '00',
+ };
+
+ function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+ }
+
+ function rejectOnAbort(signal?: AbortSignal): Promise {
+ return new Promise((_, reject) => {
+ if (signal?.aborted) {
+ reject(abortError());
+ return;
+ }
+ signal?.addEventListener('abort', () => reject(abortError()), { once: true });
+ });
+ }
+
+ it('aborts an in-flight scan when the owning root is disposed', async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const seen: AbortSignal[] = [];
+
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await Promise.race([gate, rejectOnAbort(options.signal)]);
+ yield announcement;
+ });
+
+ let primitive!: ReturnType;
+ const disposeRoot = createRoot((dispose) => {
+ primitive = createScanner();
+ return dispose;
+ });
+
+ const pending = primitive.scan('stellar').catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(primitive.scanning()).toBe(true);
+ expect(seen).toHaveLength(1);
+
+ disposeRoot();
+ expect(seen[0].aborted).toBe(true);
+
+ release();
+ const outcome = await pending;
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(primitive.announcements()).toEqual([]);
+ expect(primitive.error()).toBeNull();
+ });
+
+ it('aborts when the caller aborts the signal passed to scan()', async () => {
+ const seen: AbortSignal[] = [];
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await rejectOnAbort(options.signal);
+ yield announcement;
+ });
+
+ const primitive = createScanner();
+ const controller = new AbortController();
+ const pending = primitive.scan('stellar', { signal: controller.signal }).catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ controller.abort();
+ const outcome = await pending;
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0].aborted).toBe(true);
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(primitive.announcements()).toEqual([]);
+ expect(primitive.scanning()).toBe(false);
+ });
+
+ it('discards a superseded scan even when the source ignores the abort', async () => {
+ const releases: Array<() => void> = [];
+ const seen: AbortSignal[] = [];
+
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ const index = releases.length;
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ releases.push(release);
+ if (options.signal) seen.push(options.signal);
+ await gate;
+ yield { ...announcement, ledger: index + 1 };
+ });
+
+ const primitive = createScanner();
+
+ const first = primitive.scan('stellar').catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(seen[0].aborted).toBe(false);
+
+ const second = primitive.scan('stellar');
+ expect(seen[0].aborted).toBe(true);
+
+ releases[1]();
+ await second;
+ expect(primitive.announcements()).toEqual([{ ...announcement, ledger: 2 }]);
+
+ releases[0]();
+ const outcome = await first;
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(primitive.announcements()).toEqual([{ ...announcement, ledger: 2 }]);
+ });
+ });
});
diff --git a/packages/sdk-solid/tsconfig.json b/packages/sdk-solid/tsconfig.json
index 6ac18b7..59b4074 100644
--- a/packages/sdk-solid/tsconfig.json
+++ b/packages/sdk-solid/tsconfig.json
@@ -12,7 +12,8 @@
"jsx": "preserve",
"jsxImportSource": "solid-js",
"paths": {
- "@wraith-protocol/sdk": ["../../src"]
+ "@wraith-protocol/sdk": ["../../src"],
+ "@wraith-protocol/sdk-agent": ["../sdk-agent/src"]
}
},
"include": ["src"],
diff --git a/packages/sdk-solid/tsup.config.ts b/packages/sdk-solid/tsup.config.ts
index d238c1f..8ddcd2a 100644
--- a/packages/sdk-solid/tsup.config.ts
+++ b/packages/sdk-solid/tsup.config.ts
@@ -9,5 +9,5 @@ export default defineConfig({
splitting: true,
clean: true,
treeshake: true,
- external: ['solid-js', '@wraith-protocol/sdk'],
+ external: ['solid-js', '@wraith-protocol/sdk', '@wraith-protocol/sdk-agent'],
});
diff --git a/packages/sdk-solid/vitest.config.ts b/packages/sdk-solid/vitest.config.ts
index 7f5562b..2203c2c 100644
--- a/packages/sdk-solid/vitest.config.ts
+++ b/packages/sdk-solid/vitest.config.ts
@@ -14,6 +14,7 @@ export default defineConfig({
resolve: {
alias: {
'@wraith-protocol/sdk': path.resolve(__dirname, '../../src'),
+ '@wraith-protocol/sdk-agent': path.resolve(__dirname, '../sdk-agent/src'),
},
conditions: ['development', 'browser'],
},
diff --git a/packages/sdk-svelte/README.md b/packages/sdk-svelte/README.md
index 24a4be5..89c9cd5 100644
--- a/packages/sdk-svelte/README.md
+++ b/packages/sdk-svelte/README.md
@@ -87,6 +87,23 @@ Reactive Wraith API client.
```
+### `useStellarAnnouncementScan`
+
+Scans Stellar announcements with cancellation. Pass a signal (for example from the component's `onDestroy`) and every in-flight scan is aborted when it fires.
+
+```ts
+import { onDestroy } from 'svelte';
+import { useStellarAnnouncementScan } from '@wraith-protocol/sdk-svelte';
+
+const controller = new AbortController();
+onDestroy(() => controller.abort());
+
+const scanner = useStellarAnnouncementScan({ signal: controller.signal });
+const announcements = await scanner.scan({ fromLedger: 100 });
+```
+
+`scan()` also accepts its own `signal`, aborts any scan already in flight, and never publishes a superseded result. `dispose()` aborts on demand.
+
### `useStealthMetaAddress`
Multi-chain meta address encode/decode.
diff --git a/packages/sdk-svelte/package.json b/packages/sdk-svelte/package.json
index 8e3c9ab..92a9e2d 100644
--- a/packages/sdk-svelte/package.json
+++ b/packages/sdk-svelte/package.json
@@ -22,7 +22,8 @@
"clean": "rm -rf dist"
},
"dependencies": {
- "@wraith-protocol/sdk": "workspace:*"
+ "@wraith-protocol/sdk": "workspace:*",
+ "@wraith-protocol/sdk-agent": "workspace:*"
},
"peerDependencies": {
"@stellar/stellar-sdk": "^13.1.0",
diff --git a/packages/sdk-svelte/src/primitives/useStellarAnnouncementScan.ts b/packages/sdk-svelte/src/primitives/useStellarAnnouncementScan.ts
index 629c7bb..2038afc 100644
--- a/packages/sdk-svelte/src/primitives/useStellarAnnouncementScan.ts
+++ b/packages/sdk-svelte/src/primitives/useStellarAnnouncementScan.ts
@@ -4,30 +4,94 @@ import {
type Announcement,
type FetchAnnouncementsOptions,
} from '@wraith-protocol/sdk/chains/stellar';
+import {
+ getUnknownErrorMessage,
+ isAbortError,
+ type ScanCancellationOptions,
+} from '@wraith-protocol/sdk';
+
+export interface StellarAnnouncementScanOptions extends ScanCancellationOptions {}
-/** Store primitive for scanning Stellar stealth announcements. */
-export function useStellarAnnouncementScan() {
+function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+}
+
+/**
+ * Store primitive for scanning Stellar stealth announcements.
+ *
+ * Pass a `signal` (for example from a component's `onDestroy`) to cancel scans
+ * with the component. { scan } also accepts its own `signal`, aborts any scan
+ * already in flight, and never publishes a result that has been superseded.
+ */
+export function useStellarAnnouncementScan(options: StellarAnnouncementScanOptions = {}) {
const _announcements = writable([]);
const _scanning = writable(false);
const _error = writable(null);
- async function scan(options: FetchAnnouncementsOptions): Promise {
+ let activeController: AbortController | null = null;
+ let scanId = 0;
+ let disposed = false;
+
+ function abortActive(): void {
+ activeController?.abort();
+ activeController = null;
+ }
+
+ function dispose(): void {
+ disposed = true;
+ abortActive();
+ }
+
+ if (options.signal) {
+ if (options.signal.aborted) dispose();
+ else options.signal.addEventListener('abort', dispose, { once: true });
+ }
+
+ async function scan(scanOptions: FetchAnnouncementsOptions = {}): Promise {
+ if (disposed) throw abortError();
+
+ abortActive();
+ const controller = new AbortController();
+ activeController = controller;
+ const currentScan = ++scanId;
+
+ const externalSignal = scanOptions.signal;
+ const forwardAbort = () => controller.abort(externalSignal?.reason);
+ if (externalSignal) {
+ if (externalSignal.aborted) controller.abort(externalSignal.reason);
+ else externalSignal.addEventListener('abort', forwardAbort, { once: true });
+ }
+
+ const isCurrent = () => !disposed && currentScan === scanId && !controller.signal.aborted;
+
_scanning.set(true);
_error.set(null);
try {
const announcements: Announcement[] = [];
- for await (const announcement of fetchAnnouncementsStream('stellar', options)) {
+ for await (const announcement of fetchAnnouncementsStream('stellar', {
+ ...scanOptions,
+ signal: controller.signal,
+ })) {
+ if (!isCurrent()) throw abortError();
announcements.push(announcement);
}
+
+ if (!isCurrent()) throw abortError();
_announcements.set(announcements);
return announcements;
} catch (cause) {
- const error = cause instanceof Error ? cause : new Error(String(cause));
- _error.set(error);
+ if (isAbortError(cause) || !isCurrent()) {
+ throw isAbortError(cause) ? cause : abortError();
+ }
+ _error.set(cause instanceof Error ? cause : new Error(getUnknownErrorMessage(cause)));
throw cause;
} finally {
- _scanning.set(false);
+ externalSignal?.removeEventListener('abort', forwardAbort);
+ if (activeController === controller) {
+ activeController = null;
+ if (!disposed) _scanning.set(false);
+ }
}
}
@@ -36,5 +100,6 @@ export function useStellarAnnouncementScan() {
scanning: readonly(_scanning),
error: readonly(_error),
scan,
+ dispose,
};
}
diff --git a/packages/sdk-svelte/src/primitives/useWraith.ts b/packages/sdk-svelte/src/primitives/useWraith.ts
index 35cddc7..2003c51 100644
--- a/packages/sdk-svelte/src/primitives/useWraith.ts
+++ b/packages/sdk-svelte/src/primitives/useWraith.ts
@@ -1,6 +1,11 @@
import { writable, readonly, get } from 'svelte/store';
-import { Wraith, WraithAgent, Chain } from '@wraith-protocol/sdk';
-import type { WraithConfig, AgentConfig, AgentInfo, ChatResponse } from '@wraith-protocol/sdk';
+import { Wraith, WraithAgent, Chain } from '@wraith-protocol/sdk-agent';
+import type {
+ WraithConfig,
+ AgentConfig,
+ AgentInfo,
+ ChatResponse,
+} from '@wraith-protocol/sdk-agent';
export function useWraith(config?: WraithConfig) {
const _client = writable(null);
diff --git a/packages/sdk-svelte/test/useStellarAnnouncementScan.test.ts b/packages/sdk-svelte/test/useStellarAnnouncementScan.test.ts
new file mode 100644
index 0000000..6f592da
--- /dev/null
+++ b/packages/sdk-svelte/test/useStellarAnnouncementScan.test.ts
@@ -0,0 +1,162 @@
+import { get } from 'svelte/store';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ fetchAnnouncementsStream,
+ type Announcement,
+ type FetchAnnouncementsOptions,
+} from '@wraith-protocol/sdk/chains/stellar';
+import { useStellarAnnouncementScan } from '../src/primitives/useStellarAnnouncementScan';
+
+vi.mock('@wraith-protocol/sdk/chains/stellar', () => ({
+ fetchAnnouncementsStream: vi.fn(),
+}));
+
+const announcement: Announcement = {
+ schemeId: 1,
+ stealthAddress: 'GSTEALTH',
+ caller: 'GCALLER',
+ ephemeralPubKey: 'aa'.repeat(32),
+ metadata: '00',
+};
+
+function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+}
+
+function rejectOnAbort(signal?: AbortSignal): Promise {
+ return new Promise((_, reject) => {
+ if (signal?.aborted) {
+ reject(abortError());
+ return;
+ }
+ signal?.addEventListener('abort', () => reject(abortError()), { once: true });
+ });
+}
+
+describe('useStellarAnnouncementScan cancellation', () => {
+ beforeEach(() => {
+ vi.mocked(fetchAnnouncementsStream).mockReset();
+ });
+
+ it('publishes a completed scan', async () => {
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* () {
+ yield announcement;
+ });
+ const state = useStellarAnnouncementScan();
+
+ await expect(state.scan({ fromLedger: 10 })).resolves.toEqual([announcement]);
+ expect(get(state.announcements)).toEqual([announcement]);
+ expect(get(state.scanning)).toBe(false);
+ expect(get(state.error)).toBeNull();
+ });
+
+ it('aborts an in-flight scan on dispose and never publishes its result', async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const seen: AbortSignal[] = [];
+
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await Promise.race([gate, rejectOnAbort(options.signal)]);
+ yield announcement;
+ });
+
+ const state = useStellarAnnouncementScan();
+ const pending = state.scan().catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(get(state.scanning)).toBe(true);
+ expect(seen).toHaveLength(1);
+
+ state.dispose();
+ expect(seen[0].aborted).toBe(true);
+
+ release();
+ const outcome = await pending;
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(get(state.announcements)).toEqual([]);
+ expect(get(state.error)).toBeNull();
+ });
+
+ it('aborts when the caller aborts the signal passed to scan()', async () => {
+ const seen: AbortSignal[] = [];
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await rejectOnAbort(options.signal);
+ yield announcement;
+ });
+
+ const state = useStellarAnnouncementScan();
+ const controller = new AbortController();
+ const pending = state.scan({ signal: controller.signal }).catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ controller.abort();
+ const outcome = await pending;
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0].aborted).toBe(true);
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(get(state.announcements)).toEqual([]);
+ expect(get(state.scanning)).toBe(false);
+ });
+
+ it('cancels scans created after the factory signal aborts', async () => {
+ const controller = new AbortController();
+ const state = useStellarAnnouncementScan({ signal: controller.signal });
+ controller.abort();
+
+ await expect(state.scan()).rejects.toThrow(DOMException);
+ expect(get(state.announcements)).toEqual([]);
+ });
+
+ it('discards a superseded scan even when the source ignores the abort', async () => {
+ const releases: Array<() => void> = [];
+ const seen: AbortSignal[] = [];
+
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ const index = releases.length;
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ releases.push(release);
+ if (options.signal) seen.push(options.signal);
+ // Deliberately ignores the signal: the primitive's stale guard must win.
+ await gate;
+ yield { ...announcement, ledger: index + 1 };
+ });
+
+ const state = useStellarAnnouncementScan();
+
+ const first = state.scan().catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(seen[0].aborted).toBe(false);
+
+ const second = state.scan();
+ expect(seen[0].aborted).toBe(true);
+
+ releases[1]();
+ await second;
+ expect(get(state.announcements)).toEqual([{ ...announcement, ledger: 2 }]);
+
+ releases[0]();
+ const outcome = await first;
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(get(state.announcements)).toEqual([{ ...announcement, ledger: 2 }]);
+ });
+});
diff --git a/packages/sdk-svelte/test/useWraith.test.ts b/packages/sdk-svelte/test/useWraith.test.ts
index 6210b5c..50df95d 100644
--- a/packages/sdk-svelte/test/useWraith.test.ts
+++ b/packages/sdk-svelte/test/useWraith.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-vi.mock('@wraith-protocol/sdk', () => {
+vi.mock('@wraith-protocol/sdk-agent', () => {
const mockAgent = {
info: {
id: 'agent-1',
diff --git a/packages/sdk-svelte/tsconfig.json b/packages/sdk-svelte/tsconfig.json
index 93e9fc0..1951302 100644
--- a/packages/sdk-svelte/tsconfig.json
+++ b/packages/sdk-svelte/tsconfig.json
@@ -8,7 +8,11 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
- "outDir": "dist"
+ "outDir": "dist",
+ "paths": {
+ "@wraith-protocol/sdk": ["../../src"],
+ "@wraith-protocol/sdk-agent": ["../sdk-agent/src"]
+ }
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
diff --git a/packages/sdk-svelte/tsup.config.ts b/packages/sdk-svelte/tsup.config.ts
index ab927b6..3390cab 100644
--- a/packages/sdk-svelte/tsup.config.ts
+++ b/packages/sdk-svelte/tsup.config.ts
@@ -9,5 +9,5 @@ export default defineConfig({
splitting: true,
clean: true,
treeshake: true,
- external: ['svelte', '@wraith-protocol/sdk'],
+ external: ['svelte', '@wraith-protocol/sdk', '@wraith-protocol/sdk-agent'],
});
diff --git a/packages/sdk-svelte/vitest.config.ts b/packages/sdk-svelte/vitest.config.ts
index abf7db3..d471c0f 100644
--- a/packages/sdk-svelte/vitest.config.ts
+++ b/packages/sdk-svelte/vitest.config.ts
@@ -12,6 +12,7 @@ export default defineConfig({
conditions: ['browser'],
alias: {
'@wraith-protocol/sdk': path.resolve(__dirname, '../../src'),
+ '@wraith-protocol/sdk-agent': path.resolve(__dirname, '../sdk-agent/src'),
},
},
});
diff --git a/packages/sdk-vue/README.md b/packages/sdk-vue/README.md
index b3a7781..a3a9851 100644
--- a/packages/sdk-vue/README.md
+++ b/packages/sdk-vue/README.md
@@ -65,6 +65,46 @@ const k = deriveKeys(signature);
const addr = generateAddress(k.spendingPubKey, k.viewingPubKey);
```
+### `useScanner`
+
+Typed announcement scanner. Fetch options are a discriminated union by chain, and scan inputs pair each chain with its own key material, so mismatches are compile-time errors in TypeScript and `TypeError`s at runtime.
+
+```ts
+import { useScanner } from '@wraith-protocol/sdk-vue';
+
+const scanner = useScanner('stellar');
+
+// One cancellation shape for every chain.
+const controller = new AbortController();
+const announcements = await scanner.fetchAnnouncements({
+ chain: 'stellar',
+ fromLedger: 100,
+ signal: controller.signal,
+});
+
+const matched = scanner.scanAnnouncements({
+ chain: 'stellar',
+ announcements,
+ keys, // StellarStealthKeys
+});
+```
+
+`useScanner` aborts in-flight work when its effect scope is disposed (component unmount), when `setChain` changes the chain, and when a new scan starts. Superseded scans reject with `AbortError` and never update the refs. `dispose()` aborts on demand.
+
+### `useActivity`
+
+Builds activity events from the same chain-discriminated scan inputs.
+
+```ts
+import { useActivity } from '@wraith-protocol/sdk-vue';
+
+const activity = useActivity();
+await activity.scanAnnouncements([
+ { chain: 'evm', announcements: evmAnnouncements, keys: evmKeys },
+ { chain: 'stellar', announcements: stellarAnnouncements, keys: stellarKeys },
+]);
+```
+
### `useStealthMetaAddress`
Multi-chain meta address encode/decode.
diff --git a/packages/sdk-vue/package.json b/packages/sdk-vue/package.json
index 25642f1..a2e5877 100644
--- a/packages/sdk-vue/package.json
+++ b/packages/sdk-vue/package.json
@@ -18,11 +18,13 @@
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
+ "test:types": "tsc --noEmit -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"clean": "rm -rf dist"
},
"dependencies": {
"@wraith-protocol/sdk": "workspace:*",
+ "@wraith-protocol/sdk-agent": "workspace:*",
"vue": "^3.5.0"
},
"devDependencies": {
diff --git a/packages/sdk-vue/src/composables/useActivity.ts b/packages/sdk-vue/src/composables/useActivity.ts
index 8b7d54e..0f368ec 100644
--- a/packages/sdk-vue/src/composables/useActivity.ts
+++ b/packages/sdk-vue/src/composables/useActivity.ts
@@ -1,27 +1,41 @@
import { ref, readonly } from 'vue';
import {
scanAnnouncements as evmScan,
- type Announcement as EvmAnnouncement,
+ type MatchedAnnouncement as EvmMatchedAnnouncement,
} from '@wraith-protocol/sdk/chains/evm';
-import { scanAnnouncements as stellarScan } from '@wraith-protocol/sdk/chains/stellar';
-import { scanAnnouncements as solanaScan } from '@wraith-protocol/sdk/chains/solana';
-import type { AnyStealthKeys } from './useStealthKeys';
+import {
+ scanAnnouncements as stellarScan,
+ type MatchedAnnouncement as StellarMatchedAnnouncement,
+} from '@wraith-protocol/sdk/chains/stellar';
+import {
+ scanAnnouncements as solanaScan,
+ type MatchedAnnouncement as SolanaMatchedAnnouncement,
+} from '@wraith-protocol/sdk/chains/solana';
+import { getUnknownErrorMessage } from '@wraith-protocol/sdk';
+import { assertScannerScanInput, type ScannerScanInput } from './useScanner';
export type ActivityChain = 'evm' | 'stellar' | 'solana';
+export type AnyMatchedAnnouncement =
+ | EvmMatchedAnnouncement
+ | StellarMatchedAnnouncement
+ | SolanaMatchedAnnouncement;
+
+/**
+ * One chain's announcements and the key material that must match that chain.
+ *
+ * This is the same discriminated union {@link useScanner} scans with, so an EVM
+ * announcement list can only be paired with EVM keys.
+ */
+export type ScanActivityInput = ScannerScanInput;
+
export interface ActivityEvent {
id: string;
chain: ActivityChain;
type: 'incoming' | 'outgoing';
stealthAddress: string;
timestamp: number;
- data: unknown;
-}
-
-export interface ScanActivityInput {
- chain: ActivityChain;
- announcements: unknown[];
- keys: AnyStealthKeys;
+ data: AnyMatchedAnnouncement;
}
export function useActivity() {
@@ -37,60 +51,57 @@ export function useActivity() {
error.value = null;
try {
const newEvents: ActivityEvent[] = [];
+
for (const input of announcementsInput) {
- const { chain, announcements: anns, keys } = input;
- if (!anns || anns.length === 0) continue;
- let matched: { stealthAddress?: string }[];
- switch (chain) {
- case 'evm': {
- const k = keys as unknown as Record;
+ assertScannerScanInput(input);
+ if (input.announcements.length === 0) continue;
+
+ let matched: AnyMatchedAnnouncement[];
+ switch (input.chain) {
+ case 'evm':
matched = evmScan(
- anns as EvmAnnouncement[],
- k.viewingKey as `0x${string}`,
- k.spendingPubKey as `0x${string}`,
- k.spendingKey as `0x${string}`,
- ) as unknown as { stealthAddress?: string }[];
+ input.announcements,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingKey,
+ );
break;
- }
- case 'stellar': {
- const k = keys as unknown as Record;
+ case 'stellar':
matched = stellarScan(
- anns as any[],
- k.viewingKey as Uint8Array,
- k.spendingPubKey as Uint8Array,
- k.spendingScalar as bigint,
- ) as unknown as { stealthAddress?: string }[];
+ input.announcements,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingScalar,
+ );
break;
- }
- case 'solana': {
- const k = keys as unknown as Record;
+ case 'solana':
matched = solanaScan(
- anns as any[],
- k.viewingKey as Uint8Array,
- k.spendingPubKey as Uint8Array,
- k.spendingScalar as bigint,
- ) as unknown as { stealthAddress?: string }[];
+ input.announcements,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingScalar,
+ );
break;
- }
}
- for (const m of matched) {
+
+ for (const match of matched) {
newEvents.push({
- id: `${chain}-${newEvents.length}-${Date.now()}`,
- chain,
+ id: `${input.chain}-${newEvents.length}-${Date.now()}`,
+ chain: input.chain,
type: 'incoming',
- stealthAddress: m.stealthAddress ?? 'unknown',
+ stealthAddress: match.stealthAddress,
timestamp: Date.now(),
- data: m,
+ data: match,
});
}
}
+
events.value = [...events.value, ...newEvents];
totalMatched.value += newEvents.length;
return newEvents;
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Activity scan failed';
- error.value = msg;
- throw e;
+ } catch (cause) {
+ error.value = getUnknownErrorMessage(cause);
+ throw cause;
} finally {
scanning.value = false;
}
diff --git a/packages/sdk-vue/src/composables/useScanner.ts b/packages/sdk-vue/src/composables/useScanner.ts
index 8cc31f9..1b155cd 100644
--- a/packages/sdk-vue/src/composables/useScanner.ts
+++ b/packages/sdk-vue/src/composables/useScanner.ts
@@ -1,9 +1,10 @@
-import { ref, readonly } from 'vue';
+import { ref, readonly, getCurrentScope, onScopeDispose } from 'vue';
import {
fetchAnnouncements as evmFetchAnnouncements,
scanAnnouncements as evmScanAnnouncements,
checkStealthAddress as evmCheckAddress,
type HexString,
+ type StealthKeys as EvmStealthKeys,
type Announcement as EvmAnnouncement,
type MatchedAnnouncement as EvmMatchedAnnouncement,
} from '@wraith-protocol/sdk/chains/evm';
@@ -11,6 +12,8 @@ import {
fetchAnnouncementsStream as stellarFetchAnnouncementsStream,
scanAnnouncements as stellarScanAnnouncements,
checkStealthAddress as stellarCheckAddress,
+ type FetchAnnouncementsOptions,
+ type StealthKeys as StellarStealthKeys,
type Announcement as StellarAnnouncement,
type MatchedAnnouncement as StellarMatchedAnnouncement,
} from '@wraith-protocol/sdk/chains/stellar';
@@ -18,16 +21,18 @@ import {
fetchAnnouncements as solanaFetchAnnouncements,
scanAnnouncements as solanaScanAnnouncements,
checkStealthAddress as solanaCheckAddress,
+ type StealthKeys as SolanaStealthKeys,
type Announcement as SolanaAnnouncement,
type MatchedAnnouncement as SolanaMatchedAnnouncement,
} from '@wraith-protocol/sdk/chains/solana';
-import type { StealthChain, AnyStealthKeys } from './useStealthKeys';
+import {
+ getUnknownErrorMessage,
+ isAbortError,
+ type ScanCancellationOptions,
+} from '@wraith-protocol/sdk';
-async function collectStream(stream: AsyncGenerator): Promise {
- const result: T[] = [];
- for await (const item of stream) result.push(item);
- return result;
-}
+/** Chains the Vue scanner can read announcements from. */
+export type ScannerChain = 'evm' | 'stellar' | 'solana';
export type AnyAnnouncement = EvmAnnouncement | StellarAnnouncement | SolanaAnnouncement;
export type AnyMatchedAnnouncement =
@@ -35,167 +40,583 @@ export type AnyMatchedAnnouncement =
| StellarMatchedAnnouncement
| SolanaMatchedAnnouncement;
-export function useScanner(chain?: StealthChain) {
- const activeChain = ref(chain ?? 'stellar');
+export type EvmCheckResult = ReturnType;
+export type StellarCheckResult = ReturnType;
+export type SolanaCheckResult = ReturnType;
+
+/**
+ * Options for an EVM announcement fetch.
+ *
+ * The `chain` field is the discriminant: pairing it with Stellar or Solana key
+ * material is a compile-time error.
+ */
+export interface EvmFetchOptions extends ScanCancellationOptions {
+ chain: 'evm';
+ /** EVM deployment key, for example `horizen`. Defaults to `horizen`. */
+ network?: string;
+ /** Subgraph URL override. */
+ url?: string;
+}
+
+/** Options for a Stellar announcement fetch. */
+export interface StellarFetchOptions
+ extends Omit, ScanCancellationOptions {
+ chain: 'stellar';
+ /** Stellar deployment key. Defaults to `stellar`. */
+ network?: string;
+ /** Soroban RPC URL override. */
+ url?: string;
+}
+
+/** Options for a Solana announcement fetch. */
+export interface SolanaFetchOptions extends ScanCancellationOptions {
+ chain: 'solana';
+ /** Solana deployment key. Defaults to `solana`. */
+ network?: string;
+ /** RPC URL override. */
+ url?: string;
+}
+
+/** One cancellation shape for every chain the Vue scanner supports. */
+export type ScannerFetchOptions = EvmFetchOptions | StellarFetchOptions | SolanaFetchOptions;
+
+/** EVM announcements plus the EVM key material required to scan them. */
+export interface EvmScanInput {
+ chain: 'evm';
+ announcements: EvmAnnouncement[];
+ keys: EvmStealthKeys;
+}
+
+/** Stellar announcements plus the Stellar key material required to scan them. */
+export interface StellarScanInput {
+ chain: 'stellar';
+ announcements: StellarAnnouncement[];
+ keys: StellarStealthKeys;
+}
+
+/** Solana announcements plus the Solana key material required to scan them. */
+export interface SolanaScanInput {
+ chain: 'solana';
+ announcements: SolanaAnnouncement[];
+ keys: SolanaStealthKeys;
+}
+
+export type ScannerScanInput = EvmScanInput | StellarScanInput | SolanaScanInput;
+
+/** EVM keys plus the fetch options used by {@link useScanner}'s `scanWithKeys`. */
+export type EvmScanWithKeysInput = EvmFetchOptions & { keys: EvmStealthKeys };
+export type StellarScanWithKeysInput = StellarFetchOptions & { keys: StellarStealthKeys };
+export type SolanaScanWithKeysInput = SolanaFetchOptions & { keys: SolanaStealthKeys };
+export type ScannerScanWithKeysInput =
+ | EvmScanWithKeysInput
+ | StellarScanWithKeysInput
+ | SolanaScanWithKeysInput;
+
+export interface EvmCheckInput {
+ chain: 'evm';
+ ephemeralPubKey: HexString;
+ viewingKey: HexString;
+ spendingPubKey: HexString;
+ viewTag: number;
+}
+
+export interface StellarCheckInput {
+ chain: 'stellar';
+ ephemeralPubKey: Uint8Array;
+ viewingKey: Uint8Array;
+ spendingPubKey: Uint8Array;
+ viewTag: number;
+}
+
+export interface SolanaCheckInput {
+ chain: 'solana';
+ ephemeralPubKey: Uint8Array;
+ viewingKey: Uint8Array;
+ spendingPubKey: Uint8Array;
+ viewTag: number;
+}
+
+export type ScannerCheckInput = EvmCheckInput | StellarCheckInput | SolanaCheckInput;
+
+function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+}
+
+function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) throw abortError();
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isAbortSignal(value: unknown): value is AbortSignal {
+ return typeof AbortSignal !== 'undefined' && value instanceof AbortSignal;
+}
+
+function describe(value: unknown): string {
+ return typeof value === 'string' ? `"${value}"` : String(value);
+}
+
+/** Narrows a runtime value to a scanner chain, for JavaScript consumers. */
+export function assertScannerChain(value: unknown): asserts value is ScannerChain {
+ if (value !== 'evm' && value !== 'stellar' && value !== 'solana') {
+ throw new TypeError(
+ `Unsupported scanner chain ${describe(value)}. Expected "evm", "stellar" or "solana".`,
+ );
+ }
+}
+
+function assertOptionalString(value: unknown, field: string): void {
+ if (value !== undefined && typeof value !== 'string') {
+ throw new TypeError(`Scanner option "${field}" must be a string when provided.`);
+ }
+}
+
+/**
+ * Validates scanner fetch options at runtime.
+ *
+ * TypeScript consumers get the same guarantees from {@link ScannerFetchOptions};
+ * this guard keeps JavaScript consumers from pairing a chain with the wrong
+ * endpoint or a non-signal cancellation value.
+ */
+export function assertScannerFetchOptions(value: unknown): asserts value is ScannerFetchOptions {
+ if (!isRecord(value)) {
+ throw new TypeError('Scanner options must be an object with a "chain" field.');
+ }
+ assertScannerChain(value.chain);
+ assertOptionalString(value.network, 'network');
+ assertOptionalString(value.url, 'url');
+ if (value.signal !== undefined && !isAbortSignal(value.signal)) {
+ throw new TypeError('Scanner option "signal" must be an AbortSignal when provided.');
+ }
+ if (value.chain === 'stellar') {
+ if (value.fromLedger !== undefined && !Number.isInteger(value.fromLedger)) {
+ throw new TypeError('Scanner option "fromLedger" must be an integer when provided.');
+ }
+ if (value.toLedger !== undefined && !Number.isInteger(value.toLedger)) {
+ throw new TypeError('Scanner option "toLedger" must be an integer when provided.');
+ }
+ if (value.viewTagBuckets !== undefined && !Array.isArray(value.viewTagBuckets)) {
+ throw new TypeError('Scanner option "viewTagBuckets" must be an array when provided.');
+ }
+ }
+}
+
+function assertKeyMaterial(chain: ScannerChain, value: unknown): void {
+ if (!isRecord(value)) {
+ throw new TypeError(`Scanner keys for "${chain}" must be an object.`);
+ }
+
+ if (chain === 'evm') {
+ for (const field of ['viewingKey', 'spendingPubKey', 'spendingKey'] as const) {
+ if (typeof value[field] !== 'string') {
+ throw new TypeError(`EVM scanner keys must provide a 0x-prefixed hex string "${field}".`);
+ }
+ }
+ return;
+ }
+
+ for (const field of ['viewingKey', 'spendingPubKey'] as const) {
+ if (!(value[field] instanceof Uint8Array)) {
+ throw new TypeError(`${chain} scanner keys must provide a Uint8Array "${field}".`);
+ }
+ }
+ if (typeof value.spendingScalar !== 'bigint') {
+ throw new TypeError(`${chain} scanner keys must provide a bigint "spendingScalar".`);
+ }
+}
+
+/**
+ * Validates a {@link ScannerScanInput} at runtime.
+ *
+ * Rejects mismatched key material (for example EVM keys on a Stellar scan) with
+ * a `TypeError` instead of failing later inside the chain scanner.
+ */
+export function assertScannerScanInput(value: unknown): asserts value is ScannerScanInput {
+ if (!isRecord(value)) {
+ throw new TypeError('scanAnnouncements expects an object with chain, announcements and keys.');
+ }
+ assertScannerChain(value.chain);
+ if (!Array.isArray(value.announcements)) {
+ throw new TypeError('scanAnnouncements expects "announcements" to be an array.');
+ }
+ assertKeyMaterial(value.chain, value.keys);
+}
+
+/** Validates a {@link ScannerScanWithKeysInput} at runtime. */
+export function assertScannerScanWithKeysInput(
+ value: unknown,
+): asserts value is ScannerScanWithKeysInput {
+ assertScannerFetchOptions(value);
+ assertKeyMaterial(value.chain, (value as { keys?: unknown }).keys);
+}
+
+/** Validates a {@link ScannerCheckInput} at runtime. */
+export function assertScannerCheckInput(value: unknown): asserts value is ScannerCheckInput {
+ if (!isRecord(value)) {
+ throw new TypeError('checkAddress expects an object with chain and key material.');
+ }
+ assertScannerChain(value.chain);
+ if (
+ !Number.isInteger(value.viewTag) ||
+ (value.viewTag as number) < 0 ||
+ (value.viewTag as number) > 255
+ ) {
+ throw new TypeError('checkAddress expects "viewTag" to be an integer between 0 and 255.');
+ }
+ if (value.chain === 'evm') {
+ for (const field of ['ephemeralPubKey', 'viewingKey', 'spendingPubKey'] as const) {
+ if (typeof value[field] !== 'string') {
+ throw new TypeError(`EVM checkAddress expects a 0x-prefixed hex string "${field}".`);
+ }
+ }
+ return;
+ }
+ for (const field of ['ephemeralPubKey', 'viewingKey', 'spendingPubKey'] as const) {
+ if (!(value[field] instanceof Uint8Array)) {
+ throw new TypeError(`${value.chain} checkAddress expects a Uint8Array "${field}".`);
+ }
+ }
+}
+
+/**
+ * Wraps a promise so it rejects as soon as `signal` aborts.
+ *
+ * The EVM and Solana fetchers do not accept an `AbortSignal` yet. Racing their
+ * promise keeps the framework contract identical to Stellar's: an aborted scan
+ * rejects with `AbortError` and never publishes a late result.
+ */
+function withAbort(promise: Promise, signal: AbortSignal): Promise {
+ if (signal.aborted) return Promise.reject(abortError());
+
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(abortError());
+ signal.addEventListener('abort', onAbort, { once: true });
+ promise.then(
+ (value) => {
+ signal.removeEventListener('abort', onAbort);
+ resolve(value);
+ },
+ (error: unknown) => {
+ signal.removeEventListener('abort', onAbort);
+ reject(error);
+ },
+ );
+ });
+}
+
+async function fetchEvm(options: EvmFetchOptions, signal: AbortSignal): Promise {
+ throwIfAborted(signal);
+ const announcements = await withAbort(
+ evmFetchAnnouncements(options.network ?? 'horizen', options.url),
+ signal,
+ );
+ throwIfAborted(signal);
+ return announcements;
+}
+
+async function fetchStellar(
+ options: StellarFetchOptions,
+ signal: AbortSignal,
+): Promise {
+ throwIfAborted(signal);
+ const { chain: _chain, network, url, ...rest } = options;
+ const stream = stellarFetchAnnouncementsStream(network ?? 'stellar', {
+ ...rest,
+ signal,
+ sorobanUrl: url,
+ });
+ const announcements: StellarAnnouncement[] = [];
+ for await (const announcement of stream) {
+ throwIfAborted(signal);
+ announcements.push(announcement);
+ }
+ return announcements;
+}
+
+async function fetchSolana(
+ options: SolanaFetchOptions,
+ signal: AbortSignal,
+): Promise {
+ throwIfAborted(signal);
+ const announcements = await withAbort(
+ solanaFetchAnnouncements(options.network ?? 'solana', options.url),
+ signal,
+ );
+ throwIfAborted(signal);
+ return announcements;
+}
+
+export function useScanner(chain?: ScannerChain) {
+ const activeChain = ref(chain ?? 'stellar');
const announcements = ref([]);
const matched = ref([]);
const scanning = ref(false);
const error = ref(null);
- function setChain(c: StealthChain) {
- activeChain.value = c;
+ let activeController: AbortController | null = null;
+ let requestId = 0;
+ let disposed = false;
+
+ function abortActive(): void {
+ activeController?.abort();
+ activeController = null;
}
- async function fetchAnnouncementsList(
- chainOrOpts?: string | { chain: string; url?: string },
- url?: string,
- ): Promise {
+ function dispose(): void {
+ disposed = true;
+ abortActive();
+ }
+
+ if (getCurrentScope()) {
+ onScopeDispose(dispose);
+ }
+
+ function setChain(next: ScannerChain) {
+ assertScannerChain(next);
+ if (next === activeChain.value) return;
+ // The in-flight request belongs to the previous chain; never publish it.
+ abortActive();
+ activeChain.value = next;
+ }
+
+ function beginRequest(): { controller: AbortController; id: number } {
+ abortActive();
+ const controller = new AbortController();
+ activeController = controller;
+ return { controller, id: ++requestId };
+ }
+
+ function isCurrent(id: number, controller: AbortController): boolean {
+ return !disposed && requestId === id && !controller.signal.aborted;
+ }
+
+ /**
+ * Runs one cancellable request.
+ *
+ * Aborts any request that is already in flight, forwards the caller's signal
+ * into an internal controller, and only publishes a result that is still the
+ * newest one when it arrives.
+ */
+ async function runCancellable(
+ externalSignal: AbortSignal | undefined,
+ run: (signal: AbortSignal) => Promise,
+ publish: (value: T) => void,
+ ): Promise {
+ const { controller, id } = beginRequest();
+
+ const forwardAbort = () => controller.abort(externalSignal?.reason);
+ if (externalSignal) {
+ if (externalSignal.aborted) controller.abort(externalSignal.reason);
+ else externalSignal.addEventListener('abort', forwardAbort, { once: true });
+ }
+
scanning.value = true;
error.value = null;
try {
- let list: AnyAnnouncement[];
- switch (activeChain.value) {
- case 'evm': {
- const c = typeof chainOrOpts === 'string' ? chainOrOpts : (chainOrOpts as any)?.chain;
- const u = typeof chainOrOpts === 'object' ? (chainOrOpts as any).url : url;
- list = await evmFetchAnnouncements(c ?? activeChain.value, u);
- break;
- }
- case 'stellar': {
- const c = typeof chainOrOpts === 'string' ? chainOrOpts : undefined;
- const u = typeof chainOrOpts === 'object' ? (chainOrOpts as any).url : url;
- const stream = stellarFetchAnnouncementsStream(c, u);
- list = await collectStream(stream);
- break;
- }
- case 'solana': {
- const c = typeof chainOrOpts === 'string' ? chainOrOpts : undefined;
- const u = typeof chainOrOpts === 'object' ? (chainOrOpts as any).url : url;
- list = await solanaFetchAnnouncements(c, u);
- break;
- }
+ throwIfAborted(controller.signal);
+ const value = await run(controller.signal);
+ if (!isCurrent(id, controller)) throw abortError();
+ publish(value);
+ return value;
+ } catch (cause) {
+ if (isAbortError(cause) || controller.signal.aborted || !isCurrent(id, controller)) {
+ throw isAbortError(cause) ? cause : abortError();
}
- announcements.value = list;
- return list;
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Failed to fetch announcements';
- error.value = msg;
- throw e;
+ error.value = getUnknownErrorMessage(cause);
+ throw cause;
} finally {
- scanning.value = false;
+ externalSignal?.removeEventListener('abort', forwardAbort);
+ if (activeController === controller) {
+ activeController = null;
+ if (!disposed) scanning.value = false;
+ }
}
}
- function scanAnnouncements(
- announcementsList: AnyAnnouncement[],
- viewingKey: Uint8Array | HexString,
- spendingPubKey: Uint8Array | HexString,
- spendingKey: Uint8Array | HexString | bigint,
- ): AnyMatchedAnnouncement[] {
+ function publishAnnouncements(list: AnyAnnouncement[]): void {
+ announcements.value = list;
+ }
+
+ async function fetchAnnouncements(request?: ScannerFetchOptions): Promise {
+ const resolved: ScannerFetchOptions = request ?? { chain: activeChain.value };
+ assertScannerFetchOptions(resolved);
+
+ switch (resolved.chain) {
+ case 'evm':
+ return runCancellable(
+ resolved.signal,
+ (signal) => fetchEvm(resolved, signal),
+ publishAnnouncements,
+ );
+ case 'stellar':
+ return runCancellable(
+ resolved.signal,
+ (signal) => fetchStellar(resolved, signal),
+ publishAnnouncements,
+ );
+ case 'solana':
+ return runCancellable(
+ resolved.signal,
+ (signal) => fetchSolana(resolved, signal),
+ publishAnnouncements,
+ );
+ }
+ }
+
+ function scanAnnouncements(input: EvmScanInput): EvmMatchedAnnouncement[];
+ function scanAnnouncements(input: StellarScanInput): StellarMatchedAnnouncement[];
+ function scanAnnouncements(input: SolanaScanInput): SolanaMatchedAnnouncement[];
+ function scanAnnouncements(input: ScannerScanInput): AnyMatchedAnnouncement[];
+ function scanAnnouncements(input: ScannerScanInput): AnyMatchedAnnouncement[] {
+ assertScannerScanInput(input);
+ if (disposed) throw abortError();
+
scanning.value = true;
error.value = null;
try {
let result: AnyMatchedAnnouncement[];
- switch (activeChain.value) {
+ switch (input.chain) {
case 'evm':
result = evmScanAnnouncements(
- announcementsList as EvmAnnouncement[],
- viewingKey as HexString,
- spendingPubKey as HexString,
- spendingKey as HexString,
+ input.announcements,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingKey,
);
break;
case 'stellar':
result = stellarScanAnnouncements(
- announcementsList as StellarAnnouncement[],
- viewingKey as Uint8Array,
- spendingPubKey as Uint8Array,
- spendingKey as bigint,
+ input.announcements,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingScalar,
);
break;
case 'solana':
result = solanaScanAnnouncements(
- announcementsList as SolanaAnnouncement[],
- viewingKey as Uint8Array,
- spendingPubKey as Uint8Array,
- spendingKey as bigint,
+ input.announcements,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingScalar,
);
break;
}
matched.value = result;
return result;
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Scan failed';
- error.value = msg;
- throw e;
+ } catch (cause) {
+ error.value = getUnknownErrorMessage(cause);
+ throw cause;
} finally {
scanning.value = false;
}
}
+ function checkAddress(input: EvmCheckInput): EvmCheckResult;
+ function checkAddress(input: StellarCheckInput): StellarCheckResult;
+ function checkAddress(input: SolanaCheckInput): SolanaCheckResult;
function checkAddress(
- ephemeralPubKey: Uint8Array | HexString,
- viewingKey: Uint8Array | HexString,
- spendingPubKey: Uint8Array | HexString,
- viewTag: number,
- ) {
+ input: ScannerCheckInput,
+ ): EvmCheckResult | StellarCheckResult | SolanaCheckResult;
+ function checkAddress(
+ input: ScannerCheckInput,
+ ): EvmCheckResult | StellarCheckResult | SolanaCheckResult {
+ assertScannerCheckInput(input);
+
scanning.value = true;
error.value = null;
try {
- switch (activeChain.value) {
+ switch (input.chain) {
case 'evm':
return evmCheckAddress(
- ephemeralPubKey as HexString,
- viewingKey as HexString,
- spendingPubKey as HexString,
- viewTag,
+ input.ephemeralPubKey,
+ input.viewingKey,
+ input.spendingPubKey,
+ input.viewTag,
);
case 'stellar':
return stellarCheckAddress(
- ephemeralPubKey as Uint8Array,
- viewingKey as Uint8Array,
- spendingPubKey as Uint8Array,
- viewTag,
+ input.ephemeralPubKey,
+ input.viewingKey,
+ input.spendingPubKey,
+ input.viewTag,
);
case 'solana':
return solanaCheckAddress(
- ephemeralPubKey as Uint8Array,
- viewingKey as Uint8Array,
- spendingPubKey as Uint8Array,
- viewTag,
+ input.ephemeralPubKey,
+ input.viewingKey,
+ input.spendingPubKey,
+ input.viewTag,
);
}
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Address check failed';
- error.value = msg;
- throw e;
+ } catch (cause) {
+ error.value = getUnknownErrorMessage(cause);
+ throw cause;
} finally {
scanning.value = false;
}
}
+ async function scanWithKeys(input: EvmScanWithKeysInput): Promise;
async function scanWithKeys(
- keys: AnyStealthKeys,
- chainOrUrl?: string,
- ): Promise {
- await fetchAnnouncementsList(chainOrUrl);
- if (announcements.value.length === 0) return [];
- let spendKey: any;
- switch (activeChain.value) {
+ input: StellarScanWithKeysInput,
+ ): Promise;
+ async function scanWithKeys(input: SolanaScanWithKeysInput): Promise;
+ async function scanWithKeys(input: ScannerScanWithKeysInput): Promise;
+ async function scanWithKeys(input: ScannerScanWithKeysInput): Promise {
+ assertScannerScanWithKeysInput(input);
+
+ switch (input.chain) {
case 'evm':
- spendKey = (keys as any).spendingKey;
- break;
+ return runCancellable(
+ input.signal,
+ async (signal) => {
+ const list = await fetchEvm(input, signal);
+ return evmScanAnnouncements(
+ list,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingKey,
+ );
+ },
+ (result) => {
+ matched.value = result;
+ },
+ );
case 'stellar':
+ return runCancellable(
+ input.signal,
+ async (signal) => {
+ const list = await fetchStellar(input, signal);
+ return stellarScanAnnouncements(
+ list,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingScalar,
+ );
+ },
+ (result) => {
+ matched.value = result;
+ },
+ );
case 'solana':
- spendKey = (keys as any).spendingScalar;
- break;
+ return runCancellable(
+ input.signal,
+ async (signal) => {
+ const list = await fetchSolana(input, signal);
+ return solanaScanAnnouncements(
+ list,
+ input.keys.viewingKey,
+ input.keys.spendingPubKey,
+ input.keys.spendingScalar,
+ );
+ },
+ (result) => {
+ matched.value = result;
+ },
+ );
}
- return scanAnnouncements(
- announcements.value,
- (keys as any).viewingKey,
- (keys as any).spendingPubKey,
- spendKey,
- );
}
function clear() {
@@ -211,10 +632,11 @@ export function useScanner(chain?: StealthChain) {
error: readonly(error),
chain: readonly(activeChain),
setChain,
- fetchAnnouncements: fetchAnnouncementsList,
+ fetchAnnouncements,
scanAnnouncements,
checkAddress,
scanWithKeys,
clear,
+ dispose,
};
}
diff --git a/packages/sdk-vue/src/composables/useWraith.ts b/packages/sdk-vue/src/composables/useWraith.ts
index b7b5956..dc59a93 100644
--- a/packages/sdk-vue/src/composables/useWraith.ts
+++ b/packages/sdk-vue/src/composables/useWraith.ts
@@ -7,7 +7,7 @@ import {
type AgentConfig,
type AgentInfo,
type ChatResponse,
-} from '@wraith-protocol/sdk';
+} from '@wraith-protocol/sdk-agent';
export function useWraith(config?: WraithConfig) {
const client: Ref = ref(null);
diff --git a/packages/sdk-vue/src/index.ts b/packages/sdk-vue/src/index.ts
index 26f3487..594570f 100644
--- a/packages/sdk-vue/src/index.ts
+++ b/packages/sdk-vue/src/index.ts
@@ -11,7 +11,18 @@ export type {
AnyGeneratedStealthAddress,
} from './composables/useStealthKeys';
export { useScanner } from './composables/useScanner';
-export type { AnyAnnouncement, AnyMatchedAnnouncement } from './composables/useScanner';
+export type {
+ AnyAnnouncement,
+ AnyMatchedAnnouncement,
+ ScannerChain,
+ ScannerFetchOptions,
+ ScannerScanInput,
+ ScannerScanWithKeysInput,
+ ScannerCheckInput,
+ EvmScanInput,
+ StellarScanInput,
+ SolanaScanInput,
+} from './composables/useScanner';
export { useMetaAddress } from './composables/useMetaAddress';
export { useActivity } from './composables/useActivity';
-export type { ActivityEvent } from './composables/useActivity';
+export type { ActivityEvent, ScanActivityInput } from './composables/useActivity';
diff --git a/packages/sdk-vue/test/scanner.test-d.ts b/packages/sdk-vue/test/scanner.test-d.ts
new file mode 100644
index 0000000..5448d0a
--- /dev/null
+++ b/packages/sdk-vue/test/scanner.test-d.ts
@@ -0,0 +1,88 @@
+import { expectTypeOf, test } from 'vitest';
+import type {
+ Announcement as EvmAnnouncement,
+ MatchedAnnouncement as EvmMatchedAnnouncement,
+ StealthKeys as EvmStealthKeys,
+} from '@wraith-protocol/sdk/chains/evm';
+import type {
+ Announcement as StellarAnnouncement,
+ MatchedAnnouncement as StellarMatchedAnnouncement,
+ StealthKeys as StellarStealthKeys,
+} from '@wraith-protocol/sdk/chains/stellar';
+import { useScanner } from '../src/composables/useScanner';
+
+const evmAnnouncements = null as unknown as EvmAnnouncement[];
+const stellarAnnouncements = null as unknown as StellarAnnouncement[];
+const evmKeys = null as unknown as EvmStealthKeys;
+const stellarKeys = null as unknown as StellarStealthKeys;
+
+test('a matching chain and key material produces the chain-specific matches', () => {
+ const scanner = useScanner('evm');
+
+ expectTypeOf(
+ scanner.scanAnnouncements({
+ chain: 'evm',
+ announcements: evmAnnouncements,
+ keys: evmKeys,
+ }),
+ ).toEqualTypeOf();
+
+ expectTypeOf(
+ scanner.scanAnnouncements({
+ chain: 'stellar',
+ announcements: stellarAnnouncements,
+ keys: stellarKeys,
+ }),
+ ).toEqualTypeOf();
+});
+
+test('mismatched key material is rejected at compile time', () => {
+ const scanner = useScanner();
+
+ // @ts-expect-error - Stellar key material cannot scan an EVM announcement list
+ scanner.scanAnnouncements({ chain: 'evm', announcements: evmAnnouncements, keys: stellarKeys });
+
+ // @ts-expect-error - EVM key material cannot scan a Stellar announcement list
+ scanner.scanAnnouncements({
+ chain: 'stellar',
+ announcements: stellarAnnouncements,
+ keys: evmKeys,
+ });
+
+ // @ts-expect-error - EVM keys cannot scan an EVM-typed list under the Stellar discriminant
+ scanner.scanAnnouncements({
+ chain: 'stellar',
+ announcements: evmAnnouncements,
+ keys: evmKeys,
+ });
+});
+
+test('scanWithKeys requires the key material to match the chain', () => {
+ const scanner = useScanner();
+
+ expectTypeOf(scanner.scanWithKeys({ chain: 'evm', keys: evmKeys })).toEqualTypeOf<
+ Promise
+ >();
+
+ // @ts-expect-error - Stellar keys are not valid EVM scanner keys
+ void scanner.scanWithKeys({ chain: 'evm', keys: stellarKeys });
+
+ // @ts-expect-error - EVM keys are not valid Stellar scanner keys
+ void scanner.scanWithKeys({ chain: 'stellar', keys: evmKeys });
+});
+
+test('fetch options are a chain-discriminated union with one cancellation shape', () => {
+ const scanner = useScanner();
+
+ void scanner.fetchAnnouncements({ chain: 'stellar', network: 'stellar' });
+ void scanner.fetchAnnouncements({ chain: 'evm', signal: new AbortController().signal });
+
+ // @ts-expect-error - "bitcoin" is not a supported scanner chain
+ void scanner.fetchAnnouncements({ chain: 'bitcoin' });
+
+ // @ts-expect-error - signal must be an AbortSignal
+ void scanner.fetchAnnouncements({ chain: 'stellar', signal: 'stop' });
+
+ // @ts-expect-error - EVM fetches have no Stellar ledger options
+ void scanner.fetchAnnouncements({ chain: 'evm', fromLedger: 10 });
+});
diff --git a/packages/sdk-vue/test/useActivity.test.ts b/packages/sdk-vue/test/useActivity.test.ts
index 725f632..a6ec4c6 100644
--- a/packages/sdk-vue/test/useActivity.test.ts
+++ b/packages/sdk-vue/test/useActivity.test.ts
@@ -1,14 +1,22 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type {
+ Announcement as EvmAnnouncement,
+ StealthKeys as EvmStealthKeys,
+} from '@wraith-protocol/sdk/chains/evm';
+import type {
+ Announcement as StellarAnnouncement,
+ StealthKeys as StellarStealthKeys,
+} from '@wraith-protocol/sdk/chains/stellar';
import { useActivity } from '../src/composables/useActivity';
-const mockEvmKeys = vi.hoisted(() => ({
+const mockEvmKeys: EvmStealthKeys = vi.hoisted(() => ({
spendingKey: '0x' + 'ab'.repeat(32),
viewingKey: '0x' + 'cd'.repeat(32),
spendingPubKey: '0x' + 'ef'.repeat(33),
viewingPubKey: '0x' + '01'.repeat(33),
}));
-const mockStellarKeys = vi.hoisted(() => ({
+const mockStellarKeys: StellarStealthKeys = vi.hoisted(() => ({
spendingKey: new Uint8Array(32),
spendingScalar: 1n,
viewingKey: new Uint8Array(32),
@@ -17,6 +25,22 @@ const mockStellarKeys = vi.hoisted(() => ({
viewingPubKey: new Uint8Array(32),
}));
+const evmAnnouncement: EvmAnnouncement = {
+ schemeId: 1n,
+ stealthAddress: '0x' + '02'.repeat(20),
+ caller: '0x' + 'aa'.repeat(20),
+ ephemeralPubKey: '0x' + '03'.repeat(33),
+ metadata: '0x',
+};
+
+const stellarAnnouncement: StellarAnnouncement = {
+ schemeId: 1,
+ stealthAddress: 'GABCDEF1234567890',
+ caller: 'GDEADBEEF',
+ ephemeralPubKey: 'aa'.repeat(32),
+ metadata: '00',
+};
+
const mockEvmMatched = vi.hoisted(() => [
{
isMatch: true,
@@ -43,14 +67,7 @@ vi.mock('@wraith-protocol/sdk/chains/stellar', () => ({
}));
vi.mock('@wraith-protocol/sdk/chains/solana', () => ({
- scanAnnouncements: vi.fn().mockReturnValue([
- {
- isMatch: true,
- stealthAddress: 'FAKESOLANA...',
- hashScalar: 3n,
- stealthPubKeyBytes: new Uint8Array(32),
- },
- ]),
+ scanAnnouncements: vi.fn().mockReturnValue([]),
}));
describe('useActivity', () => {
@@ -71,16 +88,8 @@ describe('useActivity', () => {
const result = await composable.scanAnnouncements([
{
chain: 'evm',
- announcements: [
- {
- schemeId: 1n,
- stealthAddress: '0x' + '02'.repeat(20),
- caller: '0x' + 'aa'.repeat(20),
- ephemeralPubKey: '0x' + '03'.repeat(33),
- metadata: '0x',
- },
- ],
- keys: mockEvmKeys as any,
+ announcements: [evmAnnouncement],
+ keys: mockEvmKeys,
},
]);
expect(result.length).toBe(1);
@@ -96,16 +105,8 @@ describe('useActivity', () => {
const result = await composable.scanAnnouncements([
{
chain: 'stellar',
- announcements: [
- {
- schemeId: 1,
- stealthAddress: 'GABCDEF1234567890',
- caller: 'GDEADBEEF',
- ephemeralPubKey: new Uint8Array(32),
- metadata: new Uint8Array(0),
- },
- ],
- keys: mockStellarKeys as any,
+ announcements: [stellarAnnouncement],
+ keys: mockStellarKeys,
},
]);
expect(result.length).toBe(1);
@@ -121,20 +122,12 @@ describe('useActivity', () => {
{
chain: 'evm',
announcements: [],
- keys: mockEvmKeys as any,
+ keys: mockEvmKeys,
},
{
chain: 'stellar',
- announcements: [
- {
- schemeId: 1,
- stealthAddress: 'GABCDEF1234567890',
- caller: 'GDEADBEEF',
- ephemeralPubKey: new Uint8Array(32),
- metadata: new Uint8Array(0),
- },
- ],
- keys: mockStellarKeys as any,
+ announcements: [stellarAnnouncement],
+ keys: mockStellarKeys,
},
]);
expect(composable.events.value.length).toBe(1);
@@ -146,31 +139,15 @@ describe('useActivity', () => {
await composable.scanAnnouncements([
{
chain: 'evm',
- announcements: [
- {
- schemeId: 1n,
- stealthAddress: '0x' + '02'.repeat(20),
- caller: '0x' + 'aa'.repeat(20),
- ephemeralPubKey: '0x' + '03'.repeat(33),
- metadata: '0x',
- },
- ],
- keys: mockEvmKeys as any,
+ announcements: [evmAnnouncement],
+ keys: mockEvmKeys,
},
]);
await composable.scanAnnouncements([
{
chain: 'stellar',
- announcements: [
- {
- schemeId: 1,
- stealthAddress: 'GABCDEF1234567890',
- caller: 'GDEADBEEF',
- ephemeralPubKey: new Uint8Array(32),
- metadata: new Uint8Array(0),
- },
- ],
- keys: mockStellarKeys as any,
+ announcements: [stellarAnnouncement],
+ keys: mockStellarKeys,
},
]);
expect(composable.events.value.length).toBe(2);
@@ -182,16 +159,8 @@ describe('useActivity', () => {
await composable.scanAnnouncements([
{
chain: 'evm',
- announcements: [
- {
- schemeId: 1n,
- stealthAddress: '0x' + '02'.repeat(20),
- caller: '0x' + 'aa'.repeat(20),
- ephemeralPubKey: '0x' + '03'.repeat(33),
- metadata: '0x',
- },
- ],
- keys: mockEvmKeys as any,
+ announcements: [evmAnnouncement],
+ keys: mockEvmKeys,
},
]);
expect(composable.totalMatched.value).toBe(1);
@@ -204,10 +173,24 @@ describe('useActivity', () => {
it('handles empty announcements for all chains', async () => {
const composable = useActivity();
const result = await composable.scanAnnouncements([
- { chain: 'evm', announcements: [], keys: mockEvmKeys as any },
- { chain: 'stellar', announcements: [], keys: mockStellarKeys as any },
+ { chain: 'evm', announcements: [], keys: mockEvmKeys },
+ { chain: 'stellar', announcements: [], keys: mockStellarKeys },
]);
expect(result.length).toBe(0);
expect(composable.totalMatched.value).toBe(0);
});
+
+ it('rejects mismatched key material at runtime for JavaScript consumers', async () => {
+ const composable = useActivity();
+ await expect(
+ composable.scanAnnouncements([
+ {
+ chain: 'evm',
+ announcements: [evmAnnouncement],
+ keys: mockStellarKeys as never,
+ },
+ ]),
+ ).rejects.toThrow(TypeError);
+ expect(composable.error.value).toContain('EVM scanner keys');
+ });
});
diff --git a/packages/sdk-vue/test/useScanner.test.ts b/packages/sdk-vue/test/useScanner.test.ts
index 1f4c792..2b7f1a6 100644
--- a/packages/sdk-vue/test/useScanner.test.ts
+++ b/packages/sdk-vue/test/useScanner.test.ts
@@ -1,14 +1,25 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { useScanner } from '../src/composables/useScanner';
+import { effectScope } from 'vue';
+import {
+ fetchAnnouncementsStream,
+ type Announcement as StellarAnnouncement,
+ type FetchAnnouncementsOptions,
+ type StealthKeys as StellarStealthKeys,
+} from '@wraith-protocol/sdk/chains/stellar';
+import type {
+ Announcement as EvmAnnouncement,
+ StealthKeys as EvmStealthKeys,
+} from '@wraith-protocol/sdk/chains/evm';
+import { useScanner, type ScannerFetchOptions } from '../src/composables/useScanner';
-const mockEvmKeys = vi.hoisted(() => ({
+const mockEvmKeys: EvmStealthKeys = vi.hoisted(() => ({
spendingKey: '0x' + 'ab'.repeat(32),
viewingKey: '0x' + 'cd'.repeat(32),
spendingPubKey: '0x' + 'ef'.repeat(33),
viewingPubKey: '0x' + '01'.repeat(33),
}));
-const mockStellarKeys = vi.hoisted(() => ({
+const mockStellarKeys: StellarStealthKeys = vi.hoisted(() => ({
spendingKey: new Uint8Array(32),
spendingScalar: 1n,
viewingKey: new Uint8Array(32),
@@ -17,7 +28,7 @@ const mockStellarKeys = vi.hoisted(() => ({
viewingPubKey: new Uint8Array(32),
}));
-const mockEvmAnnouncements = vi.hoisted(() => [
+const mockEvmAnnouncements: EvmAnnouncement[] = vi.hoisted(() => [
{
schemeId: 1n,
stealthAddress: '0x' + '02'.repeat(20),
@@ -27,13 +38,13 @@ const mockEvmAnnouncements = vi.hoisted(() => [
},
]);
-const mockStellarAnnouncements = vi.hoisted(() => [
+const mockStellarAnnouncements: StellarAnnouncement[] = vi.hoisted(() => [
{
schemeId: 1,
stealthAddress: 'GABCDEF1234567890',
caller: 'GDEADBEEF',
- ephemeralPubKey: new Uint8Array(32),
- metadata: new Uint8Array(0),
+ ephemeralPubKey: 'aa'.repeat(32),
+ metadata: '00',
},
]);
@@ -69,8 +80,8 @@ const mockStellarStreamFactory = vi.hoisted(() => {
schemeId: 1,
stealthAddress: 'GABCDEF1234567890',
caller: 'GDEADBEEF',
- ephemeralPubKey: new Uint8Array(32),
- metadata: new Uint8Array(0),
+ ephemeralPubKey: 'aa'.repeat(32),
+ metadata: '00',
},
];
return function createStream() {
@@ -93,29 +104,25 @@ vi.mock('@wraith-protocol/sdk/chains/stellar', () => ({
}));
vi.mock('@wraith-protocol/sdk/chains/solana', () => ({
- fetchAnnouncements: vi.fn().mockResolvedValue([
- {
- schemeId: 1,
- stealthAddress: 'FAKESOLANA...',
- caller: 'FAKECALLER...',
- ephemeralPubKey: 'ab'.repeat(32),
- metadata: '',
- },
- ]),
- scanAnnouncements: vi.fn().mockReturnValue([
- {
- isMatch: true,
- stealthAddress: 'FAKESOLANA...',
- hashScalar: 3n,
- stealthPubKeyBytes: new Uint8Array(32),
- },
- ]),
- checkStealthAddress: vi.fn().mockReturnValue({
- isMatch: true,
- stealthAddress: 'FAKESOLANA...',
- }),
+ fetchAnnouncements: vi.fn().mockResolvedValue([]),
+ scanAnnouncements: vi.fn().mockReturnValue([]),
+ checkStealthAddress: vi.fn().mockReturnValue({ isMatch: true, stealthAddress: null }),
}));
+function abortError(): DOMException {
+ return new DOMException('This operation was aborted', 'AbortError');
+}
+
+function rejectOnAbort(signal?: AbortSignal): Promise {
+ return new Promise((_, reject) => {
+ if (signal?.aborted) {
+ reject(abortError());
+ return;
+ }
+ signal?.addEventListener('abort', () => reject(abortError()), { once: true });
+ });
+}
+
describe('useScanner', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -141,7 +148,7 @@ describe('useScanner', () => {
it('fetches EVM announcements', async () => {
const composable = useScanner('evm');
- const result = await composable.fetchAnnouncements('horizen');
+ const result = await composable.fetchAnnouncements({ chain: 'evm', network: 'horizen' });
expect(result).toEqual(mockEvmAnnouncements);
expect(composable.announcements.value).toEqual(mockEvmAnnouncements);
});
@@ -155,72 +162,71 @@ describe('useScanner', () => {
it('scans EVM announcements', () => {
const composable = useScanner('evm');
- const result = composable.scanAnnouncements(
- mockEvmAnnouncements,
- '0x' + 'cd'.repeat(32),
- '0x' + 'ef'.repeat(33),
- '0x' + 'ab'.repeat(32),
- );
+ const result = composable.scanAnnouncements({
+ chain: 'evm',
+ announcements: mockEvmAnnouncements,
+ keys: mockEvmKeys,
+ });
expect(result).toEqual(mockEvmMatched);
expect(composable.matched.value).toEqual(mockEvmMatched);
});
it('scans Stellar announcements', () => {
const composable = useScanner('stellar');
- const result = composable.scanAnnouncements(
- mockStellarAnnouncements,
- new Uint8Array(32),
- new Uint8Array(32),
- 1n,
- );
+ const result = composable.scanAnnouncements({
+ chain: 'stellar',
+ announcements: mockStellarAnnouncements,
+ keys: mockStellarKeys,
+ });
expect(result).toEqual(mockStellarMatched);
expect(composable.matched.value).toEqual(mockStellarMatched);
});
it('checks EVM stealth address', () => {
const composable = useScanner('evm');
- const result = composable.checkAddress(
- '0x' + '03'.repeat(33),
- '0x' + 'cd'.repeat(32),
- '0x' + 'ef'.repeat(33),
- 42,
- );
+ const result = composable.checkAddress({
+ chain: 'evm',
+ ephemeralPubKey: '0x' + '03'.repeat(33),
+ viewingKey: '0x' + 'cd'.repeat(32),
+ spendingPubKey: '0x' + 'ef'.repeat(33),
+ viewTag: 42,
+ });
expect(result.isMatch).toBe(true);
});
it('checks Stellar stealth address', () => {
const composable = useScanner('stellar');
- const result = composable.checkAddress(
- new Uint8Array(32),
- new Uint8Array(32),
- new Uint8Array(32),
- 42,
- );
+ const result = composable.checkAddress({
+ chain: 'stellar',
+ ephemeralPubKey: new Uint8Array(32),
+ viewingKey: new Uint8Array(32),
+ spendingPubKey: new Uint8Array(32),
+ viewTag: 42,
+ });
expect(result.isMatch).toBe(true);
});
it('scanWithKeys fetches and scans EVM', async () => {
const composable = useScanner('evm');
- const result = await composable.scanWithKeys(mockEvmKeys as any);
+ const result = await composable.scanWithKeys({ chain: 'evm', keys: mockEvmKeys });
expect(result).toEqual(mockEvmMatched);
expect(composable.matched.value).toEqual(mockEvmMatched);
});
it('scanWithKeys fetches and scans Stellar', async () => {
const composable = useScanner('stellar');
- const result = await composable.scanWithKeys(mockStellarKeys as any);
+ const result = await composable.scanWithKeys({ chain: 'stellar', keys: mockStellarKeys });
expect(result).toEqual(mockStellarMatched);
expect(composable.matched.value).toEqual(mockStellarMatched);
});
it('clear resets state', () => {
const composable = useScanner('evm');
- composable.scanAnnouncements(
- mockEvmAnnouncements,
- '0x' + 'cd'.repeat(32),
- '0x' + 'ef'.repeat(33),
- '0x' + 'ab'.repeat(32),
- );
+ composable.scanAnnouncements({
+ chain: 'evm',
+ announcements: mockEvmAnnouncements,
+ keys: mockEvmKeys,
+ });
expect(composable.matched.value.length).toBe(1);
composable.clear();
expect(composable.matched.value.length).toBe(0);
@@ -235,4 +241,145 @@ describe('useScanner', () => {
expect(composable.scanning.value).toBe(false);
expect(composable.error.value).toBeNull();
});
+
+ it('rejects mismatched key material at runtime for JavaScript consumers', () => {
+ const composable = useScanner('evm');
+
+ expect(() =>
+ composable.scanAnnouncements({
+ chain: 'evm',
+ announcements: mockEvmAnnouncements,
+ keys: mockStellarKeys as never,
+ }),
+ ).toThrow(TypeError);
+
+ expect(() =>
+ composable.scanAnnouncements({
+ chain: 'stellar',
+ announcements: mockStellarAnnouncements,
+ keys: mockEvmKeys as never,
+ }),
+ ).toThrow(TypeError);
+ });
+
+ it('rejects unknown chains and non-signal cancellation at runtime', async () => {
+ const composable = useScanner();
+
+ await expect(
+ composable.fetchAnnouncements({ chain: 'bitcoin' } as unknown as ScannerFetchOptions),
+ ).rejects.toThrow(TypeError);
+
+ await expect(
+ composable.fetchAnnouncements({
+ chain: 'stellar',
+ signal: 'stop',
+ } as unknown as ScannerFetchOptions),
+ ).rejects.toThrow(TypeError);
+ });
+
+ it('aborts in-flight work when the effect scope is disposed', async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const seen: AbortSignal[] = [];
+
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await Promise.race([gate, rejectOnAbort(options.signal)]);
+ yield mockStellarAnnouncements[0];
+ });
+
+ const scope = effectScope();
+ const composable = scope.run(() => useScanner('stellar'))!;
+
+ const pending = composable.fetchAnnouncements().catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(composable.scanning.value).toBe(true);
+ expect(seen).toHaveLength(1);
+
+ scope.stop();
+ expect(seen[0].aborted).toBe(true);
+
+ release();
+ const outcome = await pending;
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(composable.announcements.value).toEqual([]);
+ });
+
+ it('aborts in-flight work when the caller aborts the signal', async () => {
+ const seen: AbortSignal[] = [];
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ if (options.signal) seen.push(options.signal);
+ await rejectOnAbort(options.signal);
+ yield mockStellarAnnouncements[0];
+ });
+
+ const composable = useScanner('stellar');
+ const controller = new AbortController();
+ const pending = composable
+ .fetchAnnouncements({ chain: 'stellar', signal: controller.signal })
+ .catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ controller.abort();
+ const outcome = await pending;
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0].aborted).toBe(true);
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(composable.announcements.value).toEqual([]);
+ expect(composable.scanning.value).toBe(false);
+ expect(composable.error.value).toBeNull();
+ });
+
+ it('discards a superseded fetch even when the source ignores the abort', async () => {
+ const releases: Array<() => void> = [];
+ const seen: AbortSignal[] = [];
+
+ vi.mocked(fetchAnnouncementsStream).mockImplementation(async function* (
+ _chain: string,
+ options: FetchAnnouncementsOptions = {},
+ ) {
+ const index = releases.length;
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ releases.push(release);
+ if (options.signal) seen.push(options.signal);
+ await gate;
+ yield index === 0
+ ? mockStellarAnnouncements[0]
+ : { ...mockStellarAnnouncements[0], ledger: 2 };
+ });
+
+ const composable = useScanner('stellar');
+
+ const first = composable.fetchAnnouncements().catch((err) => err);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(seen[0].aborted).toBe(false);
+
+ const second = composable.fetchAnnouncements();
+ expect(seen[0].aborted).toBe(true);
+
+ releases[1]();
+ await second;
+ expect(composable.announcements.value).toEqual([{ ...mockStellarAnnouncements[0], ledger: 2 }]);
+
+ releases[0]();
+ const outcome = await first;
+ expect((outcome as DOMException).name).toBe('AbortError');
+ expect(composable.announcements.value).toEqual([{ ...mockStellarAnnouncements[0], ledger: 2 }]);
+ });
});
diff --git a/packages/sdk-vue/test/useWraith.test.ts b/packages/sdk-vue/test/useWraith.test.ts
index 961eabf..da86bff 100644
--- a/packages/sdk-vue/test/useWraith.test.ts
+++ b/packages/sdk-vue/test/useWraith.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useWraith } from '../src/composables/useWraith';
-vi.mock('@wraith-protocol/sdk', () => {
+vi.mock('@wraith-protocol/sdk-agent', () => {
const mockAgent = {
info: { id: 'agent-1', name: 'test-agent', chains: [], addresses: {}, metaAddresses: {} },
chat: vi.fn(),
diff --git a/packages/sdk-vue/tsconfig.json b/packages/sdk-vue/tsconfig.json
index 1ef68bd..3846193 100644
--- a/packages/sdk-vue/tsconfig.json
+++ b/packages/sdk-vue/tsconfig.json
@@ -11,7 +11,8 @@
"outDir": "dist",
"jsx": "preserve",
"paths": {
- "@wraith-protocol/sdk": ["../../src"]
+ "@wraith-protocol/sdk": ["../../src"],
+ "@wraith-protocol/sdk-agent": ["../sdk-agent/src"]
}
},
"include": ["src"],
diff --git a/packages/sdk-vue/tsup.config.ts b/packages/sdk-vue/tsup.config.ts
index 2b5a8ab..84619fd 100644
--- a/packages/sdk-vue/tsup.config.ts
+++ b/packages/sdk-vue/tsup.config.ts
@@ -9,5 +9,5 @@ export default defineConfig({
splitting: true,
clean: true,
treeshake: true,
- external: ['vue', '@wraith-protocol/sdk'],
+ external: ['vue', '@wraith-protocol/sdk', '@wraith-protocol/sdk-agent'],
});
diff --git a/packages/sdk-vue/vitest.config.ts b/packages/sdk-vue/vitest.config.ts
index 2e0dd34..84ac7d7 100644
--- a/packages/sdk-vue/vitest.config.ts
+++ b/packages/sdk-vue/vitest.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
resolve: {
alias: {
'@wraith-protocol/sdk': path.resolve(__dirname, '../../src'),
+ '@wraith-protocol/sdk-agent': path.resolve(__dirname, '../sdk-agent/src'),
},
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 47c63e1..88976b8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -594,6 +594,9 @@ importers:
'@wraith-protocol/sdk':
specifier: workspace:*
version: link:../..
+ '@wraith-protocol/sdk-agent':
+ specifier: workspace:*
+ version: link:../sdk-agent
solid-js:
specifier: ^1.9.0
version: 1.9.15
@@ -625,6 +628,9 @@ importers:
'@wraith-protocol/sdk':
specifier: workspace:*
version: link:../..
+ '@wraith-protocol/sdk-agent':
+ specifier: workspace:*
+ version: link:../sdk-agent
devDependencies:
'@stellar/stellar-sdk':
specifier: ^13.1.0
@@ -659,6 +665,9 @@ importers:
'@wraith-protocol/sdk':
specifier: workspace:*
version: link:../..
+ '@wraith-protocol/sdk-agent':
+ specifier: workspace:*
+ version: link:../sdk-agent
vue:
specifier: ^3.5.0
version: 3.5.39(typescript@5.9.3)
@@ -3251,6 +3260,7 @@ packages:
'@wraith-protocol/sdk@file:':
resolution: {directory: '', type: directory}
+ engines: {node: '>=20'}
peerDependencies:
'@solana/web3.js': ^1.95.0
'@stellar/stellar-sdk': ^13.1.0
diff --git a/scripts/compat/verify-ssr-imports.mjs b/scripts/compat/verify-ssr-imports.mjs
index c949379..9862d13 100644
--- a/scripts/compat/verify-ssr-imports.mjs
+++ b/scripts/compat/verify-ssr-imports.mjs
@@ -38,16 +38,15 @@ const SCENARIOS = {
};
// Companions whose server import is already broken, with the error it fails
-// with. `Wraith`, `WraithAgent` and `Chain` moved from `@wraith-protocol/sdk` to
-// `@wraith-protocol/sdk-agent` in #193, but these packages still import them
-// from the core, so Node refuses to link them. An entry must keep failing with
-// its message: once the import is fixed this check fails until the entry is
-// removed, which turns the SSR render back on for that package.
-const KNOWN_IMPORT_FAILURES = {
- '@wraith-protocol/sdk-solid': /does not provide an export named '(Wraith|WraithAgent|Chain)'/,
- '@wraith-protocol/sdk-svelte': /does not provide an export named '(Wraith|WraithAgent|Chain)'/,
- '@wraith-protocol/sdk-vue': /does not provide an export named '(Wraith|WraithAgent|Chain)'/,
-};
+// with. An entry must keep failing with its message: once the import is fixed
+// this check fails until the entry is removed, which turns the SSR render back
+// on for that package.
+//
+// Empty: `sdk-solid`, `sdk-svelte` and `sdk-vue` used to import `Wraith`,
+// `WraithAgent` and `Chain` from the core package after the agent client moved
+// to `@wraith-protocol/sdk-agent` (#193), so Node refused to link them. They
+// now import the agent client from its own package and load cleanly.
+const KNOWN_IMPORT_FAILURES = {};
let failed = 0;
diff --git a/src/chains/errorUtils.ts b/src/chains/errorUtils.ts
index 39d3066..46f829c 100644
--- a/src/chains/errorUtils.ts
+++ b/src/chains/errorUtils.ts
@@ -17,3 +17,17 @@ export function getUnknownErrorMessage(err: unknown): string {
return String(err);
}
}
+
+/**
+ * Returns `true` when a thrown value is a cancellation.
+ *
+ * Both `DOMException` aborts from `AbortSignal` and errors whose `name` is
+ * `AbortError` (some runtimes and libraries re-create the error) are treated
+ * as cancellation, so framework adapters can skip error state for work that
+ * was deliberately aborted.
+ */
+export function isAbortError(err: unknown): boolean {
+ return (
+ typeof err === 'object' && err !== null && (err as { name?: unknown }).name === 'AbortError'
+ );
+}
diff --git a/src/index.ts b/src/index.ts
index 0eecb02..6179850 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -41,6 +41,7 @@ export type {
ScanAllInput,
MatchedAnnouncement,
SupportedChain,
+ ScanCancellationOptions,
ChainScannerAdapter,
CustomChainInput,
EvmChainInput,
@@ -48,6 +49,7 @@ export type {
SolanaChainInput,
CkbChainInput,
} from './scanner/unified';
+export { getUnknownErrorMessage, isAbortError } from './chains/errorUtils';
export {
WraithError,
WraithInputError,
diff --git a/src/scanner/unified.ts b/src/scanner/unified.ts
index 5591288..55f8857 100644
--- a/src/scanner/unified.ts
+++ b/src/scanner/unified.ts
@@ -26,6 +26,20 @@ import { adapter as ckbAdapter } from '../chains/ckb/scan';
*/
export type SupportedChain = 'evm' | 'stellar' | 'solana' | 'ckb';
+/**
+ * Cancellation options shared by every framework scanner entry point.
+ *
+ * `@wraith-protocol/sdk-react`, `-vue`, `-svelte` and `-solid` accept this
+ * shape wherever they start a scan, so one `AbortController` cancels scanner
+ * work the same way in every framework. Aborting rejects the scan with the
+ * standard `AbortError` (`DOMException`) the chain clients throw and leaves the
+ * framework adapter's published state untouched.
+ */
+export interface ScanCancellationOptions {
+ /** Cancels an in-flight scan when aborted. */
+ signal?: AbortSignal;
+}
+
/**
* Timestamp used when a scanner adapter cannot supply a real chain time.
*
diff --git a/tsconfig.test-types.json b/tsconfig.test-types.json
index 0e42d7d..f8289a9 100644
--- a/tsconfig.test-types.json
+++ b/tsconfig.test-types.json
@@ -9,7 +9,9 @@
"src/**/*.ts",
"test/scanner/unified.test-d.ts",
"packages/sdk-agent/src/**/*.ts",
- "packages/sdk-agent/test/client.test.ts"
+ "packages/sdk-agent/test/client.test.ts",
+ "packages/sdk-react/test/useScanner.test-d.ts",
+ "packages/sdk-vue/test/scanner.test-d.ts"
],
"exclude": ["node_modules", "dist"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
index fc32b91..7e7bc32 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,17 @@
import { defineConfig } from 'vitest/config';
+import { fileURLToPath } from 'node:url';
+
+// The framework packages import the agent client from its workspace package.
+// Point that specifier at the source so the root suite does not need a build
+// of `packages/sdk-agent` first.
+const sdkAgentSrc = fileURLToPath(new URL('./packages/sdk-agent/src', import.meta.url));
export default defineConfig({
+ resolve: {
+ alias: {
+ '@wraith-protocol/sdk-agent': sdkAgentSrc,
+ },
+ },
test: {
globals: true,
typecheck: {