The running app that proves you ran — and pays you for it.
Production: movr.muhsalmanabid.com
MovrChain is a Monad-native running tracker built for the post-run check-in. It allows runners to import GPX route data, replay their routes with statistics, attest the activity on-chain with zero-overhead finality, publish it to a global community feed, and claim milestone rewards. The app also features an achievement NFT system, staking pools with NFT-backed yield boosts, and community running clubs with decentralized treasuries.
- Quick Start
- Core Application Flows
- Local Development & Frontend Setup
- Smart Contract Stack
- Security, Validation & Contract Rules
- Test & Audit Gate
- Verified Monad Testnet Deployments
- Contract Deployment Guide (Monad Testnet)
- Administrative & Operations Scripts
- Monad Testnet Network Details
For judges or quick manual verification, open the production app. It is pre-configured with active contracts on the Monad Testnet and includes a local sample run simulation.
- Install Dependencies:
npm install- Run Development Server:
npm run dev- Verify Locally:
- Open http://localhost:5173.
- Click the "Import" button.
- Select "Load Sample Run" to load
public/sample-run.gpxand test the full visual route replay and summary flow without needing a wallet. - To test the on-chain attestation and claims, connect a wallet on Monad Testnet. Desktop: browser extension (e.g. MetaMask). Phone: use Wallet app via WalletConnect (opens MetaMask / other mobile wallets).
MovrChain's user experience is split into distinct, highly interactive phases:
[Import + Parse GPX] ──► [Route Replay] ──► [Review Stats] ──► [Validate + Attest] ──► [Publish + Claim]
Users upload standard GPX files (generated by Strava, Apple Watch, Garmin, etc.) containing GPS coordinates and time telemetry. The file is parsed in the browser via [gpx.ts](src/lib/gpx.ts) to calculate distance, duration, pace, and route points.
Once loaded, a stat-focused view plays an animated trace of the run's GPX coordinates on an interactive map. In this screen, distance dominates the screen space, with pace and duration supporting it.
The user reviews a static summary of their effort. This screen lists total distance (km), average pace, duration, and the full route layout.
MovrChain supports two attestation modes:
- Hackathon/testnet mode (
selfAttestEnabled = true): runners submit their own wallet-bound route commitments, subject to hard validation limits. - Trusted attester mode (
selfAttestEnabled = false): onlyATTESTER_ROLEcan submit throughattestRunFor, allowing an oracle or backend verifier to become the production trust boundary.
Neither mode treats the route hash itself as a GPS proof. The contract binds the runner, metrics, and route commitment; trusted mode additionally restricts who may attest.
The "Verify & publish" pipeline validates the run in layers:
- Browser GPX validation
- Rejects malformed XML, files with fewer than two track points, and routes with fewer than two valid finite coordinates.
- Calculates distance from coordinates using the Haversine formula.
- Uses recorded timestamps when available; otherwise estimates duration at a documented fallback pace.
- Marks the bundled sample GPX as preview-only and disables on-chain verification for it.
- Deterministic wallet-bound commitment
- The frontend hashes sampled route coordinates into
routeCommit. - The run ID is computed as
keccak256(abi.encode(runner, distanceMeters, durationSeconds, routeCommit)). - Including the runner address prevents another wallet from front-running the same commitment as its own activity. Binding the metrics prevents distance or duration from being swapped against an unrelated route commitment.
- Attestation contract validation
- In trusted mode, rejects callers without
ATTESTER_ROLE. - Requires non-zero distance and duration.
- Rejects distances above
200 kmand durations above48 hours. - Rejects average speed above
10 m/s(approximately36 km/h) as an anti-teleport sanity bound. - Limits each wallet to
24attestations per UTC day. - Rejects an existing deterministic
runHash. - Snapshots
clubIdAtAttest; joining a club after the run cannot redirect that run's club reward. - Stores the accepted record, updates total distance, run count, best distance, and qualifying daily streaks, then emits
RunAttested. - A streak advances only on
≥1 kmdays. Missing a UTC day decays the current streak; sub-1 km activity cannot preserve a stale streak.
- Authorized feed publication
MovrFeedaccepts only a run already stored inMovrChainAttestation.- The publishing wallet must be the attested runner.
- Each
runHashcan be published only once, and the run name must contain1–64bytes. - The owner can pause publication for moderation or incident response.
- Milestone claim validation
- A run qualifies at
≥ 1 km; only its attested runner can claim. - Each
runHashcan be claimed once, and the reward pool must have enoughMOVR. - The runner receives
1 MOVRper kilometer based on the attested distance. - If
clubIdAtAttestwas non-zero, an additive1 MOVRper10 kmgoes to that snapshotted club treasury. Claim-time club membership cannot create a club cut.
- Receipt and recovery checks
- The UI waits for each transaction receipt and treats a reverted receipt as failure.
- Before retrying, it reads attestation, publication, and claim state from the contracts so an already-confirmed step is not submitted twice.
- After confirmation, targeted query refetches account for RPC indexing delay without freezing unrelated navigation.
- Achievements: The
AchievementNFTcontract defines milestone templates (e.g., First 5K, First Half Marathon, 7-Day Streak). Streak achievements require an active, time-decayed streak. Per-token boosts are snapshotted at mint, so later admin edits cannot corrupt transfer accounting. - Marketplace: Listings clear before payment and
buyNFTis reentrancy-guarded. - Staking: Achievement and soulbound club-badge boosts are combined and capped by
maxBoostBps. - Expected rewards UI: The Staking tab projects day / month / year yield from today’s rate × NFT boost, splits You keep vs To club using the donate %, and previews the amount field only when nothing is staked yet. While staked, the amount field is for stake/unstake only — the table stays on current stake. Estimates (30-day month) are not a guarantee of claimable
rewardReserve. - Rate changes: Each account accrues using a locked rate for the current interval.
configureRatesapplies to future intervals, not retroactively. - Solvency: Rewards are paid only from
rewardReserve; user principal remains tracked separately intotalStaked. Partial claims preserve unpaid reward debt. - Club donation: Stakers may route
2–5%of claimed yield to the club snapshotted when they saved donate % (donateClubId). Claim donates only while they remain a member of that club; leaving skips donate until they save again. Joining another club does not redirect yield without re-saving.
Discover and join clubs on the Clubs tab. Clubs are ranked by active treasury size and total clubmate runs.
- One club per wallet: enforced by
clubOf; leaving clears membership and burns the soulbound member NFT so rejoining works. - Manager Approval: Private clubs require approval from the club manager to join.
- Treasury proposals: amounts are reserved at creation, preventing multiple active proposals from oversubscribing the same balance. Votes are rejected after voting closes. Only Captain/Admin can
executea passed proposal. Execute still works if the proposer left; proposers cannot cancel after a passed vote closes. - Challenges: only club managers can escrow treasury rewards. Durations are capped at
90 days/3 months; managers can cancel and refund. Dust and zero-winner settlement return funds to the treasury, and approvals can be revoked before settlement.
The application contains baked-in, pre-deployed contract addresses in [contracts.ts](src/lib/contracts.ts), all pointing to source-verified Monad Testnet contracts. To deploy and run with your own contracts, set up overrides in .env.local at the root of the project:
# Override contract addresses
VITE_CONTRACT_ADDRESS=0xAttestationAddress
VITE_PROFILE_ADDRESS=0xProfileAddress
VITE_MOVR_TOKEN=0xTokenAddress
VITE_ACHIEVEMENT_NFT=0xAchievementNftAddress
VITE_STAKING=0xStakingAddress
VITE_CLUB_REGISTRY=0xClubRegistryAddress
VITE_CLUB_MEMBER_NFT=0xClubMemberNftAddress
VITE_CLUB_BADGE_NFT=0xClubBadgeNftAddress
VITE_CLUB_CHALLENGES=0xClubChallengesAddress
VITE_MILESTONE_REWARD=0xMilestoneRewardAddress
VITE_FEED_ADDRESS=0xFeedAddressThe frontend includes utilities to generate customized athletic avatars and achievement badge SVGs:
- Generate Avatars: Generates SVGs under
public/brand/avatars/npm run generate:avatars
- Generate Achievement Metadata/Art: Generates on-chain data URIs and metadata JSON
npm run generate:art
The contracts are built using Foundry and are located under [contracts/src/](contracts/src/).
| Contract | Role |
|---|---|
[MovrToken.sol](contracts/src/MovrToken.sol) |
Ecosystem utility ERC-20 token (MOVR), admin-minted under a hard 1B MOVR cap. |
[MovrChainAttestation.sol](contracts/src/MovrChainAttestation.sol) |
Wallet-bound run validation, optional trusted-attester mode, club-at-attest snapshots, and live streak calculation. |
[AchievementNFT.sol](contracts/src/AchievementNFT.sol) |
ERC-721 athletic achievements with mint-time boost snapshots and a reentrancy-guarded native MON marketplace. |
[MovrStaking.sol](contracts/src/MovrStaking.sol) |
Principal-separated staking with funded reward reserves, non-retroactive rates, Achievement + ClubBadge boosts, and optional club donation. |
[MovrProfile.sol](contracts/src/MovrProfile.sol) |
Unique lowercase handles, runner names, bios, and selected avatar IDs. |
[MovrClubRegistry.sol](contracts/src/MovrClubRegistry.sol) |
Spawns clubs and membership; deploys per-club ClubTreasury as BeaconProxy (shared upgradeable logic). |
[ClubMemberNFT.sol](contracts/src/ClubMemberNFT.sol) |
Soulbound membership badges confirming club alignment. |
[ClubBadgeNFT.sol](contracts/src/ClubBadgeNFT.sol) |
Soulbound club contribution badges whose boosts are consumed by staking. |
[MovrClubChallenges.sol](contracts/src/MovrClubChallenges.sol) |
Manager-controlled, duration-capped treasury challenges with cancel/refund and dust-safe settlement. |
[MovrMilestoneReward.sol](contracts/src/MovrMilestoneReward.sol) |
One-claim run rewards with club routing fixed to the attestation-time snapshot. |
[MovrFeed.sol](contracts/src/MovrFeed.sol) |
Pausable global index of one-shot published run hashes, telemetry, and timestamps. |
These are release rules, not optional recommendations:
- Proof before reward: reward and publishing paths consume an existing attestation; they never accept raw frontend metrics.
- Deterministic identity:
runHashbinds runner, distance, duration, androuteCommit. - Trust mode is explicit: testnet may use self-attestation; production must disable it unless the economic risk is accepted and grant
ATTESTER_ROLEto a controlled verifier. - Snapshot mutable context: club reward routing and NFT boosts are captured when the relevant run/token is created.
- No stale streaks: current streaks decay by UTC day; historical longest streak does not satisfy an active-streak claim.
- Principal is not rewards: staking principal (
totalStaked) and funded rewards (rewardReserve) are separate. - No treasury overcommit: active proposals reserve funds; challenges can spend only unreserved balance.
- Escrow must have an exit: challenges are manager-gated, duration-capped, cancellable, and settle dust back to treasury.
- Soulbound means lifecycle-complete: membership NFTs burn on leave; club badges cannot transfer.
- External value calls are guarded: marketplace and reward/treasury flows use reentrancy protection and checks-effects-interactions.
- Admin powers are bounded and observable: MOVR has a hard max supply; pause, roles, rates, and funding are explicit contract state.
- Proxy addresses are permanent after the UUPS cutover: update
contracts/.env, root.env.local, andsrc/lib/contracts.tstogether once after that deploy; later logic changes use Timelock upgrades (no address churn).
The full finding-by-finding reconciliation and test IDs live in [contracts/TEST_MATRIX.md](contracts/TEST_MATRIX.md).
No Monad deployment is ready until all gates pass:
cd contracts
forge test --offlineCurrent hardening baseline: 97 tests, 0 failures across token, attestation, feed, profile, achievements, staking, rewards, clubs, treasury, membership, badges, challenges, UUPS / Beacon / Multisig / Timelock upgrades, and privilege handoff.
Required pre-deploy checklist:
-
forge testpasses with no compiler warnings. - Every High / Medium / Low finding in
contracts/TEST_MATRIX.mdisFIXED(Jul 18 2026 reaudits #1–#4). - Constructor and one-time wiring order matches the deployment flow below (
DeployUpgradeableStack). - Frontend ABI/address constants and both env files match the UUPS cutover deployment.
- Reward pools are funded without exceeding
MovrToken.MAX_SUPPLY(). - Representative reads confirm bytecode and expected configuration.
- All new implementations are source-verified on MonadScan (proxies may remain bytecode-only; implementations verified).
- Production trust policy is explicit: self-attest on, or trusted attester configured.
- Production Multisig: set
MULTISIG_THRESHOLD≥2(hackathon uses threshold=1).
A green local suite validates the current source. It does not prove the addresses below run that bytecode.
All addresses below are deployed on chain ID 10143, have non-empty bytecode, pass representative ABI read calls, and have source code verified on MonadScan. The frontend defaults in [src/lib/contracts.ts](src/lib/contracts.ts) and local overrides in .env.local must remain synchronized with this registry.
Jul 18 2026 UUPS cutover is live on the addresses below (Beacon + Multisig threshold=1 + Timelock 24h). Proxy addresses are stable — further logic changes use
./upgrade.sh(Multisig → Timelock), not redeploy. Token + Profile were kept. See contracts/TEST_MATRIX.md.
| Contract | Address | Verified source |
|---|---|---|
| MovrMultisig | 0x61Fc8DcD97Fb5f46489Ab2fBaBf6429Ed5ADA52B |
MonadScan |
| TimelockController | 0xada899f2e96c31ca7e8De9e6f01e676a18fd67Ec |
MonadScan |
| Treasury Beacon | 0x3efAf01Cd276ff8Db71F4fD04DB98ea02f88b59A |
MonadScan |
| MovrChainAttestation | 0x8F31Cbb38b539cB9e3242E262bf40058904d739c |
MonadScan |
| MovrProfile | 0x5079c9F03BafDaD54A4CBFdbf05662fdaC285832 |
MonadScan |
| MovrToken | 0xD95C0f1F5F5F73e32F87B4f76d6a79809911B7BF |
MonadScan |
| AchievementNFT | 0x1c5E27280a2D993CE3E8E8a19009488f0F38EB5A |
MonadScan |
| MovrStaking | 0x762Ae6f37BC52CB0Ce81A6d4ad6c3b3B3658479d |
MonadScan |
| MovrClubRegistry | 0x91cb9D4A5e14E5Ac0962E2d6cba963003A4EC9D3 |
MonadScan |
| ClubMemberNFT | 0x9a3d2332E53e512DcFA65078884f5213aAfe2A0a |
MonadScan |
| ClubBadgeNFT | 0xCC97214Be43c1A6E967caC448195FA3933C15764 |
MonadScan |
| MovrClubChallenges | 0xB45d215FFb048D9477a9C4c6fA60b182B09538Ed |
MonadScan |
| MovrMilestoneReward | 0x7B013C35E7bA65e51486C3f9005b6e19809CD270 |
MonadScan |
| MovrFeed | 0xf6073E5A71D05336b6c260FA14f7a86520D403Aa |
MonadScan |
Profile migration:
0xa16938B26824c3D2aACEb4e25a08937B7fCb202cis the retired pre-handle profile deployment. It does not implementsetProfile(string,string,string,uint8)and must not be used by the frontend.
Production major updates must not redeploy new addresses (that orphans runs, stakes, NFTs, and treasuries). Stateful contracts use:
| Layer | Role |
|---|---|
| ERC1967Proxy (UUPS) | Stable address for Attestation, AchievementNFT, Staking, Registry, Member/Badge NFTs, Feed, MilestoneReward, Challenges |
| UpgradeableBeacon + BeaconProxy | Shared ClubTreasury logic for every club |
| TimelockController (default 24h) | Sole upgrade owner / admin |
| MovrMultisig (threshold 1–3, default 1 creator-only) | Sole Timelock proposer / canceller / admin |
MovrToken and MovrProfile remain non-upgradeable.
Propose an upgrade:
cd contracts
# See which proxies/beacon are configured
./upgrade.sh list
# ClubTreasury execute-manager fix (beacon — all clubs at once)
./upgrade.sh upgrade treasury
# Or any one UUPS proxy:
./upgrade.sh upgrade challenges
./upgrade.sh upgrade attestation
# Or every configured target:
./upgrade.sh upgrade allUnder the hood this deploys a new implementation, then runs UpgradeViaTimelock.s.sol.
- One Multisig signer runs
./upgrade.sh upgrade <name>(submitsTimelock.schedule). WithMULTISIG_THRESHOLD=1, the same tx also executes the Multisig call. - If threshold is 2+, a second signer confirms + executes the Multisig transaction.
- Wait
TIMELOCK_DELAY(default 86400s). ./upgrade.sh execute <name>(orEXECUTE_AFTER_DELAY=1/ Explorer).
Hackathon shortcut if the beacon/proxy is still owned by the deployer (before Timelock cutover):
./upgrade.sh direct treasuryManual env form (same as the script):
TARGET=0xProxyOrBeacon NEW_IMPLEMENTATION=0xNewImpl MODE=uups \ # or MODE=beacon
MOVR_MULTISIG=0x... TIMELOCK=0x... \
forge script script/UpgradeViaTimelock.s.sol:UpgradeViaTimelock \
--rpc-url https://testnet-rpc.monad.xyz --broadcast --legacyStorage layout is append-only (__gap reserved on each upgradeable contract). Never reorder or remove state variables in an upgrade.
After the one-time proxy cutover, update contracts/.env, .env.local, and src/lib/contracts.ts once, then run:
cd contracts
./verify-all.shverify-all.sh submits Solidity Standard JSON to the Etherscan V2 API for Monad Testnet and prints a verified MonadScan link for each contract. It requires ETHERSCAN_API_KEY or MONADSCAN_API_KEY in contracts/.env.
Before running the deployment scripts, make sure you configure your local environment.
cd contracts
forge test --offlineStop if any test fails. The deployment scripts assume the same deployer controls the one-time registry, attestation, and NFT wiring calls.
- Install Monad-compatible Foundry:
foundryup --network monad
- Change directory and copy environment template:
cd contracts cp .env.example .env - Open
contracts/.envand configure:PRIVATE_KEY: Private key of a funded Monad Testnet deployer wallet (also Multisig signer 1).MULTISIG_SIGNER_2/MULTISIG_SIGNER_3: two additional distinct Multisig signers (required for./redeploy-all.sh).ADMIN_ADDRESS: Optional AchievementNFT admin — do not grant in the deploy script (would survive Timelock handoff). After cutover, Multisig→Timelock should callsetAdmin(ADMIN_ADDRESS, true). Logged at deploy if set.TIMELOCK_DELAY: Optional seconds (default86400).MOVR_TOKEN: existing token to keep.
To request gas money for deployment, visit the Monad Faucet.
The deployment scripts run using --gas-estimate-multiplier 250 and --slow options to account for Monad gas estimations and to avoid race conditions.
For a security-hardening rollout, use the dependency-ordered orchestrator:
./redeploy-all.shIt deploys the upgradeable stack (DeployUpgradeableStack): Multisig + Timelock + UUPS proxies + ClubTreasury beacon, funds reward reserves, rewrites contracts/.env and root .env.local, and preserves MOVR_TOKEN / MOVR_PROFILE. After this cutover, do not redeploy to patch logic — use the Timelock upgrade path so addresses stay stable.
Runs deployment, registers default achievements, mints initial reward tokens, and funds the staking rewards pool:
./deploy.shOutput prints contract addresses; populate MOVR_TOKEN, ATTESTATION, ACHIEVEMENT_NFT, and STAKING in contracts/.env.
./deploy-profile.shCopy the emitted MOVR_PROFILE address to contracts/.env, and copy the emitted VITE_PROFILE_ADDRESS to the root .env.local and the baked fallback in src/lib/contracts.ts.
Deploys ClubMemberNFT, MovrClubRegistry, ClubBadgeNFT, and staking; wires registry donations and club-badge boosts:
./deploy-clubs.shOutput prints registry and member/badge NFT addresses; populate CLUB_REGISTRY in contracts/.env.
./deploy-club-challenges.shDeploys the reward router, wires attestation.setClubRegistry(registry) for clubIdAtAttest, wires treasury rewards, approves the router, and funds the pool with 100,000 MOVR:
./deploy-milestone-reward.sh./deploy-feed.shThe feed constructor receives the deployer as owner so publication can be paused during moderation or incident response.
The final deployment must satisfy:
ClubMemberNFT.MINTER_ROLE → MovrClubRegistry
MovrStaking.clubRegistry → MovrClubRegistry
MovrStaking.clubBadges → ClubBadgeNFT
MovrClubRegistry.staking → MovrStaking
MovrChainAttestation.clubRegistry → MovrClubRegistry
MovrMilestoneReward.clubRegistry → MovrClubRegistry
MovrClubRegistry.milestoneReward → MovrMilestoneReward
MovrClubRegistry.challenges → MovrClubChallenges
For trusted production attestation:
grant ATTESTER_ROLE to verifier
setSelfAttestEnabled(false)
Do not disable self-attestation before the verifier is configured and tested.
A set of shell scripts is available under contracts/ to manage, update, and audit active deployments.
To fund the MovrStaking reward pool with additional MOVR tokens (automatically mints more if owner balance is insufficient):
./fund-rewards.shAdjust reward quantity by overriding env variables: REWARD_AMOUNT=500000000000000000000000 ./fund-rewards.sh.
To refill the milestone claim pool (MovrMilestoneReward — pays 1 MOVR/km on verify). After Timelock handoff, a plain ERC20 transfer into the proxy is enough:
./fund-all-pools.sh # top milestone to ≥1M MOVR now; schedule staking fund (Timelock)
./fund-all-pools.sh status
# after ~24h Timelock delay:
./fund-all-pools.sh execute # completes staking fundRewards(1M)Or milestone only: ./fund-milestone.sh / ./fund-all-pools.sh milestone-only.
If claim reverts with empty pool, the milestone contract’s MOVR balance is too low — run ./fund-all-pools.sh (or milestone-only), then retry claim (attest + publish can still succeed).
Audit owner (DEFAULT_ADMIN_ROLE) and admin (ADMIN_ROLE) configurations on active contracts:
./check-role.sh # Inspects address defined in PRIVATE_KEY
./check-role.sh 0xYourAddress # Inspects a specific addressDirectly mint MOVR tokens to a target address:
./mint-movr.sh 10000000 # Mints 10,000,000 MOVR to the deployer walletIf achievement SVGs or metadata text have changed, you can update them without redeploying the core contracts:
- Re-generate SVGs and metadata files from the root of the project:
npm run generate:art. - Push updates to the contracts:
# Redeploys AchievementNFT and Staking contracts with fresh data-URIs
./redeploy-nft-staking.sh
# Incremental URI updates only (calls setAchievementURI)
./update-achievement-uris.shVerify every configured contract and print its MonadScan source link:
./verify-all.shThe script reads deployed addresses and an Etherscan-compatible API key from contracts/.env. A successful submission must finish with Pass - Verified; bytecode presence alone is not source verification.
The application is optimized for the following network details:
- Chain ID:
10143 - RPC URL:
https://testnet-rpc.monad.xyz - Currency Symbol:
MON - Block Explorer: MonadScan Testnet
Monad charges primarily on the transaction gas limit (not only gas used), so limits must be high enough to finish the write but not wildly oversized.
eth_estimateGas often undercounts cold storage and long string writes (feed publish, club proposals, achievement NFT mints that store data: token URIs on-chain). Observed claimAchievement estimates on testnet are ~3.1M gas for a typical metadata URI.
To avoid Out-Of-Gas (OOG) failures:
- Deployment scripts use a 2.5× multiplier (
--gas-estimate-multiplier 250). - Most frontend writes estimate then buffer (see
[src/lib/monadGas.ts](src/lib/monadGas.ts)andVerifyClaim):- Achievement NFT claim: estimate × 1.5, floor
3,500,000 - Club badge claim: estimate × 1.5, floor
400,000 - Attestation: floor
350,000(with estimate buffer in verify flow) - Feed publish: floor
800,000 - Milestone reward claim: floor
550,000
- Achievement NFT claim: estimate × 1.5, floor
- Prefer estimate + modest buffer over a huge fixed limit: on Monad an oversized
gasfield costs realMONeven if unused. - MonadScan may label a failed tx as “out of gas” when
gasUsed == gasLimit; always check the decoded revert (e.g.not eligible) before assuming the floor alone was wrong.