A Uniswap V2-style decentralized exchange built for Ark Constellation, enabling permissionless token swaps and liquidity through KASH, WKASH, and EVM assets.
ArkSwap V1 is a minimal semantic fork of Uniswap V2: constant-product
(x * y = k) pools, a 0.30% trade fee, deterministic CREATE2 pair addresses, and
a Router02-shaped periphery. The economic model and core math are unmodified.
Every deviation from upstream is enumerated in docs/UPSTREAM-DIFF.md.
Status: deployed to Ark Constellation devnet. Not production-ready. A formal audit, a multisig
feeToSetterand Ark core-team coordination remain outstanding — seedocs/PRODUCTION-READINESS.md.
apps/
web/ Next.js frontend (@arkswap/web)
api/ Go REST API — see "Backend status"
indexer/ Go blockchain indexer — see "Backend status"
packages/
contracts/ Foundry project (core solc 0.5.16, periphery 0.6.6)
abis/ GENERATED ABIs + pair init-code hash
addresses/ Canonical deployment manifests
config/ Shared chain and token configuration
types/ Shared analytics API types
sdk/ AMM math + analytics API client
infra/
postgres/migrations/ SQL migrations
docker/ Dockerfiles
scripts/ generate-abis, deploy, verify, seed-liquidity
docs/ upstream diff, security review, readiness
pnpm workspaces + Turborepo orchestrate the TypeScript side. Foundry stays the contract toolchain and Go modules stay independent — neither is forced into pnpm.
One source of truth per fact. ABIs are generated from the compiled artifacts
into packages/abis; addresses live only in packages/addresses; the pair
init-code hash is generated, never hand-copied. apps/web consumes all three
rather than keeping its own copies.
packages/contracts ──> packages/abis ──┐
└──> packages/addresses ──┼──> apps/web, apps/api, apps/indexer
└──> PostgreSQL (indexer)
pnpm install
pnpm web:dev # frontend on :3000
pnpm contracts:build
pnpm contracts:test
pnpm contracts:gate # MANDATORY init-code-hash + pairFor gate
pnpm abis:generate # regenerate packages/abis from Foundry artifacts
pnpm build # turbo: all TS packages
pnpm typecheckContract work still runs through the Makefile in packages/contracts
(make gate, make deploy-router, make verify-all, make verify-pairs-local).
Core and periphery keep their original upstream compiler versions. Modernising
to 0.8.x while also changing behaviour is exactly what llm.txt s4 warns against.
The 0.8.x test suite reaches across the version boundary by deploying the real
compiled artifacts with vm.deployCode.
On Apple Silicon, packages/contracts/tools/install-solc.sh is required: solc
0.5.16 and 0.6.6 ship x86-only macOS binaries. It installs the official emscripten
builds of the same compiler commits behind the solc CLI Foundry drives. The
generated ~/.svm wrappers embed an absolute path, so re-run it after moving
the repository.
129 passed, 0 failed
| Suite | What it covers |
|---|---|
test/core/InitCodeHash.t.sol |
library constant matches compiled pair bytecode |
test/core/PairAddress.t.sol |
createPair() == pairFor(), incl. fuzzed token addresses |
test/core/Factory.t.sol |
pair creation, sorting, duplicates, feeToSetter privilege |
test/core/Pair.t.sol |
mint/burn/swap, k-invariant, TWAP, reentrancy, flash swaps, protocol fee |
test/core/LPToken.t.sol |
ERC-20 behaviour, EIP-2612 permit, replay protection |
test/periphery/Library.t.sol |
quoting, rounding direction, 0.30% fee recovery |
test/periphery/Router.t.sol |
liquidity, all six swap paths, slippage, deadlines, KASH wrap/unwrap, fee-on-transfer |
test/fuzz/Swap.fuzz.t.sol |
k never decreases, no free output, uint112 bounds, proportional minting |
test/invariant/ |
invariants A–H from llm.txt s22 |
make test-deep runs the pre-deployment gate: 10,000 fuzz runs and 1024×256
invariant runs. make slither runs static analysis; findings are triaged in
docs/SECURITY-REVIEW.md.
PAIR_INIT_CODE_HASH = 0x30820c342fc28c16c80e536d138c0c5290a90de3583c2a126a9e19b519432e74
Renaming UniswapV2Pair to ArkSwapPair changes creation bytecode, so Uniswap's
historical hash is invalid here and must never be copied. If this constant ever
drifts from the compiled artifact, ArkSwapLibrary.pairFor() derives addresses no
pair occupies and router swaps break.
Three independent guards enforce it: InitCodeHash.t.sol, PairAddress.t.sol, and
a hard precondition in script/DeployRouter.s.sol. Regenerate with
make init-code-hash after any change to pair bytecode or compiler settings,
then re-run the tests and re-review.
ArkSwap runs in ABI-compatibility mode (llm.txt s12, option A). The router
keeps Uniswap's function names — addLiquidityETH, swapExactETHForTokens, and
so on — so Uniswap-shaped frontends and tooling work unchanged.
On Ark, those functions move native KASH. msg.value is KASH. The ETH in
the ABI is inherited terminology and nothing more. The router also exposes
WKASH() alongside WETH(); both return the same canonical WKASH address.
The UI must always say KASH, never ETH. Native KASH and WKASH remain distinct assets in the token selector: choosing one never silently gives the other.
| Contract | Address | Verified |
|---|---|---|
| ArkSwapFactory | 0x594f74aDCa63Af06d10Ee06fEe8F237DBb560e06 |
yes |
| ArkSwapRouter02 | 0xF2a7fD4072d70D2e7eDF5a5eB653965CEe0871b6 |
yes |
| WKASH (canonical) | 0x6792d2fD02d8A55c543F627d0e90526f9278C6d6 |
yes |
| mUSDC (devnet mock) | 0xC8d15f9A42Ee3107ab1257E38199e3Bf899dCfB3 |
yes |
| mUSDT (devnet mock) | 0x073D643F712C136D2e7F71d683d9E26b6EFC845b |
yes |
| WKASH/mUSDC pair | 0x022850A98a241FF9E3978Cd1B596295cF1451719 |
bytecode-attested¹ |
| WKASH/mUSDT pair | 0x5575dB26621DEe45d6067D647c58648F98DCf160 |
bytecode-attested¹ |
| mUSDC/mUSDT pair | 0x6120C976169e4fEcfD7b5ab1C024f4015a60daB4 |
bytecode-attested¹ |
feeTo is address(0) — the protocol fee is disabled and the full 0.30% stays
with liquidity providers. Full metadata, transaction hashes and blocks are in
packages/addresses/ark-devnet.json.
¹ The pairs cannot be verified on this explorer, and the cause is upstream of
ArkSwap. The Ark devnet JSON-RPC node exposes no trace API — debug_traceTransaction,
trace_transaction, debug_traceBlockByNumber and trace_block all return
"does not exist/is not available". Blockscout's internal-transaction fetcher
needs one of them to see the factory's CREATE2 call, so it never records a
creation for these addresses and rejects verification server-side with "The
address is not a smart contract" — before any source is compared.
Instead, the deployment is attested by bytecode. ArkSwapPair has no immutables
and no constructor arguments, so every pair the factory deploys must carry
byte-identical runtime code. All three do, and it matches this repository's
compiled artifact exactly:
keccak256(runtime bytecode) = 0x2ecfe3b325ceceff28477c0c7f82692713e4c2e8d08a11d1ffc535c19af1d75f
solc 0.5.16 · optimizer 999999 runs · evmVersion istanbul
Reproduce it yourself with make verify-pairs-local. Once the Ark team enables
tracing on the node (in Cosmos EVM, add debug to --json-rpc.api and let
Blockscout re-index), make verify-pairs will publish source in the usual way.
Live checks performed: KASH → mUSDC and mUSDC → KASH swaps, a multi-hop
mUSDT → WKASH → mUSDC route that delivered exactly the quoted amount, and a 25%
liquidity removal that returned principal plus accrued fees. The off-chain
ArkSwapLibrary.pairFor derivation was checked against the deployed factory for
all three pairs.
Fill .env from .env.example, then:
make deploy-mocks # 4. devnet mock stablecoins
make deploy-factory # 5. ArkSwapFactory
make gate # 7. MANDATORY: init-code hash + pairFor == createPair
make deploy-router # 8. ArkSwapRouter02 (re-runs the gate itself)
make create-pairs # 11. WKASH/mUSDC and friends
make seed # 13. seed liquidity
make smoke # 14. live swaps, both directions
make verify-all # 16. BlockscoutDeployment targets run with --slow --gas-estimate-multiplier 300. Ark's
in-simulation gas estimates assume warm storage and under-estimate swaps by
roughly 25k gas; without the headroom a swap can leave the pair's SSTORE with
≤ 2300 gas and trip the EIP-2200 sentry as ReentrancySentryOOG. See
docs/PRODUCTION-READINESS.md.
pnpm install
cp apps/web/.env.example apps/web/.env.local # fill from packages/addresses/ark-devnet.json
pnpm web:dev # :3000Next.js + TypeScript + wagmi + viem, with /swap, /pool and /pools routes.
There are no hardcoded addresses: with configuration missing the app renders
a blocking "Configuration required" screen naming each absent variable, rather
than presenting a swap form that could send funds nowhere.
What the app does, all against the deployed contracts as they are:
- Swaps in both directions of intent: type what you sell (exact input) or
what you want to receive (exact output,
amountInMaxbounded). Routes are searched direct, one hop and two hops through the manifest's routing hubs. - Fee-on-transfer tokens via the router's
SupportingFeeOnTransferTokenscalls, behind a setting that is off by default. - Permit-based liquidity removal: one EIP-2612 signature carried inside
removeLiquidityWithPermit, with an approve-then-remove fallback for wallets that cannot sign typed data. - Positions and pools read from the factory and pairs: every LP position the wallet holds, and an explorer of every pool with a mid-price TVL. Volume and fee columns come from the analytics API when it is reachable.
- Token import by address (badged unverified), transaction activity kept per wallet in the browser, shareable URLs for swaps and pools, add-to-wallet, a devnet faucet for the mock tokens, WalletConnect when a project id is set, and dark or light metallic themes.
Quotes are computed locally from pool reserves for display, but execution is
always bounded on-chain by amountOutMin / amountInMax. amountOutMin = 0 is
never sent: zero is not offered as a slippage preset and is rejected on entry.
Price impact is shown separately from the 0.30% LP fee, and trades above 15%
impact are blocked outright.
The indexer and API are analytics only. They are non-custodial, never sign a transaction, and are not on the swap path: swapping, adding liquidity and removing liquidity talk to the chain directly through the user's wallet. With the API stopped the app still quotes and executes trades and the analytics panels show "Analytics temporarily unavailable" — this is verified behaviour, not an aspiration.
PostgreSQL holds a rebuildable projection of chain history, never a source of
truth. Dropping the database and re-syncing reproduces the same state; every
event is keyed on (tx_hash, log_index) so replaying a range is idempotent.
createdb arkswap # or: docker compose up -d postgres
cp .env.example .env # fill DATABASE_URL and the factory block
pnpm indexer:dev # historical sync, then follow the head
pnpm indexer:once # one pass, useful in CI
pnpm api:dev # :8080
pnpm go:build
pnpm go:test
pnpm sdk:test # analytics client (node --test)ARKSWAP_FACTORY_DEPLOYMENT_BLOCK is required and has no default. The indexer
starts there and never scans from genesis; docker-compose.yml fails fast if
it is unset rather than silently starting a scan that would take days.
Once caught up it follows the chain head over ARK_WS_URL (newHeads), which
brings the lag from a poll interval down to about a block. The poll timer stays
underneath as a floor: what gets indexed never depends on which of the two
fired, because each tick re-reads the cursor and the confirmed head over HTTP.
An unreachable WebSocket is therefore a latency problem, not a correctness one —
the indexer logs it, polls instead, and reconnects when the socket comes back.
GET /api/v1 — health, stats, pairs, pairs/{address},
pairs/{address}/swaps, pairs/{address}/liquidity, pairs/{address}/chart,
tokens, tokens/{address}, tokens/{address}/price,
accounts/{address}/swaps.
Reads only. There is no write path, no key custody, and no endpoint that can move
funds. ALLOWED_ORIGINS is an explicit allowlist — * is rejected at startup.
Prices are derived from pool reserves against a stablecoin anchor: a direct
stable pair first, then a hop through WKASH, preferring the deepest pool. A pool
thinner than MIN_PRICE_LIQUIDITY_USD is not used as a price source, so a
token with no route to a stablecoin reports priceUsd: null and its pair reports
tvlUsd: null. Volume is counted on one side of each swap only, never both.
Null is deliberate. An unpriceable pool is reported as unpriceable rather than
assigned a number the chain does not support, and /stats returns
pairsUnpriced so a caller can see how much of the protocol that covers.
Charts read the same way. The indexer writes an hourly and a daily snapshot for each pair whose reserves changed, closing the bucket with the prices in force at that point rather than today's prices applied backwards — a chart built from retroactive prices shows a history that never happened. Volume and transaction counts still come from the swaps table, so there is one source of truth for volume and a reorg that removes swaps corrects the chart with no second set of counters to unwind.
Verified against the live devnet: reserves for all 7 pairs reconcile exactly with
getReserves(), the indexed pair count matches allPairsLength(), replaying
5,000 indexed blocks produces zero duplicates, and a full drop-and-resync
reproduces identical state. Reorgs are handled by re-reading the cursor header
and walking back to the common ancestor.
- ArkSwap's spot price is not a secure oracle. It is manipulable within a single transaction. External protocols must not trust an instantaneous pool price; the cumulative-price accumulators are TWAP primitives requiring a meaningful sampling window.
- Protocol fee ships disabled (
feeTo == address(0)). The full LP fee stays in the pool. Enabling it requires an economics proposal, a treasury decision, and review (llm.txt s49). feeToSetteris scoped to fee configuration only. It cannot seize liquidity, pause pairs, blacklist, or change swap math. Use a multisig for any long-lived deployment.- ArkSwap is non-custodial. No backend ever holds user funds or signs user swaps. The invariant suite asserts the router retains nothing.
- Devnet mocks are not real assets. mUSDC and mUSDT have unrestricted minting and no value; the UI badges them accordingly.
docs/SECURITY-REVIEW.md is a static-analysis triage, not an audit. A formal
audit and the full readiness review in llm.txt s53 are required before testnet or
mainnet.
ArkSwap derives from Uniswap V2, which is GPL-3.0(-or-later). Every derived file carries an accurate SPDX header and upstream attribution.
The repository root currently carries an MIT LICENSE, which does not
accurately describe the GPL-derived contracts. This needs an explicit decision —
see LICENSING.md. Both license texts are in LICENSES/.
ArkSwap matches Uniswap's functionality and UX; it does not use Uniswap's name, logo, or branding, and claims no affiliation.