Skip to main content
Canton Network uses Ed25519 keypairs for external party identities — wallets that participate in the ledger without running a Canton node. Because Para-managed Solana wallets are native Ed25519 keys, they work directly with Canton’s external party API. This walkthrough shows how to connect through ParaModal, prove key ownership to Canton, and receive a partyId on the ledger — entirely from a React app.

How it works

1

Connect with Para

User authenticates through ParaModal and gets an embedded Solana wallet.
2

Register the public key with Canton

Server sends the Solana public key to Canton’s generateExternalParty, which returns a multiHash challenge.
3

Sign the challenge with Para

Client signs the multiHash with useSignMessage.
4

Allocate the party

Server submits the signature to Canton’s allocateExternalParty, which returns a partyId.

Prerequisites

  • A Para API key from the Para Developer Portal
  • Access to a Canton ledger and validator — either a hosted deployment or a local Splice LocalNet stack via docker-compose
  • Node.js 18+ with Next.js (for the server-side Canton SDK calls)
  • Basic familiarity with React and TypeScript

Installation

npm install @getpara/react-sdk @canton-network/wallet-sdk @tanstack/react-query bs58 pino server-only
The Canton SDK (@canton-network/wallet-sdk) must run on the server. Using server-only ensures it never bundles into the browser.

Project structure

The Canton SDK holds credentials for your validator. Keep it in Next.js API routes so those credentials never reach the client.
src/
├── app/page.tsx                         # React UI — ParaModal + sign button
├── app/api/canton/generate/route.ts     # Server: generateExternalParty
├── app/api/canton/allocate/route.ts     # Server: allocateExternalParty
├── components/ParaProvider.tsx          # Para SDK + Solana config
├── hooks/useCantonOnboarding.ts         # generate → sign → allocate
└── lib/canton.ts                        # Server-only Canton SDK setup

Setup

1. Configure the Para Provider

Set up ParaProvider with embedded-wallet signups enabled. Para automatically provisions a Solana (Ed25519) wallet for each user, which is what Canton needs.
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ParaWeb, { Environment, ParaProvider as ParaSDKProvider } from "@getpara/react-sdk";

const para = new ParaWeb(Environment.BETA, process.env.NEXT_PUBLIC_PARA_API_KEY!);
const queryClient = new QueryClient();

export function ParaProvider({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ParaSDKProvider
        paraClientConfig={para}
        config={{ appName: "My Canton App" }}
        paraModalConfig={{
          authLayout: ["AUTH:FULL"],
          oAuthMethods: ["APPLE", "DISCORD", "FACEBOOK", "GOOGLE", "TWITTER"],
        }}>
        {children}
      </ParaSDKProvider>
    </QueryClientProvider>
  );
}
No externalWalletConfig is set — Canton external parties are backed by Para’s own MPC-managed Solana key, not a user-supplied wallet like Phantom or MetaMask.

2. Initialize the Canton SDK (server-only)

Create src/lib/canton.ts. This mirrors Canton’s own localNetAuthDefault pattern and is memoized so the SDK connects once per server process.
import "server-only";

import { WalletSDKImpl, localNetAuthDefault, LedgerController } from "@canton-network/wallet-sdk";
import { pino } from "pino";

const logger = pino({ name: "canton", level: "info" });

let sdkPromise: Promise<WalletSDKImpl> | null = null;

export function getSdk(): Promise<WalletSDKImpl> {
  if (sdkPromise) return sdkPromise;

  const ledgerApiUrl = process.env.LEDGER_API_URL!;
  const validatorApiUrl = process.env.VALIDATOR_API_URL!;
  const validatorAudience = process.env.VALIDATOR_AUDIENCE!;
  const unsafeSecret = process.env.AUTH_UNSAFE_SECRET!;
  const userId = process.env.AUTH_USER_ID ?? "ledger-api-user";

  const authFactory = () => {
    const auth = localNetAuthDefault(logger as any);
    auth.userId = userId;
    (auth as any).audience = validatorAudience;
    (auth as any).unsafeSecret = unsafeSecret;
    return auth;
  };

  sdkPromise = (async () => {
    const sdk = new WalletSDKImpl().configure({
      logger,
      authFactory,
      ledgerFactory: (uid, auth, isAdmin) =>
        new LedgerController(uid, new URL(ledgerApiUrl), undefined, isAdmin, auth),
    });
    await sdk.connect();
    await sdk.connectAdmin();
    await sdk.connectTopology(new URL(validatorApiUrl));
    return sdk;
  })().catch((err) => { sdkPromise = null; throw err; });

  return sdkPromise;
}
For production Canton deployments, replace localNetAuthDefault with your validator’s authentication method (typically OAuth/JWT). Update VALIDATOR_AUDIENCE to match your validator’s expected audience.

3. Create the API routes

/api/canton/generate — decodes the Solana address and calls Canton to prepare the external party challenge.
// src/app/api/canton/generate/route.ts
import { NextResponse } from "next/server";
import bs58 from "bs58";
import { getSdk } from "@/lib/canton";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const { solanaAddress, partyHint } = await request.json();

  // Solana address (base58) → raw 32-byte Ed25519 public key → base64
  const rawPubkey = bs58.decode(solanaAddress);
  const publicKeyBase64 = Buffer.from(rawPubkey).toString("base64");

  const sdk = await getSdk();
  const generatedParty = await sdk.userLedger?.generateExternalParty(
    publicKeyBase64,
    partyHint
  );

  return NextResponse.json({
    multiHash: generatedParty!.multiHash,
    generatedParty,
  });
}
/api/canton/allocate — submits the Para-produced Ed25519 signature to finalize the party.
// src/app/api/canton/allocate/route.ts
import { NextResponse } from "next/server";
import { getSdk } from "@/lib/canton";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const { signatureBase64, generatedParty } = await request.json();

  const sdk = await getSdk();
  const allocatedParty = await sdk.userLedger?.allocateExternalParty(
    signatureBase64,
    generatedParty
  );

  return NextResponse.json({ partyId: allocatedParty!.partyId });
}

