> 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/description/authentication.md).

# Authentication

Every API request is authenticated with a combination of a session and a secp256k1 request-signature — there are no API keys or passwords. The API supports **EVM chains**, **Tron**, and **Solana**. Signing method and session creation are the same across chains; only the cryptographic primitive for the session wallet signature differs.

## Two session modes

Every session has an `authMode`:

* **Normal mode** (`useEIP712: false`, default) — the convenient path. The session signature includes explicit consent to submit transactions. All subsequent requests — reads and writes — are authenticated with a secp256k1 request signature only. No per-transaction wallet signature is required.
* **EIP-712 mode** (`useEIP712: true`) — the explicit-consent path. Each transaction carries its own EIP-712 typed-data (EVM/Tron) or ed25519 plain-text (Solana) signature that commits to the exact operation parameters. Read endpoints use the secp256k1 request signature (same as normal mode).

See [Examples](/hinkal/hinkal-api/description/examples.md) for complete, runnable code.

## Sessions

A session is a `sessionId` (a UUID) bound to your `address`, registered with the enclave and valid for **24 hours** by default. Pass `expiresAt` (ISO-8601) at creation to set a custom expiry. You create one by signing a fixed message and posting it to `POST /create-session`. One session works across all supported chains for that address.

## Request signing (secp256k1)

Every session uses a **secp256k1 key pair** generated on the client:

{% stepper %}
{% step %}

## Generate a secp256k1 key pair

Generate a secp256k1 private key (32 random bytes) and derive the compressed public key (33 bytes = 66 hex chars).
{% endstep %}

{% step %}

## Include the public key in the session request

Include `clientPublicKey` in the create-session request body.
{% endstep %}

{% step %}

## Store the public key for the session

The enclave stores the public key for the session.
{% endstep %}

{% step %}

## Sign each subsequent request payload

For every subsequent request, sign a payload that binds the request to its own route:

* Build an **action binding**: `"<METHOD> <routePath>"`, e.g. `"POST /withdraw"` or `"GET /balance"`. `routePath` is the server's route pattern — for a parameterized route like the private-send status endpoint it's the literal `"GET /private-send/:orderId"`, **not** the concrete order ID from the URL.
* **POST**: sign `` `${binding}\n${JSON.stringify(body)}` `` (SHA-256 hash then secp256k1 ECDSA compact signature).
* **GET**: sign `` `${binding}\n${queryString}` `` (raw query string — everything after `?`).
  {% endstep %}

{% step %}

## Send the request signature header

Send the hex-encoded compact signature in the `x-hinkal-request-signature` request header.

Binding the route into the signed digest means a signature captured for one endpoint (say, `GET /balance`) cannot be replayed against another (`POST /withdraw`) even though both are signed with the same key pair.
{% endstep %}
{% endstepper %}

**Exception — `POST /create-session`**: at that point no session exists yet to bind a route against, so the client signs the **raw body only**, with no action binding. Every other route uses the binding scheme below.

```js
import { secp256k1 } from "@noble/curves/secp256k1";
import { createHash, randomBytes } from "crypto";

// Generate key pair once per session
const privateKey = randomBytes(32);
const clientPublicKey = Buffer.from(secp256k1.getPublicKey(privateKey, true)).toString("hex");

function normalizeRoutePath(path) {
  const withSlash = path.startsWith("/") ? path : `/${path}`;
  return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}

// Must match the server's route pattern exactly, e.g. "POST /withdraw"
function buildActionBinding(method, routePath) {
  return `${method.toUpperCase()} ${normalizeRoutePath(routePath)}`;
}

function signPayload(privateKey, payload) {
  const msgHash = new Uint8Array(createHash("sha256").update(payload).digest());
  return Buffer.from(secp256k1.sign(msgHash, privateKey).toBytes("compact")).toString("hex");
}

// routePath e.g. "/withdraw"
function requestSignaturePostHeader(privateKey, routePath, body) {
  const binding = buildActionBinding("POST", routePath);
  return { "x-hinkal-request-signature": signPayload(privateKey, `${binding}\n${JSON.stringify(body)}`) };
}

function requestSignatureGetHeader(privateKey, routePath, queryString) {
  const binding = buildActionBinding("GET", routePath);
  return { "x-hinkal-request-signature": signPayload(privateKey, `${binding}\n${queryString}`) };
}
```

