@topazdex/id-connect
Add Topaz ID — a BNB Chain smart-wallet global account — to your dapp as a one-click login. Users sign in with their existing Topaz ID account (id.topazdex.com) — email, Google, or an external wallet — and connect with their Topaz ID smart contract wallet (Kernel/ZeroDev). No seed phrase, no extension, and no Privy app of your own.
Topaz ID is built on Privy's global wallets. Your app is the requester and references Topaz ID's public app id — that's the whole integration. You don't need a Privy account, and your domain does not need to be allowlisted by Topaz ID.
Demo
See it live: topaz-id-demo.vercel.app — a Next.js + RainbowKit app demonstrating connect, profile display, smart-wallet sends, and a batched approve + swap. Source: topazdex/topaz-id-connect-demo.
New in 0.4
0.4 adds the smart-wallet action client — the recommended way to send
transactions: useTopazIdClient on /react, and createTopazIdClient on the
new /actions entry for non-React apps. See
Using the wallet. Purely additive: existing integrations
keep working unchanged.
If value-bearing transactions were failing for you on plain wagmi (the consent
popup can't estimate the cost, and submitting errors), this client is the fix —
upgrade to ^0.4.1 and route sends through it. As with 0.3, ^0.3 consumers
don't automatically cross the minor — upgrade deliberately.
Upgrading to 0.3.0
0.3.0 makes Topaz ID smart-account-first: the connected account is now the
user's smart contract wallet (Kernel/ZeroDev) — their identity on
id.topazdex.com — instead of the embedded signer EOA. Most apps need no code change
(you already read useAccount().address), but note:
- The address changes.
useAccount().addressis now the smart wallet, so anything keyed on the old EOA (allowlists, prior balances) won't carry over. - Sends are gas-sponsored UserOperations, and signatures are ERC-1271/6492,
not ECDSA — see Using the wallet. Update SIWE/
ecrecoverbackends to an ERC-1271-aware check. - Opt out: pass
{ smartWalletMode: false }totopazIdConnector(),topazIdWallet(), orTopazIdProviderto keep the Legacy signer-EOA mode — see Smart vs Legacy wallets.
^0.2 consumers don't automatically cross the minor — you upgrade deliberately.
Install
yarn add @topazdex/id-connect @privy-io/cross-app-connect wagmi viem \
@tanstack/react-query
Add @rainbow-me/rainbowkit if you use the RainbowKit picker, or
@privy-io/react-auth if your app is itself a Privy app. All peer dependencies
are optional and only pulled in by the entrypoints that need them — see
Peer dependencies.
@privy-io/cross-app-connectpinsviem@2.52.0. Match it to avoid peer warnings.
Quick start
The fastest path: wrap your app in TopazIdProvider (it sets up wagmi for BNB
Chain, the Topaz ID connector, and React Query for you), then connect with
useTopazIdLogin. No createConfig, no RainbowKit.
// app/providers.tsx
"use client";
import { TopazIdProvider } from "@topazdex/id-connect/react";
export function Providers({
children,
cookie,
}: {
children: React.ReactNode;
cookie?: string | null;
}) {
return <TopazIdProvider cookie={cookie}>{children}</TopazIdProvider>;
}
// app/layout.tsx (Next.js App Router) — pass the cookie for clean SSR hydration
import { headers } from "next/headers";
import { Providers } from "./providers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookie = (await headers()).get("cookie");
return (
<html lang="en">
<body>
<Providers cookie={cookie}>{children}</Providers>
</body>
</html>
);
}
// any client component
import { useTopazIdLogin } from "@topazdex/id-connect/react";
import { useAccount } from "wagmi";
export function SignIn() {
const { login, logout } = useTopazIdLogin();
const { address, isConnected } = useAccount();
return isConnected ? (
<button onClick={logout}>{address}</button>
) : (
<button onClick={login}>Sign in with Topaz ID</button>
);
}
TopazIdProvider accepts appId (target a staging app), smartWalletMode
(defaults to true; pass false for the legacy signer-EOA), transport (custom
RPC), queryClient (bring your own), ssr (defaults to true, enabling wagmi
cookie storage), and cookie (the request cookie header, so a connected wallet
survives SSR without a flash). Draw the "use client" boundary in your app — the
library stays framework-agnostic.
RainbowKit
Prefer RainbowKit's wallet picker? Configure wagmi yourself and add the Topaz ID
wallet. Connector helpers live at @topazdex/id-connect/connectors.
import { topazIdWallet, TOPAZ_ID_CHAIN } from "@topazdex/id-connect/connectors";
import { connectorsForWallets } from "@rainbow-me/rainbowkit";
import { createConfig, http } from "wagmi";
const connectors = connectorsForWallets(
[{ groupName: "Sign in", wallets: [topazIdWallet()] }],
{ appName: "Your App", projectId: "<walletconnect-project-id>" },
);
export const wagmiConfig = createConfig({
chains: [TOPAZ_ID_CHAIN], // BNB Chain (56)
transports: { [TOPAZ_ID_CHAIN.id]: http() },
connectors,
ssr: true,
});
"Topaz ID" now appears in the RainbowKit picker. Selecting it opens a Topaz ID
consent window where the user signs in — no new wallet is created.
The
@topazdex/id-connect/rainbow-kitsubpath still works as a deprecated alias of/connectors, so existing imports keep compiling. New code should use/connectors.
Plain wagmi (no RainbowKit)
import { topazIdConnector, TOPAZ_ID_CHAIN } from "@topazdex/id-connect/connectors";
import { createConfig, http } from "wagmi";
export const wagmiConfig = createConfig({
chains: [TOPAZ_ID_CHAIN],
transports: { [TOPAZ_ID_CHAIN.id]: http() },
connectors: [topazIdConnector()],
ssr: true,
});
Using the wallet
For the smoothest smart-wallet UX, use the high-level Topaz ID client instead of
hand-rolling provider RPC calls. It exposes sendTransaction, sendCalls, and
writeContract, and hides Topaz smart-wallet details such as
privy_sendSmartWalletTx, native BNB value formatting, and approval+action
batching.
import { useTopazIdClient } from "@topazdex/id-connect/react";
import { erc20Abi, parseEther, parseUnits } from "viem";
const { data: topazClient } = useTopazIdClient();
await topazClient?.sendTransaction({
to,
value: parseEther("0.01"),
});
await topazClient?.sendCalls({
calls: [
{
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "approve",
args: [ROUTER_ADDRESS, parseUnits("100", 18)],
},
{
to: ROUTER_ADDRESS,
data: swapCalldata,
},
],
});
useTopazIdClient also returns isTopazId, and data stays undefined when
the connected wallet isn't Topaz ID — so in a multi-wallet dapp the drop-in
pattern is a single branch, no connector sniffing:
const { data: topazClient } = useTopazIdClient();
const hash = topazClient
? await topazClient.sendTransaction({ to, value }) // Topaz ID smart wallet
: await sendTransactionAsync({ to, value, chainId: 56 }); // any other wallet
Pass useTopazIdClient({ appId }) when your connector was configured with a
custom app id.
Framework-agnostic apps can use the action client directly with any EIP-1193-ish provider:
import { createTopazIdClient } from "@topazdex/id-connect/actions";
const topazClient = await createTopazIdClient({
provider,
account,
chainId: 56,
});
await topazClient.sendCalls({ calls: [approvalCall, swapCall] });
Plain object literals work for every call; the optional txCall(...) /
contractCall(...) builders do the same thing but validate the target address
eagerly, so a typo fails before a consent popup ever opens.
Plain wagmi (useSendTransaction / useWriteContract) still works for calls
that carry no native BNB — approvals and most contract writes. Anything with
a native value must go through this client: wagmi hex-encodes value, and the
Topaz ID popup rejects hex quantity strings. That mismatch is why value-bearing
transactions fail with a "can't estimate cost" popup on raw connector
integrations while approvals sail through.
A few rules keep transactions routing through the smart wallet reliably:
- Sign and send with this client (or plain wagmi for zero-value calls) — never
@privy-io/react-authsigning hooks. Those are embedded-wallet-only and execute from the underlying Privy EOA instead of the user's Topaz ID smart wallet. - Every action opens a Topaz ID consent window; the user approves each one.
Trigger sends from a direct user interaction (a button click) so browsers don't
block the popup — a send fired after a long
awaitchain can be popup-blocked. Prefer batching an approval + action into onesendCallsbundle: one popup, one approval, atomic execution. - Pass native
valueas abigintand let the SDK format it. The popup expects a plain JSON number and rejects hex. Above 2^53−1 wei (~0.009 BNB) the conversion can round by sub-1000-wei dust — economically negligible, and round amounts (0.1 / 1 / 10 BNB) are exactly representable. Don't pre-encodevalueyourself. sendCallsdegrades gracefully. It submits one atomic bundle; if the wallet rejects the bundle, the SDK retries the calls sequentially (one consent popup per call) and returns the last call's hash. PassatomicRequired: trueto get the batch error instead of the fallback.- Treat receipt lookups as best-effort. The returned hash is usually a
transaction hash, but some smart-wallet flows return an id that
eth_getTransactionReceiptcannot resolve. Poll for the receipt with a timeout and fall back to re-reading your app state (balances, allowances) rather than blocking the UI on the receipt alone.
The connected account is a smart contract wallet (Kernel/ZeroDev on BNB Chain), which differs from a plain EOA in two ways worth knowing:
- Sends are UserOperations relayed through Topaz ID.
sendTransaction/writeContractare submitted via Topaz ID's bundler + paymaster — gas is sponsored by Topaz ID's paymaster policy (so the user typically needs no BNB for gas), and multiple calls (e.g.approve+ swap) can batch into a single atomic action. - Signatures are ERC-1271 / ERC-6492, not ECDSA.
personal_signandeth_signTypedData_v4(wagmi'suseSignMessage/useSignTypedData) return a contract signature. If your backend verifies signatures (e.g. SIWE), use an ERC-1271/6492-aware check —viem'sverifyMessage/verifyTypedDatawith a BNB Chain public client — notecrecover.
Need the Legacy signer EOA instead of the Smart wallet? Pass
{ smartWalletMode: false }totopazIdConnector(),topazIdWallet(), orTopazIdProvider. That address is signer-only — not where the user holds funds. See Smart vs Legacy wallets.
Smart vs Legacy wallets
Topaz ID has two wallet modes, labelled here the same way as on id.topazdex.com:
- Smart — the user's smart contract wallet (Kernel/ZeroDev). The default, and the right choice for every new integration.
- Legacy — the underlying Privy signer EOA. Exposed only for backward compatibility with existing dapps whose users transacted with that EOA directly before the smart-wallet cutover.
New integrations need to do nothing — the connector defaults to Smart, and you
shouldn't surface Legacy at all. Only an existing dapp with users who hold funds on
the signer EOA should offer both. When you show both modes or let the user switch,
label them "Smart" and "Legacy" — the canonical strings, descriptions, and a
TopazIdWalletMode type are exported so your toggle matches ours:
import {
TOPAZ_ID_WALLET_MODES,
topazIdWalletMode,
type TopazIdWalletMode,
} from "@topazdex/id-connect";
TOPAZ_ID_WALLET_MODES.smart;
// → { mode: "smart", label: "Smart", description: "Gas-free smart wallet (recommended)" }
TOPAZ_ID_WALLET_MODES.legacy;
// → { mode: "legacy", label: "Legacy", description: "Your original Privy signing wallet" }
// Map the connector flag to a mode (undefined/true → "smart", false → "legacy"):
topazIdWalletMode(false); // "legacy"
To offer both in a RainbowKit picker, add a second, Legacy-labelled connector alongside the default:
import { topazIdWallet } from "@topazdex/id-connect/connectors";
import { TOPAZ_ID_NAME, TOPAZ_ID_LEGACY_WALLET_LABEL } from "@topazdex/id-connect";
const wallets = [
topazIdWallet(), // Smart (default)
topazIdWallet({
smartWalletMode: false,
name: `${TOPAZ_ID_NAME} (${TOPAZ_ID_LEGACY_WALLET_LABEL})`, // "Topaz ID (Legacy)"
}),
];
Show the user's Topaz ID profile
Topaz ID owns each wallet's name, handle, and avatar. Render real identity instead
of a bare address. Framework-agnostic helpers live at the root entry; a React Query
hook lives at /react.
import { displayNameForWallet, avatarForWallet } from "@topazdex/id-connect";
import { useTopazIdProfile } from "@topazdex/id-connect/react";
const { data: profile } = useTopazIdProfile(address);
const label = displayNameForWallet(profile ?? null, address);
const avatar = avatarForWallet(profile ?? null, "/default-avatar.png");
Reads are public and CORS-open. found: false → fall back to the address; never
block your UI on the fetch. fetchTopazIdProfile returns null on a network or
HTTP failure (aborts re-throw so React Query can tell a cancellation from an empty
result).
Already using Privy?
If your app is itself a Privy app, skip the connector and add Topaz ID as a
cross-app login method. The /privy entry gives you the login-method constant, a
login/link hook, and a thin provider — all using your own Privy app id.
import {
TopazIdPrivyProvider,
topazIdLoginMethod,
useTopazIdCrossAppLogin,
} from "@topazdex/id-connect/privy";
// 1. Wrap your app. Topaz ID is prepended to your login methods.
<TopazIdPrivyProvider
appId={MY_PRIVY_APP_ID}
config={{ loginMethodsAndOrder: { primary: ["email", "wallet"] } }}
>
<App />
</TopazIdPrivyProvider>;
// 2. Or wire it into a plain <PrivyProvider> yourself:
// config={{ loginMethodsAndOrder: { primary: ["email", topazIdLoginMethod] } }}
// 3. Trigger the cross-app login from a button.
const { login } = useTopazIdCrossAppLogin();
<button onClick={login}>Continue with Topaz ID</button>;
To read the linked Topaz ID smart wallet address from the Privy user, use
useTopazIdAccount — it returns the smart wallet as address (the identity to
display and look up) and the embedded signer EOA separately:
import { useTopazIdAccount } from "@topazdex/id-connect/privy";
const { address, signerAddress } = useTopazIdAccount();
// address → the user's Topaz ID smart contract wallet (their identity)
// signerAddress → the embedded EOA that signs for it (signer-only)
address is undefined until the user's smart wallet is provisioned and linked, so
guard on it with a loading state before rendering or transacting. If you display
both, label them Smart (address) and Legacy (signerAddress) — see
Smart vs Legacy wallets.
Exports
| Entry | Contents |
|---|---|
@topazdex/id-connect |
TOPAZ_ID_APP_ID, TOPAZ_ID_CONNECTOR_ID, TOPAZ_ID_CHAIN_ID, TOPAZ_ID_NAME, TOPAZ_ID_ICON_URL, TOPAZ_ID_BASE_URL, TOPAZ_ID_SMART_WALLET_LABEL, TOPAZ_ID_LEGACY_WALLET_LABEL, TOPAZ_ID_WALLET_MODES, topazIdWalletMode, TopazIdWalletMode, TopazIdWalletModeInfo, fetchTopazIdProfile, displayNameForWallet, avatarForWallet, shortenAddress, TopazIdProfile |
@topazdex/id-connect/connectors |
topazIdWallet, topazIdConnector, TOPAZ_ID_CHAIN, TopazIdConnectorOptions |
@topazdex/id-connect/actions |
createTopazIdClient, txCall, contractCall, isTopazIdConnectorId, TopazIdClient, TopazIdClientOptions, TopazIdCall, TopazIdContractCall, TopazIdSendCallsParameters, TopazIdCapabilities, TopazIdProviderLike |
@topazdex/id-connect/rainbow-kit |
Deprecated alias of /connectors |
@topazdex/id-connect/react |
TopazIdProvider, useTopazIdLogin, useTopazIdClient, useTopazIdProfile |
@topazdex/id-connect/privy |
TopazIdPrivyProvider, useTopazIdCrossAppLogin, useTopazIdAccount, topazIdLoginMethod |
Peer dependencies
All peers are optional; install only what your entrypoints use.
| You use | Install |
|---|---|
Profile helpers only (@topazdex/id-connect) |
nothing extra |
TopazIdProvider / useTopazIdLogin / useTopazIdClient (/react) |
wagmi, viem, @tanstack/react-query, react, @privy-io/cross-app-connect |
Connectors (/connectors) |
@privy-io/cross-app-connect, viem, wagmi (+ @rainbow-me/rainbowkit for topazIdWallet) |
Action client (/actions) |
viem |
useTopazIdProfile only (/react) |
@tanstack/react-query, react |
Privy cross-app (/privy) |
@privy-io/react-auth, react |
Releasing
Publishing is automated: .github/workflows/publish.yml runs on a published
GitHub Release and npm publishes via OIDC trusted publishing (no token,
provenance included). It does not publish on a push to main or a bare tag
push — creating the Release is the trigger.
- Land changes on
main(green CI). - Bump
versioninpackage.json(semver). - Commit
Release vX.Y.Zand push. - Create the Release — its tag (minus
v) must equalpackage.json#version:gh release create vX.Y.Z --title vX.Y.Z --generate-notes - Watch the Actions tab, then confirm:
npm view @topazdex/id-connect version.
See CLAUDE.md for the full process and gotchas.
License
MIT