maas
A spot and perp trading terminal with a delta-neutral USDC vault, built on Uniswap v4, Chainlink CRE and Arkiv.
maas is a trading venue on Base Sepolia. Spot trades settle on Uniswap v4 pools behind our own oracle-aware hook. Perps match on an off-chain order book and settle on-chain. The USDC vault holds spot long against an equal perp short in each market, so price moves cancel out and the vault earns funding. A Chainlink CRE workflow decides when each market is hedged or flat, and every funding print, decision and NAV snapshot is published to Arkiv so anyone can audit the vault.
All tokens are testnet tokens with no value.
| App | https://maas.exchange |
| Gateway API | https://api.maas.exchange |
| Demo video | TODO: video URL |
| Network | Base Sepolia, chain id 84532 |
Contents
- Architecture
- Key mechanisms
- Tracks: Uniswap v4 · Chainlink CRE · Arkiv
- Contract addresses
- Use the app
- Use the contracts directly
- Repository structure
- Tech stack
- Run locally
- Prices (Pyth)
- CI/CD
- Deploy the backend
Architecture
maas splits work across four layers, and each one holds only the power it needs:
| Layer | Role | Trust property |
|---|---|---|
| Custody | PerpExchange and DeltaNeutralVault on Base Sepolia | Funds move only through user calls, signed orders, or a CRE report from the registered forwarder |
| Matching | Rust order book engine + Go gateway and settler | Can only settle fills that match orders the traders signed (EIP-712); cannot withdraw anyone's margin |
| Decision | Chainlink CRE rebalancer workflow | The only caller that can change vault modes or pay LP epochs (onReport, forwarder-gated) |
| Record | Arkiv entities written by the recorder | Public, append-only history of funding, decisions and NAV; readable without a key |
Key mechanisms
Delta-neutral legs. For each market (BTC, ETH, HYPE) the vault holds a spot long on the Uniswap v4 pool and an equal short on PerpExchange. Price moves on the two legs cancel, and the short collects funding while longs pay shorts.
HEDGED / FLAT modes. Each market runs in one of two modes. A HEDGED market goes FLAT only when every funding print in the window is negative. A FLAT market goes HEDGED again on the first positive print. FLAT legs unwind to USDC, so the vault never pays funding for long.
Oracle-aware hook fee. MaasHook charges a 3 bps base fee. When a swap pushes the pool price further from the Pyth price, it adds 50% of that gap, capped at 100 bps. If the oracle is stale the fee is a flat 10 bps. The vault itself pays 0. Fees stream to full-range LPs over one hour.
Instant exits with a buffer. Withdrawals pay out at once from a 10% idle USDC buffer. Anything larger is queued with requestRedeem, and the keeper unwinds legs pro-rata and calls fulfill. Deposits are free and withdrawals pay 0.10%.
Signed orders, on-chain settlement. Traders sign perp orders (EIP-712) and send them to the gateway. The engine matches them, and the settler posts fills to PerpExchange with a fresh Pyth update. The contract checks every signature, margin and price band itself.
Public audit trail. The recorder writes funding prints, vault decisions, LP epochs and hourly NAV snapshots to Arkiv. Writes scale with time, not with trade count.
Tracks
| Track | What it does in maas | Main code |
|---|---|---|
| Uniswap v4 | All spot trading and the vault's spot leg, with a custom dynamic-fee hook | contracts/src/hooks/MaasHook.sol |
| Chainlink CRE | The vault's decision layer: reads funding, sets HEDGED / FLAT, pays LP epochs | workflows/rebalancer |
| Arkiv | Public record of funding, decisions, LP epochs and NAV; the vault page reads it directly | backend/internal/recorder |
Uniswap v4
There is no spot matching engine in maas: spot swaps and the vault's spot leg both run on our v4 pools (BTC, ETH and HYPE against USDC). MaasHook is the live hook on every pool.
beforeSwapprices the fee from the Pyth oracle. The fee grows when a swap moves the pool away from the oracle price, which stops arbitrage at LPs' expense, and it drops to 0 for the vault.afterSwaptakes the fee and streams it to full-range LPs over one hour. LPs callclaimto collect.- The vault swaps directly through
PoolManager.unlock, so its rebalances never pay the hook fee. - LP deposits go through the modify-liquidity router, which the hook trusts to credit the depositing wallet. The app's Spot and Portfolio pages both offer full-range deposits and fee claims.
Files using Uniswap v4
| File | Usage |
|---|---|
contracts/src/hooks/MaasHook.sol | Live hook: oracle-gap dynamic fee, vault exemption, LP fee stream, claim (UUPS proxy) |
MaasHook.sol#L105 | Default fee parameters (base 3 bps, gap share 50%, cap 100 bps, stale 10 bps) |
MaasHook.sol#L164 | beforeSwap fee decision |
MaasHook.sol#L184 | afterSwap fee collection |
contracts/src/interfaces/IMaasHook.sol | Hook interface |
contracts/src/vault/DeltaNeutralVault.sol#L293 | Vault unlockCallback: swaps through the PoolManager at 0 fee |
contracts/script/DeploySpot.s.sol | Pools, routers and test tokens |
contracts/script/DeployHook.s.sol | Mines the hook address and deploys MaasHook |
contracts/script/AddLiquidity.s.sol, SyncPools.s.sol | Seed liquidity; swap pools back to the on-chain Pyth price |
client/src/features/spot/hooks/use-lp-rewards.ts | Full-range LP deposit and fee claim from the app |
client/src/features/spot/lib/liquidity.ts | Full-range liquidity math (v4 tick bounds, max deposit) |
| FEEDBACK.md | Uniswap developer feedback |
The older flat-fee VaultFeeHook.sol stays in the repo because DeploySpot uses it for the first pool deploy. It is not the live hook.
Chainlink CRE
The CRE workflow is the vault's brain. It runs on a cron trigger. On each tick it fetches the funding window for every market, reads the vault's current modes over EVM, applies the HEDGED / FLAT rule, and writes one signed report to vault.onReport only when a mode should change. The vault rejects any report that doesn't come from the registered forwarder. A second hourly trigger computes the LP epoch, which pays a share of the vault's funding to spot LPs, and sends it as a second report kind.
CRE can't sign order book orders, so it decides and vault-keeper executes: it trades the perp leg and swaps the spot leg to match the new mode.
| Setting | Staging | Production |
|---|---|---|
| Mode check schedule | hourly, 1 min after funding | hourly, 1 min after funding |
| Funding window | 3 h (last 3 prints) | 3 h (last 3 prints) |
| Funding source | maas gateway | Arkiv |
| LP epoch | hourly | off |
Files using Chainlink CRE
| File | Usage |
|---|---|
workflows/rebalancer/rebalancer/workflow.go | cron.Trigger handlers, HTTP funding fetch with consensus, EVM read of vault.mode, GenerateReport + WriteReport |
workflows/rebalancer/rebalancer/funding.go#L27 | Decide: the HEDGED / FLAT rule |
workflows/rebalancer/rebalancer/lp.go | Hourly LP epoch: splits vault funding across pools by volume |
workflows/rebalancer/rebalancer/workflow.yaml | CRE workflow definition |
workflows/rebalancer/rebalancer/config.staging.json, config.production.json | Schedules, window, funding source, vault address |
workflows/rebalancer/project.yaml, secrets.yaml | CRE project settings and secrets |
workflows/rebalancer/contracts | Generated vault bindings for the CRE EVM client |
workflows/rebalancer/rebalancer/workflow_test.go, fixtures | Workflow tests and funding fixtures |
contracts/src/vault/DeltaNeutralVault.sol#L360 | onReport receiver: forwarder check, kind 0 = modes, kind 1 = LP epoch |
backend/cmd/vault-keeper | Executes the legs after a mode change |
Setup and cre workflow simulate commands: workflows/rebalancer/README.md. The live vault uses the simulation forwarder (see the deploy notes).
Simulation output on the current vault: TODO: paste cre workflow simulate output.
Arkiv
Arkiv stores only data that is public, not already on-chain, and needed for trust. The recorder writes four entity types, all owned by one writer wallet. The vault page reads them straight from Arkiv, and the production CRE workflow uses Arkiv as its funding source, so the decision and its evidence come from the same public record.
| Entity | What it holds | Kept for |
|---|---|---|
perpvault.FundingRate | Every funding print per market | 7 days |
perpvault.VaultDecision | Every HEDGED / FLAT mode change | 182 days |
perpvault.VaultSnapshot | Hourly NAV and share price | 7 days |
perpvault.LPEpoch | LP payouts per epoch | 182 days |
| Network | Rabdi testnet, chain id 7794095 |
| RPC | https://rpc.rabdi.db-chain.testnet.arkiv.network |
| Explorer | https://rabdi.explorer.arkiv.network |
| Writer wallet | 0x458b0f52446e2747b077c92690813642fb326123 |
Runnable query, no key needed. Always filter on the owner, because anyone can write entities:
curl https://rpc.rabdi.db-chain.testnet.arkiv.network -H 'content-type: application/json' --data '{
"jsonrpc":"2.0","id":1,"method":"arkiv_query",
"params":["arkiv.schema = str('"'"'perpvault.VaultDecision'"'"') AND $owner = addr(0x458b0f52446e2747b077c92690813642fb326123)",
{"select":{"key":true,"attributes":true},"limit":"0x64"}]}'
Swap the schema for perpvault.FundingRate, perpvault.VaultSnapshot or perpvault.LPEpoch.
Files using Arkiv
| File | Usage |
|---|---|
backend/internal/recorder | Recorder service: watches funding and vault logs, writes entities, hourly NAV snapshots with backfill |
backend/internal/platform/arkiv/records.go | Entity schemas, attributes and expiry |
backend/internal/platform/arkiv/client.go | Arkiv write client |
backend/cmd/recorder, backend/cmd/arkivctl | Recorder entry point; CLI to query and inspect entities |
client/src/features/vault/api/arkiv.ts | Vault page reader (funding history, decisions, NAV) |
workflows/rebalancer/rebalancer/funding.go#L91 | CRE reads funding from Arkiv in production |
| arkiv/ | why-arkiv, schema, usage, friction, reach |
Contract addresses
Base Sepolia (84532). Source of truth: backend/config/{perp,spot,vault}.json. Explorer: https://sepolia.basescan.org
Core
| Contract | Address | Role |
|---|---|---|
| PerpExchange | 0xA715BedceEE7b9B4CE3AAaac5CbFc745A35854e1 | Margin, trade settlement, funding, liquidation. Deploy block 55148591 |
DeltaNeutralVault (proxy, pvUSDC) | 0x986741e435ef20b042057A05Cc74e42DfEA79E57 | ERC-4626 USDC vault. Implementation 0xe92dc5ee0452eebfb76057d1c743b46354237066. Deploy block 55315107 |
| CRE forwarder (MockKeystoneForwarder) | 0x82300bd7c3958625581cc2F77bC6464dcEcDF3e5 | Only sender allowed to call vault.onReport |
| Pyth (official) | 0xA2aa501b19aff244D90cc15a4Cf739D2725B5729 | Price oracle for perps, hook fee and vault |
Uniswap v4
| Contract | Address | Role |
|---|---|---|
| PoolManager | 0x326E8d968fD7C3FD660851B66bEAc80E1D7e103d | v4 singleton, deployed by us for testnet |
| MaasHook (proxy) | 0x5D45A542A6EBE3a0d8E47D357Ef67df1DaC3bFFF | Live hook on all pools. Implementation 0xb9bd5cdd837843adb78e35a8e0b08a7f60a7b044; its vault is set to the vault above |
| PoolSwapTest router | 0x50bfec15064492eF1d3861c076D31b69838d3Ea1 | Spot swaps |
| PoolModifyLiquidityTest router | 0x49E85be8Ee85D46d58fe39889344e7EE28Fe79CD | LP deposits, credited to the depositing wallet |
| Pool | Pool ID |
|---|---|
| BTC / USDC | 0xb15ac7bcec05bc74f8a869efe0a5645fdf582c36ee1c28a4d7570480d7ce1aef |
| ETH / USDC | 0x949940040fe297ce6938c528a025f18414e30f6d08d9267e82ff214c971eb1bc |
| HYPE / USDC | 0x6a247b9c6481dfa5a2ade1a03ec03af161998d5ed9f409ee657afdd8ba16d8fb |
Test tokens (MockToken, open mint)
| Token | Address | Role |
|---|---|---|
| USDC (6 decimals) | 0x7bB2796270a9c8f94Dd4aECA94D086272D40B51A | Quote, margin and vault asset |
| WBTC | 0x9f521945Ae6d1c32d888235E37a8A6A51ad4fCBb | BTC spot leg |
| WETH | 0xbfF703dA9993751a6732969a9070fA1e8aC9b9e5 | ETH spot leg |
| HYPE | 0x5DE93554d4e2F0bE86E5a12BC91aAc3aD695e822 | HYPE spot leg |
Operator, keeper and deployer roles: deployments/base-sepolia/README.md.
Use the app
You need a browser wallet on Base Sepolia and a little Base Sepolia ETH for gas.
- Open https://maas.exchange and connect your wallet.
- Open Faucet and mint test USDC and any market tokens.
- Trade: open
/trade/btc, deposit USDC margin, place an order, close it. - Spot: open
/spot/btc, swap on the v4 pool, or add full-range liquidity, withdraw it, and claim fees. - Vault: open
/vault, deposit USDC, watch modes and decisions, withdraw. - Portfolio: PnL, open positions including the vault, and one Manage USDC box for perp margin or spot LP.
- Leaderboard: open
/leaderboardfor perp traders ranked by PnL.
| Market | Initial margin | Maintenance margin | Maker fee | Taker fee |
|---|---|---|---|---|
| BTC, ETH | 5% | 2.5% | 2 bps | 5 bps |
| HYPE | 10% | 5% | 2 bps | 5 bps |
Use the contracts directly
The app covers all of this. For scripts you need foundry and a funded Base Sepolia key. USDC amounts use 6 decimals.
RPC=https://sepolia.base.org
USDC=0x7bB2796270a9c8f94Dd4aECA94D086272D40B51A
VAULT=0x986741e435ef20b042057A05Cc74e42DfEA79E57
EXCHANGE=0xA715BedceEE7b9B4CE3AAaac5CbFc745A35854e1
ME=<your address>
cast send $USDC 'mint(address,uint256)' $ME 1000000000 --rpc-url $RPC --account <keystore> # 1000 USDC
cast send $USDC 'approve(address,uint256)' $VAULT 1000000000 --rpc-url $RPC --account <keystore>
cast send $VAULT 'deposit(uint256,address)' 1000000000 $ME --rpc-url $RPC --account <keystore>
cast call $VAULT 'totalAssets()(uint256)' --rpc-url $RPC
cast call $VAULT 'mode(uint256)(uint8)' 0 --rpc-url $RPC # 0 FLAT, 1 HEDGED
cast send $EXCHANGE 'deposit(uint256)' 1000000000 --rpc-url $RPC --account <keystore> # perp margin, approve first
Large vault exits: requestRedeem(shares, receiver), then the keeper calls fulfill.
Perp orders are EIP-712 signed and sent to the gateway, not the contract. Public reads:
curl https://api.maas.exchange/markets
curl 'https://api.maas.exchange/book/0?depth=5'
curl 'https://api.maas.exchange/funding/0?window=3h'
curl 'https://api.maas.exchange/leaderboard?window=7d' # 24h | 7d | 30d | all
Other routes: /markets/stats, /trades/{market}, /candles/{market}, /account, /positions, /funding, /orders, /fills, POST /orders, DELETE /orders/{id}, WebSocket /ws.
Repository structure
maas/
contracts/ Foundry: PerpExchange, DeltaNeutralVault, MaasHook, deploy scripts
engine/ Rust matching engine (order book, risk)
backend/ Go services: gateway, settler, indexer, keeper, vault-keeper, recorder, mm-bot
workflows/rebalancer/ Chainlink CRE workflow (Go)
proto/ Shared message schemas (Protobuf)
client/ Next.js trading terminal, vault and portfolio
landing/ Next.js marketing page
docs/ Docusaurus docs site
arkiv/ Arkiv track write-ups
deployments/ Per-network deployment records
infra/ Docker compose, terraform, ansible
Tech stack
| Component | Technology |
|---|---|
| Chain | Base Sepolia (84532) |
| Contracts | Solidity, Foundry, OpenZeppelin upgradeable (UUPS) |
| Spot | Uniswap v4 (PoolManager + MaasHook) |
| Matching engine | Rust |
| Services | Go, NATS, Postgres |
| Decision layer | Chainlink CRE SDK (Go) |
| Public record | Arkiv (Rabdi testnet) |
| Prices | Pyth (Hermes + on-chain) |
| Client | Next.js 16, React 19, Tailwind 4, wagmi, Privy |
| Docs | Docusaurus |
Run locally
The contracts are already deployed, so local runs need no deploy. Prerequisites: docker (running), foundry, go 1.25+, rust/cargo, bun, jq.
git submodule update --init --recursive
cd client && bun install && cd ..
make env # root .env (from .env.example if missing) + client/landing .env.local
The root .env is the only env file (gitignored); re-run make env after editing it. Deployed addresses are not env values: they live in backend/config/*.json.
Client only, against the live gateway, no keys needed:
cd client && bun run dev # http://localhost:3000
Full stack:
make dev # terminal 1: nats, postgres, engine, go services, mm-bot, gateway :8080
cd client && bun run dev # terminal 2
make test # all suites
Wallet connect uses PRIVY_APP_ID (allow http://localhost:3000 as an origin). Full guide, manual and automated tests: TESTING.md. Demo flow: DEMO.md. Docs site: cd docs && bun install && bun start.
Prices (Pyth)
No hand-typed prices: Hermes → on-chain Pyth → everything reads the contract.
make check-prices # Hermes vs on-chain Pyth vs spot pools; exit 1 on drift or stale
make pyth-push # push a fresh Hermes update on-chain
make sync-pools # push, swap each pool to the on-chain price, re-check
PYTH_MAX_AGE (default 120s) bounds staleness. Feeds the API key isn't entitled to are skipped; set PRICE_<SYM> to seed them.
CI/CD
ci.yml(PRs tomain/staging/**, pushes tomain): contracts fmt/build/test, backend vet/test and integration, engine fmt/clippy/test, client lint/build.images.yml(pushes tomain/staging/**): builds backend images toghcr.io/<owner>/maas-<svc>.deploy-contracts.yml(manual):gh workflow run deploy-contracts.yml -f network=base-sepolia.
Deploy the backend
One EC2 box running docker compose (nats, postgres, engine, Go services, Caddy):
make tf-init tf-apply # t3.small + EIP
cp infra/ansible/prod.env.example infra/ansible/prod.env # fill keys, RPC_URL, POSTGRES_PASSWORD, HERMES_API_KEY
make ship # build images, copy, compose up
AI usage
Built with Claude Code (AI-assisted coding, docs, review). TODO: team to confirm disclosure wording.