Tying the signature to the registered `clientPublicKey` ensures that only the holder of the private key generated at session creation can authenticate requests.

### The session message

A session is opened by signing a fixed plain-text message with a standard EIP-191 `personal_sign` on EVM and Tron, or an ed25519 signature over the same UTF-8 bytes on Solana.

The message always includes `Public Key: <clientPublicKey>` — this binds the secp256k1 public key to the wallet signature so the enclave can be sure the public key belongs to the same party that controls the wallet.

**Normal mode** — includes explicit transaction consent:

```
Authorize Hinkal session
Session ID: <sessionId>
Public Key: <clientPublicKey>
This signature can also be used to submit transactions.
```

**EIP-712 mode** — no transaction consent (each transaction is signed individually):

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

`buildSessionMessage` produces the right message for either mode:

```js
function buildSessionMessage(sessionId, clientPublicKey, useEIP712) {
  const lines = [
    "Authorize Hinkal session",
    `Session ID: ${sessionId}`,
    `Public Key: ${clientPublicKey}`,
  ];
  if (!useEIP712) {
    lines.push("This signature can also be used to submit transactions.");
  }
  return lines.join("\n");
}
```

### Signing the session message by chain

{% tabs %}
{% tab title="EVM" %}
Sign with EIP-191 `personal_sign` (ethers `wallet.signMessage`):

```js
const sessionId = randomUUID();
const message = buildSessionMessage(sessionId, clientPublicKey, useEIP712);
const signature = await wallet.signMessage(message); // EIP-191
```

{% endtab %}

