Problem
Build a complete DeFi protocol on Aptos rather than a single contract: launch a token, trade it, and let holders decide where liquidity incentives go, with an indexer and a frontend that make the on-chain state usable.
My role: Personal project. Designed and built all five repos: three Move packages, the Rust indexer and the Next.js app.
Constraints
- Three Move packages depend on each other (Swap on Forge, Core on both) and are published from one account, so module boundaries and the minting permission have to be explicit.
- Emissions, gauge rewards and bribes must stay exact over many weekly epochs without trusting a keeper.
- History (swaps, volume, votes) only exists in events, and one view call per pool per page load does not scale.
Architecture
Forge (token_factory)
Creates fungible assets for a flat DYNEXUS fee; mints DYNEXUS only for allowlisted contracts
Swap (dex)
x*y=k pools, LP tokens, routes of up to 3 hops, swap fee for LPs plus a platform fee
Core (voting_escrow, voter, gauge, minter, bribe)
veDYNA locks, weekly gauge votes, decaying emissions, LP staking, per-epoch bribes
Indexer (Rust)
Aptos Transaction Stream, filtered to Dynexus events, into PostgreSQL; axum read API
Web (Next.js)
Swap, pools, tokens, create-token and governance pages; Aptos wallet adapter
What I Built
- Forge: permissionless fungible-asset factory with a paginated token registry, a DYNEXUS faucet and a minter allowlist (
add_dynexus_minter) so Core can mint emissions without holding the admin key. - Swap: constant-product AMM with 1,000 LP tokens locked on pool creation, a 10% cap on single-swap size, deadlines, slippage-aware quotes and
swap_exact_in_routefor multi-hop trades. Swap and platform fees are set in basis points and each capped at 1%. - Core
voting_escrow: lock DYNEXUS for 1 week to 4 years; voting power isamount * remaining / 4 years, decaying to zero at unlock, with unlock times rounded to whole weeks. - Core
voterandminter: weekly epochs, votes split across up to 10 gauges, and a permissionlessdistributethat mints each epoch's emission (decaying by a set percentage down to a tail floor) and sends it to gauges pro-rata to votes. - Core
gaugeandbribe: Synthetix-style staking that streams each gauge's emission over one week; anyone can bribe a pool's voters with any fungible asset, claimable pro-rata after the epoch. - Move unit tests across all three packages.
- Indexer: Rust workspace (
dynexus-db,dynexus-processor,dynexus-api) on the Aptos indexer SDK, Diesel and axum, serving tokens, pools, swaps, OHLC candles, gauges, epochs and per-user activity. - Web: Next.js App Router with the Aptos TS SDK, wallet adapter and TanStack Query, a strict CSP and security headers; falls back to on-chain view calls when no indexer URL is configured.
Technical Challenges
- Minting without sharing the admin key
- Emissions are minted by Core, but DYNEXUS belongs to Forge. Forge keeps an allowlist of minter addresses and exposes
mint_dynexusonly to them; Core's minter object is added once after deploy. - Keeper-free epochs
- Anyone can call
voter::distributeafter an epoch ends. It catches up on missed epochs, at most 52 per call to bound gas, so emissions never depend on one operator. - Exactly-once indexing
- Each batch is written in one database transaction. Every event is inserted into
dynexus_eventskeyed by transaction version and event index withON CONFLICT DO NOTHING, and typed tables update only when that insert succeeds, so replaying a batch after a crash is a no-op and running totals stay exact. - Less RPC load
- The Transaction Stream filters on the server to transactions that emit Dynexus events, token metadata is read from the same transaction's write set instead of a follow-up call, and the web app fetches every pool in one
GET /poolsinstead of a view call per pool.
Trade-offs & Limitations
- Testnet only, with no external audit.
- Constant-product curve only: simple and predictable, but less capital-efficient than concentrated liquidity.
- One account publishes all three packages, which keeps addresses simple but makes that key the upgrade authority for everything.
- The indexer is not hosted yet, so the live app reads chain state through view calls and history views are off.
- On-chain integers are stored as
NUMERICand served as decimal strings, so clients must parse them rather than use JS numbers.
