Starknet Indexer + Marketplace API for Medialane
The backend service that powers Medialane.io, a programmable IP marketplace on Starknet. It continuously indexes on-chain events, resolves token metadata from IPFS, and exposes a REST API for dApps and SDK consumers.
Starknet RPC ──► Mirror (Indexer) ──► PostgreSQL ◄── Orchestrator (jobs)
│
Hono REST API ◄── dApps / @medialane/sdk
Mirror + Orchestrator run together in the src/worker.ts process (Railway service
medialane-worker); the REST API runs separately in src/index.ts (Railway service
medialane-backend) — see Deployment below. Locally, run bun dev for the API and
bun run dev:worker in a second terminal if you need indexing/background jobs too.
Polls the ERC-721 and ERC-1155 marketplace contracts every 6 seconds in batches of 500 blocks. Each tick:
- Fetches
OrderCreated,OrderFulfilled,OrderCancelled(both contracts) and ERC-721Transferevents - Parses felt data (including Cairo ByteArray token URIs)
- Writes to PostgreSQL atomically and advances the cursor
- Enqueues
METADATA_FETCHandSTATS_UPDATEjobs
ERC-1155 partial fills: Order.remainingAmount tracks how many units remain after each fill. Orders stay ACTIVE until remainingAmount == 0, at which point they transition to FULFILLED. The handleOrderFulfilled1155 handler reads remaining_amount from the OrderFulfilled event data.
Polls the Job table every 2s with optimistic locking, exponential backoff, and a max of 3 attempts.
| Job | What it does |
|---|---|
METADATA_FETCH |
Resolves token_uri on-chain, fetches JSON from IPFS (Pinata → Cloudflare → ipfs.io fallback), stores on Token |
STATS_UPDATE |
Recomputes floor price, total volume, holder count, total supply for a Collection. Floor price is stored as "1.5 USDC" (human-readable + symbol), or null when the consideration token is unknown, keeping raw wei out of the column. |
COLLECTION_METADATA_FETCH |
Fetches collection name/symbol/baseUri on-chain; recovers image/description/owner from CREATE_COLLECTION intent typedData; upserts, so it can create new collection records from scratch |
METADATA_PIN |
Not yet implemented (Pinata free plan doesn't support pin_by_cid) |
Multi-tenant API with API key auth. All /v1/* routes require a valid x-api-key or Authorization: Bearer header.
GET /v1/orders List orders (status, collection, currency, sort, offerer, page, limit)
GET /v1/orders/:orderHash Single order
GET /v1/orders/token/:contract/:tokenId Active orders for a token
GET /v1/orders/user/:address All orders by user
GET /v1/tokens/owned/:address Tokens owned by address
GET /v1/tokens/:contract/:tokenId Token + metadata (?wait=true for JIT fetch)
GET /v1/tokens/:contract/:tokenId/history Transfer + order history
GET /v1/tokens/:contract/:tokenId/comments On-chain comments for token (page, limit; excludes hidden)
GET /v1/tokens/:contract/:tokenId/remixes Public remixes of token (page, limit)
GET /v1/collections All collections (sort, page, limit, isKnown, owner)
GET /v1/collections?sort=recent Sort: recent (default) | supply | volume | floor | name
GET /v1/collections?isKnown=true Verified collections only
GET /v1/collections?owner=:address Collections owned by address (includes collectionId)
GET /v1/collections/:contract Single collection
GET /v1/collections/:contract/tokens Tokens in collection
GET /v1/activities Global activity feed (type, page, limit)
GET /v1/activities/:address Activity by user
POST /v1/remix-offers Submit a custom license offer (SIWS token required)
POST /v1/remix-offers/auto Auto-approve offer for open-license assets (SIWS token required)
POST /v1/remix-offers/self/confirm Record completed self-remix (owner only, SIWS token required)
GET /v1/remix-offers List offers for authenticated user (?role=creator|requester, ?status, page, limit)
GET /v1/remix-offers/:id Single offer
POST /v1/remix-offers/:id/approve Creator approves offer (sets approvedCollection, SIWS token required)
POST /v1/remix-offers/:id/reject Creator rejects offer (SIWS token required)
POST /v1/remix-offers/:id/confirm Mark offer completed after mint (SIWS token required)
GET /v1/tokens/:contract/:tokenId/remixes Public remixes for a token (page, limit)
All remix-offer mutation endpoints require both a valid x-api-key header and Authorization: Bearer <siws-token>. The SIWS token is used to derive the caller's Starknet wallet address. Price/currency fields are visible only in responses to the creator or requester.
RemixOffer statuses: PENDING (awaiting creator), AUTO_PENDING (open-license, auto-approved), APPROVED (creator approved), COMPLETED (remix minted + listed), REJECTED, EXPIRED, SELF_MINTED (owner self-remix recorded).
GET /v1/search?q=... Search tokens + collections + creators (min 2 chars, max 50 results)
GET /v1/creators List creators (search, page, limit)
GET /v1/creators/by-username/:username Resolve username slug → creator profile
GET /v1/creators/:address Creator profile by wallet address
PATCH /v1/creators/:address/profile Update profile (SIWS token required)
The intent system handles SNIP-12 typed data signing flow for marketplace operations, and pre-signed calls for mint + collection creation.
POST /v1/intents/listing Create listing intent (SNIP-12)
POST /v1/intents/offer Create offer intent (SNIP-12)
POST /v1/intents/fulfill Create fulfill intent
POST /v1/intents/cancel Create cancel intent
POST /v1/intents/mint Pre-signed mint calls (no SNIP-12)
POST /v1/intents/create-collection Pre-signed collection deployment
GET /v1/intents/:id Get intent status
PATCH /v1/intents/:id/signature Submit SNIP-12 signature
GET /v1/metadata/signed-url Pinata presigned URL (30s TTL)
POST /v1/metadata/upload Upload JSON to IPFS → ipfs:// URI
POST /v1/metadata/upload-file Upload file to IPFS (multipart)
POST /v1/metadata/upload-directory Upload a directory of JSON files → ipfs:// base URI
GET /v1/metadata/resolve?uri=... Resolve ipfs://, data:, https://
GET /v1/portal/me Tenant profile + plan
GET /v1/portal/keys API keys
POST /v1/portal/keys Create API key (plaintext shown once)
DELETE /v1/portal/keys/:id Revoke key
GET /v1/portal/usage 30-day usage by day
GET /v1/portal/webhooks List webhooks (PREMIUM)
POST /v1/portal/webhooks Create webhook (PREMIUM, secret shown once)
DELETE /v1/portal/webhooks/:id Delete webhook (PREMIUM)
POST /admin/tenants Create tenant + initial API key
GET /admin/tenants List all tenants
PATCH /admin/tenants/:id Update plan or status
POST /admin/tenants/:id/keys Create additional API key for tenant
DELETE /admin/keys/:keyId Revoke any key
GET /admin/usage Usage stats (?tenantId, ?days up to 90)
POST /admin/tokens/:contract/:tokenId/refresh Force metadata re-fetch (bypasses queue)
POST /admin/collections Register collection by address
PATCH /admin/collections/:contract Update isKnown, owner, or metadata
POST /admin/collections/backfill-metadata Enqueue COLLECTION_METADATA_FETCH for all pending/failed collections
POST /admin/collections/backfill-registry Scan all CollectionCreated events on-chain and upsert missing collections
POST /admin/collections/:contract/refresh Force COLLECTION_METADATA_FETCH for one collection
POST /admin/collections/:contract/stats-refresh Force STATS_UPDATE for one collection
GET /admin/comments List comments (?hidden=true|false, ?author, ?contract, page, limit)
PATCH /admin/comments/:id/hide Set isHidden = true
PATCH /admin/comments/:id/show Set isHidden = false
| Plan | Limit | Window |
|---|---|---|
| FREE | 50 requests | per calendar month |
| PREMIUM | 3,000 requests | per minute |
Response headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
Backed by Redis (REDIS_URL) when set, so the limit is shared correctly across API
replicas; falls back to in-process memory otherwise (fine for a single replica, but each
replica then counts independently). Fails open on any store error — the rate limiter
must never be the reason a request fails.
| Layer | Technology |
|---|---|
| Runtime | Bun |
| Web Framework | Hono v4 |
| Database | PostgreSQL + Prisma v5 |
| Blockchain | starknet.js v6 |
| IPFS | Pinata SDK v2 |
| Logging | pino |
| Deployment | Railway |
| Symbol | Type | Address | Decimals |
|---|---|---|---|
| USDC | Circle-native (canonical) | 0x033068f6539f8e6e6b131e6b2b814e6c34a5224bc66947c47dab9dfee93b35fb |
6 |
| USDT | Tether | 0x068f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8 |
6 |
| ETH | Ether | 0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 |
18 |
| STRK | Starknet native | 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c988d |
18 |
| WBTC | Wrapped Bitcoin | 0x03fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac |
8 |
USDC.e (bridged Starkgate) was removed from the active token list. Its address is retained in
serialize.tsas a legacy read entry for existing orders denominated in USDC.e.
| Contract | Address |
|---|---|
| Marketplace Protocol (ERC-721) | 0x03eda9a2b6ad90845a43591bac8083ebaf677d51fdf20f503b2c01889e3131fc |
| Marketplace Protocol (ERC-1155) | 0x07c4ce1c19ea48cc11135ed22b19ff745f5aec508c3828593002e4f76fdb1b38 |
| Collection Protocol (ERC-721 registry) | 0x0225c3ae09506b8d97adc39649ca740dad5aac195b7f5f0441cc1852947acaea |
| Collection Protocol (ERC-1155 factory) | 0x015368976d46fae5bfa1c58600f641d5aa5dbbf53ebc6b78aa3922194aad3551 |
| NFTComments | 0x02cdac70c94447189af0389dfea63f4d5e4154ea8a563de288a5ab1c39e37843 |
| Indexer start block | 11198146 |
This is a partial list. The full current registry, including Launchpad services
(IP Tickets, IP Club, IP Sponsorship, POP, Collection Drop) and Creator Coin,
all of which this indexer also mirrors, lives in @medialane/sdk's
src/chains.ts (getCoordinates("STARKNET")), the single source of truth.
git clone https://github.com/medialane-io/medialane-backend
cd medialane-backend
bun install
# Database setup
bunx prisma migrate dev
bunx prisma generate
# Start
bun dev| Variable | Notes |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
ALCHEMY_RPC_URL |
Starknet mainnet RPC |
PINATA_JWT |
Pinata JWT for metadata uploads |
PINATA_GATEWAY |
Gateway domain used server-side to resolve token/collection metadata JSON during indexing (not for serving images — apps resolve those directly via Pinata's public gateway) |
API_SECRET_KEY |
Min 16 chars, used for admin routes auth |
CORS_ORIGINS |
Comma-separated allowed origins (e.g. https://medialane.io,https://www.medialane.io) |
Optional env vars (all have sensible defaults):
| Variable | Default | Notes |
|---|---|---|
INDEXER_START_BLOCK |
9196722 |
Block to start scanning marketplace events from |
COLLECTION_START_BLOCK |
8660000 |
Block to start scanning CollectionCreated events from. Update to first block of the current collection registry contract when upgrading. |
INDEXER_POLL_INTERVAL_MS |
6000 |
Mirror poll cadence |
INDEXER_BLOCK_BATCH_SIZE |
500 |
Blocks per indexer tick |
STARKNET_NETWORK |
mainnet |
mainnet or sepolia |
MARKETPLACE_721_CONTRACT_MAINNET |
current audited address | ERC-721 marketplace protocol override |
MARKETPLACE_1155_CONTRACT_MAINNET |
current audited address | ERC-1155 marketplace protocol override |
COLLECTION_721_CONTRACT_MAINNET |
current audited address | ERC-721 mint / collection registry override |
COLLECTION_1155_CONTRACT_MAINNET |
current audited address | ERC-1155 mint / collection factory override |
REDIS_URL |
none — falls back to in-process memory | Shared store for API-key rate limiting. Required for correct limits once the API runs more than one replica (each replica's in-memory counter is otherwise independent); a Redis outage fails open (requests proceed, a warning is logged) rather than failing the request. |
bun dev # Watch mode — REST API only
bun start # Production — REST API only
bun run dev:worker # Watch mode — mirror indexer + orchestrator loops
bun run start:worker # Production — mirror indexer + orchestrator loops
bun run db:migrate # Prisma migrate dev
bun run db:generate # Regenerate Prisma client
bun run db:push # Push schema (no migration file)
bun run db:studio # Prisma Studio at localhost:5555
bun run reset-cursor # Reset indexer cursor to start block (mirror replays the window)The REST API (src/index.ts) and the mirror indexer + background orchestrator loops
(src/worker.ts — reaper, webhook delivery, metadata retry, rewards recompute,
wallet-activity refresh) are two separate entrypoints from the same codebase. Locally you
typically only need bun dev; the worker only matters if you're touching indexing or the
background loops.
Modern OpenZeppelin ERC-721 contracts return token_uri as a Cairo ByteArray struct. The ABI must include the core::byte_array::ByteArray struct definition alongside the function entry; otherwise starknet.js v6 drops pending_word bytes, truncating IPFS CIDs into invalid ones. The backend tries ByteArray ABI first, then falls back to felt array for legacy contracts.
OrderCreated events only include order_hash in the keys, so full order parameters are fetched by calling get_order_details(order_hash) on-chain. Bid orders (ERC20 → ERC721) derive nftContract from the consideration side.
All route handlers apply normalizeAddress(chain, address) (src/utils/starknet.ts, re-exported from @medialane/sdk) to every address parameter before DB queries. Chain-dispatched (v0.37.0): Starknet pads to 0x + 64 lowercase hex; EVM uses EIP-55; Solana base58. DB stores each chain's canonical form. Today every live caller is Starknet ("STARKNET"); the chain dimension is wired so non-Starknet assets/accounts slot in without a rewrite.
Prisma fields startTime, endTime, and createdBlockNumber are stored as String in the DB (Starknet felts). Always run orders through the serializeOrder() / serializeToken() helper functions before returning them in API responses.
priceRaw is a String column, so sorting stays on $queryRaw with an ::numeric NULLS LAST cast.
GET /v1/collections supports a sort query param with values: recent (default, createdAt DESC), supply (totalSupply DESC), name (name ASC), floor (floorPrice::numeric ASC NULLS LAST, raw SQL), volume (totalVolume::numeric DESC NULLS LAST, raw SQL). Floor and volume use $queryRaw because the columns are stored as String in the DB. Page/limit are clamped: limit = min(100, max(1, …)), page = max(1, …).
Production on Railway, as two services from this one repo:
medialane-backend— the REST API. Config-as-code:railway.json.Migrations run automatically on every deploy. Health check:bun run scripts/pre-migrate.ts; bunx prisma migrate deploy; bun run src/scripts/seed-rewards.ts; bun run src/scripts/seed-pricing.ts; bun run src/scripts/apply-pricing.ts; bun run src/index.tsGET /health(60s timeout).medialane-worker— the mirror indexer + background orchestrator loops. Config-as-code:railway.worker.json.No HTTP server, so no health check path — Railway just monitors the process.bun run scripts/pre-migrate.ts; bunx prisma migrate deploy; bun run start:worker
They were split into separate services on 2026-08-25: continuous background work (block indexing, rewards recompute, etc.) scales with total platform history, not request volume, and sharing one process's memory with live API traffic meant a spike in either could (and did) OOM-kill both.
After adding or changing environment variables in Railway, manually trigger a redeploy on whichever service(s) need them to pick them up.
| Repo | Description |
|---|---|
| medialane-io | Consumer app: Media Wallet, email login, sponsored transactions |
| medialane-starknet | Wallet-sovereign Starknet app: creator launchpad + marketplace |
| medialane-sdk | TypeScript SDK (@medialane/sdk), wraps this API |
| medialane-portal | Developer portal (API keys, docs, webhooks) |