Skip to main content
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.

Configure Openfort

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. 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.