> For the complete documentation index, see [llms.txt](https://hinkal-team.gitbook.io/hinkal/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://hinkal-team.gitbook.io/hinkal/hinkal-api/api-integration/openfort.md).

# Openfort

Openfort gives your users a secure embedded wallet from a simple email login - no seed phrase, with private keys protected by Openfort's Shield key-management service and never exposed, not even to Openfort. Because Hinkal only needs a standard signer to operate, an Openfort wallet plugs in directly: the same wallet a user signs in with can hold a private, shielded balance and run confidential deposits, withdrawals, transfers, swaps, and private sends, while the keys stay protected by Openfort the entire time.

Openfort's role is unchanged. It authenticates the user, manages the embedded wallet and its recovery, and produces signatures. Hinkal uses those signatures to authorize private operations and never takes custody of the key.

***

### Compatibility

| Environment | Supported | Notes                 |
| ----------- | --------- | --------------------- |
| Browser     | ✅         | React, Next.js        |
| Libraries   | ✅         | @openfort/react v1.6+ |

***

### How the integration works

Three parties are involved:

* **The user's Openfort wallet** - produces every signature, with keys protected by Openfort Shield.
* **The Hinkal API** - an HTTP service running inside a secure enclave. It decrypts the user's shielded balance, generates the zero-knowledge proofs, and builds the on-chain transactions. The wallet's private key never reaches it.
* **The relayer** - broadcasts private transactions, so the user's public wallet is never the on-chain sender of a confidential operation.

***

### Installation

```bash
npm install @openfort/react ethers
# or
yarn add @openfort/react ethers
```

***

### 1. Get a signer from Openfort

Every Hinkal request is authorized by the user's wallet. Openfort is headless: create a client, authenticate the user (email OTP), provision an EVM EOA embedded wallet, then take its EIP-1193 provider and wrap it in ethers.

```typescript
import { ethers } from "ethers";
import {
  OpenfortProvider,
  OpenfortButton,
  AccountTypeEnum,
  RecoveryMethod,
  AuthProvider,
} from "@openfort/react";
import { useEthereumEmbeddedWallet } from "@openfort/react/ethereum";

// 1. Wrap your app in OpenfortProvider (once, at the root).
// Pass your OWN per-chain RPC URLs for EVERY chain you support — the SDK's
// default public RPCs (e.g. viem's eth.merkle.io for mainnet) are rate-limited
// and cause intermittent "could not detect network" errors during recovery.
<OpenfortProvider
  publishableKey={OPENFORT_PUBLISHABLE_KEY}
  walletConfig={{
    shieldPublishableKey: OPENFORT_SHIELD_PUBLISHABLE_KEY, // backs recovery
    ethereum: {
      chainId: DEFAULT_CHAIN_ID, // chain to activate on first mount
      rpcUrls: {
        1: mainnetRpcUrl,
        8453: baseRpcUrl,
        // …one entry per chain you support
      },
      accountType: AccountTypeEnum.EOA,
    },
  }}
  uiConfig={{
    authProviders: [AuthProvider.EMAIL_OTP],
    walletRecovery: {
      allowedMethods: [RecoveryMethod.PASSWORD],
      defaultMethod: RecoveryMethod.PASSWORD,
    },
  }}
>
  <App />
</OpenfortProvider>;

// 2. Authenticate with Openfort's prebuilt modal. Render its connect button;
// the modal runs email OTP and provisions the EOA embedded wallet.
// <OpenfortButton />   (or drive it with the useEmailOtpAuth() hook)

// 3. Once connected, take the embedded wallet's EIP-1193 provider and wrap it
// in ethers. Rebuild the signer per chain (Openfort is single-chain per signer).
const embedded = useEthereumEmbeddedWallet();

const getSigner = async (chainId: number) => {
  if (embedded.status !== "connected") {
    throw new Error("Openfort wallet not connected");
  }
  const provider = await embedded.activeWallet.getProvider();
  await provider.request({
    method: "wallet_switchEthereumChain",
    params: [{ chainId: `0x${chainId.toString(16)}` }],
  });
  const signer  = await new ethers.BrowserProvider(provider).getSigner();
  const account = await signer.getAddress();
  return { signer, account };
};
```

`activeWallet.getEthereumProvider()` returns the wallet's EIP-1193 provider; wrapping it in an `ethers.BrowserProvider` yields a `Signer`. From here, every Hinkal call is identical to any other wallet.

#### Notes specific to Openfort

* **Shield key required.** `shieldConfiguration.shieldPublishableKey` backs key recovery - `configure` fails without it. Use a real per-user recovery secret in production; a fixed password is for demos only.
* **EOA account type.** The wallet is provisioned as `AccountTypeEnum.EOA`, giving a standard EIP-1193 provider and ethers signer.
* **Per-chain signer.** Openfort builds the signer for one chain at a time. To switch chains, rebuild the signer with the new chain rather than switching an existing one.
* **Gas pre-fill required.** Openfort routes `eth_estimateGas` to its backend, which returns 400 for EOA wallets. Before sending any transaction the user broadcasts (deposit, ERC-20 approval, private-send deposit), pre-fill `gasLimit`, fee fields, and nonce from your own RPC so ethers skips estimation:

```typescript
const populateGas = async (
  signer: ethers.Signer,
  txRequest: ethers.TransactionRequest,
): Promise<void> => {
  const from = await signer.getAddress();
  const { chainId } = await signer.provider!.getNetwork();
  const rpc = new ethers.JsonRpcProvider(rpcUrlFor(Number(chainId)));
  const [gasLimit, feeData, nonce] = await Promise.all([
    rpc.estimateGas({ from, to: txRequest.to ?? undefined, data: txRequest.data, value: txRequest.value }),
    rpc.getFeeData(),
    rpc.getTransactionCount(from, "pending"),
  ]);
  txRequest.gasLimit = (gasLimit * 12n) / 10n; // 20% headroom
  txRequest.nonce    = nonce;
  if (feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) {
    txRequest.maxFeePerGas         = feeData.maxFeePerGas;
    txRequest.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas;
  } else if (feeData.gasPrice) {
    txRequest.gasPrice = feeData.gasPrice;
  }
};
```

***

### 2. How requests are authorized

Hinkal authorizes requests with a session. The user signs one message with their Openfort wallet to open the session; from then on, requests are authenticated by a session key - not by a wallet popup per request. The enclave reconstructs the same data and verifies it server-side before doing anything.

#### Open a session (sign once)

The client generates a secp256k1 session keypair and a `sessionId`, then has the Openfort wallet sign the session message. The signed message is exactly:

```
Authorize Hinkal session
Session ID: <sessionId>
Public Key: <clientPublicKey>
```

```typescript
import { ethers } from "ethers";

// Generate a session keypair
const privateKey      = crypto.getRandomValues(new Uint8Array(32));
const clientPublicKey = new ethers.SigningKey(privateKey).compressedPublicKey.slice(2);
const sessionId       = crypto.randomUUID();

const message = [
  "Authorize Hinkal session",
  `Session ID: ${sessionId}`,
  `Public Key: ${clientPublicKey}`,
].join("\n");

const signature = await signer.signMessage(message);

await fetch(`${API_BASE_URL}/create-session`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    signature,
    address: account,
    sessionId,
    clientPublicKey,
    nonce: crypto.randomUUID(),
    useEIP712: true,
  }),
});
```

The session has two modes. In **EIP712 mode** (`useEIP712: true`) each transaction is additionally authorized with a per-tx typed-data signature from the wallet (next section). In **Normal mode** (`useEIP712: false`) the session message carries a fourth line - `This signature can also be used to submit transactions.` - and that single signature authorizes every transaction for the session's lifetime; the per-transaction wallet signature is skipped.

#### Per-request session signature (GET requests and Normal-mode POSTs)

Every GET request and every Normal-mode POST is signed with the session private key and sent in a header. The enclave checks it against the session's registered public key:

```typescript
const signPayload = async (privateKey: Uint8Array, payload: string): Promise<string> => {
  const hash = new Uint8Array(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload)),
  );
  const sig = new ethers.SigningKey(privateKey).sign(hash);
  return sig.r.slice(2) + sig.s.slice(2); // compact r||s, 64 bytes
};

// GET requests: sign the query string
headers["x-hinkal-request-signature"] = await signPayload(privateKey, queryString);

// Normal-mode POST requests: sign the JSON body string
headers["x-hinkal-request-signature"] = await signPayload(privateKey, JSON.stringify(body));
```

#### Transaction signature (EIP-712 typed data - EIP712 mode)

The two POST auth modes are **mutually exclusive**:

| Mode   | Body contains `signature`?    | `x-hinkal-request-signature` header? |
| ------ | ----------------------------- | ------------------------------------ |
| EIP712 | ✅ wallet typed-data signature | ❌ not sent                           |
| Normal | ❌ not present                 | ✅ session key signs body             |

In EIP712 mode each transaction is authorized with a typed-data signature that commits to the exact operation. The enclave rebuilds the identical structure and verifies it.

The domain is:

```typescript
const domain = { name: "Hinkal Enclave", chainId };
```

Each operation has its own primary type. The full set:

```typescript
const types = {
  TokenAmount: [
    { name: "token",  type: "address" },
    { name: "amount", type: "int256"  },
  ],
  RecipientAmount: [
    { name: "recipient", type: "address" },
    { name: "amount",    type: "int256"  },
  ],
  FeeStructure: [
    { name: "feeToken",     type: "address" },
    { name: "flatFee",      type: "uint256" },
    { name: "variableRate", type: "uint256" },
  ],
  Deposit: [
    { name: "nonce",        type: "string"        },
    { name: "sessionId",    type: "string"        },
    { name: "chainId",      type: "uint256"       },
    { name: "tokenAmounts", type: "TokenAmount[]" },
  ],
  Withdraw: [
    { name: "nonce",        type: "string"        },
    { name: "sessionId",    type: "string"        },
    { name: "chainId",      type: "uint256"       },
    { name: "tokenAmounts", type: "TokenAmount[]" },
    { name: "recipient",    type: "string"        },
  ],
  Transfer: [
    { name: "nonce",        type: "string"        },
    { name: "sessionId",    type: "string"        },
    { name: "chainId",      type: "uint256"       },
    { name: "tokenAmounts", type: "TokenAmount[]" },
    { name: "recipient",    type: "string"        },
  ],
  Swap: [
    { name: "nonce",            type: "string"        },
    { name: "sessionId",        type: "string"        },
    { name: "chainId",          type: "uint256"       },
    { name: "tokenAmounts",     type: "TokenAmount[]" },
    { name: "externalActionId", type: "string"        },
    { name: "swapData",         type: "string"        },
  ],
  PrivateSend: [
    { name: "nonce",        type: "string"            },
    { name: "sessionId",    type: "string"            },
    { name: "chainId",      type: "uint256"           },
    { name: "tokenAddress", type: "address"           },
    { name: "recipients",   type: "RecipientAmount[]" },
  ],
};
```

When signing, include only the primary type you are using plus the types it references. Append `feeToken` (`address`), `feeStructure` (`FeeStructure`), and/or `txCompletionTime` (`uint256`) to the primary type's fields only when those values are present. A deposit, for example:

```typescript
const nonce     = crypto.randomUUID();
const signature = await signer.signTypedData(
  { name: "Hinkal Enclave", chainId },
  { Deposit: types.Deposit, TokenAmount: types.TokenAmount },
  { nonce, sessionId, chainId, tokenAmounts: [{ token, amount }] },
);
```

**Two rules the enclave enforces - the signature must match exactly:**

1. Sort `tokenAmounts` by token address (checksummed, ascending) before signing - and sort `recipients` the same way for a private send. The enclave re-sorts before verifying; an unsorted array produces a non-matching signature.
2. The nonce is single-use and expires after 60 seconds. Generate a fresh UUID for every signed request; a reused or expired nonce is rejected server-side.

***

### 3. Read the shielded balance

The shielded balance is the user's private, encrypted holdings. Reading it uses the session: query parameters plus the session-key request-signature header.

```typescript
const params = new URLSearchParams({
  sessionId,
  chainId:   String(chainId),
  nonce:     crypto.randomUUID(),
  timestamp: Date.now().toString(),
});

const res = await fetch(`${API_BASE_URL}/balance?${params}`, {
  headers: {
    "x-hinkal-request-signature": await signPayload(privateKey, params.toString()),
  },
});
const { balances } = await res.json(); // { success: true, balances: [...] }
```

The enclave decrypts the user's shielded outputs internally and returns the balances.

***

### 4. Shield - deposit (public → private)

A deposit moves funds from the user's public Openfort wallet into their shielded balance.

1. Authorize the deposit.
2. `POST /deposit` with the auth fields and details.
3. The enclave returns an unsigned transaction (`txData`).
4. The user's Openfort wallet signs and broadcasts it - a deposit is the public on-ramp into privacy, sent from the user's own address. **Pre-fill gas (section 1) before sending.**

```typescript
// EIP712 mode - wallet signs typed data; no x-hinkal-request-signature header
const nonce  = crypto.randomUUID();
const body   = {
  sessionId,
  nonce,
  timestamp: Date.now(),
  chainId,
  tokenAddresses,
  amounts,
  signature: await signer.signTypedData(
    { name: "Hinkal Enclave", chainId },
    { Deposit: types.Deposit, TokenAmount: types.TokenAmount },
    { nonce, sessionId, chainId, tokenAmounts: sortedTokenAmounts(tokenAddresses, amounts) },
  ),
};

const res = await fetch(`${API_BASE_URL}/deposit`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },   // no x-hinkal-request-signature
  body: JSON.stringify(body),
});

// Normal mode - session key signs the body; no typed-data signature in body
const nonce  = crypto.randomUUID();
const body   = { sessionId, nonce, timestamp: Date.now(), chainId, tokenAddresses, amounts };

const res = await fetch(`${API_BASE_URL}/deposit`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-hinkal-request-signature": await signPayload(privateKey, JSON.stringify(body)),
  },
  body: JSON.stringify(body),
});

const { txData } = await res.json(); // { success: true, txData: { to, data, value? } }

// User broadcasts with their Openfort wallet (gas pre-filled)
const txRequest = { to: txData.to, data: txData.data, value: txData.value };
await populateGas(signer, txRequest);
const tx = await signer.sendTransaction(txRequest);
await tx.wait();
```

After confirmation, the funds are in the user's shielded balance.

***

### 5. Unshield - withdraw (private → public)

A withdrawal sends funds from the shielded balance to any public address. The user does not broadcast it: the enclave builds and proves the transaction and the relayer broadcasts it, so the user's wallet is never the on-chain sender.

1. Authorize the withdrawal.
2. `POST /withdraw`.
3. The response is the resulting `txHash`.

```typescript
// EIP712 mode
const nonce = crypto.randomUUID();
const body  = {
  sessionId, nonce, timestamp: Date.now(), chainId,
  tokenAddresses,
  amounts,          // positive values; the enclave negates them internally
  recipientAddress, // public address receiving the funds
  feeToken,         // token used to pay the relayer fee
  feeStructure,     // from GET /get-fee-structure
  signature: await signer.signTypedData(
    { name: "Hinkal Enclave", chainId },
    { Withdraw: types.Withdraw, TokenAmount: types.TokenAmount },
    { nonce, sessionId, chainId, tokenAmounts: sortedTokenAmounts(tokenAddresses, amounts), recipient: recipientAddress },
  ),
};

const res = await fetch(`${API_BASE_URL}/withdraw`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(body),
});

// Normal mode
const nonce = crypto.randomUUID();
const body  = { sessionId, nonce, timestamp: Date.now(), chainId, tokenAddresses, amounts, recipientAddress, feeToken, feeStructure };

const res = await fetch(`${API_BASE_URL}/withdraw`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-hinkal-request-signature": await signPayload(privateKey, JSON.stringify(body)),
  },
  body: JSON.stringify(body),
});

const { txHash } = await res.json(); // { success: true, txHash }
```

***

### 6. Transfer (private → private)

A transfer moves funds from the user's shielded balance to another user's shielded balance. Both sides stay private - there is no public on-chain link between sender and recipient, and the amount is not visible. The user does not broadcast it: the enclave proves the transaction and the relayer broadcasts it.

The recipient is identified by their **recipient info** - a private identifier the recipient obtains from `GET /recipient-info`(authorized by their own session) and shares with the sender. It is not a public address.

```typescript
// The recipient fetches their own recipient info and shares it with the sender:
const recipientParams = new URLSearchParams({
  sessionId: recipientSessionId,
  chainId:   String(chainId),
  nonce:     crypto.randomUUID(),
  timestamp: Date.now().toString(),
});
const { recipientInfo } = await (
  await fetch(`${API_BASE_URL}/recipient-info?${recipientParams}`, {
    headers: {
      "x-hinkal-request-signature": await signPayload(recipientPrivateKey, recipientParams.toString()),
    },
  })
).json();
```

The sender then authorizes the `Transfer` and submits:

```typescript
// EIP712 mode
const nonce = crypto.randomUUID();
const body  = {
  sessionId, nonce, timestamp: Date.now(), chainId,
  tokenAddresses,
  amounts,          // positive values; the enclave negates them internally
  recipientAddress: recipientInfo, // the recipient's recipientInfo string
  feeToken,
  feeStructure,
  signature: await signer.signTypedData(
    { name: "Hinkal Enclave", chainId },
    { Transfer: types.Transfer, TokenAmount: types.TokenAmount },
    { nonce, sessionId, chainId, tokenAmounts: sortedTokenAmounts(tokenAddresses, amounts), recipient: recipientInfo },
  ),
};

const res = await fetch(`${API_BASE_URL}/transfer`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(body),
});

// Normal mode
const nonce = crypto.randomUUID();
const body  = { sessionId, nonce, timestamp: Date.now(), chainId, tokenAddresses, amounts, recipientAddress: recipientInfo, feeToken, feeStructure };

const res = await fetch(`${API_BASE_URL}/transfer`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-hinkal-request-signature": await signPayload(privateKey, JSON.stringify(body)),
  },
  body: JSON.stringify(body),
});

const { txHash } = await res.json(); // { success: true, txHash }
```

The enclave proves and the relayer broadcasts.

***

### 7. Swap (within the shielded balance)

A swap exchanges one token for another inside the shielded balance. It is a two-call flow: first fetch a quote, then execute. The `Swap` typed data commits to the quote via `externalActionId` and `swapData`.

```typescript
// 1. Quote - session-authorized GET (same for both modes)
const quoteParams = new URLSearchParams({
  sessionId, chainId: String(chainId),
  nonce: crypto.randomUUID(), timestamp: Date.now().toString(),
  inputTokenAddress, outputTokenAddress, amount,
});
const quote = await (
  await fetch(`${API_BASE_URL}/get-swap-data?${quoteParams}`, {
    headers: {
      "x-hinkal-request-signature": await signPayload(privateKey, quoteParams.toString()),
    },
  })
).json();
// quote: { externalActionId, swapData, outSwapAmount }

// 2. Execute - EIP712 mode
const nonce = crypto.randomUUID();
const body  = {
  sessionId, nonce, timestamp: Date.now(), chainId,
  tokenAddresses,  // [inputToken, outputToken]
  amounts,         // [inputAmount (negative), expectedOutputAmount (positive, after Hinkal fee)]
  externalActionId: quote.externalActionId,
  swapData:         quote.swapData,
  feeToken,
  feeStructure,
  signature: await signer.signTypedData(
    { name: "Hinkal Enclave", chainId },
    { Swap: types.Swap, TokenAmount: types.TokenAmount },
    { nonce, sessionId, chainId, tokenAmounts: sortedTokenAmounts(tokenAddresses, amounts), externalActionId: quote.externalActionId, swapData: quote.swapData },
  ),
};

const res = await fetch(`${API_BASE_URL}/swap`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(body),
});

// 2. Execute - Normal mode
const nonce = crypto.randomUUID();
const body  = { sessionId, nonce, timestamp: Date.now(), chainId, tokenAddresses, amounts, externalActionId: quote.externalActionId, swapData: quote.swapData, feeToken, feeStructure };

const res = await fetch(`${API_BASE_URL}/swap`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-hinkal-request-signature": await signPayload(privateKey, JSON.stringify(body)),
  },
  body: JSON.stringify(body),
});

const { txHash } = await res.json();
```

As with withdrawals and transfers, the enclave proves and the relayer broadcasts.

***

### 8. Private send (one deposit → many private payouts)

A private send deposits public funds once and has the enclave pay out privately to one or more recipients - the on-chain link between sender and recipients is never visible. The `PrivateSend` typed data commits to the token and the full recipient set.

1. `POST /private-send` with the token, recipients, and amounts → returns an order (`orderId`, `approvalAddress`, `serializedTx`, `amountIn`, `amountOut`, `fee`).
2. Approve the ERC-20 spend if `approvalAddress` is set.
3. Sign and broadcast the deposit with the user's Openfort wallet.
4. Poll `GET /private-send/{orderId}` until every scheduled payout completes.

```typescript
// 1. Prepare the order - EIP712 mode
const nonce = crypto.randomUUID();
const body  = {
  sessionId, nonce, timestamp: Date.now(), chainId,
  tokenAddress,        // single token
  recipients,          // [{ address, amount }, ...] amounts in base units
  feeToken,            // optional - defaults to tokenAddress
  // txCompletionTime, // optional Unix-seconds deadline
  signature: await signer.signTypedData(
    { name: "Hinkal Enclave", chainId },
    { PrivateSend: types.PrivateSend, RecipientAmount: types.RecipientAmount },
    { nonce, sessionId, chainId, tokenAddress, recipients: sortedRecipients(recipients) },
  ),
};

const res = await fetch(`${API_BASE_URL}/private-send`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(body),
});

// 1. Prepare the order - Normal mode
const nonce = crypto.randomUUID();
const body  = { sessionId, nonce, timestamp: Date.now(), chainId, tokenAddress, recipients, feeToken };

const res = await fetch(`${API_BASE_URL}/private-send`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-hinkal-request-signature": await signPayload(privateKey, JSON.stringify(body)),
  },
  body: JSON.stringify(body),
});

const order = await res.json();
// { orderId, approvalAddress, serializedTx, amountIn, amountOut, fee }

// 2. Approve if required (gas pre-filled for Openfort)
if (order.approvalAddress) {
  const data = new ethers.Interface(ERC20_ABI).encodeFunctionData("approve", [
    order.approvalAddress,
    BigInt(order.amountIn),
  ]);
  const approveReq = { to: tokenAddress, data };
  await populateGas(signer, approveReq);
  await (await signer.sendTransaction(approveReq)).wait();
}

// 3. Broadcast the deposit (serializedTx is base64 RLP; gas pre-filled for Openfort)
const parsed     = ethers.Transaction.from(ethers.hexlify(ethers.decodeBase64(order.serializedTx)));
const depositReq = { to: parsed.to!, data: parsed.data, value: parsed.value ?? undefined };
await populateGas(signer, depositReq);
await (await signer.sendTransaction(depositReq)).wait();

// 4. Poll until all payouts settle
const result = await pollUntilComplete(`${API_BASE_URL}/private-send/${order.orderId}`);
```

`amountIn` is what leaves the wallet; `amountOut` is what recipients collectively receive; the difference is the protocol fee. Amounts are in the token's smallest unit. The user broadcasts the single deposit; the enclave then proves and the relayer broadcasts each private payout.

***

### Summary

| Operation                    | Endpoint                                 | Auth                                 | Broadcaster                        |
| ---------------------------- | ---------------------------------------- | ------------------------------------ | ---------------------------------- |
| Open session                 | `POST /create-session`                   | Wallet message signature             | -                                  |
| Read balance                 | `GET /balance`                           | Session key (request signature)      | -                                  |
| Deposit (shield)             | `POST /deposit`                          | Deposit typed data / session key     | The user's wallet                  |
| Withdraw (unshield)          | `POST /withdraw`                         | Withdraw typed data / session key    | Relayer                            |
| Transfer (private → private) | `GET /recipient-info` + `POST /transfer` | Transfer typed data / session key    | Relayer                            |
| Swap                         | `GET /get-swap-data` + `POST /swap`      | Swap typed data / session key        | Relayer                            |
| Private send                 | `POST /private-send` + poll              | PrivateSend typed data / session key | User (deposit) + relayer (payouts) |
