> ## Documentation Index
> Fetch the complete documentation index at: https://docs.unlink.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Create Unlink accounts with Openfort

> Use an Openfort embedded EOA to derive, register, and authorize a non-custodial Unlink account.

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.

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

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

```tsx theme={null}
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.

```ts theme={null}
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.

```tsx theme={null}
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.

```ts theme={null}
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),
);
```

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

## What you built

The browser now has a non-custodial Unlink client backed by an Openfort embedded
EOA:

```ts theme={null}
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](https://www.openfort.io/docs/recipes/unlink-private-payments).

For the security model behind these routes, see [Custody models](/custody-models)
and [Accounts and keys](/accounts-and-keys#derive-account-from-a-wallet-signature).
