@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/web3Peers: react, @tanstack/react-query.
What it owns
Web3Provider(alsoWalletProvider) — the wallet families, the connection store behinduseConnection(), and wagmi + connectkit, from oneWeb3Config(appName,chains,enableTestnets,localnetfor 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),./viteand./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)andconfirmTransaction(hash)wait with no timeout. Pass{ timeout }for a deadline, which holds even while an RPC call hangs. Running out rejects withReceiptPendingError(hash,chainId,explorerUrl,nonceonce 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 withReceiptWaitAbortedError, anAbortError. confirmTransactionalso rejects a reverted receipt withTransactionRevertedError.waitForTransactionReceiptstill resolves it, as it always has.- When another transaction takes the nonce, both reject with
TransactionReplacedError:reasoniscancelled(the wallet's cancel),replaced(a different transaction), orunmatched(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.ensureAllowanceconfirms its approvals the same way and takes the sametimeoutandsignal.erc20.transferandapprovereturn 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
