Skip to main content

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.

maas

All tokens are testnet tokens with no value.

Apphttps://maas.exchange
Gateway APIhttps://api.maas.exchange
Demo videoTODO: video URL
NetworkBase Sepolia, chain id 84532

Contents​

Architecture​

maas splits work across four layers, and each one holds only the power it needs:

LayerRoleTrust property
CustodyPerpExchange and DeltaNeutralVault on Base SepoliaFunds move only through user calls, signed orders, or a CRE report from the registered forwarder
MatchingRust order book engine + Go gateway and settlerCan only settle fills that match orders the traders signed (EIP-712); cannot withdraw anyone's margin
DecisionChainlink CRE rebalancer workflowThe only caller that can change vault modes or pay LP epochs (onReport, forwarder-gated)
RecordArkiv entities written by the recorderPublic, 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​

TrackWhat it does in maasMain code
Uniswap v4All spot trading and the vault's spot leg, with a custom dynamic-fee hookcontracts/src/hooks/MaasHook.sol
Chainlink CREThe vault's decision layer: reads funding, sets HEDGED / FLAT, pays LP epochsworkflows/rebalancer
ArkivPublic record of funding, decisions, LP epochs and NAV; the vault page reads it directlybackend/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.

  • beforeSwap prices 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.
  • afterSwap takes the fee and streams it to full-range LPs over one hour. LPs call claim to 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​

FileUsage
contracts/src/hooks/MaasHook.solLive hook: oracle-gap dynamic fee, vault exemption, LP fee stream, claim (UUPS proxy)
MaasHook.sol#L105Default fee parameters (base 3 bps, gap share 50%, cap 100 bps, stale 10 bps)
MaasHook.sol#L164beforeSwap fee decision
MaasHook.sol#L184afterSwap fee collection
contracts/src/interfaces/IMaasHook.solHook interface
contracts/src/vault/DeltaNeutralVault.sol#L293Vault unlockCallback: swaps through the PoolManager at 0 fee
contracts/script/DeploySpot.s.solPools, routers and test tokens
contracts/script/DeployHook.s.solMines the hook address and deploys MaasHook
contracts/script/AddLiquidity.s.sol, SyncPools.s.solSeed liquidity; swap pools back to the on-chain Pyth price
client/src/features/spot/hooks/use-lp-rewards.tsFull-range LP deposit and fee claim from the app
client/src/features/spot/lib/liquidity.tsFull-range liquidity math (v4 tick bounds, max deposit)
FEEDBACK.mdUniswap 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.

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.

SettingStagingProduction
Mode check schedulehourly, 1 min after fundinghourly, 1 min after funding
Funding window3 h (last 3 prints)3 h (last 3 prints)
Funding sourcemaas gatewayArkiv
LP epochhourlyoff
FileUsage
workflows/rebalancer/rebalancer/workflow.gocron.Trigger handlers, HTTP funding fetch with consensus, EVM read of vault.mode, GenerateReport + WriteReport
workflows/rebalancer/rebalancer/funding.go#L27Decide: the HEDGED / FLAT rule
workflows/rebalancer/rebalancer/lp.goHourly LP epoch: splits vault funding across pools by volume
workflows/rebalancer/rebalancer/workflow.yamlCRE workflow definition
workflows/rebalancer/rebalancer/config.staging.json, config.production.jsonSchedules, window, funding source, vault address
workflows/rebalancer/project.yaml, secrets.yamlCRE project settings and secrets
workflows/rebalancer/contractsGenerated vault bindings for the CRE EVM client
workflows/rebalancer/rebalancer/workflow_test.go, fixturesWorkflow tests and funding fixtures
contracts/src/vault/DeltaNeutralVault.sol#L360onReport receiver: forwarder check, kind 0 = modes, kind 1 = LP epoch
backend/cmd/vault-keeperExecutes 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.

EntityWhat it holdsKept for
perpvault.FundingRateEvery funding print per market7 days
perpvault.VaultDecisionEvery HEDGED / FLAT mode change182 days
perpvault.VaultSnapshotHourly NAV and share price7 days
perpvault.LPEpochLP payouts per epoch182 days
NetworkRabdi testnet, chain id 7794095
RPChttps://rpc.rabdi.db-chain.testnet.arkiv.network
Explorerhttps://rabdi.explorer.arkiv.network
Writer wallet0x458b0f52446e2747b077c92690813642fb326123

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​

