Skip to content
EmmyhackPublic

About

A Uniswap V2-style decentralized exchange built for Ark Constellation, enabling permissionless token swaps and liquidity through KASH, WKASH, and EVM assets.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ArkSwap

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 feeToSetter and Ark core-team coordination remain outstanding — see docs/PRODUCTION-READINESS.md.


Layout

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)

Commands

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 typecheck

Contract work still runs through the Makefile in packages/contracts (make gate, make deploy-router, make verify-all, make verify-pairs-local).

Compilers

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.

Tests

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.

The pair init-code hash

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.

KASH, WKASH, and the ETH names

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.

Deployed — Ark Constellation devnet (chain id 9000)

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.

Reproducing the deployment

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. Blockscout

Deployment 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.

Frontend

pnpm install
cp apps/web/.env.example apps/web/.env.local   # fill from packages/addresses/ark-devnet.json
pnpm web:dev                                   # :3000

Next.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, amountInMax bounded). Routes are searched direct, one hop and two hops through the manifest's routing hubs.
  • Fee-on-transfer tokens via the router's SupportingFeeOnTransferTokens calls, 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.

Backend status

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.

Endpoints

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.

Pricing and what stays null

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.

Correctness

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.

Security

  • 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).
  • feeToSetter is 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.

Licensing

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.

About

A Uniswap V2-style decentralized exchange built for Ark Constellation, enabling permissionless token swaps and liquidity through KASH, WKASH, and EVM assets.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages