A Spring Boot REST API that tracks ETH and ERC-20 token balances for any Ethereum wallet. On-chain data comes from Alchemy, USD pricing from CoinGecko; balances are captured as point-in-time snapshots in PostgreSQL so reads never have to hit either.
- Wallet lookup — reads the latest cached snapshot for a wallet (no blockchain call)
- On-demand refresh — forces a fresh sync: live ETH balance + automatic ERC-20 token discovery via Alchemy's enhanced APIs (no need to know in advance which tokens a wallet holds)
- History — every past snapshot for a wallet, newest first
- Spam filtering — tokens with a bogus supply/decimals (common with scam contracts) or implausibly long symbols are filtered out before they ever reach the database
- Health check — independent liveness of Alchemy and the database
- Interactive API docs — Swagger UI, generated from the actual code
- One-command startup — the whole stack (API + Postgres + pgAdmin) runs via Docker Compose
| Language / runtime | Java 21, Spring Boot 4.1 |
| Persistence | PostgreSQL, Spring Data JPA, Flyway migrations |
| Blockchain | Web3j + Alchemy (JSON-RPC and enhanced APIs) |
| Pricing | CoinGecko (/coins/list, /coins/markets) |
| API docs | springdoc-openapi (Swagger UI) |
| Testing | JUnit 5, Mockito, AssertJ, Testcontainers |
| Containerization | Docker, Docker Compose |
flowchart TD
Client(["Client"]) -->|HTTP| Controllers
subgraph Controllers["Controllers"]
PC["PortfolioController"]
WC["WalletController"]
TC["TokenController"]
HC["HealthController"]
end
PC --> PS["PortfolioService<br/>(orchestration)"]
WC --> WS["WalletService"]
TC --> TS
HC --> HS["HealthService"]
PS --> TS["TokenService<br/>(normalize + spam filter + pricing + token details)"]
PS --> SS["SnapshotService<br/>(persistence only)"]
PS -->|"validate + ETH balance"| BC
HS -->|"latest block"| BC
TS --> BC["BlockchainClient"]
TS --> PSV["PricingService"]
SS --> Repos[("Repositories")]
WS --> Repos
PS --> Repos
BC -->|"JSON-RPC +<br/>enhanced APIs"| Web3j["Web3j"]
Web3j --> Alchemy[("Alchemy")]
PSV --> CGC["CoinGeckoClient"]
PSV --> CGM["CoinGeckoIdMappingService<br/>(lazy, memoized)"]
CGM --> CGC
CGC -->|"REST"| CoinGecko[("CoinGecko")]
Repos --> Postgres[("PostgreSQL")]
Each layer has one job: controllers only handle HTTP, PortfolioService only orchestrates, TokenService only normalizes chain data into clean DTOs (symbol/balance stay Alchemy-sourced; CoinGecko only adds name/price/icon), SnapshotService only persists, BlockchainClient is the only class that talks to Web3j/Alchemy, and CoinGeckoClient is the only class that talks to CoinGecko. CoinGeckoIdMappingService lazily loads and memoizes the contractAddress -> coinGeckoId mapping (~18k entries) on first use rather than blocking startup; a failed load isn't cached, so the next lookup retries.
wallet (id, address, created_at)
└── portfolio_snapshot (id, wallet_id, eth_balance, total_usd, created_at)
└── token_balance (id, snapshot_id, symbol, contract, balance, price_usd, value_usd)
portfolio_snapshot and token_balance cascade-delete with their parent.
Token name and icon URL come from CoinGecko but are not persisted — they're static token metadata, not snapshot data, so persisting them would duplicate the same value across every snapshot row for a token. Practical effect: they're present in the response from POST /refresh (built before persistence) but always null on cached reads (GET /portfolio/{wallet}, /history), which read straight from the database.
Brings up the API, PostgreSQL, and pgAdmin together with one command.
cp .env.example .env # then fill in ALCHEMY_API_KEY and COINGECKO_API_KEY (both free)
docker compose up- API: http://localhost:8080
- Swagger UI: http://localhost:8080/swagger-ui.html
- pgAdmin: http://localhost:5050
Run Postgres/pgAdmin in Docker but the API directly via Gradle (faster iteration, hot reload via DevTools):
cp .env.example .env
docker compose up postgres pgadmin -d
./gradlew bootRun./gradlew testRequires Docker — repository and integration tests spin up a real, throwaway PostgreSQL via Testcontainers. No .env or Alchemy/CoinGecko credentials needed; the blockchain and pricing clients are mocked in tests.
Note on
bootRun+ DevTools: adding a new required env var to.envonly takes effect on a fresh./gradlew bootRunlaunch — DevTools' hot-restart reloads code within the same process, but can't inject new environment variables into an already-running one. If you add a key and a live-reloaded app silently stops pricing/syncing, fully restartbootRunrather than waiting for hot-reload.
| Variable | Purpose |
|---|---|
POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD |
Database credentials |
ALCHEMY_API_KEY |
Alchemy API key (required) |
ALCHEMY_NETWORK |
Alchemy network, e.g. eth-mainnet |
COINGECKO_API_KEY |
CoinGecko Demo API key (required) |
PGADMIN_DEFAULT_EMAIL / PGADMIN_DEFAULT_PASSWORD |
pgAdmin login |
CORS_ALLOWED_ORIGINS |
Origin(s) allowed to call the API from a browser; defaults to http://localhost:3000 |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/portfolio/{wallet} |
Latest cached portfolio |
GET |
/api/v1/portfolio/{wallet}/history |
All past snapshots, newest first |
POST |
/api/v1/portfolio/{wallet}/refresh |
Force a new blockchain sync |
GET |
/api/v1/wallets |
20 most recently seen wallets |
GET |
/api/v1/tokens/{contract} |
Full CoinGecko market data for one token (details page) — fetched fresh, not cached |
GET |
/health |
Alchemy + database status |
Full interactive documentation, generated from the code, is at /swagger-ui.html; the raw OpenAPI spec is at /v3/api-docs.
Built so far:
- Project scaffold, Postgres, Docker Compose for local dev
-
wallet/portfolio_snapshot/token_balanceschema with Flyway migrations - Blockchain layer — ETH balance, automatic ERC-20 discovery, transaction history, address validation
- Portfolio orchestration, token normalization, and snapshot persistence as separate services
- REST API — lookup, history, refresh, recent wallets, token details, health
- Centralized exception handling with typed error codes
- Full test suite — controller, service, repository, and integration tests with Testcontainers
- Full containerization via Docker Compose
- API documentation (this README, Swagger)
- USD pricing —
priceUsd/valueUsd/name/iconUrl(via CoinGecko, batched) andtotalUsd
Interactive Swagger UI, generated from the live API:
A live GET /api/v1/portfolio/{wallet} call executed from Swagger UI against a real wallet:

