This recipe shows how to use Openfort as the wallet layer for Unlink accounts.
Openfort signs the user in and creates a passkey-secured embedded EOA. Unlink
then derives a non-custodial unlink1... account from that EOA’s deterministic
wallet signature.
By the end, the browser has registered a private Unlink account, the backend
has issued only short-lived authorization tokens, and no server has handled
the user’s Unlink spending key.
Prerequisites
- An Openfort project with an Openfort publishable key, Openfort secret key, and Shield publishable key.
- An Unlink project for
monad-testnet, with the backend-only UNLINK_API_KEY stored on your server.
- Packages:
@unlink-xyz/sdk@canary, @openfort/react, @openfort/openfort-node, wagmi, @wagmi/core, @wagmi/connectors, viem, and @tanstack/react-query.
- A Monad testnet RPC URL. If you continue into deposits, faucet funding, or private payments, also copy the token address configured for your Unlink project.
Why Openfort should create an EOA
Unlink’s browser wallet constructor, account.fromWallet, derives a stable
Unlink account from an EOA personal_sign signature. In v1, this path rejects
smart-account and delegated-account signatures because the recovered signer must
match the connected EOA.
Configure Openfort to create an embedded EOA for this flow. You can still use
Openfort’s smart-account features elsewhere in your app, but the Unlink account
derivation step needs an EOA signer.
Wrap your React app with Openfort and wagmi. The important bits are Monad testnet
chain ID 10143, AccountTypeEnum.EOA, automatic wallet connection, and passkey
recovery.
import {
AccountTypeEnum,
OpenfortProvider,
RecoveryMethod,
} from "@openfort/react";
import { getDefaultConfig, OpenfortWagmiBridge } from "@openfort/react/wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { monadTestnet } from "viem/chains";
import { createConfig, http, WagmiProvider } from "wagmi";
const MONAD_CHAIN_ID = 10143;
const queryClient = new QueryClient();
const wagmiConfig = createConfig(
getDefaultConfig({
appName: "Openfort + Unlink",
chains: [monadTestnet],
transports: {
[monadTestnet.id]: http(import.meta.env.VITE_MONAD_RPC_URL),
},
}),
);
export function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<WagmiProvider config={wagmiConfig}>
<OpenfortWagmiBridge>
<OpenfortProvider
publishableKey={import.meta.env.VITE_OPENFORT_PUBLISHABLE_KEY}
walletConfig={{
shieldPublishableKey: import.meta.env.VITE_OPENFORT_SHIELD_KEY,
connectOnLogin: true,
ethereum: {
chainId: MONAD_CHAIN_ID,
accountType: AccountTypeEnum.EOA,
rpcUrls: {
[MONAD_CHAIN_ID]: import.meta.env.VITE_MONAD_RPC_URL,
},
},
}}
uiConfig={{
walletRecovery: {
allowedMethods: [RecoveryMethod.PASSKEY],
defaultMethod: RecoveryMethod.PASSKEY,
},
}}>
{children}
</OpenfortProvider>
</OpenfortWagmiBridge>
</WagmiProvider>
</QueryClientProvider>
);
}
With connectOnLogin, Openfort creates or recovers the embedded EOA after
authentication.
Build the Unlink client
After Openfort connects the embedded EOA, pass its EIP-1193 provider to
account.fromWallet. Use a stable appId: changing appId or chainId
derives a different Unlink account for the same Openfort wallet.
import { account, createUnlinkClient, evm } from "@unlink-xyz/sdk/browser";
const MONAD_CHAIN_ID = 10143;
const UNLINK_ENVIRONMENT = "monad-testnet";
const UNLINK_APP_ID = "your-app-openfort";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
type Eip1193Provider = {
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
};
export async function createOpenfortUnlinkClient(opts: {
provider: Eip1193Provider;
getAccessToken: () => Promise<string | null | undefined>;
}) {
const { provider, getAccessToken } = opts;
const { account: unlinkAccount, address: eoaAddress } =
await account.fromWallet({
provider,
appId: UNLINK_APP_ID,
chainId: MONAD_CHAIN_ID,
});
const customFetch: typeof fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (!url.includes("/api/unlink/")) {
return fetch(input, init);
}
const token = await getAccessToken();
const headers = new Headers(init?.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return fetch(input, { ...init, headers });
};
const client = createUnlinkClient({
environment: UNLINK_ENVIRONMENT,
account: unlinkAccount,
evm: evm.fromEip1193({ provider, address: eoaAddress }),
registerUrl: `${API_BASE_URL}/api/unlink/register`,
authorizationToken: {
url: `${API_BASE_URL}/api/unlink/authorization-token`,
},
customFetch,
});
await client.ensureRegistered();
const unlinkAddress = await client.getAddress();
return { client, eoaAddress, unlinkAddress };
}
Use the helper only after useEthereumEmbeddedWallet reports a connected wallet.
The first call asks the Openfort EOA to sign Unlink’s derivation message.
import { useUser } from "@openfort/react";
import { useEthereumEmbeddedWallet } from "@openfort/react/ethereum";
import type { UnlinkClient } from "@unlink-xyz/sdk/browser";
import { useEffect, useRef, useState } from "react";
import { createOpenfortUnlinkClient } from "./unlink";
const MONAD_CHAIN_ID = 10143;
type SetupResult = Awaited<ReturnType<typeof createOpenfortUnlinkClient>>;
export function UnlinkAccountStatus() {
const wallet = useEthereumEmbeddedWallet({ chainId: MONAD_CHAIN_ID });
const { getAccessToken } = useUser();
const [client, setClient] = useState<UnlinkClient | null>(null);
const [unlinkAddress, setUnlinkAddress] = useState<string | null>(null);
const [setupError, setSetupError] = useState<string | null>(null);
const [setupAttempt, setSetupAttempt] = useState(0);
const setupRef = useRef<{
walletAddress: string;
promise: Promise<SetupResult>;
} | null>(null);
const walletAddress =
wallet.status === "connected" ? wallet.address : undefined;
useEffect(() => {
if (wallet.status !== "connected") {
setupRef.current = null;
setClient(null);
setUnlinkAddress(null);
setSetupError(null);
return;
}
let cancelled = false;
const provider = wallet.provider;
const walletAddress = wallet.address;
setClient(null);
setUnlinkAddress(null);
setSetupError(null);
const setupPromise =
setupRef.current?.walletAddress === walletAddress
? setupRef.current.promise
: createOpenfortUnlinkClient({ provider, getAccessToken });
setupRef.current = {
walletAddress,
promise: setupPromise,
};
setupPromise
.then((result) => {
if (cancelled) return;
setClient(result.client);
setUnlinkAddress(result.unlinkAddress);
})
.catch((error: unknown) => {
if (cancelled) return;
setSetupError(
error instanceof Error
? error.message
: "Failed to set up Unlink account",
);
});
return () => {
cancelled = true;
};
}, [wallet.status, walletAddress, getAccessToken, setupAttempt]);
if (setupError) {
return (
<>
<p>{setupError}</p>
<button
type="button"
onClick={() => {
setupRef.current = null;
setSetupAttempt((attempt) => attempt + 1);
}}>
Retry
</button>
</>
);
}
if (!walletAddress) return <p>Waiting for Openfort wallet...</p>;
return (
<p>
{client && unlinkAddress
? unlinkAddress
: "Setting up private account..."}
</p>
);
}
Mount the backend routes
The backend owns the Unlink admin API key. It validates the Openfort bearer token
on the two app routes, then registers addresses and issues short-lived
authorization tokens.
import Openfort from "@openfort/openfort-node";
import {
createUnlinkAdmin,
createUnlinkAuthRoutes,
} from "@unlink-xyz/sdk/admin";
const openfort = new Openfort(process.env.OPENFORT_SECRET_KEY!, {
publishableKey: process.env.OPENFORT_PUBLISHABLE_KEY!,
});
const admin = createUnlinkAdmin({
environment: "monad-testnet",
apiKey: process.env.UNLINK_API_KEY!,
});
async function authenticateOpenfort(request: Request) {
const header = request.headers.get("authorization");
const token = header?.replace("Bearer ", "");
if (!token) throw new Error("Missing Openfort access token");
const { user } = await openfort.iam.getSession({ accessToken: token });
return { openfortUserId: user.id };
}
const routes = createUnlinkAuthRoutes({
admin,
authenticate: authenticateOpenfort,
onRegister: async ({ session, registration }) => {
await db.linkUnlinkAddress({
openfortUserId: session.openfortUserId,
unlinkAddress: registration.address,
});
},
authorizeUnlinkAddress: async ({ session, unlinkAddress }) =>
db.userOwnsUnlinkAddress(session.openfortUserId, unlinkAddress),
});
app.post("/api/unlink/register", (c) => routes.register(c.req.raw));
app.post("/api/unlink/authorization-token", (c) =>
routes.authorizationToken(c.req.raw),
);
Do not return true unconditionally from authorizeUnlinkAddress outside a
local throwaway demo. Persist the Openfort user id to Unlink address mapping
in onRegister, and only mint tokens for addresses owned by the authenticated
Openfort user.
What you built
The browser now has a non-custodial Unlink client backed by an Openfort embedded
EOA:
await client.ensureRegistered();
const unlinkAddress = await client.getAddress();
From here, use the same client for balances, faucet funding, deposits, transfers,
withdrawals, and execute(). For a complete invoice-payment demo that builds on
this account setup, see Openfort’s
private payments recipe.
For the security model behind these routes, see Custody models
and Accounts and keys.