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 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:
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".routePathis 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?).
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.
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.
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:
EIP-712 mode - no transaction consent (each transaction is signed individually):
buildSessionMessage produces the right message for either mode:
Signing the session message by chain
Sign with EIP-191 personal_sign (ethers wallet.signMessage):
Sign with tronWeb.trx.signMessageV2 (same message format, TronWeb's EIP-191-compatible method):
Sign the UTF-8 message bytes with ed25519 (nacl.sign.detached). Pass the address as your Solana base58 public key:
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.
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:
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:
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.
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:
Primary types by endpoint:
/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:
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
feeTokenis in the request → append{ name: "feeToken", type: "address" }to the type and include it in the value.If
feeAmountis in the request → append{ name: "feeAmount", type: "uint256" }and include it.If
txCompletionTimeis 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:
Normalization rules (apply before signing):
All addresses are checksummed (EIP-55):
token,feeToken,tokenAddress, andrecipient.tokenAmountsis sorted bytokenaddress, ascending.recipients(private-send) is sorted byrecipientaddress, ascending.Amounts are encoded as
int256(JavaScriptBigInt).
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):
For operations with token amounts (deposit, withdraw, transfer, swap):
For operations with a recipient (withdraw, transfer) - append after token amounts:
The Fee Amount: line is omitted entirely when feeAmount is not provided.
For DepositForOther:
For Swap - append after token amounts:
The Fee Amount: line is omitted when feeAmount is not provided.
For PrivateSend:
Normalization rules (apply before signing):
tokenAmountspairs 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:
Last updated