Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
201 changes: 201 additions & 0 deletions docs/guides/framework-scanner-cancellation.mdx
Original file line number Diff line number Diff line change
@@ -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 <Feed items={announcements} loading={scanning} error={error} />;
}
```

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
<script setup lang="ts">
import { useScanner } from '@wraith-protocol/sdk-vue';

const scanner = useScanner('stellar');

const announcements = await scanner.fetchAnnouncements({ chain: 'stellar', fromLedger: 100 });
const matched = scanner.scanAnnouncements({
chain: 'stellar',
announcements,
keys,
});
</script>
```

`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
<script lang="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 });
</script>
```

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 <List items={announcements()} />;
}
```

`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<FetchAnnouncementsOptions, 'signal' | 'sorobanUrl'>, 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.
Loading
Loading