{% tab title="Tron" %}
Sign with `tronWeb.trx.signMessageV2` (same message format, TronWeb's EIP-191-compatible method):

```js
const sessionId = randomUUID();
const message = buildSessionMessage(sessionId, clientPublicKey, useEIP712);
const signature = tronWeb.trx.signMessageV2(message, privateKey);
```

{% endtab %}

{% tab title="Solana" %}
Sign the UTF-8 message bytes with ed25519 (`nacl.sign.detached`). Pass the `address` as your Solana base58 public key:

```js
import nacl from "tweetnacl";

const sessionId = randomUUID();
const message = buildSessionMessage(sessionId, clientPublicKey, useEIP712);
const messageBytes = Buffer.from(message, "utf8");
const signature = Buffer.from(
  nacl.sign.detached(messageBytes, keypair.secretKey)
).toString("hex");
```

{% endtab %}
{% endtabs %}

### Creating a session

Generate a secp256k1 key pair, then POST to `/create-session`. Include a per-request `nonce` (UUID) so the response is bound to your specific request. Set a custom expiry via `expiresAt` (ISO-8601); otherwise the session lasts 24 hours.

```js
import { secp256k1 } from "@noble/curves/secp256k1";
import { createHash, randomBytes } from "crypto";
import { randomUUID } from "crypto";

async function createSession(wallet, useEIP712 = false, expiresAt) {
  // 1. Generate secp256k1 key pair
  const privateKey = randomBytes(32);
  const clientPublicKey = Buffer.from(secp256k1.getPublicKey(privateKey, true)).toString("hex");

  // 2. Sign the session message with the wallet
  const sessionId = randomUUID();
  const message = buildSessionMessage(sessionId, clientPublicKey, useEIP712);
  const signature = await wallet.signMessage(message); // EVM example

  // 3. Build request body and secp256k1 header
  const nonce = randomUUID(); // per-request identifier
  const body = {
    signature,
    address: wallet.address,
    sessionId,
    clientPublicKey,
    nonce,
    useEIP712,
    ...(expiresAt ? { expiresAt } : {}),
  };

  // /create-session is the one route with no action binding — sign the raw body directly
  const res = await fetch(`${BASE_URL}/create-session`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-hinkal-request-signature": signPayload(privateKey, JSON.stringify(body)),
    },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  // json: { success, expiresAt }
  return { sessionId, privateKey, useEIP712 };
}
```

The response body contains:

* `expiresAt` — ISO-8601 expiry

## Authenticating read endpoints

Read endpoints — `GET /balance`, `GET /stuck-utxo-balance`, `GET /recipient-info`, `GET /get-fee`, `GET /get-swap-data`, `GET /private-send/{orderId}` — require an active session (normal or EIP-712). Send `sessionId` and `nonce` in the **query string**, plus `x-hinkal-request-signature` bound to the route and over the query string:

```js
// Example: fetch shielded balance
const params = new URLSearchParams({
  sessionId: session.sessionId,
  nonce: randomUUID(),     // fresh per-request UUID
  chainId: CHAIN_ID.toString(),
  timestamp: Date.now().toString(),
});
const queryString = params.toString();

const res = await fetch(`${BASE_URL}/balance?${queryString}`, {
  headers: requestSignatureGetHeader(session.privateKey, "/balance", queryString),
});
const { balances } = await res.json();
```

`routePath` must be the endpoint's own path — `"/balance"` for `GET /balance`, `"/recipient-info"` for `GET /recipient-info`, and so on. The one exception is `GET /private-send/{orderId}`: its `routePath` is the literal `"/private-send/:orderId"`, not the concrete order ID, because binding uses the server's route pattern.

You must call `POST /create-session` first — requests with an unknown `sessionId` are rejected with `Session not found`.

## Authenticating transaction endpoints

Transaction endpoints accept **either** of two authentication modes, depending on the session's `authMode`.

### Normal mode — secp256k1 only

If you opened a session with `useEIP712: false` (the default), authenticate each transaction request with a fresh `nonce` and `x-hinkal-request-signature` bound to the route and over the raw JSON body — no wallet signature required in the body:

```js
function sessionBodyParams(session, chainId) {
  return {
    sessionId: session.sessionId,
    nonce: randomUUID(),   // fresh per-request UUID
    chainId,
    timestamp: Date.now(),
  };
}

// Example: deposit
const bodyParams = sessionBodyParams(session, CHAIN_ID);
const body = {
  ...bodyParams,
  tokenAddresses: [USDC],
  amounts: ["1000000"],
};
const { txData } = await fetch(`${BASE_URL}/deposit`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    ...requestSignaturePostHeader(session.privateKey, "/deposit", body),
  },
  body: JSON.stringify(body),
}).then((r) => r.json());
```

The secp256k1 signature over the body authorizes the operation. No per-transaction typed-data signature is required. One key pair covers every request for the lifetime of the session.

### EIP-712 mode — per-transaction typed-data signature

If you opened a session with `useEIP712: true`, each transaction carries its own signature over a struct that commits to the whole operation — the tokens, amounts, recipient, chain, and fee fields. This prevents a compromised API layer from swapping transaction parameters after the fact.

The enclave rebuilds the struct from the request parameters, verifies the signature, and rejects the request if the signer address does not match the session's registered address.

Each `nonce` in the typed-data struct is **single-use and expires after 60 seconds** — use a fresh UUID per transaction.

Send `sessionId`, `nonce`, and `signature` in the request body. Do **not** include the `x-hinkal-request-signature` header.

```js
const nonce = randomUUID();
// Sign the typed-data struct (EVM example — see type definitions below)
const signature = await wallet.signTypedData(DOMAIN(CHAIN_ID), { TokenAmount, Deposit }, {
  nonce,
  sessionId: session.sessionId,
  chainId: BigInt(CHAIN_ID),
  tokenAmounts: tokenAmounts([USDC], ["1000000"]),
});

const { txData } = await post("/deposit", {
  sessionId: session.sessionId,
  nonce,
  signature,
  chainId: CHAIN_ID,
  tokenAddresses: [USDC],
  amounts: ["1000000"],
});
```

Use EIP-712 mode when you want each individual transaction to require an explicit wallet signature from the end user.

#### EVM / Tron — EIP-712 typed-data v4

Sign with `eth_signTypedData_v4`. For EVM use ethers `wallet.signTypedData`; for Tron use `tronWeb.trx._signTypedData(domain, types, value)` — both produce EIP-712-compatible signatures.

**Domain:**

```json
{ "name": "Hinkal Enclave", "chainId": <chainId> }
```

**Primary types by endpoint:**

| Endpoint                | Primary type         |
| ----------------------- | -------------------- |
| `/deposit`              | `Deposit`            |
| `/proofless-deposit`    | `ProoflessDeposit`   |
| `/deposit-for-other`    | `DepositForOther`    |
| `/withdraw`             | `Withdraw`           |
| `/transfer`             | `Transfer`           |
| `/swap`                 | `Swap`               |
| `/private-send`         | `PrivateSend`        |
| `/withdraw-stuck-utxos` | `WithdrawStuckUtxos` |

Every struct includes `nonce`, `sessionId`, and `chainId`, plus the operation's parameters.

**Type definitions:**

```js
const TYPES = {
  TokenAmount: [
    { name: "token", type: "address" },
    { name: "amount", type: "int256" },
  ],
  RecipientAmount: [
    { name: "recipient", type: "address" },
    { name: "amount", type: "int256" },
  ],
  Deposit: [
    { name: "nonce", type: "string" },
    { name: "sessionId", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "tokenAmounts", type: "TokenAmount[]" },
  ],
  ProoflessDeposit: [
    { name: "nonce", type: "string" },
    { name: "sessionId", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "tokenAmounts", type: "TokenAmount[]" },
  ],
  DepositForOther: [
    { name: "nonce", type: "string" },
    { name: "sessionId", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "tokenAmounts", type: "TokenAmount[]" },
    { name: "recipientInfo", type: "string" },
  ],
  // Base Transfer type — feeToken and feeAmount appended dynamically if present
  Transfer: [
    { name: "nonce", type: "string" },
    { name: "sessionId", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "tokenAmounts", type: "TokenAmount[]" },
    { name: "recipient", type: "string" },
    // + { name: "feeToken", type: "address" }  — only if feeToken is in the request body
    // + { name: "feeAmount", type: "uint256" } — only if feeAmount is in the request body
  ],
  // Base Withdraw type — same optional appending as Transfer
  Withdraw: [
    { name: "nonce", type: "string" },
    { name: "sessionId", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "tokenAmounts", type: "TokenAmount[]" },
    { name: "recipient", type: "string" },
    // + { name: "feeToken", type: "address" }  — only if feeToken is in the request body
    // + { name: "feeAmount", type: "uint256" } — only if feeAmount is in the request body
  ],
  // Base Swap type — same optional appending
  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" },
    // + { name: "feeToken", type: "address" }  — only if feeToken is in the request body
    // + { name: "feeAmount", type: "uint256" } — only if feeAmount is in the request body
  ],
  // Base PrivateSend type — feeToken and txCompletionTime appended dynamically if present
  PrivateSend: [
    { name: "nonce", type: "string" },
    { name: "sessionId", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "tokenAddress", type: "address" },
    { name: "recipients", type: "RecipientAmount[]" },
    // + { name: "feeToken", type: "address" }        — only if feeToken is in the request body
    // + { name: "txCompletionTime", type: "uint256" } — only if txCompletionTime is in the request body
  ],
  WithdrawStuckUtxos: [
    { name: "nonce", type: "string" },
    { name: "sessionId", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "tokenAddress", type: "address" },
    { name: "recipient", type: "address" },
  ],
};
```

**Optional fee fields — dynamic struct construction:**

`feeToken`, `feeAmount`, and `txCompletionTime` are optional. The enclave dynamically builds the EIP-712 struct based on which fields are actually present in the request body:

* If `feeToken` is in the request → append `{ name: "feeToken", type: "address" }` to the type and include it in the value.
* If `feeAmount` is in the request → append `{ name: "feeAmount", type: "uint256" }` and include it.
* If `txCompletionTime` is in the request → append `{ name: "txCompletionTime", type: "uint256" }` and include it.
* If a field is absent → it is **not** in the struct type at all (no zero-address substitution).

You must sign over exactly the same struct the server will reconstruct. Include only the fields you actually send; omit the fields you don't:

```js
// feeToken and feeAmount present — include in both type and value
const Transfer = [
  { name: "nonce", type: "string" },
  { name: "sessionId", type: "string" },
  { name: "chainId", type: "uint256" },
  { name: "tokenAmounts", type: "TokenAmount[]" },
  { name: "recipient", type: "string" },
  { name: "feeToken", type: "address" },  // only because feeToken is in body
  { name: "feeAmount", type: "uint256" }, // only because feeAmount is in body
];

// feeToken and feeAmount absent — base type only
const Transfer = [
  { name: "nonce", type: "string" },
  { name: "sessionId", type: "string" },
  { name: "chainId", type: "uint256" },
  { name: "tokenAmounts", type: "TokenAmount[]" },
  { name: "recipient", type: "string" },
];
```

**Normalization rules** (apply before signing):

* All addresses are checksummed (EIP-55): `token`, `feeToken`, `tokenAddress`, and `recipient`.
* `tokenAmounts` is sorted by `token` address, ascending.
* `recipients` (private-send) is sorted by `recipient` address, ascending.
* Amounts are encoded as `int256` (JavaScript `BigInt`).

#### Solana — ed25519 plain-text message

Solana transactions use plain-text messages signed with your ed25519 keypair. The enclave rebuilds the message from the request parameters and verifies the ed25519 signature. Each `nonce` is single-use.

**Message format** (base — all types start with this header):

```
Hinkal Enclave

Primary Type: {type}
Session ID: {sessionId}
Nonce: {nonce}
Chain ID: {chainId}
```

For operations with token amounts (deposit, withdraw, transfer, swap):

```
Hinkal Enclave

Primary Type: Deposit
Session ID: {sessionId}
Nonce: {nonce}
Chain ID: {chainId}
Token Amounts:
  0:
    Token: {tokenAddress}
    Amount: {amount}
  1:
    Token: {tokenAddress}
    Amount: {amount}
```

For operations with a recipient (withdraw, transfer) — append after token amounts:

```
Recipient: {recipientAddress}
Fee Amount: {feeAmount}
```

The `Fee Amount:` line is omitted entirely when `feeAmount` is not provided.

For `DepositForOther`:

```
Hinkal Enclave

Primary Type: DepositForOther
Session ID: {sessionId}
Nonce: {nonce}
Chain ID: {chainId}
Token Amounts:
  0:
    Token: {tokenAddress}
    Amount: {amount}
Recipient Info: {recipientInfo}
```

For `Swap` — append after token amounts:

```
External Action ID: {externalActionId}
Swap Data: {swapData}
Fee Amount: {feeAmount}
```

The `Fee Amount:` line is omitted when `feeAmount` is not provided.

For `PrivateSend`:

```
Hinkal Enclave

Primary Type: PrivateSend
Session ID: {sessionId}
Nonce: {nonce}
Chain ID: {chainId}
Token Address: {tokenAddress}
Recipients:
  0:
    Recipient: {address}
    Amount: {amount}
Fee Token: {feeToken}
Tx Completion Time: {txCompletionTime}
```

**Normalization rules** (apply before signing):

* `tokenAmounts` pairs are sorted by token address, ascending (same as EVM).
* `recipients` (private-send) are sorted by recipient address, ascending.
* The signed message is the UTF-8 byte encoding of the text above.

**Signing:**

```js
import nacl from "tweetnacl";

function signSolanaMessage(message, keypair) {
  const bytes = Buffer.from(message, "utf8");
  return Buffer.from(nacl.sign.detached(bytes, keypair.secretKey)).toString("hex");
}
```
