Skip to content

Use mera with wagmi

The mera connector turns a passkey account into a wagmi connection, so wagmi’s actions and hooks sign with it as they do with a wallet app.

Prerequisites:

  • @category-labs/mera, @wagmi/core 3, and viem installed. React apps install wagmi, which brings @wagmi/core.
  • A PRF-capable authenticator (authenticator support).
import {
function mera({ rp, accountIndex, webAuthnClient, keepSessionOpen, }: MeraOptions): CreateConnectorFn<Provider, Properties, StorageItem>

Creates a wagmi connector for a passkey account: an EVM account whose key derives from a passkey's PRF output.

@param ― options - Relying party, account index, and WebAuthn client.

@returns ― A wagmi connector function with id "mera".

@remarks ―

Connecting runs one passkey ceremony, or two when it creates a passkey on an authenticator that evaluates the PRF only at sign-in. Signing requests run one at a time. Each runs its own ceremony, restricted to the stored credential, and ends its signing session once it settles. With keepSessionOpen, the session that connecting opens, or that the first signing request after a page load opens, stays open and signs later requests without a prompt. A passkey that opens a different address is refused.

The connector keeps the account's address and credential metadata in wagmi's storage, never key material, so reconnecting after a page load shows no prompt. It ignores a record stored for another rp.id or accountIndex. The connector starts on the config's first chain.

The provider signs personal_sign, eth_signTypedData_v4, and eth_sendTransaction itself, and refuses typed data whose domain names another chain. viem fills, signs, and sends each transaction through the config's transport for the current chain, or the chain's default RPC when the config has none. Transactions sent together get consecutive nonces. Other signing and wallet_ methods, including wallet_sendCalls, reject with MethodNotSupportedRpcError (code -32004); other methods go to the transport. A ceremony that fails with PASSKEY_CANCELLED, such as a dismissed or timed-out prompt, rejects with UserRejectedRequestError (code 4001); other ceremony failures reject with their MeraError, which viem wraps for signing requests.

@throws ― MeraError with code INPUT_INVALID when accountIndex is not an integer from 0 to 2^31 - 1.

mera
} from "@category-labs/mera/wagmi";
import {
function createConfig<const chains extends readonly [Chain, ...Chain[]], transports extends Record<chains[number]["id"], Transport>, const connectorFns extends readonly CreateConnectorFn[]>(parameters: CreateConfigParameters<chains, transports, connectorFns>): Config<chains, transports, connectorFns>
createConfig
,
function http<rpcSchema extends RpcSchema | undefined = undefined, raw extends boolean = false>(url?: string | undefined, config?: HttpTransportConfig<rpcSchema, raw>): HttpTransport<rpcSchema, raw>

@description Creates a HTTP transport that connects to a JSON-RPC API.

http
} from "@wagmi/core";
import {
const sepolia: {
blockExplorers: {
readonly default: {
readonly name: "Etherscan";
readonly url: "https://sepolia.etherscan.io";
readonly apiUrl: "https://api-sepolia.etherscan.io/api";
};
};
blockTime?: number | undefined | undefined;
contracts: {
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 751532;
};
readonly ensUniversalResolver: {
readonly address: "0xeeeeeeee14d718c2b47d9923deab1335e144eeee";
readonly blockCreated: 8928790;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}
sepolia
} from "viem/chains";
const
const rpId: "account.example.com"
rpId
= "account.example.com";
export const
const config: Config<readonly [{
blockExplorers: {
readonly default: {
readonly name: "Etherscan";
readonly url: "https://sepolia.etherscan.io";
readonly apiUrl: "https://api-sepolia.etherscan.io/api";
};
};
blockTime?: number | undefined | undefined;
contracts: {
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 751532;
};
readonly ensUniversalResolver: {
readonly address: "0xeeeeeeee14d718c2b47d9923deab1335e144eeee";
readonly blockCreated: 8928790;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}], {
...;
}, readonly [...]>
config
=
createConfig<readonly [{
blockExplorers: {
readonly default: {
readonly name: "Etherscan";
readonly url: "https://sepolia.etherscan.io";
readonly apiUrl: "https://api-sepolia.etherscan.io/api";
};
};
blockTime?: number | undefined | undefined;
contracts: {
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 751532;
};
readonly ensUniversalResolver: {
readonly address: "0xeeeeeeee14d718c2b47d9923deab1335e144eeee";
readonly blockCreated: 8928790;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}], {
...;
}, readonly [...]>(parameters: CreateConfigParameters<...>): Config<...>
createConfig
({
chains: readonly [{
blockExplorers: {
readonly default: {
readonly name: "Etherscan";
readonly url: "https://sepolia.etherscan.io";
readonly apiUrl: "https://api-sepolia.etherscan.io/api";
};
};
blockTime?: number | undefined | undefined;
contracts: {
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 751532;
};
readonly ensUniversalResolver: {
readonly address: "0xeeeeeeee14d718c2b47d9923deab1335e144eeee";
readonly blockCreated: 8928790;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}]
chains
: [
const sepolia: {
blockExplorers: {
readonly default: {
readonly name: "Etherscan";
readonly url: "https://sepolia.etherscan.io";
readonly apiUrl: "https://api-sepolia.etherscan.io/api";
};
};
blockTime?: number | undefined | undefined;
contracts: {
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 751532;
};
readonly ensUniversalResolver: {
readonly address: "0xeeeeeeee14d718c2b47d9923deab1335e144eeee";
readonly blockCreated: 8928790;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}
sepolia
],
connectors?: readonly [CreateConnectorFn<Provider, Properties, StorageItem>] | undefined
connectors
: [
function mera({ rp, accountIndex, webAuthnClient, keepSessionOpen, }: MeraOptions): CreateConnectorFn<Provider, Properties, StorageItem>

Creates a wagmi connector for a passkey account: an EVM account whose key derives from a passkey's PRF output.

@param ― options - Relying party, account index, and WebAuthn client.

@returns ― A wagmi connector function with id "mera".

@remarks ―

Connecting runs one passkey ceremony, or two when it creates a passkey on an authenticator that evaluates the PRF only at sign-in. Signing requests run one at a time. Each runs its own ceremony, restricted to the stored credential, and ends its signing session once it settles. With keepSessionOpen, the session that connecting opens, or that the first signing request after a page load opens, stays open and signs later requests without a prompt. A passkey that opens a different address is refused.

The connector keeps the account's address and credential metadata in wagmi's storage, never key material, so reconnecting after a page load shows no prompt. It ignores a record stored for another rp.id or accountIndex. The connector starts on the config's first chain.

The provider signs personal_sign, eth_signTypedData_v4, and eth_sendTransaction itself, and refuses typed data whose domain names another chain. viem fills, signs, and sends each transaction through the config's transport for the current chain, or the chain's default RPC when the config has none. Transactions sent together get consecutive nonces. Other signing and wallet_ methods, including wallet_sendCalls, reject with MethodNotSupportedRpcError (code -32004); other methods go to the transport. A ceremony that fails with PASSKEY_CANCELLED, such as a dismissed or timed-out prompt, rejects with UserRejectedRequestError (code 4001); other ceremony failures reject with their MeraError, which viem wraps for signing requests.

@throws ― MeraError with code INPUT_INVALID when accountIndex is not an integer from 0 to 2^31 - 1.

mera
({
rp: PasskeyRelyingParty

Relying party the passkeys belong to. rp.name is shown when a passkey is created.

rp
: {
id: string

Relying party ID: the host the passkey is scoped to.

id
:
const rpId: "account.example.com"
rpId
,
name: string

Relying party name the authenticator may show.

name
: "Example" } })],
transports: {
11155111: HttpTransport<undefined, false>;
}
transports
: { [
const sepolia: {
blockExplorers: {
readonly default: {
readonly name: "Etherscan";
readonly url: "https://sepolia.etherscan.io";
readonly apiUrl: "https://api-sepolia.etherscan.io/api";
};
};
blockTime?: number | undefined | undefined;
contracts: {
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 751532;
};
readonly ensUniversalResolver: {
readonly address: "0xeeeeeeee14d718c2b47d9923deab1335e144eeee";
readonly blockCreated: 8928790;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}
sepolia
.
id: 11155111
id
]:
http<undefined, false>(url?: string | undefined, config?: HttpTransportConfig<undefined, false> | undefined): HttpTransport<undefined, false>

@description Creates a HTTP transport that connects to a JSON-RPC API.

http
() },
});
export const [
const passkey: Connector<CreateConnectorFn<Provider, Properties, StorageItem>>
passkey
] =
const config: Config<readonly [{
blockExplorers: {
readonly default: {
readonly name: "Etherscan";
readonly url: "https://sepolia.etherscan.io";
readonly apiUrl: "https://api-sepolia.etherscan.io/api";
};
};
blockTime?: number | undefined | undefined;
contracts: {
readonly multicall3: {
readonly address: "0xca11bde05977b3631167028862be2a173976ca11";
readonly blockCreated: 751532;
};
readonly ensUniversalResolver: {
readonly address: "0xeeeeeeee14d718c2b47d9923deab1335e144eeee";
readonly blockCreated: 8928790;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}], {
11155111: HttpTransport<undefined, false>;
}, readonly [...]>
config
.
connectors: readonly [Connector<CreateConnectorFn<Provider, Properties, StorageItem>>]
connectors
;

connect with create creates a passkey and connects its account:

import {
function connect<config extends Config, connector extends Connector | CreateConnectorFn, withCapabilities extends boolean = false>(config: config, parameters: ConnectParameters<config, connector, withCapabilities>): Promise<ConnectReturnType<config, connector, withCapabilities>>
connect
} from "@wagmi/core";
const {
const accounts: readonly [`0x${string}`, ...`0x${string}`[]]
accounts
} = await
connect<Config, Connector<CreateConnectorFn<Provider, Properties, StorageItem>>, false>(config: Config, parameters: ConnectParameters<Config, Connector<CreateConnectorFn<Provider, Properties, StorageItem>>, false, Omit<{
chainId?: number | undefined;
isReconnecting?: boolean | undefined;
withCapabilities?: boolean | undefined;
create?: CreatePasskey | undefined;
immediate?: boolean | undefined;
}, "isReconnecting">>): Promise<...>
connect
(
const config: Config
config
, {
connector: CreateConnectorFn | Connector<CreateConnectorFn<Provider, Properties, StorageItem>>
connector
:
const passkey: Connector<CreateConnectorFn<Provider, Properties, StorageItem>>
passkey
,
create?: CreatePasskey | undefined

Creates a passkey for this user and connects its account. Without it, connecting asks for any passkey the relying party already has. When the second prompt that some authenticators need is dismissed, the new passkey stays, and a later connect without create offers it.

create
: {
user: {
name: string;
displayName: string;
}
user
: {
name: string

User name displayed or stored by the authenticator.

name
: "account@example.com",
displayName: string

Human-readable display name for the authenticator UI.

displayName
: "Example account" },
},
});

A returning visitor connects without create, and the browser offers the passkeys it holds for the relying party:

import {
function connect<config extends Config, connector extends Connector | CreateConnectorFn, withCapabilities extends boolean = false>(config: config, parameters: ConnectParameters<config, connector, withCapabilities>): Promise<ConnectReturnType<config, connector, withCapabilities>>
connect
} from "@wagmi/core";
await
connect<Config, Connector<CreateConnectorFn<Provider, Properties, StorageItem>>, false>(config: Config, parameters: ConnectParameters<Config, Connector<CreateConnectorFn<Provider, Properties, StorageItem>>, false, Omit<{
chainId?: number | undefined;
isReconnecting?: boolean | undefined;
withCapabilities?: boolean | undefined;
create?: CreatePasskey | undefined;
immediate?: boolean | undefined;
}, "isReconnecting">>): Promise<...>
connect
(
const config: Config
config
, {
connector: CreateConnectorFn | Connector<CreateConnectorFn<Provider, Properties, StorageItem>>
connector
:
const passkey: Connector<CreateConnectorFn<Provider, Properties, StorageItem>>
passkey
});

With immediate: true, connect rejects at once with UserRejectedRequestError when this device holds no passkey, including when the passkey is on a phone; Create passkey accounts shows the one-button flow built on it.

Signing works through wagmi’s usual actions, and each one asks for the passkey:

import {
function sendTransaction<config extends Config, chainId extends config["chains"][number]["id"]>(config: config, parameters: SendTransactionParameters<config, chainId>): Promise<SendTransactionReturnType>
sendTransaction
,
function signMessage(config: Config, parameters: SignMessageParameters): Promise<SignMessageReturnType>
signMessage
} from "@wagmi/core";
import {
function parseEther(ether: string, unit?: "wei" | "gwei"): bigint

Converts a string representation of ether to numerical wei.

@example

import { parseEther } from 'viem'

parseEther('420') // 420000000000000000000n

parseEther
} from "viem";
const
const signature: `0x${string}`
signature
= await
function signMessage(config: Config, parameters: SignMessageParameters): Promise<SignMessageReturnType>
signMessage
(
const config: Config
config
, {
message: SignableMessage
message
: "Sign in to Example" });
const
const recipient: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
recipient
= "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const
const hash: `0x${string}`
hash
= await
sendTransaction<Config, number>(config: Config, parameters: {
type?: "legacy" | undefined;
account?: `0x${string}` | Account | null | undefined;
to?: `0x${string}` | null | undefined;
data?: `0x${string}` | undefined;
nonce?: number | undefined;
value?: bigint | undefined;
blobs?: undefined;
blobVersionedHashes?: undefined;
kzg?: undefined;
gasPrice?: bigint | undefined;
maxFeePerBlobGas?: undefined | undefined;
maxFeePerGas?: undefined | undefined;
maxPriorityFeePerGas?: undefined | undefined;
accessList?: undefined;
sidecars?: undefined;
authorizationList?: undefined;
assertChainId?: boolean | undefined | undefined;
dataSuffix?: `0x${string}` | undefined;
chainId?: number | undefined;
connector?: Connector | undefined;
gas?: bigint | ... 1 more ... | undefined;
} | ... 4 more ... | {
...;
}): Promise<SendTransactionReturnType>
sendTransaction
(
const config: Config
config
, {
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
to
:
const recipient: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
recipient
,
value: bigint
value
:
function parseEther(ether: string, unit?: "wei" | "gwei"): bigint

Converts a string representation of ether to numerical wei.

@example

import { parseEther } from 'viem'

parseEther('420') // 420000000000000000000n

parseEther
("0.01"),
});

After a page load, reconnect, which wagmi’s React provider runs on mount, restores the account without a prompt.

mera({ rp, keepSessionOpen: true }) keeps the signing session that connecting opens, so signing after a connect shows no prompt and only the first signature after a page load asks for the passkey. The derived key stays in memory until disconnect or a page unload.

A dismissed or timed-out prompt rejects with viem’s UserRejectedRequestError, the error a wallet app gives when the person declines. Other failures reject with a MeraError, such as PRF_UNAVAILABLE for an authenticator without PRF:

import {
class MeraError

Error thrown by this package.

MeraError
} from "@category-labs/mera";
import {
function connect<config extends Config, connector extends Connector | CreateConnectorFn, withCapabilities extends boolean = false>(config: config, parameters: ConnectParameters<config, connector, withCapabilities>): Promise<ConnectReturnType<config, connector, withCapabilities>>
connect
} from "@wagmi/core";
import {
class UserRejectedRequestError
UserRejectedRequestError
} from "viem";
try {
await
connect<Config, Connector<CreateConnectorFn<Provider, Properties, StorageItem>>, false>(config: Config, parameters: ConnectParameters<Config, Connector<CreateConnectorFn<Provider, Properties, StorageItem>>, false, Omit<{
chainId?: number | undefined;
isReconnecting?: boolean | undefined;
withCapabilities?: boolean | undefined;
create?: CreatePasskey | undefined;
immediate?: boolean | undefined;
}, "isReconnecting">>): Promise<...>
connect
(
const config: Config
config
, {
connector: CreateConnectorFn | Connector<CreateConnectorFn<Provider, Properties, StorageItem>>
connector
:
const passkey: Connector<CreateConnectorFn<Provider, Properties, StorageItem>>
passkey
});
} catch (
var error: unknown
error
) {
if (
var error: unknown
error
instanceof
class UserRejectedRequestError
UserRejectedRequestError
) {
// The person closed the prompt, or had no passkey to offer.
} else if (
var error: unknown
error
instanceof
class MeraError

Error thrown by this package.

MeraError
&&
var error: MeraError
error
.
MeraError.code: MeraErrorCode

Stable machine-readable category for the failure.

code
=== "PRF_UNAVAILABLE") {
// Point at a PRF-capable authenticator.
} else {
throw
var error: unknown
error
;
}
}

The connector sends transactions through viem with the config’s chain, so a chain’s prepareTransactionRequest hook applies to them. On a chain that charges the full gas limit, such as Monad, the hook can add a margin to each estimate:

import {
function defineChain<formatters extends ChainFormatters, const chain extends Chain<formatters>>(chain: chain): DefineChainReturnType<Assign<Chain<undefined>, chain>>
defineChain
} from "viem";
import {
const monad: {
blockExplorers: {
readonly default: {
readonly name: "Monadscan";
readonly url: "https://monadscan.com";
readonly apiUrl: "https://api.etherscan.io/v2/api?chainid=143";
};
readonly monadvision: {
readonly name: "MonadVision";
readonly url: "https://monadvision.com";
};
};
blockTime: 300;
contracts: {
readonly multicall3: {
readonly address: "0xcA11bde05977b3631167028862bE2a173976CA11";
readonly blockCreated: 9248132;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}
export monad
monad
as
const monadBase: {
blockExplorers: {
readonly default: {
readonly name: "Monadscan";
readonly url: "https://monadscan.com";
readonly apiUrl: "https://api.etherscan.io/v2/api?chainid=143";
};
readonly monadvision: {
readonly name: "MonadVision";
readonly url: "https://monadvision.com";
};
};
blockTime: 300;
contracts: {
readonly multicall3: {
readonly address: "0xcA11bde05977b3631167028862bE2a173976CA11";
readonly blockCreated: 9248132;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}
monadBase
} from "viem/chains";
const
const monad: {
blockExplorers: {
readonly default: {
readonly name: "Monadscan";
readonly url: "https://monadscan.com";
readonly apiUrl: "https://api.etherscan.io/v2/api?chainid=143";
};
readonly monadvision: {
readonly name: "MonadVision";
readonly url: "https://monadvision.com";
};
};
blockTime: 300;
contracts: {
readonly multicall3: {
readonly address: "0xcA11bde05977b3631167028862bE2a173976CA11";
readonly blockCreated: 9248132;
};
};
... 16 more ...;
readonly formatters?: undefined | undefined;
}
monad
=
defineChain<ChainFormatters, {
readonly prepareTransactionRequest: [(request: PrepareTransactionRequestParameters) => Promise<{
gas: bigint | undefined;
type?: "legacy" | undefined;
data?: `0x${string}` | undefined;
nonce?: number | undefined;
to?: `0x${string}` | null | undefined;
value?: bigint | undefined;
blobs?: undefined;
blobVersionedHashes?: undefined;
kzg?: undefined;
gasPrice?: bigint | undefined;
maxFeePerBlobGas?: undefined | undefined;
maxFeePerGas?: undefined | undefined;
maxPriorityFeePerGas?: undefined | undefined;
accessList?: undefined;
sidecars?: undefined;
... 5 more ...;
chainId?: number | undefined;
} | ... 4 more ... | {
...;
}>, {
...;
}];
... 18 more ...;
readonly verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}>(chain: {
...;
}): {
...;
}
defineChain
({
...
const monadBase: {
blockExplorers: {
readonly default: {
readonly name: "Monadscan";
readonly url: "https://monadscan.com";
readonly apiUrl: "https://api.etherscan.io/v2/api?chainid=143";
};
readonly monadvision: {
readonly name: "MonadVision";
readonly url: "https://monadvision.com";
};
};
blockTime: 300;
contracts: {
readonly multicall3: {
readonly address: "0xcA11bde05977b3631167028862bE2a173976CA11";
readonly blockCreated: 9248132;
};
};
... 16 more ...;
verifyHash?: ((client: Client<...>, parameters: VerifyHashParameters) => Promise<boolean>) | undefined;
}
monadBase
,
prepareTransactionRequest: [(request: PrepareTransactionRequestParameters) => Promise<{
gas: bigint | undefined;
type?: "legacy" | undefined;
data?: `0x${string}` | undefined;
nonce?: number | undefined;
to?: `0x${string}` | null | undefined;
value?: bigint | undefined;
blobs?: undefined;
blobVersionedHashes?: undefined;
kzg?: undefined;
gasPrice?: bigint | undefined;
maxFeePerBlobGas?: undefined | undefined;
maxFeePerGas?: undefined | undefined;
maxPriorityFeePerGas?: undefined | undefined;
accessList?: undefined;
sidecars?: undefined;
authorizationList?: undefined;
nonceManager?: NonceManager | undefined;
parameters?: readonly PrepareTransactionRequestParameterType[] | undefined;
account?: `0x${string}` | ... 2 more ... | undefined;
chain: Chain | ... 1 more ... | undefined;
chainId?: number | undefined;
} | ... 4 more ... | {
...;
}>, {
...;
}]
prepareTransactionRequest
: [
async (
request: PrepareTransactionRequestParameters
request
) => ({
...
request: PrepareTransactionRequestParameters
request
,
gas: bigint | undefined
gas
:
request: PrepareTransactionRequestParameters
request
.
gas?: bigint | undefined

Gas provided for transaction execution

gas
===
var undefined
undefined
?
var undefined
undefined
: (
request: PrepareTransactionRequestParameters
request
.
gas?: bigint

Gas provided for transaction execution

gas
* 11n) / 10n,
}),
{
runAt: readonly ["afterFillParameters"]
runAt
: ["afterFillParameters"] },
],
});

disconnect(config) deletes the stored account, so the next page load does not reconnect.