diff --git a/README.md b/README.md
index a39fd1c2..d551ca6d 100644
--- a/README.md
+++ b/README.md
@@ -70,14 +70,170 @@ Common options include:
- tracking callbacks
Configuration passed after mount is normalized and becomes live where the
-running widget can safely consume it. Wallet topology—connector mode, provider
-presence, connector construction, and related wallet setup—is fixed during
-bootstrap; remount the widget to change it.
+running widget can safely consume it. Wallet topology, including connector
+mode, provider presence, connector construction, and related wallet setup, is
+fixed during bootstrap. Remount the widget to change it.
The package exports the supported chain constants, dashboard yield categories,
wallet types, Wallet Policy, transaction metadata types, and themes. Prefer
those exports over copying their shapes into host code.
+## External providers
+
+Use `externalProviders` when your application already manages the user's
+wallet. The host owns the wallet connection, active account, and chain. The
+widget calls your adapter to request signatures, switch chains, and submit
+transactions. This adapter implements `SKWallet`; it is not a raw EIP-1193
+provider or a wallet SDK client.
+
+### React setup
+
+Pass the host's wallet state as props. This example limits the widget to
+Arbitrum and Polygon, which must also be available in its configured wallet
+topology:
+
+```tsx
+import "@stakekit/widget/style.css";
+import {
+ EvmChainIds,
+ SKApp,
+ type SKWallet,
+ type SupportedSKChainIds,
+} from "@stakekit/widget";
+
+export function ExternalWalletWidget({
+ apiKey,
+ wallet,
+ address,
+ chainId,
+}: {
+ apiKey: string;
+ wallet: SKWallet;
+ address: string;
+ chainId: SupportedSKChainIds;
+}) {
+ return (
+
+ );
+}
+```
+
+Implement `wallet` using your wallet SDK:
+
+| Method | Contract |
+| --- | --- |
+| `signMessage(message)` | Return a promise resolving to the signature string. |
+| `switchChain(chainId)` | Switch the host wallet and resolve when the switch completes. Publish the new `currentChain` through props. |
+| `sendTransaction(tx, txMeta)` | Sign and broadcast the transaction. Return its hash, `{ type: "success", txHash }`, or `{ type: "error", error }`. |
+| `signTypedData(typedData)` | Optional EIP-712 signing callback. Required by flows that request typed-data signatures. |
+
+Use `SKWallet` to type the adapter. Transaction inputs are the widget's `SKTx`
+and `SKTxMeta`, not a wallet SDK's transaction types. Narrow `tx.type` before
+converting its payload for your SDK. Bind SDK methods or wrap them in arrow
+functions if they depend on `this`.
+
+Reject callback promises when the wallet rejects an operation. Do not return
+an empty signature or a fabricated transaction hash to indicate failure.
+
+### Live updates and browser hosts
+
+Changes to `currentChain`, `currentAddress`, `supportedChainIds`, and provider
+callbacks are consumed live. In React, pass updated props without changing the
+component's `key`. In Angular or another non-React host, use the browser
+renderer and call `rerender` when the wallet changes:
+
+```ts
+import "@stakekit/widget/style.css";
+import {
+ renderSKWidget,
+ type BundledSKWidgetProps,
+} from "@stakekit/widget/bundle";
+
+type ExternalProvider = NonNullable;
+
+export function mountExternalWallet(
+ container: Element,
+ apiKey: string,
+ externalProviders: ExternalProvider,
+) {
+ const props = { apiKey, borrowEnabled: false, externalProviders } satisfies
+ BundledSKWidgetProps;
+ const widget = renderSKWidget({ ...props, container });
+
+ return {
+ update(nextProvider: ExternalProvider) {
+ widget.rerender({ ...props, externalProviders: nextProvider });
+ },
+ unmount: widget.unmount,
+ };
+}
+```
+
+Call the returned `update` method with a new provider snapshot on host wallet
+changes, and call `unmount` when the host component is destroyed.
+`rerender` replaces the widget props; it does not merge a partial update.
+Preserve the API key and any other settings on every call.
+
+Keep `externalProviders` present while the host wallet is disconnected. Set
+`currentAddress` to `""`, not `null` or `undefined`. Restoring the address
+reconnects the provider. Do not remove the provider or remount just to report
+an account change.
+
+Deferred signing and transaction operations use the current provider callbacks
+when execution starts. Updating an adapter does not cancel an operation that
+has already started.
+
+### Chain selection
+
+- `currentChain` is the host wallet's actual chain, including on initial
+ connection. If omitted, the widget uses the first supported configured chain.
+ Pass it explicitly rather than relying on chain ordering.
+- `supportedChainIds` filters the chains the adapter supports. Omitting it
+ allows all chains in the widget's configured topology. It does not add chains
+ to that topology.
+- An explicit `[]`, or a list with no chains in the configured topology, is an
+ error at initialization and on live updates. It does not mean disconnected.
+ An invalid live list fails the wallet runtime. Remount with valid
+ configuration to recover.
+- A `currentChain` outside the allowed chains is unsupported. The widget
+ preserves the host's chain identity but disables signing and transactions.
+ Update the host chain or the supported list to restore a usable connection.
+
+Use the exported `EvmChainIds`, `MiscChainIds`, and `SubstrateChainIds`
+constants. `SupportedSKChainIds` is a union of supported numeric IDs, not an
+arbitrary `number`. Validate IDs from your wallet SDK before passing them to
+the widget rather than casting unknown values to this type.
+
+Remount when changing provider presence, connector mode, or the configured
+wallet topology. Ordinary account, chain, supported-list, and callback updates
+do not need a remount. Unmount the previous instance before mounting another.
+
+### Borrow support
+
+For staking without Borrow, use `SKWallet` and `borrowEnabled: false`.
+`sendBorrowTransaction` is not required.
+
+To enable Borrow with an external provider:
+
+- Set `borrowEnabled: true`.
+- Set `externalProviders.supportsBorrow: true`.
+- Implement `SKBorrowWallet`, which adds
+ `sendBorrowTransaction(tx, txMeta)` to `SKWallet`.
+
+The Borrow callback receives `SKBorrowTx` and `SKBorrowTxMeta` and returns the
+same success or error shapes as `sendTransaction`. Do not cast a staking-only
+adapter to `SKBorrowWallet` to satisfy a compiler error.
+
## Styling
Import `@stakekit/widget/style.css` once. Start with `lightTheme` or `darkTheme`
diff --git a/packages/widget/src/features/wallet/react/provider.tsx b/packages/widget/src/features/wallet/react/provider.tsx
index 09afbe57..1697ef89 100644
--- a/packages/widget/src/features/wallet/react/provider.tsx
+++ b/packages/widget/src/features/wallet/react/provider.tsx
@@ -1,13 +1,16 @@
+import { useAtomValue } from "@effect/atom-react";
import { Option } from "effect";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { type PropsWithChildren, useState } from "react";
import { WagmiContext } from "wagmi";
import { makeDefaultConfig } from "../../../services/wallet/default-wagmi-config";
import { useGeoBlock } from "../../preferences/index";
+import { currentWalletStateResultAtom } from "../state/root-atom";
import { useWalletConfig } from "./use-wallet-config";
export const WagmiConfigProvider = ({ children }: PropsWithChildren) => {
const walletConfigResult = useWalletConfig();
+ const walletStateResult = useAtomValue(currentWalletStateResultAtom);
const walletConfig = walletConfigResult.pipe(
AsyncResult.value,
Option.getOrUndefined
@@ -16,10 +19,15 @@ export const WagmiConfigProvider = ({ children }: PropsWithChildren) => {
AsyncResult.error,
Option.getOrUndefined
);
+ const walletStateError = walletStateResult.pipe(
+ AsyncResult.error,
+ Option.getOrUndefined
+ );
const [fallbackConfig] = useState(makeDefaultConfig);
const geoBlock = useGeoBlock();
if (walletConfigError && !geoBlock) throw walletConfigError;
+ if (walletStateError && !geoBlock) throw walletStateError;
const value = walletConfig ?? fallbackConfig;
diff --git a/packages/widget/src/services/wallet/external-provider.ts b/packages/widget/src/services/wallet/external-provider.ts
index f7ef5fca..f693594a 100644
--- a/packages/widget/src/services/wallet/external-provider.ts
+++ b/packages/widget/src/services/wallet/external-provider.ts
@@ -28,35 +28,39 @@ export class ExternalProvider {
constructor(private variantProvider: CurrentRef) {}
sendTransaction(tx: SKTx, txMeta: SKTxMeta) {
- const sendTransaction =
- this.variantProvider.current.provider.sendTransaction;
-
- if (!sendTransaction) {
- return Effect.fail(
- new ExternalProviderError({
- customMessage: null,
- message: "Invalid provider type",
- })
- );
- }
+ return Effect.suspend(() => {
+ const sendTransaction =
+ this.variantProvider.current.provider.sendTransaction;
+
+ if (!sendTransaction) {
+ return Effect.fail(
+ new ExternalProviderError({
+ customMessage: null,
+ message: "Invalid provider type",
+ })
+ );
+ }
- return sendExternalTransaction(() => sendTransaction(tx, txMeta));
+ return sendExternalTransaction(() => sendTransaction(tx, txMeta));
+ });
}
sendBorrowTransaction(tx: SKBorrowTx, txMeta: SKBorrowTxMeta) {
- const config = this.variantProvider.current;
- if (!isBorrowExternalProvider(config)) {
- return Effect.fail(
- new ExternalProviderError({
- customMessage: null,
- message: "Borrow transaction capability is unavailable",
- })
- );
- }
+ return Effect.suspend(() => {
+ const config = this.variantProvider.current;
+ if (!isBorrowExternalProvider(config)) {
+ return Effect.fail(
+ new ExternalProviderError({
+ customMessage: null,
+ message: "Borrow transaction capability is unavailable",
+ })
+ );
+ }
- return sendExternalTransaction(() =>
- config.provider.sendBorrowTransaction(tx, txMeta)
- );
+ return sendExternalTransaction(() =>
+ config.provider.sendBorrowTransaction(tx, txMeta)
+ );
+ });
}
switchChain({ chainId }: { chainId: number }) {
@@ -79,19 +83,21 @@ export class ExternalProvider {
}
signTypedData(typedData: SKEip712TypedData) {
- const signTypedData = this.variantProvider.current.provider.signTypedData;
- if (!signTypedData) {
- return Effect.fail(
- new ExternalProviderError({
- customMessage: null,
- message: "Typed-data signing capability is unavailable",
- })
- );
- }
+ return Effect.suspend(() => {
+ const signTypedData = this.variantProvider.current.provider.signTypedData;
+ if (!signTypedData) {
+ return Effect.fail(
+ new ExternalProviderError({
+ customMessage: null,
+ message: "Typed-data signing capability is unavailable",
+ })
+ );
+ }
- return Effect.tryPromise({
- try: () => signTypedData(typedData),
- catch: toExternalProviderError,
+ return Effect.tryPromise({
+ try: () => signTypedData(typedData),
+ catch: toExternalProviderError,
+ });
});
}
}
diff --git a/packages/widget/src/services/wallet/internal/adapters/external-provider/index.ts b/packages/widget/src/services/wallet/internal/adapters/external-provider/index.ts
index c2592bca..7e9004c7 100644
--- a/packages/widget/src/services/wallet/internal/adapters/external-provider/index.ts
+++ b/packages/widget/src/services/wallet/internal/adapters/external-provider/index.ts
@@ -9,6 +9,7 @@ import { config } from "../../../../../shared/config/widget-defaults";
import { makeCurrentValueStream } from "../../../../../shared/effect/current-value-stream";
import { type CurrentRef, ExternalProvider } from "../../../external-provider";
import type { ConnectorWithFilteredChains } from "../../../wallet-connectors";
+import { WalletRuntimeInvariantError } from "../../../wallet-errors";
import { normalizeChainId } from "../../normalize-chain-id";
import type { RunWalletEffect } from "../../runtime/effect-runner";
import { wagmiConnectResult } from "../wagmi-connect-result";
@@ -28,7 +29,7 @@ type ExtraProps = ConnectorWithFilteredChains &
| "signTypedData"
> & {
onSupportedChainsChanged: (args: {
- supportedChainIds: number[];
+ supportedChainIds: number[] | undefined;
currentChainId: number;
}) => void;
};
@@ -57,20 +58,29 @@ export const externalProviderConnector = (
},
createConnector: () =>
createConnector((connectorConfig) => {
- const filteredChains = makeCurrentValueStream(
- variant.current.supportedChainIds
+ const resolveSupportedChains = (
+ supportedChainIds: number[] | undefined
+ ) => {
+ const supported =
+ supportedChainIds === undefined
+ ? null
+ : new Set(supportedChainIds);
+ const chains = supported
? connectorConfig.chains.filter((chain) =>
- new Set(variant.current.supportedChainIds).has(
- chain.id
- )
+ supported.has(chain.id)
)
- : (connectorConfig.chains as [Chain, ...Chain[]])
+ : (connectorConfig.chains as [Chain, ...Chain[]]);
+ if (chains.length === 0) {
+ throw new WalletRuntimeInvariantError({
+ reason: "external-provider-no-supported-chains",
+ });
+ }
+ return chains;
+ };
+ const filteredChains = makeCurrentValueStream(
+ resolveSupportedChains(variant.current.supportedChainIds)
);
- if (filteredChains.get().length === 0) {
- throw new Error("No supported chains found!");
- }
-
const provider = new ExternalProvider(variant);
const getFirstFilteredChain = () =>
@@ -81,10 +91,15 @@ export const externalProviderConnector = (
);
const getAccounts: ReturnType["getAccounts"] =
- async () => [variant.current.currentAddress as Address];
+ async () =>
+ variant.current.currentAddress
+ ? [variant.current.currentAddress as Address]
+ : [];
+ // Preserve the host's identity even when the topology cannot route it.
const getChainId: ReturnType["getChainId"] =
- async () => getFirstFilteredChain().id;
+ async () =>
+ variant.current.currentChain ?? getFirstFilteredChain().id;
const connect: ReturnType["connect"] = async (
args
@@ -95,6 +110,9 @@ export const externalProviderConnector = (
getAccounts(),
getChainId(),
]);
+ if (accounts.length === 0) {
+ throw new Error("External provider has no connected account");
+ }
return wagmiConnectResult(
args?.withCapabilities,
@@ -123,7 +141,7 @@ export const externalProviderConnector = (
async () => ({});
const isAuthorized: ReturnType["isAuthorized"] =
- async () => true;
+ async () => Boolean(variant.current.currentAddress);
const onDisconnect: ReturnType["onDisconnect"] =
() => {
@@ -139,26 +157,27 @@ export const externalProviderConnector = (
const onAccountsChanged: ReturnType["onAccountsChanged"] =
(accounts) => {
+ const connectedAccounts = accounts.filter(
+ (account) => !!account
+ ) as Address[];
+ if (connectedAccounts.length === 0) {
+ onDisconnect();
+ return;
+ }
connectorConfig.emitter.emit("change", {
- accounts: accounts.filter((a) => !!a) as Address[],
+ accounts: connectedAccounts,
});
};
const onSupportedChainsChanged: ExtraProps["onSupportedChainsChanged"] =
({ currentChainId, supportedChainIds }) => {
- filteredChains.set(
- supportedChainIds.length
- ? connectorConfig.chains.filter((chain) =>
- new Set(supportedChainIds).has(chain.id)
- )
- : (connectorConfig.chains as [Chain, ...Chain[]])
- );
+ filteredChains.set(resolveSupportedChains(supportedChainIds));
- // If the current chain is not in the supported chains, switch to the first supported chain
- if (filteredChains.get().every((c) => c.id !== currentChainId)) {
- getChainId().then((chainId) =>
- onChainChanged(chainId.toString())
- );
+ if (
+ variant.current.currentChain === undefined &&
+ filteredChains.get().every((c) => c.id !== currentChainId)
+ ) {
+ onChainChanged(getFirstFilteredChain().id.toString());
}
};
diff --git a/packages/widget/src/services/wallet/internal/runtime/external-provider-sync.ts b/packages/widget/src/services/wallet/internal/runtime/external-provider-sync.ts
index 43b2a90b..8d176e51 100644
--- a/packages/widget/src/services/wallet/internal/runtime/external-provider-sync.ts
+++ b/packages/widget/src/services/wallet/internal/runtime/external-provider-sync.ts
@@ -1,4 +1,4 @@
-import { Effect, Ref, type Scope, Stream } from "effect";
+import { Effect, Ref, Result, type Scope, Stream } from "effect";
import {
diffWidgetWalletConfig,
selectWidgetBootstrapSnapshot,
@@ -126,24 +126,31 @@ export const installExternalProviderSynchronization = Effect.fn(
snapshot.currentChain ??
connection.chainId ??
bootstrap.controller.wagmiConfig.state.chainId;
- const supportedChainIds = snapshot.supportedChainIds
- ? [...snapshot.supportedChainIds]
- : [];
+ const supportedChainIds = snapshot.supportedChainIds;
const currentMemory = yield* Ref.get(memory);
const supportedChainsKey = `${connector.uid}:${currentChainId}:${
- supportedChainIds.join(",") || "all"
+ supportedChainIds?.join(",") ?? "all"
}`;
if (currentMemory.supportedChainsNotification !== supportedChainsKey) {
yield* Ref.update(memory, (current) => ({
...current,
supportedChainsNotification: supportedChainsKey,
}));
- yield* runConnectorNotification(() =>
+ const notification = yield* Effect.try(() =>
connector.onSupportedChainsChanged({
currentChainId,
supportedChainIds,
})
- );
+ ).pipe(Effect.result);
+ if (Result.isFailure(notification)) {
+ const cause = notification.failure.cause;
+ return yield* failInvariant(
+ cause instanceof WalletRuntimeInvariantError
+ ? cause.reason
+ : "external-provider-supported-chains-update-failed",
+ { cause }
+ );
+ }
}
if (
diff --git a/packages/widget/src/services/wallet/internal/runtime/lifecycle.ts b/packages/widget/src/services/wallet/internal/runtime/lifecycle.ts
index ace3fcd1..2a9adc18 100644
--- a/packages/widget/src/services/wallet/internal/runtime/lifecycle.ts
+++ b/packages/widget/src/services/wallet/internal/runtime/lifecycle.ts
@@ -1,6 +1,7 @@
import { Effect, Ref } from "effect";
import { TrackingService } from "../../../tracking/tracking-service";
import type { NormalizedWalletState } from "../../wallet-state";
+import { isExternalProviderConnector } from "../adapters/external-provider";
import type { WagmiActions } from "./wagmi-actions";
type WalletLifecycleMemory = {
@@ -47,7 +48,12 @@ export const makeWalletLifecyclePolicy = Effect.gen(function* () {
return [null, current];
}
- if (state.status !== "unsupported" || !state.connector || !state.chain) {
+ if (
+ state.status !== "unsupported" ||
+ !state.connector ||
+ !state.chain ||
+ isExternalProviderConnector(state.connector)
+ ) {
return [null, initialMemory];
}
diff --git a/packages/widget/src/services/wallet/internal/runtime/state-projection.ts b/packages/widget/src/services/wallet/internal/runtime/state-projection.ts
index 60a478a4..1ec410a1 100644
--- a/packages/widget/src/services/wallet/internal/runtime/state-projection.ts
+++ b/packages/widget/src/services/wallet/internal/runtime/state-projection.ts
@@ -27,6 +27,7 @@ import {
isCosmosConnector,
} from "../adapters/cosmos/cosmos-connector-meta";
import type { EvmChainsMap } from "../adapters/evm/chains";
+import { isExternalProviderConnector } from "../adapters/external-provider";
import { isLedgerLiveConnector } from "../adapters/ledger/ledger-live-connector-meta";
import type { SubstrateChainsMap } from "../adapters/substrate/chains";
import type { WalletRoutingContext } from "./router";
@@ -96,15 +97,21 @@ const decodeProjectionFacts = ({
: null;
const chain = connection.chain ?? null;
const connector = connection.connector ?? null;
- const network = chain
- ? wagmiNetworkToSKNetwork({
- chain,
- cosmosChainsMap: controller.cosmosConfig.cosmosChainsMap,
- evmChainsMap: controller.evmConfig.evmChainsMap,
- miscChainsMap: controller.miscConfig.miscChainsMap,
- substrateChainsMap: controller.substrateConfig.substrateChainsMap,
- })
- : null;
+ const isChainExcluded =
+ chain !== null &&
+ connector !== null &&
+ isExternalProviderConnector(connector) &&
+ !connectorChains.some((supportedChain) => supportedChain.id === chain.id);
+ const network =
+ chain && !isChainExcluded
+ ? wagmiNetworkToSKNetwork({
+ chain,
+ cosmosChainsMap: controller.cosmosConfig.cosmosChainsMap,
+ evmChainsMap: controller.evmConfig.evmChainsMap,
+ miscChainsMap: controller.miscConfig.miscChainsMap,
+ substrateChainsMap: controller.substrateConfig.substrateChainsMap,
+ })
+ : null;
return {
additionalAddresses,
@@ -184,6 +191,23 @@ const connectingWalletState = (
previous.connector &&
previous.ledgerAccounts
) {
+ if (
+ isExternalProviderConnector(previous.connector) &&
+ !common.connectorChains.some(
+ (supportedChain) => supportedChain.id === previous.chain.id
+ )
+ ) {
+ return {
+ ...previous,
+ ...common,
+ additionalAddresses: null,
+ isLedgerLiveAccountPlaceholder: false,
+ ledgerAccounts: null,
+ network: null,
+ status: "unsupported",
+ };
+ }
+
return {
...previous,
...common,
@@ -277,7 +301,18 @@ export const transitionalWalletState = (
return normalizeWalletState(input);
}
- return connectingWalletState(decodeProjectionFacts(input));
+ const previous = input.previous;
+ const connectorChains =
+ previous?.connector &&
+ isExternalProviderConnector(previous.connector) &&
+ (!input.connection.connector ||
+ previous.connector.uid === input.connection.connector.uid)
+ ? previous.connectorChains
+ : input.connectorChains;
+
+ return connectingWalletState(
+ decodeProjectionFacts({ ...input, connectorChains })
+ );
};
const makeConnectorChainsStream = ({
diff --git a/packages/widget/src/services/wallet/internal/runtime/wagmi-config.ts b/packages/widget/src/services/wallet/internal/runtime/wagmi-config.ts
index d08f9d69..8780bb2a 100644
--- a/packages/widget/src/services/wallet/internal/runtime/wagmi-config.ts
+++ b/packages/widget/src/services/wallet/internal/runtime/wagmi-config.ts
@@ -435,18 +435,27 @@ export const buildWagmiConfig = (
)?.wagmiChain.id
: undefined;
- const wagmiConfig = createConfig({
- // Wagmi requires one transport chain even when the project has no Wallet
- // Networks. The fallback is absent from every adapter map and connector,
- // so the exposed wallet topology remains empty.
- chains: wagmiChains,
- client: ({ chain }) => createClient({ chain, transport: http() }),
- multiInjectedProviderDiscovery: false,
- // The host owns external-provider connection state. Hydrating Wagmi's
- // persisted connector can restore a connector from another topology
- // before the external provider synchronizer establishes its connection.
- storage: opts.externalProviders ? null : undefined,
- connectors,
+ const wagmiConfig = yield* Effect.try({
+ try: () =>
+ createConfig({
+ // Wagmi requires one transport chain even when the project has no Wallet
+ // Networks. The fallback is absent from every adapter map and connector,
+ // so the exposed wallet topology remains empty.
+ chains: wagmiChains,
+ client: ({ chain }) => createClient({ chain, transport: http() }),
+ multiInjectedProviderDiscovery: false,
+ // The host owns external-provider connection state. Hydrating Wagmi's
+ // persisted connector can restore a connector from another topology
+ // before the external provider synchronizer establishes its connection.
+ storage: opts.externalProviders ? null : undefined,
+ connectors,
+ }),
+ catch: (cause) =>
+ new WalletIntegrationError({
+ cause,
+ message: "Could not create wallet configuration",
+ operation: "create-config",
+ }),
});
if (multiInjectedProviderDiscovery && evmConfig.evmChains.length > 0) {
diff --git a/packages/widget/src/services/wallet/wallet-errors.ts b/packages/widget/src/services/wallet/wallet-errors.ts
index 8da1b2b0..5db50f9b 100644
--- a/packages/widget/src/services/wallet/wallet-errors.ts
+++ b/packages/widget/src/services/wallet/wallet-errors.ts
@@ -54,7 +54,9 @@ export class WalletRuntimeInvariantError extends Data.TaggedError(
readonly reason:
| "external-provider-connector-mismatch"
| "external-provider-connector-missing"
- | "external-provider-presence-changed";
+ | "external-provider-presence-changed"
+ | "external-provider-no-supported-chains"
+ | "external-provider-supported-chains-update-failed";
}> {}
export class WalletIntegrationError extends Data.TaggedError(
diff --git a/packages/widget/tests/providers/external-provider-contract.test.ts b/packages/widget/tests/providers/external-provider-contract.test.ts
index b4120196..93a1c655 100644
--- a/packages/widget/tests/providers/external-provider-contract.test.ts
+++ b/packages/widget/tests/providers/external-provider-contract.test.ts
@@ -95,6 +95,38 @@ const makeBorrowProviderRef = (
});
describe("generic external provider callback contract", () => {
+ it.effect("uses the live provider when a deferred operation starts", () =>
+ Effect.gen(function* () {
+ const oldWallet: SKBorrowWallet = {
+ signMessage: async () => "old-message",
+ signTypedData: async () => "old-typed",
+ switchChain: async () => undefined,
+ sendTransaction: async () => "old-transaction",
+ sendBorrowTransaction: async () => "old-borrow",
+ };
+ const ref = makeBorrowProviderRef(oldWallet);
+ const provider = new ExternalProvider(ref);
+ const send = provider.sendTransaction(transaction, transactionMeta);
+ const borrow = provider.sendBorrowTransaction(
+ borrowTransaction,
+ borrowTransactionMeta
+ );
+ const sign = provider.signTypedData(typedData);
+ ref.current = {
+ ...ref.current,
+ supportsBorrow: true,
+ provider: {
+ ...oldWallet,
+ signTypedData: async () => "new-typed",
+ sendTransaction: async () => "new-transaction",
+ sendBorrowTransaction: async () => "new-borrow",
+ },
+ };
+ expect(yield* send).toBe("new-transaction");
+ expect(yield* borrow).toBe("new-borrow");
+ expect(yield* sign).toBe("new-typed");
+ })
+ );
it.effect(
"passes message, chain, transaction, and metadata through Promise callbacks",
() =>
@@ -205,25 +237,23 @@ describe("generic external provider callback contract", () => {
"rejects Borrow invocation when the live provider loses its Borrow capability",
() =>
Effect.gen(function* () {
- const provider = new ExternalProvider(
- makeProviderRef({
- signMessage: async () => "signed-message",
- switchChain: async () => undefined,
- sendTransaction: async () => "classic-hash",
- })
- );
-
- const error = yield* Effect.flip(
- provider.sendBorrowTransaction(
- borrowTransaction,
- borrowTransactionMeta
- )
+ const wallet: SKBorrowWallet = {
+ signMessage: async () => "signed-message",
+ switchChain: async () => undefined,
+ sendTransaction: async () => "classic-hash",
+ sendBorrowTransaction: vi.fn(async () => "borrow-hash"),
+ };
+ const ref = makeBorrowProviderRef(wallet);
+ const provider = new ExternalProvider(ref);
+ const pending = provider.sendBorrowTransaction(
+ borrowTransaction,
+ borrowTransactionMeta
);
+ ref.current = makeProviderRef(wallet).current;
+ const error = yield* Effect.flip(pending);
expect(error).toBeInstanceOf(ExternalProviderError);
- expect((error as ExternalProviderError).message).toBe(
- "Borrow transaction capability is unavailable"
- );
+ expect(wallet.sendBorrowTransaction).not.toHaveBeenCalled();
})
);
});
diff --git a/packages/widget/tests/providers/external-provider-lifecycle.dom.test.tsx b/packages/widget/tests/providers/external-provider-lifecycle.dom.test.tsx
new file mode 100644
index 00000000..ade996fe
--- /dev/null
+++ b/packages/widget/tests/providers/external-provider-lifecycle.dom.test.tsx
@@ -0,0 +1,643 @@
+import { useAtomValue } from "@effect/atom-react";
+import { Option } from "effect";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import { HttpResponse, http } from "msw";
+import {
+ type act,
+ Component,
+ type PropsWithChildren,
+ StrictMode,
+ useEffect,
+} from "react";
+import { avalanche, base, mainnet, optimism } from "viem/chains";
+import {
+ type Config,
+ type Connector,
+ type UseConnectionReturnType,
+ useAccount,
+} from "wagmi";
+import { SKAtomRegistryProvider } from "../../src/app/composition/providers/atom-runtime";
+import { ThirdPartyQueryClientProvider } from "../../src/app/composition/providers/query-client";
+import { applicationRoutes } from "../../src/app/routes/application-routes";
+import { WagmiConfigProvider } from "../../src/features/wallet/composition";
+import {
+ useSKWallet,
+ useWalletConfig,
+ walletScopeAtom,
+ walletStateResultAtom,
+} from "../../src/features/wallet/index";
+import type { SKExternalProviders } from "../../src/public-api/types";
+import type { NormalizedWalletState } from "../../src/services/wallet/wallet-state";
+import { yieldApiRoute } from "../mocks/api-routes";
+import { describe, expect, it, vi } from "../utils/test-extend.dom";
+import { renderHook } from "../utils/test-utils.dom";
+
+const firstAddress = "0x0000000000000000000000000000000000000001";
+const secondAddress = "0x0000000000000000000000000000000000000002";
+const thirdAddress = "0x0000000000000000000000000000000000000003";
+
+const externalProvider = (
+ currentAddress: string,
+ currentChain: NonNullable,
+ supportedChainIds: SKExternalProviders["supportedChainIds"] = [currentChain]
+): SKExternalProviders => ({
+ currentAddress,
+ currentChain,
+ supportedChainIds,
+ type: "generic",
+ provider: {
+ sendTransaction: async () => "transaction-hash",
+ signMessage: async () => "signature",
+ switchChain: async () => {},
+ },
+});
+
+const enabledNetworks = http.get(yieldApiRoute("/v1/networks"), () =>
+ HttpResponse.json([
+ { id: "ethereum" },
+ { id: "optimism" },
+ { id: "avalanche-c" },
+ ])
+);
+
+type ConnectedIdentity = {
+ readonly address: string | undefined;
+ readonly chainId: number | undefined;
+};
+
+type RuntimeResult = {
+ readonly config: AsyncResult.AsyncResult;
+ readonly wallet: AsyncResult.AsyncResult;
+};
+
+const RuntimeResultProbe = ({
+ onResult,
+}: {
+ readonly onResult: (result: RuntimeResult) => void;
+}) => {
+ const config = useWalletConfig();
+ const wallet = useAtomValue(walletStateResultAtom);
+ useEffect(() => onResult({ config, wallet }), [config, onResult, wallet]);
+ return null;
+};
+
+class WalletFailureBoundary extends Component<
+ PropsWithChildren<{ readonly onError: (error: Error) => void }>,
+ { readonly failed: boolean }
+> {
+ override state = { failed: false };
+
+ static getDerivedStateFromError() {
+ return { failed: true };
+ }
+
+ override componentDidCatch(error: Error) {
+ this.props.onError(error);
+ }
+
+ override render() {
+ return this.state.failed ? (
+
+ ) : (
+ this.props.children
+ );
+ }
+}
+
+const mountWallet = async (
+ initialProvider: SKExternalProviders,
+ strictMode = false
+) => {
+ let externalProviders = initialProvider;
+ const connectedIdentities: ConnectedIdentity[] = [];
+ const errors: Error[] = [];
+ const runtimeResults: RuntimeResult[] = [];
+ const recordError = (error: Error) => {
+ errors.push(error);
+ };
+ const recordRuntimeResult = (result: RuntimeResult) => {
+ runtimeResults.push(result);
+ };
+ const hook = await renderHook(
+ () => {
+ const wallet = useSKWallet();
+ const account = useAccount();
+ const config = useWalletConfig();
+ const scope = useAtomValue(walletScopeAtom);
+ useEffect(() => {
+ if (wallet?.status === "connected") {
+ connectedIdentities.push({
+ address: wallet.address,
+ chainId: wallet.chain?.id,
+ });
+ }
+ }, [wallet]);
+ return { account, config, scope, wallet };
+ },
+ {
+ wrapper: ({ children }) => {
+ const runtime = (
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+ return strictMode ? {runtime} : runtime;
+ },
+ }
+ );
+
+ return {
+ ...hook,
+ connectedIdentities,
+ errors,
+ get runtimeResult() {
+ const latest = runtimeResults.at(-1);
+ if (!latest) throw new Error("Expected the wallet runtime result");
+ return latest;
+ },
+ updateProvider: async (next: SKExternalProviders) => {
+ externalProviders = next;
+ await hook.rerender(undefined);
+ },
+ };
+};
+
+type MountedWallet = {
+ readonly act: typeof act;
+ readonly result: {
+ readonly current: {
+ readonly account: UseConnectionReturnType;
+ readonly config: AsyncResult.AsyncResult;
+ readonly wallet: NormalizedWalletState | null;
+ };
+ };
+};
+
+const waitForIdentity = (
+ mounted: MountedWallet,
+ address: string,
+ chainId: number
+) =>
+ mounted.act(async () => {
+ await expect
+ .poll(() => ({
+ account: {
+ address: mounted.result.current.account.address,
+ chainId: mounted.result.current.account.chainId,
+ status: mounted.result.current.account.status,
+ },
+ wallet: {
+ address: mounted.result.current.wallet?.address,
+ chainId: mounted.result.current.wallet?.chain?.id,
+ status: mounted.result.current.wallet?.status,
+ },
+ }))
+ .toEqual({
+ account: { address, chainId, status: "connected" },
+ wallet: { address, chainId, status: "connected" },
+ });
+ });
+
+const waitForDisconnected = (mounted: MountedWallet) =>
+ mounted.act(async () => {
+ await expect
+ .poll(() => ({
+ accountAddress: mounted.result.current.account.address,
+ accountStatus: mounted.result.current.account.status,
+ walletAddress: mounted.result.current.wallet?.address,
+ walletStatus: mounted.result.current.wallet?.status,
+ }))
+ .toEqual({
+ accountAddress: undefined,
+ accountStatus: "disconnected",
+ walletAddress: null,
+ walletStatus: "disconnected",
+ });
+ });
+
+const getConnector = async (mounted: MountedWallet) => {
+ await mounted.act(async () => {
+ await expect
+ .poll(() => AsyncResult.isSuccess(mounted.result.current.config))
+ .toBe(true);
+ });
+ const config = AsyncResult.getOrThrow(mounted.result.current.config);
+ const connector = config.connectors.find(
+ (candidate) => candidate.id === "externalProviderConnector"
+ );
+ if (!connector)
+ throw new Error("Expected the real external provider connector");
+ return connector;
+};
+
+const emitObsoleteIdentity = (connector: Connector) => {
+ connector.onAccountsChanged([thirdAddress]);
+ connector.onChainChanged(avalanche.id.toString());
+};
+
+describe("external provider runtime generations", () => {
+ it("settles an initially empty host address as disconnected", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const mounted = await mountWallet(externalProvider("", mainnet.id));
+
+ await waitForDisconnected(mounted);
+ expect(mounted.connectedIdentities).toEqual([]);
+ });
+
+ it("uses the new identity and topology after a sequential mount", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const first = await mountWallet(externalProvider(firstAddress, mainnet.id));
+ await waitForIdentity(first, firstAddress, mainnet.id);
+ const oldConnector = await getConnector(first);
+ first.unmount();
+
+ const second = await mountWallet(
+ externalProvider(secondAddress, optimism.id)
+ );
+ await waitForIdentity(second, secondAddress, optimism.id);
+ await second.act(async () => emitObsoleteIdentity(oldConnector));
+ await waitForIdentity(second, secondAddress, optimism.id);
+ expect(
+ second.connectedIdentities.every(
+ (identity) =>
+ identity.address === secondAddress && identity.chainId === optimism.id
+ )
+ ).toBe(true);
+ });
+
+ it("ignores an old connection that completes after a new mount", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const first = await mountWallet(externalProvider(firstAddress, mainnet.id));
+ await waitForIdentity(first, firstAddress, mainnet.id);
+ await first.updateProvider(externalProvider("", mainnet.id));
+ await waitForDisconnected(first);
+ const oldConnector = await getConnector(first);
+ const connect = oldConnector.connect.bind(oldConnector);
+ const release = Promise.withResolvers();
+ const completed = Promise.withResolvers();
+ // Keep the real connector result and delay only its delivery to Wagmi.
+ const delayedConnect = vi
+ .spyOn(oldConnector, "connect")
+ .mockImplementation(async (parameters) => {
+ const result = await connect(parameters);
+ await release.promise;
+ completed.resolve();
+ return result;
+ });
+
+ try {
+ await first.updateProvider(externalProvider(firstAddress, mainnet.id));
+ await first.act(async () => {
+ await expect
+ .poll(() => first.result.current.wallet?.status)
+ .toBe("connecting");
+ });
+ first.unmount();
+
+ const second = await mountWallet(
+ externalProvider(secondAddress, optimism.id)
+ );
+ await waitForIdentity(second, secondAddress, optimism.id);
+ await second.act(async () => {
+ release.resolve();
+ await completed.promise;
+ });
+ await second.act(async () => emitObsoleteIdentity(oldConnector));
+ await waitForIdentity(second, secondAddress, optimism.id);
+ expect(
+ second.connectedIdentities.every(
+ (identity) =>
+ identity.address === secondAddress &&
+ identity.chainId === optimism.id
+ )
+ ).toBe(true);
+ } finally {
+ release.resolve();
+ delayedConnect.mockRestore();
+ }
+ });
+
+ it("clears the connected identity and restores the new host address and chain", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const mounted = await mountWallet(
+ externalProvider(firstAddress, mainnet.id, [mainnet.id, optimism.id])
+ );
+ await waitForIdentity(mounted, firstAddress, mainnet.id);
+
+ await mounted.updateProvider(
+ externalProvider("", mainnet.id, [mainnet.id, optimism.id])
+ );
+ await waitForDisconnected(mounted);
+
+ await mounted.updateProvider(
+ externalProvider(secondAddress, optimism.id, [mainnet.id, optimism.id])
+ );
+ await waitForIdentity(mounted, secondAddress, optimism.id);
+ });
+
+ it("keeps an excluded host chain unsupported without reconnecting and restores it when allowed", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const mounted = await mountWallet(
+ externalProvider(firstAddress, mainnet.id, [mainnet.id, optimism.id])
+ );
+ await waitForIdentity(mounted, firstAddress, mainnet.id);
+ const connector = await getConnector(mounted);
+ const connect = vi.spyOn(connector, "connect");
+
+ try {
+ await mounted.updateProvider(
+ externalProvider(firstAddress, mainnet.id, [optimism.id])
+ );
+ await mounted.act(async () => {
+ await expect
+ .poll(() => ({
+ account: {
+ address: mounted.result.current.account.address,
+ chainId: mounted.result.current.account.chainId,
+ status: mounted.result.current.account.status,
+ },
+ wallet: {
+ address: mounted.result.current.wallet?.address,
+ chainId: mounted.result.current.wallet?.chain?.id,
+ network: mounted.result.current.wallet?.network,
+ status: mounted.result.current.wallet?.status,
+ },
+ }))
+ .toEqual({
+ account: {
+ address: firstAddress,
+ chainId: mainnet.id,
+ status: "connected",
+ },
+ wallet: {
+ address: firstAddress,
+ chainId: mainnet.id,
+ network: null,
+ status: "unsupported",
+ },
+ });
+ });
+ mounted.connectedIdentities.length = 0;
+
+ await mounted.act(
+ () => new Promise((resolve) => setTimeout(resolve, 100))
+ );
+ expect(mounted.result.current.wallet?.status).toBe("unsupported");
+ expect(mounted.result.current.scope).toBeNull();
+ expect(mounted.connectedIdentities).toEqual([]);
+ expect(connect).not.toHaveBeenCalled();
+
+ await mounted.updateProvider(
+ externalProvider(firstAddress, mainnet.id, [mainnet.id, optimism.id])
+ );
+ await waitForIdentity(mounted, firstAddress, mainnet.id);
+ } finally {
+ connect.mockRestore();
+ }
+ });
+
+ for (const { name, supportedChainIds } of [
+ { name: "an empty supported chain list", supportedChainIds: [] },
+ {
+ name: "a supported chain list outside the configured topology",
+ supportedChainIds: [base.id],
+ },
+ ] satisfies {
+ name: string;
+ supportedChainIds: SKExternalProviders["supportedChainIds"];
+ }[]) {
+ it(`fails bootstrap for ${name}`, async ({ worker }) => {
+ worker.use(enabledNetworks);
+ const mounted = await mountWallet(
+ externalProvider(firstAddress, mainnet.id, supportedChainIds)
+ );
+
+ await mounted.act(async () => {
+ await expect
+ .poll(() => AsyncResult.isFailure(mounted.runtimeResult.config))
+ .toBe(true);
+ });
+ expect(
+ Option.isNone(AsyncResult.value(mounted.runtimeResult.config))
+ ).toBe(true);
+ expect(mounted.connectedIdentities).toEqual([]);
+ expect(
+ document.querySelector('[data-testid="wallet-runtime-error"]')
+ ).not.toBeNull();
+ expect(
+ document.querySelector('[data-testid="wallet-runtime-ready"]')
+ ).toBeNull();
+ });
+
+ it(`fails the live wallet and removes its usable view for ${name}`, async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const mounted = await mountWallet(
+ externalProvider(firstAddress, mainnet.id, [mainnet.id, optimism.id])
+ );
+ await waitForIdentity(mounted, firstAddress, mainnet.id);
+ expect(
+ document.querySelector('[data-testid="wallet-runtime-ready"]')
+ ).not.toBeNull();
+
+ await mounted.updateProvider(
+ externalProvider(firstAddress, mainnet.id, supportedChainIds)
+ );
+ await mounted.act(async () => {
+ await expect
+ .poll(() =>
+ Option.getOrNull(AsyncResult.error(mounted.runtimeResult.wallet))
+ )
+ .toMatchObject({
+ _tag: "WalletRuntimeInvariantError",
+ reason: "external-provider-no-supported-chains",
+ });
+ await expect
+ .poll(() => mounted.errors)
+ .toContainEqual(
+ expect.objectContaining({
+ _tag: "WalletRuntimeInvariantError",
+ reason: "external-provider-no-supported-chains",
+ })
+ );
+ });
+ expect(
+ document.querySelector('[data-testid="wallet-runtime-error"]')
+ ).not.toBeNull();
+ expect(
+ document.querySelector('[data-testid="wallet-runtime-ready"]')
+ ).toBeNull();
+ });
+ }
+
+ it("uses every configured chain when supportedChainIds is omitted", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const mounted = await mountWallet({
+ ...externalProvider(firstAddress, mainnet.id),
+ supportedChainIds: undefined,
+ });
+ await waitForIdentity(mounted, firstAddress, mainnet.id);
+
+ await mounted.updateProvider({
+ ...externalProvider(secondAddress, optimism.id),
+ supportedChainIds: undefined,
+ });
+ await waitForIdentity(mounted, secondAddress, optimism.id);
+ });
+
+ it("connects the restored identity after an earlier pending connection fails", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const mounted = await mountWallet(
+ externalProvider(firstAddress, mainnet.id, [mainnet.id, optimism.id])
+ );
+ await waitForIdentity(mounted, firstAddress, mainnet.id);
+ await mounted.updateProvider(
+ externalProvider("", mainnet.id, [mainnet.id, optimism.id])
+ );
+ await waitForDisconnected(mounted);
+ mounted.connectedIdentities.length = 0;
+ const connector = await getConnector(mounted);
+ const connect = connector.connect.bind(connector);
+ const release = Promise.withResolvers();
+ const failed = Promise.withResolvers();
+ const pendingConnect = vi
+ .spyOn(connector, "connect")
+ .mockImplementationOnce(async (parameters) => {
+ await connect(parameters);
+ await release.promise;
+ failed.resolve();
+ throw new Error("The earlier external-provider connection failed");
+ });
+
+ try {
+ await mounted.updateProvider(
+ externalProvider(firstAddress, mainnet.id, [mainnet.id, optimism.id])
+ );
+ await mounted.act(async () => {
+ await expect
+ .poll(() => mounted.result.current.wallet?.status)
+ .toBe("connecting");
+ });
+ await mounted.updateProvider(
+ externalProvider("", mainnet.id, [mainnet.id, optimism.id])
+ );
+ await mounted.updateProvider(
+ externalProvider(secondAddress, optimism.id, [mainnet.id, optimism.id])
+ );
+ await mounted.act(async () => {
+ release.resolve();
+ await failed.promise;
+ });
+
+ await waitForIdentity(mounted, secondAddress, optimism.id);
+ expect(
+ mounted.connectedIdentities.every(
+ (identity) => identity.address === secondAddress
+ )
+ ).toBe(true);
+ } finally {
+ release.resolve();
+ pendingConnect.mockRestore();
+ }
+ });
+
+ it("keeps repeated generations isolated from every retired connector", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const retiredConnectors: Connector[] = [];
+ for (const provider of [
+ externalProvider(firstAddress, mainnet.id),
+ externalProvider(secondAddress, optimism.id),
+ externalProvider(thirdAddress, avalanche.id),
+ externalProvider(firstAddress, mainnet.id),
+ ]) {
+ const mounted = await mountWallet(provider);
+ const chainId = provider.currentChain!;
+ await waitForIdentity(mounted, provider.currentAddress, chainId);
+ await mounted.act(async () => {
+ for (const connector of retiredConnectors) {
+ connector.onAccountsChanged([secondAddress]);
+ connector.onChainChanged(optimism.id.toString());
+ connector.onDisconnect();
+ }
+ });
+ await waitForIdentity(mounted, provider.currentAddress, chainId);
+ expect(
+ mounted.connectedIdentities.every(
+ (identity) =>
+ identity.address === provider.currentAddress &&
+ identity.chainId === chainId
+ )
+ ).toBe(true);
+ retiredConnectors.push(await getConnector(mounted));
+ mounted.unmount();
+ }
+ });
+
+ it("survives StrictMode replay, live identity changes, and a fresh mount", async ({
+ worker,
+ }) => {
+ worker.use(enabledNetworks);
+ const first = await mountWallet(
+ externalProvider(firstAddress, mainnet.id, [mainnet.id, optimism.id]),
+ true
+ );
+ await waitForIdentity(first, firstAddress, mainnet.id);
+ const oldConnector = await getConnector(first);
+ await first.updateProvider(
+ externalProvider(secondAddress, optimism.id, [mainnet.id, optimism.id])
+ );
+ await waitForIdentity(first, secondAddress, optimism.id);
+ first.unmount();
+
+ const second = await mountWallet(
+ externalProvider(thirdAddress, avalanche.id),
+ true
+ );
+ await waitForIdentity(second, thirdAddress, avalanche.id);
+ await second.act(async () => {
+ oldConnector.onAccountsChanged([firstAddress]);
+ oldConnector.onChainChanged(mainnet.id.toString());
+ oldConnector.onDisconnect();
+ });
+ await waitForIdentity(second, thirdAddress, avalanche.id);
+ expect(
+ second.connectedIdentities.every(
+ (identity) =>
+ identity.address === thirdAddress && identity.chainId === avalanche.id
+ )
+ ).toBe(true);
+ });
+});
diff --git a/packages/widget/tests/providers/wallet-state-atom.test.ts b/packages/widget/tests/providers/wallet-state-atom.test.ts
index a035a629..d0072bee 100644
--- a/packages/widget/tests/providers/wallet-state-atom.test.ts
+++ b/packages/widget/tests/providers/wallet-state-atom.test.ts
@@ -46,7 +46,7 @@ const connected = {
isConnected: true,
isDisconnected: false,
status: "connected",
-} as WalletConnectionSnapshot;
+} satisfies WalletConnectionSnapshot;
const normalize = (
connection: WalletConnectionSnapshot,
@@ -103,6 +103,72 @@ describe("normalized wallet state atom", () => {
});
});
+ it("removes an excluded external chain's scope through normalization and transitions", () => {
+ const externalConnector = {
+ id: "externalProviderConnector",
+ uid: "external-provider",
+ } as Connector;
+ const externalConnection = { ...connected, connector: externalConnector };
+ const previous = normalize(externalConnection);
+ const excluded = normalize(externalConnection, {
+ connectorChains: [optimism],
+ previous,
+ });
+
+ expect(excluded).toMatchObject({
+ additionalAddresses: null,
+ address,
+ chain: mainnet,
+ connector: externalConnector,
+ ledgerAccounts: null,
+ network: null,
+ status: "unsupported",
+ });
+ expect(
+ transitionalWalletState({
+ additionalAddresses: null,
+ connection: externalConnection,
+ connectorChains: [mainnet],
+ controller,
+ forceAddress: undefined,
+ ledgerState: disconnectedLedgerConnectorState,
+ previous: excluded,
+ })
+ ).toMatchObject({
+ chain: mainnet,
+ connectorChains: [optimism],
+ network: null,
+ status: "unsupported",
+ });
+ expect(
+ normalize(
+ {
+ ...disconnectedWalletConnection,
+ isDisconnected: false,
+ isReconnecting: true,
+ status: "reconnecting",
+ },
+ { connectorChains: [optimism], previous }
+ )
+ ).toMatchObject({
+ additionalAddresses: null,
+ ledgerAccounts: null,
+ network: null,
+ status: "unsupported",
+ });
+ expect(
+ normalize(externalConnection, {
+ connectorChains: [mainnet],
+ previous: excluded,
+ })
+ ).toMatchObject({
+ address,
+ chain: mainnet,
+ network: "ethereum",
+ status: "connected",
+ });
+ });
+
it("normalizes supported state, force address, chains, and auxiliary data", () => {
const account = { id: "ledger-account" } as Account;
const additionalAddresses = { cosmosPubKey: "A".repeat(44) };
diff --git a/packages/widget/tests/providers/wallet/external-provider-connector.test.ts b/packages/widget/tests/providers/wallet/external-provider-connector.test.ts
new file mode 100644
index 00000000..2b3f8bea
--- /dev/null
+++ b/packages/widget/tests/providers/wallet/external-provider-connector.test.ts
@@ -0,0 +1,292 @@
+import { describe, expect, it } from "@effect/vitest";
+import { connectorsForWallets } from "@stakekit/rainbowkit";
+import { type Chain, createClient } from "viem";
+import { arbitrum, mainnet, polygon } from "viem/chains";
+import { createConfig, http } from "wagmi";
+import {
+ connect,
+ disconnect,
+ getConnection,
+ reconnect,
+ watchConnection,
+} from "wagmi/actions";
+import type { ExternalProviderSnapshot } from "../../../src/public-api/external-provider-contract";
+import type { SKExternalProviders } from "../../../src/public-api/types";
+import { solana } from "../../../src/services/wallet/internal/adapters/configured-chains";
+import {
+ externalProviderConnector,
+ isExternalProviderConnector,
+} from "../../../src/services/wallet/internal/adapters/external-provider";
+import { normalizeWalletState } from "../../../src/services/wallet/internal/runtime/state-projection";
+import { WalletRuntimeInvariantError } from "../../../src/services/wallet/wallet-errors";
+import { disconnectedLedgerConnectorState } from "../../../src/services/wallet/wallet-state";
+import { runWalletEffect } from "../../utils/run-wallet-effect";
+
+const firstAddress = "0x0000000000000000000000000000000000000001";
+const secondAddress = "0x0000000000000000000000000000000000000002";
+
+const makeHarness = (
+ snapshot: Partial = {},
+ chains: readonly [Chain, ...Chain[]] = [mainnet, arbitrum, polygon, solana]
+) => {
+ const variant: { current: ExternalProviderSnapshot } = {
+ current: {
+ currentAddress: firstAddress,
+ provider: {
+ sendTransaction: async () => "transaction-hash",
+ signMessage: async () => "signature",
+ switchChain: async () => undefined,
+ },
+ type: "generic",
+ ...snapshot,
+ },
+ };
+ const config = createConfig({
+ chains,
+ client: ({ chain }) => createClient({ chain, transport: http() }),
+ connectors: connectorsForWallets(
+ [externalProviderConnector(variant, runWalletEffect)],
+ { appName: "Connector regression", projectId: "connector-regression" }
+ ),
+ multiInjectedProviderDiscovery: false,
+ storage: null,
+ });
+ const connector = config.connectors[0];
+ if (!connector || !isExternalProviderConnector(connector)) {
+ throw new Error("External provider connector missing");
+ }
+ return { config, connector, variant };
+};
+
+describe("external-provider connector", () => {
+ it.each([arbitrum, polygon, solana])(
+ "connects directly to the host's $name routing ID without publishing Ethereum",
+ async (chain) => {
+ const { config, connector } = makeHarness({ currentChain: chain.id });
+ const connectedChainIds: Array = [];
+ const unsubscribe = watchConnection(config, {
+ onChange: (connection) => {
+ if (connection.isConnected) {
+ connectedChainIds.push(connection.chainId);
+ }
+ },
+ });
+
+ try {
+ await expect(connector.getChainId()).resolves.toBe(chain.id);
+ await expect(connect(config, { connector })).resolves.toMatchObject({
+ accounts: [firstAddress],
+ chainId: chain.id,
+ });
+ expect(getConnection(config)).toMatchObject({
+ address: firstAddress,
+ chain,
+ chainId: chain.id,
+ status: "connected",
+ });
+ expect(connectedChainIds).toEqual([chain.id]);
+ } finally {
+ unsubscribe();
+ await disconnect(config);
+ }
+ }
+ );
+
+ it("reconnects with the latest host chain and address", async () => {
+ const { config, connector, variant } = makeHarness({
+ currentChain: arbitrum.id,
+ });
+ await connect(config, { connector });
+ await disconnect(config);
+ variant.current = {
+ ...variant.current,
+ currentAddress: secondAddress,
+ currentChain: polygon.id,
+ };
+ const connectedChainIds: Array = [];
+ const unsubscribe = watchConnection(config, {
+ onChange: (connection) => {
+ if (connection.isConnected) {
+ connectedChainIds.push(connection.chainId);
+ }
+ },
+ });
+
+ try {
+ await expect(
+ reconnect(config, { connectors: [connector] })
+ ).resolves.toMatchObject([
+ { accounts: [secondAddress], chainId: polygon.id },
+ ]);
+ await expect(connector.getChainId()).resolves.toBe(polygon.id);
+ expect(getConnection(config)).toMatchObject({
+ address: secondAddress,
+ chainId: polygon.id,
+ status: "connected",
+ });
+ expect(connectedChainIds).toEqual([polygon.id]);
+ } finally {
+ unsubscribe();
+ await disconnect(config);
+ }
+ });
+
+ it("uses the first configured chain when the host omits its chain", async () => {
+ const { config, connector } = makeHarness();
+ try {
+ await expect(connect(config, { connector })).resolves.toMatchObject({
+ chainId: mainnet.id,
+ });
+ } finally {
+ await disconnect(config);
+ }
+ });
+
+ it("preserves supported-chain fallback and its change notification when the host omits its chain", async () => {
+ const { config, connector, variant } = makeHarness({
+ supportedChainIds: [polygon.id, arbitrum.id],
+ });
+ const changes: Array = [];
+ connector.emitter.on("change", (change) => changes.push(change.chainId));
+
+ try {
+ await expect(connect(config, { connector })).resolves.toMatchObject({
+ chainId: arbitrum.id,
+ });
+ variant.current = { ...variant.current, supportedChainIds: [polygon.id] };
+ connector.onSupportedChainsChanged({
+ currentChainId: arbitrum.id,
+ supportedChainIds: [polygon.id],
+ });
+
+ await expect(connector.getChainId()).resolves.toBe(polygon.id);
+ expect(changes).toEqual([polygon.id]);
+ expect(getConnection(config)).toMatchObject({
+ chainId: polygon.id,
+ status: "connected",
+ });
+ } finally {
+ await disconnect(config);
+ }
+ });
+
+ it("does not replace a live host chain when the supported list removes it", async () => {
+ const { config, connector, variant } = makeHarness({
+ currentChain: polygon.id,
+ });
+ const changes: Array = [];
+ connector.emitter.on("change", (change) => changes.push(change.chainId));
+
+ try {
+ await connect(config, { connector });
+ variant.current = { ...variant.current, supportedChainIds: [mainnet.id] };
+ connector.onSupportedChainsChanged({
+ currentChainId: polygon.id,
+ supportedChainIds: [mainnet.id],
+ });
+
+ await expect(connector.getChainId()).resolves.toBe(polygon.id);
+ expect(changes).toEqual([]);
+ expect(getConnection(config).chainId).toBe(polygon.id);
+
+ variant.current = { ...variant.current, currentChain: arbitrum.id };
+ connector.onChainChanged(`0x${arbitrum.id.toString(16)}`);
+
+ await expect(connector.getChainId()).resolves.toBe(arbitrum.id);
+ expect(changes).toEqual([arbitrum.id]);
+ expect(getConnection(config).chainId).toBe(arbitrum.id);
+ } finally {
+ await disconnect(config);
+ }
+ });
+
+ it("keeps a host chain outside the topology unsupported", async () => {
+ const { config, connector } = makeHarness(
+ { currentChain: arbitrum.id, supportedChainIds: [mainnet.id] },
+ [mainnet]
+ );
+ try {
+ await connect(config, { connector });
+ const connection = getConnection(config);
+ expect(connection.chainId).toBe(arbitrum.id);
+ expect(connection.chain).toBeUndefined();
+ expect(
+ normalizeWalletState({
+ additionalAddresses: null,
+ connection,
+ connectorChains: [mainnet],
+ controller: {
+ cosmosConfig: { cosmosChainsMap: {} },
+ evmConfig: {
+ evmChainsMap: {
+ ethereum: {
+ network: "ethereum",
+ type: "evm",
+ wagmiChain: mainnet,
+ },
+ },
+ },
+ isLedgerLive: false,
+ miscConfig: { miscChainsMap: {} },
+ substrateConfig: { substrateChainsMap: {} },
+ },
+ forceAddress: undefined,
+ ledgerState: disconnectedLedgerConnectorState,
+ })
+ ).toMatchObject({
+ address: firstAddress,
+ chain: null,
+ network: null,
+ status: "unsupported",
+ });
+ } finally {
+ await disconnect(config);
+ }
+ });
+
+ it.each([
+ { supportedChainIds: [], currentChain: undefined },
+ { supportedChainIds: [], currentChain: mainnet.id },
+ { supportedChainIds: [arbitrum.id], currentChain: undefined },
+ { supportedChainIds: [arbitrum.id], currentChain: arbitrum.id },
+ ])(
+ "rejects an empty configured intersection: $supportedChainIds / $currentChain",
+ (snapshot) => {
+ expect(() => makeHarness(snapshot, [mainnet])).toThrow(
+ WalletRuntimeInvariantError
+ );
+ }
+ );
+
+ it("rejects empty live chain lists instead of widening them to all chains", () => {
+ const { connector } = makeHarness({ currentChain: mainnet.id });
+ expect(() =>
+ connector.onSupportedChainsChanged({
+ currentChainId: mainnet.id,
+ supportedChainIds: [],
+ })
+ ).toThrow(WalletRuntimeInvariantError);
+ });
+
+ it("does not reconnect an external provider without a host account", async () => {
+ const { config, connector } = makeHarness({
+ currentAddress: "",
+ currentChain: mainnet.id,
+ });
+ expect(await reconnect(config)).toEqual([]);
+ expect(getConnection(config).status).toBe("disconnected");
+ await expect(connect(config, { connector })).rejects.toThrow();
+ expect(getConnection(config).status).toBe("disconnected");
+ });
+
+ it("disconnects when the host clears its last account", async () => {
+ const { config, connector } = makeHarness({ currentChain: mainnet.id });
+ await connect(config, { connector });
+ connector.onAccountsChanged([""]);
+ expect(getConnection(config)).toMatchObject({
+ status: "disconnected",
+ address: undefined,
+ });
+ expect(config.state.connections.size).toBe(0);
+ });
+});
diff --git a/packages/widget/tests/providers/wallet/wallet-lifecycle.test.ts b/packages/widget/tests/providers/wallet/wallet-lifecycle.test.ts
index a8feb877..4a6739f3 100644
--- a/packages/widget/tests/providers/wallet/wallet-lifecycle.test.ts
+++ b/packages/widget/tests/providers/wallet/wallet-lifecycle.test.ts
@@ -179,6 +179,39 @@ describe("Wallet lifecycle policy", () => {
})
);
+ it.effect(
+ "does not disconnect an unsupported external wallet and trigger reconnection",
+ () =>
+ Effect.gen(function* () {
+ const disconnect = vi.fn(() => Effect.void);
+ const externalConnector = {
+ ...connector,
+ id: "externalProviderConnector",
+ };
+ const supportedState = {
+ ...connected(),
+ connector: externalConnector,
+ };
+ const unsupportedState = {
+ ...unsupported,
+ connector: externalConnector,
+ };
+ const policy = yield* makePolicy(() => Effect.void);
+
+ for (const state of [
+ supportedState,
+ unsupportedState,
+ unsupportedState,
+ supportedState,
+ unsupportedState,
+ ]) {
+ yield* policy.transition({ actions: { disconnect }, state });
+ }
+
+ expect(disconnect).not.toHaveBeenCalled();
+ })
+ );
+
it.effect("localizes tracking and disconnect failures", () =>
Effect.gen(function* () {
expect(
diff --git a/packages/widget/tests/providers/wallet/wallet-state.test.ts b/packages/widget/tests/providers/wallet/wallet-state.test.ts
index 70b743d9..56cd9758 100644
--- a/packages/widget/tests/providers/wallet/wallet-state.test.ts
+++ b/packages/widget/tests/providers/wallet/wallet-state.test.ts
@@ -311,6 +311,83 @@ describe("WalletService authoritative Wallet State", () => {
})
);
+ it.effect(
+ "removes and restores external chain availability without changing core identity",
+ () =>
+ Effect.gen(function* () {
+ const chains = yield* SubscriptionRef.make([mainnet]);
+ const connector = {
+ $filteredChains: SubscriptionRef.changes(chains),
+ id: "externalProviderConnector",
+ name: "External Provider",
+ type: "externalProvider",
+ uid: "external-provider",
+ } as unknown as Connector;
+ const controller = makeController(makeDefaultConfig());
+ const core = yield* SubscriptionRef.make({
+ connection: connectedConnection(connector),
+ connectors: [connector],
+ });
+
+ const result = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const state = yield* makeWalletStateRuntime({
+ controller: controller as WalletController,
+ core: {
+ current: SubscriptionRef.get(core),
+ states: SubscriptionRef.changes(core),
+ },
+ readStoredPublicKeys: Effect.succeed({}),
+ });
+ const excluded = yield* state.contexts.pipe(
+ Stream.filter(
+ (context) => context.state.connection.status === "unsupported"
+ ),
+ Stream.runHead,
+ Effect.map(Option.getOrThrow),
+ Effect.forkChild({ startImmediately: true })
+ );
+ yield* SubscriptionRef.set(chains, [optimism]);
+ const unsupported = yield* Fiber.join(excluded);
+ const restored = yield* state.contexts.pipe(
+ Stream.filter(
+ (context) => context.state.connection.status === "connected"
+ ),
+ Stream.runHead,
+ Effect.map(Option.getOrThrow),
+ Effect.forkChild({ startImmediately: true })
+ );
+ yield* SubscriptionRef.set(chains, [mainnet]);
+ return { restored: yield* Fiber.join(restored), unsupported };
+ })
+ );
+
+ expect(result.unsupported.core.connection).toMatchObject({
+ address,
+ chainId: mainnet.id,
+ status: "connected",
+ });
+ expect(result.unsupported.state.connection).toMatchObject({
+ additionalAddresses: null,
+ address,
+ chain: mainnet,
+ connector,
+ connectorChains: [optimism],
+ ledgerAccounts: null,
+ network: null,
+ status: "unsupported",
+ });
+ expect(result.restored.state.connection).toMatchObject({
+ address,
+ chain: mainnet,
+ connector,
+ connectorChains: [mainnet],
+ network: "ethereum",
+ status: "connected",
+ });
+ })
+ );
+
it.effect("keeps the last Wallet Scope Owner through a reconnect gap", () =>
Effect.gen(function* () {
const connector = {
diff --git a/packages/widget/tests/use-cases/external-provider/dashboard-chain.browser.test.tsx b/packages/widget/tests/use-cases/external-provider/dashboard-chain.browser.test.tsx
new file mode 100644
index 00000000..7a7c2617
--- /dev/null
+++ b/packages/widget/tests/use-cases/external-provider/dashboard-chain.browser.test.tsx
@@ -0,0 +1,114 @@
+import { HttpResponse, http } from "msw";
+import { SKApp } from "../../../src/App";
+import { useEarnYieldSelection } from "../../../src/features/earn/index";
+import type { SKAppProps } from "../../../src/public-api/react-types";
+import { yieldApiYieldDtoFixture } from "../../fixtures";
+import { legacyApiRoute, yieldApiRoute } from "../../mocks/api-routes";
+import { describe, expect, it } from "../../utils/test-extend";
+import { render } from "../../utils/test-utils";
+
+const Selection = () => {
+ const { view } = useEarnYieldSelection();
+ return ;
+};
+
+const provider = {
+ signMessage: async () => "signature",
+ switchChain: async () => {},
+ sendTransaction: async () => "hash",
+};
+
+const props = (currentChain: 42161 | 137): SKAppProps => ({
+ apiKey: import.meta.env.VITE_API_KEY,
+ dashboardVariant: true,
+ externalProviders: {
+ type: "generic",
+ currentAddress: "0x0000000000000000000000000000000000000001",
+ currentChain,
+ provider,
+ },
+});
+
+const yields = (["ethereum", "arbitrum", "polygon"] as const).map((network) => {
+ const token = {
+ network,
+ name: `${network} deposit asset`,
+ symbol: network.toUpperCase(),
+ decimals: 18,
+ };
+ return yieldApiYieldDtoFixture({
+ id: `${network}-test-staking`,
+ metadata: {
+ ...yieldApiYieldDtoFixture().metadata,
+ name: `${network} staking opportunity`,
+ },
+ token,
+ tokens: [token],
+ inputTokens: [token],
+ outputToken: token,
+ });
+});
+
+describe("External provider dashboard chain", () => {
+ it("replaces the selected opportunity when the host changes chain without remounting", async ({
+ worker,
+ }) => {
+ worker.use(
+ http.get(yieldApiRoute("/v1/networks"), () =>
+ HttpResponse.json(yields.map((item) => ({ id: item.token.network })))
+ ),
+ http.get(legacyApiRoute("/v1/tokens"), ({ request }) => {
+ const query = new URL(request.url).searchParams;
+ const network = query.get("network");
+ const types = query
+ .getAll("yieldTypes")
+ .flatMap((value) => value.split(","));
+ const items = yields
+ .filter(
+ (item) =>
+ (!network || item.token.network === network) &&
+ (types.length === 0 || types.includes(item.mechanics.type))
+ )
+ .map((item) => ({ token: item.token, availableYields: [item.id] }));
+ return HttpResponse.json(items);
+ }),
+ http.get(yieldApiRoute("/v1/yields"), () =>
+ HttpResponse.json({
+ items: yields,
+ total: yields.length,
+ offset: 0,
+ limit: 100,
+ })
+ ),
+ http.get(yieldApiRoute("/v1/yields/:yieldId"), ({ params }) =>
+ HttpResponse.json(yields.find((item) => item.id === params.yieldId))
+ )
+ );
+ const app = await render(
+
+
+
+ );
+ await expect
+ .element(app.getByTestId("selected-yield"))
+ .toHaveTextContent("arbitrum-test-staking");
+ await expect
+ .element(app.getByText("ARBITRUM", { exact: true }).first())
+ .toBeInTheDocument();
+ await app.rerender(
+
+
+
+ );
+ await expect
+ .element(app.getByTestId("selected-yield"))
+ .toHaveTextContent("polygon-test-staking");
+ await expect
+ .element(app.getByText("POLYGON", { exact: true }).first())
+ .toBeInTheDocument();
+ await expect
+ .element(app.getByText("ARBITRUM", { exact: true }))
+ .not.toBeInTheDocument();
+ await app.unmount();
+ });
+});