npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@multiplatform.one/web3

v7.31.0

Published

Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi

Readme

@multiplatform.one/web3

Web3 wallet integration for multiplatform.one — wagmi + viem wired into the connectkit modal, with EVM/Solana/Sui support, native (WebView bridge) builds, and ready-made wallet UI components.

Install

pnpm add @multiplatform.one/web3

Peers: react, @tanstack/react-query.

What it owns

  • Web3Provider (also WalletProvider) — the wallet families, the connection store behind useConnection(), and wagmi + connectkit, from one Web3Config (appName, chains, enableTestnets, localnet for a hardhat dev chain, Solana/Sui RPC URLs)
  • Hooks — useWallet() (unified state + actions), useContractRead, useContractWrite, plus re-exported wagmi hooks (useAccount, useBalance, useConnect, useSignMessage, useSwitchChain, …)
  • Components — ConnectKitButton, WalletButton, WalletAvatar, WalletStatus, NetworkSwitcher
  • Subpaths — ./web (the root without the tamagui components, for a plain web bundler, below), ./native (WebView wallet bridge), ./connectkit, ./message (the wallet sign-in messages), ./keycloak (wallet login against the Keycloak wallet SPI), ./vite and ./esbuild (bundler plugins, below)

Wallet families and useConnection()

Web3Provider registers the wallet families an app uses and holds one active connection across them. WalletProvider is the same component under its first name.

import { Web3Provider, eth, gpg, sol, useConnection } from "@multiplatform.one/web3";

function Account() {
  const { status, family, address, client } = useConnection();
  if (status !== "connected") return null;
  return <button onClick={() => client?.signMessage("hello")}>{`${family} ${address}`}</button>;
}

export function App() {
  return (
    <Web3Provider config={{ appName: "My App" }} families={[eth(), sol(), gpg()]}>
      <Account />
    </Web3Provider>
  );
}

Leave families out and the provider takes the ones config.families names, or every built-in family: eth, sol, sui, btc and gpg. Pass one or the other, not both. Each family carries its sign-in message, which useConnectionStore().registry.messages.build(family, opts) builds, and its connection driver: eth() runs on wagmi, and eth({ config }) hands the provider the app's own wagmi config; sol(), sui() and btc() run on the light driver in a store an app makes itself with createConnectionStore; gpg() signs without connecting. Under Web3Provider the Solana, Sui and Bitcoin families connect through the wagmi config for now (below), so a config handed over with eth({ config }) lists their wallets only if it carries the Solana, Sui and Bitcoin wallet connectors, as getDefaultConfig builds it.

useConnection() returns { status, family, connector, address, chainId, client } whichever way the connection opened: the connect modal, a wagmi hook, or useConnectionStore().connect(family, connectorId). Connecting a wallet of another family closes the first. useEvmClient(), useSuiClient() and useBitcoinClient() return the store's client when one is mounted.

Until the modal reads the store in 8.0.0, eth, sol, sui and btc connect through the modal's wagmi config, and wagmi still restores the last connection on load. Solana, Sui and Bitcoin connections report solana:mainnet, sui:mainnet and bitcoin as their chain id, not the synthetic EVM id the wagmi shim uses. A ConnectKitProvider under a bare WagmiProvider mounts a store of its own. The shim's exports are deprecated and go in 8.0.0: walletAdapterConnector, getWalletConnectors, syntheticChains, solanaChain, suiChain, bitcoinChain, SOLANA_CHAIN_ID, SUI_CHAIN_ID, BITCOIN_CHAIN_ID, isSyntheticChainId, clientForConnector and connectorAdapter.

Sign-in with SignInProvider

SignInProvider signs in with whatever the connection store holds: the connected family builds its own sign-in message and the connection's client signs it, so an Ethereum, Solana, Sui or Bitcoin wallet takes the same path. Hand it a nonce source and an onSign, or pass the same config as Web3Provider's signIn prop.

const kc = keycloakWallet({ baseUrl, realm, domain: "app.example.com" });
const grant = kc.directGrant({ clientId: "app" });

<Web3Provider config={config} signIn={kc.signInConfig(grant.onSign)}>
  <App />
</Web3Provider>;

// inside App: useDirectLogin(grant).signIn(), or useSignIn().signIn()

