@hinkal/common
v0.3.1
Published
Hinkal is a privacy middleware and smart-contract SDK for public blockchains that enables confidential transactions and settlement flows without changing wallets, custody, or chains.
Readme
Hinkal SDK
Hinkal is a privacy middleware and smart-contract SDK for public blockchains that enables confidential transactions and settlement flows without changing wallets, custody, or chains.
The SDK allows wallets, dApps, and payment platforms to integrate protocol-level privacy on Ethereum, Solana, Tron, Polygon, Base, Arbitrum, and Optimism. It hides transaction history, wallet relationships, and asset flows on-chain while preserving public-chain finality and compliance.
With Hinkal SDK, developers can: • Enable private sends between public wallets • Perform confidential payouts and settlements • Route transactions through Hinkal’s privacy contracts without exposing sender, recipient, or amounts • Maintain non-custodial control with optional compliance visibility via viewing keys
Compatibility
| Environment | Supported | Notes | | ----------- | --------- | -------------------------- | | Node.js | ✅ | v18+ | | Browser | ✅ | React, Next.js, vanilla JS |
Installation
Using npm:
npm install @hinkal/commonOr, yarn:
yarn add @hinkal/commonUsage
Initialization
To begin using Hinkal in your application, you'll need to initialize the SDK with your preferred Web3 connection library. Hinkal supports multiple popular libraries out of the box, allowing seamless integration with your existing wallet connection setup.
Initializing the SDK creates a Hinkal object that encapsulates:
- The user's shielded balances
- Actions the user can perform, such as shielding (depositing), transfers, and swapping
- Cryptographic keys for privacy-preserving operations
Each provider exposes three prepare helpers:
prepare*Hinkal— signs the Hinkal login message and initializes user keys (deterministic signers)prepare*HinkalWithEnclaveSignIn— signs in through the secure enclave and stabilizes identity for non-deterministic signers (smart contract wallets, some hardware wallets)prepare*HinkalFromSignature— initializes user keys from a previously stored signature
ethers.js:
import { prepareEthersHinkal } from '@hinkal/common/providers/prepareEthersHinkal';
// signer: ethers.Signer
const hinkal = await prepareEthersHinkal(signer, hinkalConfig);wagmi:
import { prepareWagmiHinkal } from '@hinkal/common/providers/prepareWagmiHinkal';
// connector: wagmi.Connector
// wagmiConfig: wagmi.Config
const hinkal = await prepareWagmiHinkal(connector, wagmiConfig, hinkalConfig);Solana:
import { prepareSolanaHinkal } from '@hinkal/common/providers/prepareSolanaHinkal';
// connector: SolanaWallet
// ethereumAddress: optional linked EVM address
const hinkal = await prepareSolanaHinkal(connector, ethereumAddress, hinkalConfig);Tron:
import { prepareTronHinkal } from '@hinkal/common/providers/prepareTronHinkal';
// connector: TronWeb
const hinkal = await prepareTronHinkal(connector, hinkalConfig);The same WithEnclaveSignIn and FromSignature variants are available for each provider (for example, prepareWagmiHinkalWithEnclaveSignIn, prepareSolanaHinkalFromSignature).
The hinkalConfig is defined as follows:
type HinkalConfig = {
/** Disables caching in browser localStorage, storing data only in memory. Front-end only. Defaults to false. */
disableCaching?: boolean;
/** If true, allows caching in a file locally. Node.js only. Defaults to false. */
useFileCache?: boolean;
/**
* Path to the cache file used for storing temporary data. Node.js only.
* It should be a valid file path string. Defaults to hinkalCache.json in the current working directory.
*/
cacheFilePath?: string;
/**
* Indicator controlling whether the proof should be constructed remotely in secure enclave. Defaults to true.
*/
generateProofRemotely?: boolean;
/** Disables automatic merkle tree updates. Defaults to false. */
disableMerkleTreeUpdates?: boolean;
/** Override which Tron chain this Hinkal instance targets. */
tronChainOverride?: number;
};Identity persistence
When a user connects their wallet, they sign a fixed login message to authenticate with Hinkal. That signature defines their Hinkal identity. Their shielded balances, transaction ability, and all private operations depend on it.
Most wallets return the same signature every time for the same message. Some do not. Smart contract wallets, certain hardware wallets, and other non-deterministic signers may produce a different signature on each login, even for the same address and message.
When that happens, a returning user appears as a new account. Funds deposited in an earlier session remain tied to the original identity and are not accessible from the new one.
Recommended approach: use prepare*HinkalWithEnclaveSignIn instead of prepare*Hinkal. It signs the login message, stores the first signature server-side through the secure enclave, and always initializes with the original identity on later sessions. Solana Ledger wallets are handled automatically.
Manual approach: if you manage identity yourself, call storeAndGetInitialSignature and then either initUserKeysWithSignature or prepare*HinkalFromSignature:
function storeAndGetInitialSignature(
authSignature: string,
isSolanaLedger?: boolean,
txMessageForSolanaLedger?: string,
): Promise<string>;Parameters:
authSignature— signature from the current login sessionisSolanaLedger— set totruefor a Solana Ledger wallet. Defaults tofalsetxMessageForSolanaLedger— base64-encoded transaction message used for Solana Ledger authentication. Required whenisSolanaLedgeristrue
Typical flow with a stored signature:
const initialSignature = await hinkal.storeAndGetInitialSignature(authSignature);
hinkal.initUserKeysWithSignature(initialSignature);Call this once per session, after wallet connection and before fetching balances or submitting transactions.
You do not need enclave sign-in if your wallet produces deterministic signatures for the same login message on every session — in that case, prepare*Hinkal is sufficient. It is also not needed if you persist the signature yourself via prepare*HinkalFromSignature, or if you use seed-phrase-based login through initUserKeysFromSeedPhrases.
Security
The stored signature is protected at every stage. Before leaving the client, the signature is encrypted with hybrid encryption. The payload is encrypted with a symmetric key, and that key is encrypted with the enclave's public key.
Inside the secure enclave, Google Cloud KMS decrypts the symmetric key. Only then is the signature decrypted. The plaintext signature never leaves the enclave unprotected.
At rest, only the encrypted signature and encrypted key are stored in the database. A caller cannot retrieve a stored signature by wallet address alone. Each request must include any valid signature that proves wallet ownership.
Requests that fail this check are rejected. The first signature stored for a given address is never replaced. Later logins only use a fresh signature to authenticate retrieval of the original.
Shielded balance
Shielded balances are encrypted token holdings stored within the Hinkal protocol. Unlike regular blockchain balances that are publicly visible, shielded balances are hidden from external observers.
After initializing the Hinkal object and calling initUserKeys (or a prepare helper), fetch balances for a specific chain:
function getTotalBalance(
chainId: number,
resetCacheBefore?: boolean,
updateTokensListBefore?: boolean,
): Promise<TokenBalance[]>;TokenBalance contains chainId, erc20Address, balance, and an optional timestamp.
For reactive UI updates, subscribe to balance changes with USD values:
// Current state keyed by chainId
hinkal.privateBalancesWithUSD;
// Subscribe to updates; returns unsubscribe function
const unsubscribe = hinkal.onPrivateBalancesWithUSDChange((state) => {
// state: Record<chainId, TokenBalanceWithUsd[]>
});
// Trigger a refresh after a transaction
hinkal.refreshBalance({ chainIdToUpdate: chainId, updateType: PrivateBalanceUpdateType.Fresh });Shielding: depositing funds to the shielded balance
Shielding is the process of moving your tokens from a public blockchain address into a private, encrypted balance. Once shielded, your tokens are no longer visible on-chain to external observers. This provides privacy for your holdings and subsequent transactions.
A user can deposit funds to their shielded address using:
function deposit(
chainId: number,
erc20Addresses: string[],
amountChanges: bigint[],
preEstimateGas?: boolean,
returnTxData?: boolean,
): Promise<
| ethers.TransactionResponse
| ethers.TransactionRequest
| string
| TronWebTypes.Transaction<TronWebTypes.TriggerSmartContract>
>;where:
chainId— target chainerc20Addresses— token contract addresses to depositamountChanges— corresponding deposit amounts in the token's smallest unitpreEstimateGas— if true (default), gas is estimated before executing the depositreturnTxData— if true, returns unsigned transaction data without executing. Defaults to false
On Solana, use depositSolana(chainId, erc20Address, amount).
To shield funds for another user's private address, use depositForOther (EVM/Tron) or depositSolanaForOther (Solana) with their recipientInfo string from getRecipientInfo().
Private Send to Public Address: withdrawing funds from the shielded balance
Private Send to Public Address allows you to send tokens from your private, shielded balance directly to any public blockchain address. The sender's identity is not exposed during this transaction. The recipient receives the funds at their public address, where the tokens become visible on-chain. This is useful when you need to interact with public DeFi protocols, send funds to exchanges, or transfer to any public wallet while maintaining privacy for your shielded balance.
A user can withdraw funds from their shielded address to a public address using:
function withdraw(
chainId: number,
erc20Addresses: string[],
deltaAmounts: bigint[],
recipientAddress: string,
isRelayerOff: boolean,
feeToken?: string,
feeStructureOverride?: FeeStructure,
): Promise<ethers.TransactionResponse | string>;where:
recipientAddress— public address that receives the withdrawn fundsisRelayerOff— whenfalse, a relayer handles gas fees; whentrue, the user pays gas directlyfeeToken— optional token address used to pay protocol feesfeeStructureOverride— optional custom fee structure
Private Send to Private Address: transfering funds from shielded balance
Private Send to Private Address enables fully confidential transfers between shielded balances. Both the sender and recipient remain anonymous, and the transaction amount is hidden from external observers. This is the most private way to transfer tokens, as neither party's identity nor the transaction details are exposed on-chain.
A user can transfer tokens from their shielded balance to another shielded address using:
function transfer(
chainId: number,
erc20Addresses: string[],
amountChanges: bigint[],
recipientAddress: string,
feeToken?: string,
feeStructureOverride?: FeeStructure,
): Promise<string>;where:
recipientAddress— recipient's private address string fromgetRecipientInfo(). Pass it as-is; do not reformat. It is a comma-separated string with five components:stealthAddress— recipient's stealth address (hex,0xprefix, 64–66 characters)H0[0]— first coordinate of the H0 elliptic-curve pointH0[1]— second coordinate of the H0 elliptic-curve pointH1[1]— second coordinate of the H1 elliptic-curve pointencryptionKey— recipient's encryption public key (hex,0xprefix, 66 characters)
Private Send from Public to Public addresses
Private Send from Public to Public addresses enables you to transfer tokens between two public addresses while using Hinkal's privacy infrastructure. The tokens are first shielded from the sender's public address, then unshielded to the recipient's public address either immediately or after some interval. This ensures there is no traceable connection between the sender and recipient on-chain, providing transaction privacy even when both parties use public addresses.
A user can perform a private transfer between public addresses using:
function depositAndWithdraw(
chainId: number,
erc20Address: string,
recipientAmounts: bigint[],
recipientAddresses: string[],
txCompletionTime?: number,
feeStructureOverride?: FeeStructure,
preEstimateGas?: boolean,
): Promise<DepositAndSendExtendedResult>;where:
erc20Address— token contract address (single-token transfers only)recipientAmounts— amounts to send to each recipient in the token's smallest unitrecipientAddresses— public addresses that receive the fundstxCompletionTime— optional Unix timestamp in seconds by which all scheduled withdrawals must completefeeStructureOverride— optional custom fee structurepreEstimateGas— if true (default), gas is estimated before executing the deposit
The function returns:
type DepositAndSendExtendedResult = {
depositTxHash: string;
scheduleId: string;
};depositTxHash— on-chain hash of the deposit transactionscheduleId— relayer schedule identifier used to fetch withdrawal status
For cross-chain private sends, use depositAndBridge(chainId, erc20Address, recipientBridges, ...) with BridgeRecipient entries that include bridge quotes and destination addresses.
Checking scheduled send status
After depositAndWithdraw or depositAndBridge, fetch scheduled withdrawal status using the returned scheduleId:
function checkSendTransactionStatus(scheduleId: string): Promise<ScheduledTransactionByIdResponse>;where:
scheduleId— schedule identifier returned fromdepositAndWithdrawordepositAndBridge
The function returns:
type ScheduledTransactionByIdResponse = {
scheduleId: string;
chainId: number;
transactions: ScheduledTransactionItemStatus[];
};
type ScheduledTransactionItemStatus = {
status: ScheduledTransactionStatus;
scheduledTime: string;
txHash: string | null;
};where:
scheduleIdis the schedule identifierchainIdis the chain on which the scheduled transactions are executedtransactionsis the list of scheduled withdrawals, one entry per recipientstatusindicates the current state of a scheduled withdrawalscheduledTimeis the planned execution time in ISO 8601 formattxHashis the on-chain transaction hash once the withdrawal is sent on-chain, ornullbefore submission
Possible values for ScheduledTransactionStatus are:
pending— the withdrawal is scheduled and waiting for its execution timeprocessing— the relayer is currently submitting the withdrawal transaction on-chainwaiting_for_relayer— the relayer is busy; the withdrawal is queued to startsent_on_chain— the withdrawal was submitted on-chain andtxHashis availablecompleted— the withdrawal was confirmed on-chainfailed— the withdrawal transaction failed
Swapping tokens from the shielded balance
Swapping allows you to exchange tokens directly from your shielded balance without revealing your identity. The swap is executed through integrated DEX protocols (Uniswap, 1Inch, Odos) while keeping your transaction private. Your tokens are withdrawn from your shielded balance, swapped through the specified protocol, and the resulting tokens are deposited back into your shielded balance—all in a single private transaction.
A user can swap tokens directly from their shielded balance using:
function swap(
chainId: number,
erc20Addresses: string[],
deltaAmounts: bigint[],
externalActionId: ExternalActionId,
swapData: string,
feeToken?: string,
feeStructureOverride?: FeeStructure,
): Promise<string>;where:
externalActionId— swap protocol to use (ExternalActionId.Uniswap,ExternalActionId.OneInch,ExternalActionId.Odos, etc.)swapData— encoded swap parameters for the chosen protocolfeeToken— optional fee-payment token addressfeeStructureOverride— optional custom fee structure
Getting swap quotes and calldata:
Fetch quotes from all supported aggregators for a chain in one call.
EVM chains:
function getEvmSwapPrices(
chainId: number,
inSwapAmount: string,
inSwapTokenAddress: string,
outSwapTokenAddress: string,
): Promise<EvmSwapPrices>;type EvmSwapPrices = {
uniswap: { tokenPrice: bigint; poolFee: string } | null;
odos: { outSwapAmountValue: bigint; odosDataValue: string } | null;
oneInch: { outSwapAmountValue: bigint; oneInchDataValue: string } | null;
};Pass the swap calldata from the chosen quote to swap as swapData, with the matching externalActionId:
- Uniswap —
uniswap.poolFeewithExternalActionId.Uniswap(uniswap.tokenPriceis the quoted output amount) - Odos —
odos.odosDataValuewithExternalActionId.Odos - 1Inch —
oneInch.oneInchDataValuewithExternalActionId.OneInch
Solana:
function getSolanaSwapPrices(
chainId: number,
inSwapAmount: string,
inSwapTokenAddress: string,
outSwapTokenAddress: string,
): Promise<SolanaSwapPrices>;type SolanaSwapPrices = {
okx: { outSwapAmountValue: bigint; okxDataValue: string } | null;
};Pass okx.okxDataValue to swap as swapData with ExternalActionId.Okx.
Interacting with smart contracts privately
The SDK lets you interact with any smart contract on the blockchain while keeping your identity private. When you initiate a private wallet action, your funds are first unshielded from your Hinkal shielded balance to an intermediary called an Emporium contract. The Emporium then executes your desired actions on-chain (such as swaps, staking, or other DeFi interactions) without exposing who initiated them. After the operations complete, the resulting tokens are automatically shielded back into your Hinkal shielded balance. This means you can use DeFi protocols, NFT marketplaces, or any other smart contract while maintaining full anonymity.
function actionPrivateWallet(
chainId: number,
erc20Addresses: string[],
deltaAmounts: bigint[],
onChainCreation: boolean[],
ops: string[],
feeToken?: string,
feeStructureOverride?: FeeStructure,
): Promise<string>;Parameters:
chainId— blockchain network identifiererc20Addresses— token contract addresses involved in the actiondeltaAmounts— token amount changesonChainCreation—truefor tokens received,falsefor tokens spentops— encoded user operations (see below)feeToken— optional fee-payment token addressfeeStructureOverride— optional custom fee structure
User operations (ops) tell the Emporium contract what to execute. Generate them with emporiumOp:
function emporiumOp(
contract: ethers.Contract | string,
func?: string,
args?: unknown[],
callDataString?: string,
invokeWallet?: boolean,
value?: bigint,
): string;Arguments:
contract(required) — target address or contract instancefunc(optional) — function name to callargs(optional) — function argumentscallDataString(optional) — pre-encoded calldata; do not combine withfuncandargsinvokeWallet(optional) — execute from a persistent wallet address (stateful interactions)value(optional) — ETH/value sent with the call
When the Emporium contract executes a user operation, it receives:
(address endpoint, bool invokeWallet, uint256 value, bytes data)This enables the Emporium contract to execute generic calls using user operations:
(bool success, bytes memory err) = endpoint.call{value: value}(data);User operations can be categorized into two types based on whether the target protocol needs to track the caller account's history:
Stateless interactions are operations where the resulting token amount changes depend only on the calldata provided. Two different accounts executing the same calldata should receive the same result, regardless of their transaction history. Examples include token swaps, liquidity provision, and simple staking operations.
For example, consider exchanging USDC for ETH using a DEX. To perform a swap, the DEX does not need to know historical data about the caller (e.g., when and what swaps have been performed from his account in the past). It only needs to know how much token to swap and the exchange rate.
const operations = [
hinkal.emporiumOp(usdcContractInstance, 'approve', [swapRouterAddress, amountIn]),
hinkal.emporiumOp(swapRouterContractInstance, 'exactInputSingle', [swapSingleParams]),
hinkal.emporiumOp(wethContractInstance, 'withdraw', [amountOut]),
];In this example:
- First operation approves the swap router to spend USDC tokens
- Second operation executes the swap from USDC to WETH
- Third operation unwraps WETH to ETH
Stateful interactions are operations where the target protocol needs to track the account's history for future calculations, such as staking rewards, voting power, or checkpoints. In these cases, set invokeWallet: true to ensure the operation is executed from a persistent wallet address that the protocol can track.
Consider a scenario where you have already staked Curve LP tokens and want to claim your rewards. The gauge contract needs to track your staking history to calculate accumulated rewards, so it must recognize the same wallet address across multiple interactions.
const operations = [
hinkal.emporiumOp(lpTokenInstance, 'approve', [gaugeAddressInstance, amount], undefined, true),
hinkal.emporiumOp(gaugeAddressInstance, 'deposit', [amount, invokeWalletAddress], undefined, true),
];In this example:
- First operation approves the gauge contract to spend LP tokens, executed from the persistent wallet
- Second operation deposits LP tokens into the gauge, with the wallet address as the recipient for reward tracking
Supported Chains
Hinkal SDK is available on the following blockchain networks:
| Chain | Chain ID | Status | | --------------- | ---------- | ------- | | Ethereum | 1 | ✅ Live | | Arbitrum | 42161 | ✅ Live | | Optimism | 10 | ✅ Live | | Polygon | 137 | ✅ Live | | Base | 8453 | ✅ Live | | Solana | 501 | ✅ Live | | Tron | 728126428 | ✅ Live | | Tempo | 4217 | ✅ Live | | Arc Testnet | 5042002 | ✅ Live | | Sepolia Testnet | 11155111 | ✅ Live | | Tron Nile | 3448148188 | ✅ Live |
Each chain supports the full suite of Hinkal privacy features including shielding, transfers, and confidential interactions with DeFi protocols.
References
Wallet: Hinkal Wallet
Application: Hinkal Pay
Docs: Hinkal Documentation