FileUsage
backend/internal/recorderRecorder service: watches funding and vault logs, writes entities, hourly NAV snapshots with backfill
backend/internal/platform/arkiv/records.goEntity schemas, attributes and expiry
backend/internal/platform/arkiv/client.goArkiv write client
backend/cmd/recorder, backend/cmd/arkivctlRecorder entry point; CLI to query and inspect entities
client/src/features/vault/api/arkiv.tsVault page reader (funding history, decisions, NAV)
workflows/rebalancer/rebalancer/funding.go#L91CRE 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​

ContractAddressRole
PerpExchange0xA715BedceEE7b9B4CE3AAaac5CbFc745A35854e1Margin, trade settlement, funding, liquidation. Deploy block 55148591
DeltaNeutralVault (proxy, pvUSDC)0x986741e435ef20b042057A05Cc74e42DfEA79E57ERC-4626 USDC vault. Implementation 0xe92dc5ee0452eebfb76057d1c743b46354237066. Deploy block 55315107
CRE forwarder (MockKeystoneForwarder)0x82300bd7c3958625581cc2F77bC6464dcEcDF3e5Only sender allowed to call vault.onReport
Pyth (official)0xA2aa501b19aff244D90cc15a4Cf739D2725B5729Price oracle for perps, hook fee and vault

Uniswap v4​

ContractAddressRole
PoolManager0x326E8d968fD7C3FD660851B66bEAc80E1D7e103dv4 singleton, deployed by us for testnet
MaasHook (proxy)0x5D45A542A6EBE3a0d8E47D357Ef67df1DaC3bFFFLive hook on all pools. Implementation 0xb9bd5cdd837843adb78e35a8e0b08a7f60a7b044; its vault is set to the vault above
PoolSwapTest router0x50bfec15064492eF1d3861c076D31b69838d3Ea1Spot swaps
PoolModifyLiquidityTest router0x49E85be8Ee85D46d58fe39889344e7EE28Fe79CDLP deposits, credited to the depositing wallet
PoolPool ID
BTC / USDC0xb15ac7bcec05bc74f8a869efe0a5645fdf582c36ee1c28a4d7570480d7ce1aef
ETH / USDC0x949940040fe297ce6938c528a025f18414e30f6d08d9267e82ff214c971eb1bc
HYPE / USDC0x6a247b9c6481dfa5a2ade1a03ec03af161998d5ed9f409ee657afdd8ba16d8fb

Test tokens (MockToken, open mint)​

TokenAddressRole
USDC (6 decimals)0x7bB2796270a9c8f94Dd4aECA94D086272D40B51AQuote, margin and vault asset
WBTC0x9f521945Ae6d1c32d888235E37a8A6A51ad4fCBbBTC spot leg
WETH0xbfF703dA9993751a6732969a9070fA1e8aC9b9e5ETH spot leg
HYPE0x5DE93554d4e2F0bE86E5a12BC91aAc3aD695e822HYPE 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.

  1. Open https://maas.exchange and connect your wallet.
  2. Open Faucet and mint test USDC and any market tokens.
  3. Trade: open /trade/btc, deposit USDC margin, place an order, close it.
  4. Spot: open /spot/btc, swap on the v4 pool, or add full-range liquidity, withdraw it, and claim fees.
  5. Vault: open /vault, deposit USDC, watch modes and decisions, withdraw.
  6. Portfolio: PnL, open positions including the vault, and one Manage USDC box for perp margin or spot LP.
  7. Leaderboard: open /leaderboard for perp traders ranked by PnL.
MarketInitial marginMaintenance marginMaker feeTaker fee
BTC, ETH5%2.5%2 bps5 bps
HYPE10%5%2 bps5 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​

ComponentTechnology
ChainBase Sepolia (84532)
ContractsSolidity, Foundry, OpenZeppelin upgradeable (UUPS)
SpotUniswap v4 (PoolManager + MaasHook)
Matching engineRust
ServicesGo, NATS, Postgres
Decision layerChainlink CRE SDK (Go)
Public recordArkiv (Rabdi testnet)
PricesPyth (Hermes + on-chain)
ClientNext.js 16, React 19, Tailwind 4, wagmi, Privy
DocsDocusaurus

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 to main / staging/**, pushes to main): contracts fmt/build/test, backend vet/test and integration, engine fmt/clippy/test, client lint/build.
  • images.yml (pushes to main / staging/**): builds backend images to ghcr.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.

License​

MIT