useSignIn() returns { status, error, signIn, reset }, whose status takes StatusState's values. useDirectLogin and useWalletLogin sign through a SignInProvider when there is one. The wagmi-only SIWEProvider, useSIWE, kc.siweConfig and kc.createMessage, and Web3Provider's siwe prop, are deprecated and go in 8.0.0; under only a SIWEProvider the Keycloak hooks sign through it as before. KeycloakWalletLogin keeps its SIWEProvider until then: the connect modal's sign-in step, which a phone lands on after the wallet trip, reads the SIWE provider until the modal reads the store.

Per-chain clients

Apps talk to a chain through a client, never through wagmi, window.ethereum or a provider's request. Every client shares { family, address, chainId?, capabilities, signMessage(message) }, and signMessage returns the family's native signature, the one the Keycloak verifiers take: EVM 0x hex, Solana base58, Sui base64, Bitcoin base64 BIP-322, GPG the armored signature plus the armored public key.

| Hook / factory | What it adds | | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | useEvmClient() / evmClient({ walletClient } \| { provider, account, chain }) | viem walletClient + publicClient, sendTransaction, writeContract, waitForTransactionReceipt, confirmTransaction, explorerTxUrl, erc20.{transfer, approve, ensureAllowance, balanceOf, allowance, decimals}, safe for tokens like USDT that return nothing or refuse a non-zero to non-zero approve | | useEvmReadClient({ chainId? }) / evmReadClient({ chain, rpcUrl? }) / evmReadClients(web3Config).get(chainId?) | no wallet needed: publicClient, readContract, erc20.{balanceOf, allowance, decimals} with the owner named, explorerTxUrl; address is null, and signMessage, sendTransaction and writeContract reject with ReadOnlyClientError | | useSolanaClient() / solanaClient({ signer, rpcUrl?, chain? }) from ./solana | web only: @solana/kit rpc, signTransaction, signAndSendTransaction, sendTransaction, confirmTransaction, getBalance, transferSol | | useGpgSigner() / createGpgSigner() | the copy/paste GPG signer: signMessage parks a request with its terminal command; submit(pasted, request) resolves it only while request is still the pending one and the pasted signature covers its message | | useSuiClient(), useBitcoinClient() | message signing only; signTransaction / signPsbt reject with UnsupportedClientMethodError until transactions land |

import { useEvmClient } from "@multiplatform.one/web3";
import { useSolanaClient } from "@multiplatform.one/web3/solana";

const evm = useEvmClient();
await evm?.erc20.ensureAllowance({ token: qcc, spender: store, amount });
await evm?.writeContract({ address: store, abi, functionName: "buy", args: [packageId] });

const sol = useSolanaClient();
const sent = await sol?.transferSol({ to, lamports: 1_000_000n });
await sol?.confirmTransaction(sent!);

transferSol returns the signature with the blockhash's lastValidBlockHeight, and confirmTransaction waits until the chain passes that height before it gives up. Its SolanaConfirmationError has a reason and mayStillLand. failed and expired set it false: the transaction will not land and a retry is safe. timeout (there was no lastValidBlockHeight to judge by) and rpc (a getSignatureStatuses or getBlockHeight poll failed, with the transport error as cause) set it true: resending can pay twice, so call confirmTransaction again on the same submission instead. lastStatus is the last confirmationStatus the poll saw, null when it never saw the signature.

A Solana client runs over a byte-level SolanaSigner: standardSolanaSigner (Wallet Standard: Phantom, Solflare, Backpack), injectedSolanaSigner (what the modal connected), or connectSolanaWallet() without the modal. RPC defaults to Web3Config.solanaRpcUrl.

The Solana client is the web-only @multiplatform.one/web3/solana subpath, with no react-native condition. The root is also the native entry, and a native app signs Solana through the WebView bridge, so the root never imports @solana/kit. Sign-in does not need it either: SIWEProvider signs Solana through solanaMessageClient, so a login page carries no kit. tests/clients/importGraph.spec.ts holds both lines, for native and web.

EVM reads without a wallet

Reads that must work before a wallet connects, such as prices, sale state or a quote for an anonymous visitor, or a balance on a chain other than the connected one, go through a read client. Inside a WalletProvider, useEvmReadClient({ chainId }) wraps the provider's own public client for that chain. With no chainId it follows the chain wagmi is on, which moves when the wallet switches network, so pass chainId to read a contract that lives on one chain. Outside React, evmReadClients(config) takes the same Web3Config chain fields and resolves the same chains and RPCs, localnet included. Both clients and evmClient have readContract and the erc20 reads, so useEvmClient() ?? useEvmReadClient({ chainId }) reads either way.

