@calamari-dex/sdk
v0.0.2
Published
TypeScript SDK for Calamari: quote, swap, and manage liquidity on the Calamari v4 DEX.
Downloads
394
Maintainers
Readme
@calamari-dex/sdk
TypeScript SDK for Calamari — quote, swap, and manage liquidity on the Calamari v4 DEX.
Quick start
import { CalamariClient, createPoolKey, poolId, NATIVE } from "@calamari-dex/sdk";
import { createPublicClient, http } from "viem";
import { inkSepolia } from "viem/chains";
const publicClient = createPublicClient({ chain: inkSepolia, transport: http(RPC) });
const calamari = new CalamariClient({ publicClient });
const pool = createPoolKey({
currencyA: NATIVE, // sorting is handled for you
currencyB: USDC,
fee: 3000,
tickSpacing: 60,
});
const state = await calamari.getPoolState(poolId(pool));
const quote = await calamari.quoteExactInputSingle(pool, true, 10n ** 15n);
const [tx] = await calamari.prepareSwapExactInSingle(pool, true, 10n ** 15n);Why this is viem-native
Calamari runs unmodified Uniswap v4 — v4-core, v4-periphery and
permit2 are vendored as clean upstream submodules, and the only bespoke
contract is a thin V4Router subclass wiring Permit2 payment.
This package used to re-export @uniswap/v4-sdk's entity layer, on the
reasoning that the encoding format is not ours to define. That was wrong twice
over.
It was wrong on cost. Everything this SDK needs from that layer is a five-field
PoolKey, a keccak, and an ABI encoder — it does no tick math, no liquidity
math, no routing. import { Pool } alone pulls 981KB, because the barrel
export reaches v4BaseActionsParser, which imports all of ethers v5. That is a
JSON-RPC provider and a secp256k1/AES signing stack inside a package whose
whole premise is that it never signs or sends anything, sitting next to viem,
which already does all of it.
It was wrong on correctness, which matters more. The struct a router decodes is
a property of your deployment, and an upstream encoder versions on its own
schedule. V4Planner.addAction defaults to the pre-minHopPriceX36 struct;
the router here reads the one with it. The mismatch did not revert — it slipped
the decoder's params.length < 0x160 guard by exactly one word, read the
hookData offset as a price bound, then read hookData's length off
currency0. Native swaps survived because that address is zero. Every
ERC20-to-ERC20 swap did not.
So the encoding is built here, against ABIs generated from the deployed
artifacts, and src/actions.ts is generated from Actions.sol. Nothing
describing the protocol is hand-written, and nothing versions independently of
what is deployed.
For tick and price math, or position sizing, use @uniswap/v4-sdk alongside
this package. That is pure math, independent of any deployment, and it composes
with the plain types here.
Chain types
CalamariClient is generic over the chain and infers it from the client you
pass, so calamari.publicClient hands back exactly what you gave it. That is
load-bearing rather than cosmetic: every OP-stack chain — Ink included — ships
formatters that widen getBlock's transaction union with a "deposit" type,
and a client typed with one of them does not assign to a bare
PublicClient. Taking the wide type would have red-lined the snippet above on
its first line with TS2719. test-types/consumer.ts compiles these shapes on
every npm run typecheck so it cannot come back.
Addresses
src/addresses.ts is generated from deployments/*.json, never written by
hand. Regenerate after any deploy:
forge build && npm run generateContracts absent from some chains are typed optional, so consumers handle the
gap instead of reading undefined off a type that promised an address. Which
keys are optional is derived, not declared: a key present on every deployment
is required, and the type changes when that stops being true. router (the
InkV4Router) and positionDescriptor are currently the optional ones — both
anvil-only.
The books are frozen. client.addresses is the shared module singleton, so an
unfrozen one would let a single consumer's mutation reach every client in the
process.
Swaps
All four shapes, each with a matching quote:
await calamari.prepareSwapExactInSingle(pool, zeroForOne, amountIn, opts);
await calamari.prepareSwapExactOutSingle(pool, zeroForOne, amountOut, opts);
await calamari.prepareSwapExactIn(route, amountIn, opts); // multi-hop
await calamari.prepareSwapExactOut(route, amountOut, opts); // multi-hopslippageBps means opposite things either way round, which is the whole
difference between them. Exact-input lowers a floor on what you receive;
exact-output raises a ceiling on what you pay.
Two consequences of exact-output worth knowing. The pool takes only what the
output actually costs, so a native input overpays by construction: the ceiling
goes out as msg.value, and the remainder comes back via the UniversalRouter's
SWEEP command, appended automatically. It has to be the router's command
rather than the v4 SWEEP action, because V4Router does not implement that
action and rejects it with UnsupportedAction — and the overpay is held by the
router, not the PoolManager. Left behind it would belong to whoever swept next.
Multi-hop takes a Route from createRoute(currencyIn, pools, hookData?),
which walks the pools to derive the output currency — a list that does not
actually connect fails there rather than as an opaque on-chain revert. Only the
two ends are settled; intermediate currencies net out inside the same unlock.
Hook data is per hop, so it goes to createRoute, not to the swap.
Per-hop price bounds
The deployed V4Router carries minHopPriceX36 on every swap struct. This SDK
sends 0 (single) and [] (multi), which the router reads as "no per-hop
check"; the aggregate amountOutMinimum still bounds the swap end to end. A
multi-hop array must be either empty or exactly one entry per hop —
InvalidHopPriceLength otherwise.
That field is also why these builders pass URVersion.V2_1_1 to the planner.
V4Planner defaults to the older 2.0 struct, which omits it, and the older
encoding still decodes against the deployed router rather than reverting: it
reads the hookData offset as the price bound and drops hookData entirely.
The quote keeps the hook data and the swap loses it, so on a hooked pool the
two stop agreeing. Encode with the version the deployment actually runs.
Creating a pool
prepareCreatePool reads the pool's state before building anything, because
the transaction itself cannot report failure:
PositionManager.initializePool wraps poolManager.initialize in a try/catch
and returns type(int24).max rather than reverting. Creating a pool that
already exists therefore succeeds and leaves the existing price untouched —
so a create-then-seed batch would add liquidity at whatever price the first
caller chose, with nothing to signal it. prepareCreatePoolUnchecked skips the
read for cases where the pool provably cannot exist yet.
Permit2 approvals
Swap and liquidity builders do not automatically include approvals. Call
prepareApprovals({ owner, token, flow, amount }) first: it reads both
allowances and returns only the missing approval transactions. Use flow:
"swap" for swaps and flow: "liquidity" for liquidity. Send the returned
transactions in order and wait for confirmation before submitting the trade.
Native inputs return an empty approval list.
ERC20 inputs require two grants, and the spender differs by flow:
// once per token
token.approve(PERMIT2, MAX_UINT160)
// swapping: the router spends
permit2.approve(token, universalRouter, amount, expiration)
// adding liquidity: the PositionManager spends
permit2.approve(token, positionManager, amount, expiration)A mint reverts if only the router grant exists, and a swap reverts if only the
PositionManager grant does. For explicit control, use prepareErc20Approve
and preparePermit2Approve; erc20Abi and permit2Abi are also exported.
Native inputs need neither — the value rides along on the transaction.
What is not here
No tick math, no price conversion, no position sizing, and no path-finding. The
builders take ticks and liquidity from you. For that math use @uniswap/v4-sdk
alongside this package: it is pure and deployment-independent, and composes
with the plain PoolKey type here.
Wallets the SDK cannot drive
Privy and other embedded or MPC signers never hand over a private key, so there
is no viem WalletClient to give the SDK. prepare* builds the transactions
and stops there:
const txs = await calamari.prepareSwapExactInSingle(pool, true, amountIn);
for (const tx of txs) {
await privyWallet.sendTransaction({ to: tx.to, data: tx.data, value: tx.value });
}Send them in order and wait for each to confirm.
Regenerating
forge build && npm run generate # ABIs from out/, addresses from deployments/Build
dist/ is plain tsc output — no bundler. There is nothing to inline: the
package has zero runtime dependencies, with viem as a peer. smoke.mjs
asserts that, because a runtime dependency creeping back is what would force
bundling again.
License
MIT (see LICENSE), with no third-party code inlined. Uniswap's packages
are devDependencies only — used by src/differential.test.ts to prove this
encoder is byte-identical to theirs — so npm handles their attribution
normally and there is no notice file to ship.
The ABIs in src/abis/ are generated from contracts under several licenses,
which are not all MIT:
| Artifact | Source | License |
| --- | --- | --- |
| poolManagerAbi | v4-core | BUSL-1.1 until 2027-06-15, then MIT |
| universalRouterAbi | universal-router | GPL-3.0-or-later |
| positionManagerAbi, stateViewAbi, quoterAbi | v4-periphery | MIT |
| permit2Abi | permit2 | MIT |
| routerAbi | src/InkV4Router.sol (ours) | MIT |
An ABI is an interface description rather than the implementation, and is
generally treated as unprotected — but the blanket "MIT" this file once claimed
was wrong. The deployment side is a separate question: v4-core is BUSL-1.1
until 2027-06-15, and production use before then needs the Additional Use
Grant at v4-core-license-grants.uniswap.eth or a license from Uniswap Labs.