4. Build the onboarding hook

useCantonOnboarding does three things on the client:
  1. Makes sure the user’s Para Solana wallet is the active one (embedded accounts default to EVM).
  2. Fetches the Canton challenge, signs it with useSignMessage, and submits the signature.
useSignMessage goes straight to Para’s MPC signMessage endpoint, so there’s no Solana RPC client to configure.
"use client";

import { useCallback, useEffect, useState } from "react";
import { useAccount, useSignMessage, useWallet, useWalletState } from "@getpara/react-sdk";

export function useCantonOnboarding() {
  const account = useAccount();
  const { data: wallet } = useWallet();
  const { setSelectedWallet } = useWalletState();
  const { signMessageAsync } = useSignMessage();

  // Para accounts can hold multiple embedded wallets (EVM, Solana, …).
  // Force-select the Solana one so `wallet` / `wallet.address` is Ed25519.
  useEffect(() => {
    if (account?.isConnected && wallet?.type !== "SOLANA") {
      const solanaWallet = account.embedded.wallets?.find((w) => w.type === "SOLANA");
      if (solanaWallet) {
        setSelectedWallet({ id: solanaWallet.id, type: "SOLANA" });
      }
    }
  }, [account, wallet, setSelectedWallet]);

  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [multiHash, setMultiHash] = useState<string>();
  const [partyId, setPartyId] = useState<string>();

  const isSolanaWallet = wallet?.type === "SOLANA";
  const address = isSolanaWallet ? wallet?.address : undefined;
  const walletId = isSolanaWallet ? wallet?.id : undefined;

  const onboard = useCallback(async () => {
    if (!address || !walletId) return;
    setIsPending(true);
    setError(null);

    try {
      // Step 1: Register public key with Canton, receive multiHash challenge
      const genRes = await fetch("/api/canton/generate", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ solanaAddress: address, partyHint: "my-app-party" }),
      });
      const { multiHash: mh, generatedParty } = await genRes.json();
      setMultiHash(mh);

      // Step 2: Sign the multiHash with Para's MPC-managed Ed25519 key
      const signRes = await signMessageAsync({ walletId, messageBase64: mh });
      if (!("signature" in signRes) || !signRes.signature) {
        throw new Error("Para signing was denied or returned no signature");
      }
      const signatureBase64 = signRes.signature;

      // Step 3: Submit signature to Canton to allocate the party
      const allocRes = await fetch("/api/canton/allocate", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ signatureBase64, generatedParty }),
      });
      const { partyId: pid } = await allocRes.json();
      setPartyId(pid);
    } catch (err) {
      setError(err instanceof Error ? err : new Error("Onboarding failed"));
    } finally {
      setIsPending(false);
    }
  }, [address, walletId, signMessageAsync]);

  return { onboard, address, partyId, multiHash, isPending, error };
}

5. Wire up the UI

"use client";

import { useModal, useAccount } from "@getpara/react-sdk";
import { useCantonOnboarding } from "@/hooks/useCantonOnboarding";

export default function Home() {
  const { openModal } = useModal();
  const { isConnected } = useAccount();
  const { onboard, address, partyId, multiHash, isPending, error } = useCantonOnboarding();

  if (!isConnected) {
    return <button onClick={openModal}>Connect with Para</button>;
  }

  return (
    <div>
      <p>Solana address: {address ?? "Selecting Solana wallet…"}</p>
      <button onClick={onboard} disabled={!address || isPending || Boolean(partyId)}>
        {isPending ? "Onboarding…" : partyId ? "Onboarded" : "Onboard as Canton external party"}
      </button>
      {error && <p style={{ color: "red" }}>{error.message}</p>}
      {multiHash && <p>Signed multiHash: {multiHash}</p>}
      {partyId && <p>Canton partyId: {partyId}</p>}
    </div>
  );
}

Environment variables

# Client
NEXT_PUBLIC_PARA_API_KEY=your_para_api_key
NEXT_PUBLIC_PARA_ENVIRONMENT=BETA

# Server-only (never prefix with NEXT_PUBLIC_)
LEDGER_API_URL=http://localhost:2975
VALIDATOR_API_URL=http://localhost:2903/api/validator
VALIDATOR_AUDIENCE=https://canton.network.global
AUTH_USER_ID=ledger-api-user
AUTH_UNSAFE_SECRET=unsafe
These defaults target the app-user node of a Splice LocalNet docker-compose stack. For a hosted Canton deployment, update the URLs, swap localNetAuthDefault for the appropriate auth factory, and set VALIDATOR_AUDIENCE to whatever your validator expects.

Complete example

A fully working Next.js app with this flow, including styled UI and error handling, is available in the Para Examples Hub:

Canton External Party Example

Next.js + ParaModal + Canton Network — generate, sign, and allocate an external party on the Canton ledger

Solana Transfers

Intermediate · 25 min · Send SOL with Web3.js, Signers v2, and Anchor

Ethereum Transfers

Intermediate · 20 min · Send ETH with Ethers and Viem

EIP-712 Typed Data Signing

Intermediate · 20 min · Structured data signing