import { evmReadClients } from "@multiplatform.one/web3";

const readers = evmReadClients({ chains: [base, polygon] });
const price = await readers
  .get(base.id)
  .readContract({ address: sale, abi, functionName: "price" });
const held = await readers.get(polygon.id).erc20.balanceOf(qcc, visitor);

EVM payments: pending is not failed

A sent transaction can sit in the mempool for minutes and still land. viem's receipt wait gives up after 180 s with an error that reads like a failure, so an app would offer "try again" and the user would pay twice. The EVM client never does that:

  • waitForTransactionReceipt(hash) and confirmTransaction(hash) wait with no timeout. Pass { timeout } for a deadline, which holds even while an RPC call hangs. Running out rejects with ReceiptPendingError (hash, chainId, explorerUrl, nonce once the node has seen it, mayStillLand: true), never a generic error. A failing RPC never ends the wait: it backs off, up to 30 s between polls, and retries.
  • Pass { signal } and abort it when the screen unmounts. A wait with no timeout otherwise polls until the transaction settles, which for a dropped one is forever. It rejects with ReceiptWaitAbortedError, an AbortError.
  • confirmTransaction also rejects a reverted receipt with TransactionRevertedError. waitForTransactionReceipt still resolves it, as it always has.
  • When another transaction takes the nonce, both reject with TransactionReplacedError: reason is cancelled (the wallet's cancel), replaced (a different transaction), or unmatched (the original was never seen, so it could be a speed-up). A speed-up resolves with the faster copy's receipt. The wait binary-searches the nonce's block however long it slept, so resuming one far back needs an RPC that serves state that old, an archive node. A chain's default public RPC often keeps only the last 128 blocks, and past that the wait stays pending.
  • erc20.ensureAllowance confirms its approvals the same way and takes the same timeout and signal. erc20.transfer and approve return once the wallet sends, and their rejections classify too.

classifyTxError(error) sorts any of these, and viem's own errors, into what to show:

| Kind | Meaning | Show | | --------------- | --------------------------------------------------------------------- | ---------------------------------------------- | | user-rejected | declined or cancelled in the wallet; nothing paid | "Cancelled", buy enabled | | reverted | the contract refused it, in simulation or on chain; nothing paid | the reason, buy enabled | | pending | sent with no receipt yet, or its nonce went to what may be a speed-up | "Pending" with the explorer link, buy disabled | | aborted | the app stopped waiting; the transaction is as pending as it was | nothing; keep the pending hash | | failed | anything else: no hash came back, or a different transaction won | the error, buy enabled |

failed with no hash usually means nothing was sent. A wallet that errors after the user approved may still have broadcast it, so tell the user to check the wallet's activity before paying again.

Persist the hash from the moment the wallet returns it, and what onTrack hands over: the nonce once the node has seen the transaction, and a speed-up's hash when one replaces it. A wait resumed after a reload needs the nonce to notice a cancel or replacement that landed while the app was closed; without it, a transaction the node has forgotten stays pending. A speed-up the page never saw can't be told from a replacement after a reload, so it stays pending too. Refuse a second buy while one is pending:

import { classifyTxError, useEvmClient } from "@multiplatform.one/web3";

const evm = useEvmClient();
const [pending, setPending] = usePersistedState<{ hash: Hash; nonce?: number } | null>("buy", null);

async function settle(hash: Hash, nonce: number | undefined, signal: AbortSignal) {
  try {
    await evm.confirmTransaction(hash, { nonce, signal, onTrack: setPending });
    setPending(null);
    showPaid();
  } catch (error) {
    const kind = classifyTxError(error);
    // aborted: unmounted, and the next mount resumes it. pending: superseded,
    // maybe sped up, so keep it locked.
    if (kind === "aborted" || kind === "pending") return;
    setPending(null);
    showFailure(kind, error);
  }
}

// Every wait belongs to a mounted screen, and stops when it unmounts.
useEffect(() => {
  if (!evm || !pending) return;
  const controller = new AbortController();
  void settle(pending.hash, pending.nonce, controller.signal);
  return () => controller.abort();
}, [evm, pending?.hash]);

async function buy() {
  if (!evm || pending) return;
  try {
    await evm.erc20.ensureAllowance({ token: qcc, spender: store, amount });
    const hash = await evm.writeContract({ address: store, abi, functionName: "buy", args: [id] });
    setPending({ hash });
  } catch (error) {
    showFailure(classifyTxError(error), error);
  }
}

While pending is set, disable buy and link evm.explorerTxUrl(pending.hash). The effect settles it on mount and after a reload; a "check again" button can remount it. When the client can't settle it, your backend, which sees the purchase land, is the one to release the lock.

Solana on mobile (follow-up)

The web path is done. A native app cannot reach Phantom through the WebView bridge alone: the WebView has no injected Phantom, and a dapp opened inside Phantom's in-app browser can never hand control back to the calling app. The chosen direction is Phantom's deeplink protocol (Solflare speaks the same one), bridged to the WebView like every other native signer and never a native module: an X25519 keypair per session, connect, signMessage, signTransaction and signAndSendTransaction sent to https://phantom.app/ul/v1/<method> with a redirect_link back into the app, payloads sealed with NaCl box, and the session token kept for later calls. The native host owns Linking; the client, the modal and login stay unchanged. Android can later add Solana Mobile Wallet Adapter as a second signer.

What it must not do

  • No modal shell — the modal UI lives in @multiplatform.one/connectkit; this package registers its wallet screens into it
  • No auth/session — Keycloak wallet-claim helpers live in @multiplatform.one/keycloak
  • No WalletConnect or Reown, and no project id of any kind

Wallets

MetaMask connects through MetaMask Connect (@metamask/connect-evm, connector id metaMaskSDK): the extension on desktop, the MetaMask app over MetaMask's own relay on mobile. Coinbase Wallet runs with telemetry off, and its SDK is built only when the user picks it or the page restores its connection, since building it sends a HEAD request for the page's own path. A browser that last connected Coinbase Wallet still sends it on load, because wagmi keeps recentConnectorId after a disconnect. Aave Account is opt-in (enableAaveAccount: true).

Bundler plugin (required)

MetaMask Connect gives up on a wallet reply after 60 s, and has no option to change that. On Android the browser loses its network while MetaMask is in front, so a user who takes longer than that in MetaMask loses the connect or the signature. Every build that bundles the wallet modal must add the plugin that raises those timers to ten minutes. Resuming a stored session on page load keeps its stock two minutes. The build, and the dev server's dependency pre-bundle, fail if they bundle MetaMask Connect and a timer could not be raised.

Vite (also works as a rollup or rolldown plugin, and covers the dev server's dependency pre-bundling):

import { defineConfig } from "vite";
import { metaMaskConnectTimeouts } from "@multiplatform.one/web3/vite";

export default defineConfig({
  plugins: [metaMaskConnectTimeouts()],
});

esbuild:

import { build } from "esbuild";
import { metaMaskConnectTimeoutsEsbuild } from "@multiplatform.one/web3/esbuild";

await build({ bundle: true, plugins: [metaMaskConnectTimeoutsEsbuild()] });

React Native builds don't need it: the native WalletProvider renders the wallet UI from assets/webview.html, which this package builds with the plugin.

Plain web bundlers: the ./web entry

The root entry also exports the tamagui components (WalletButton, WalletAvatar, WalletStatus, NetworkSwitcher, TransactionStatusComponent) and the LockContract example. tamagui's web build imports react-native-web, and @multiplatform.one/platform reads process.env at load, so a web-only app that imports the root has to install react-native-web, alias react-native to it and define process.env.

@multiplatform.one/web3/web is everything else the root exports, with none of that in its module graph: WalletProvider, the hooks, ConnectKitButton, the per-chain clients, the bridge, the message builder and the core contracts. A Vite app or library build needs only the bundler plugin above, plus the process.env.NODE_ENV define React needs in any library build.

import { WalletProvider, evmClient, useEvmClient } from "@multiplatform.one/web3/web";

web-entry/webEntry.spec.ts (pnpm test:web-entry) holds it to that: it builds this import as a Vite app and as an IIFE library with react-native unresolvable, checks that tamagui, platform and theme stay out of the module graph, and mounts WalletProvider from the bundle on a page with no process.

Native apps: the WebView bridge

A native app does not link wallet SDKs. The native WalletProvider (the ./native entry) loads the wallet UI, assets/webview.html, in a WebView and talks to it over a JSON bridge: evm (EIP-1193), solana, sui and gpg signing, and auth, the Keycloak wallet sign-in. Everything runs on react-native-webview and Linking, so stock Expo Go runs it.

Serve webview.html over https and set webViewUrl to it. The page's host is the sign-in message's domain and the host the Keycloak nonce cookie is set for, so it must be in the realm's siww.allowedDomains (SHC Keycloak) or wallet.allowedDomains (mpo Keycloak), and its origin in the client's web origins. The provider refuses any other URL; plain http is accepted only on localhost, 127.0.0.1, [::1] and 10.0.2.2, in development builds.

import { useWalletBridge } from "@multiplatform.one/web3";

const { auth, sui, gpg } = useWalletBridge();
const outcome = await auth.signIn({
  keycloak: { baseUrl: "https://account.example.com", realm: "main", clientId: "siwe-public" },
  onboarding: { clientId: "wallet-onboarding", redirectUri: "myapp://auth" },
});
if (outcome.status === "signed-in") save(outcome.tokens);
// "profile-incomplete": Keycloak's own login was opened in the system browser
// to finish the profile; call signIn again once it redirects back.

Each wallet family names the bridge namespace the page serves it on (family.bridge.namespace: evm, solana, sui, gpg), and a family that brings a bridge driver connects across it. On native, useConnection() reports the store's connection for eth through the bridge's wagmi connector and for sol and sui through solanaBridgeDriver and suiBridgeDriver: useConnectionStore().connect("sol", "webViewSolana") asks the page's Solana wallet, and its client signs base58 as on web. When the page reloads, the store asks it for the account again and drops a session the new page does not hold. Bitcoin has no bridge namespace yet. A wallet connected straight through useSolanaWallet() or useWalletBridge() stays outside the store.

The webViewSolana and webViewSui connectors are only as good as the page's wallets. Today that is the mock wallet page (E2E): on a phone the WebView has no injected Phantom (Solana on mobile, above, is the follow-up) and no Wallet Standard Sui wallet, so connecting them fails there, as useSolanaWallet() does. The native store keeps no record across app restarts.

@multiplatform.one/web3/bridge is the same bridge without wagmi, for a host that drives the page itself. Kotlin and Swift hosts implement docs/bridge-protocol.md, the versioned wire protocol.

Usage

import { ConnectKitButton, WalletProvider, useWallet } from "@multiplatform.one/web3";

function Balance() {
  const { address, isConnected } = useWallet();
  return isConnected ? <span>{address}</span> : <ConnectKitButton />;
}

export function App() {
  return (
    <WalletProvider config={{ appName: "My App" }}>
      <Balance />
    </WalletProvider>
  );
}

Keycloak direct login and onboarding

kc.directGrant({ clientId }) trades a wallet signature for tokens without a browser. A brand-new user, or a realm that later adds a required profile field, has required actions Keycloak cannot run on a direct grant: it answers 400 "Account is not fully set up", which login() rejects as ProfileIncompleteError and useDirectLogin reports as status: "profile-incomplete". Open the url of kc.browserLoginUrl({ clientId, redirectUri }) in the system browser or a WebView page, where Keycloak shows its own onboarding form, then call signIn() again once it redirects back.

browserLoginUrl draws a fresh state and a PKCE S256 verifier on every call and returns { url, state, codeVerifier }. Keep state and accept the redirect only when it matches; codeVerifier is for a caller that exchanges the code at kc.tokenUrl. The client needs standardFlowEnabled and the redirect URI registered exactly, with no wildcard. A direct-grant client does not qualify: mpo's siwe-public and siws-public have standardFlowEnabled: false, so mpo's realm carries a separate public client, wallet-onboarding (PKCE S256 required, redirect URIs http://localhost:8000/wallet, http://localhost:3456/wallet and multiplatform-one://wallet). The wallet screen in features/wallet takes this path on its own.

const { status, signIn } = useDirectLogin(grant);
if (status === "profile-incomplete") {
  const login = kc.browserLoginUrl({ clientId: "wallet-onboarding", redirectUri: "app://auth" });
  await saveState(login.state);
  await Linking.openURL(login.url);
}

License

Apache-2.0