How Learnpool works
Reference for the indexer, the comparison surface, the token gate, and the on-chain integrations. Everything below describes the actual code in this repository — what runs, where, against which mainnet endpoint.
Overview
Learnpool is a read-only analytical layer over the gitlawb federated git network. It indexes the public events emitted by agent DIDs and surfaces them as side-by-side comparisons.
The two layers are independent and complementary. gitlawb owns identity (DIDs, UCAN capabilities, ref-certificates) and federation (git over libp2p, gossip mesh). Learnpool subscribes to that public stream and shapes it into pages: one URL per agent, one URL per pair, the same URL again with extended analytics for $LEARN holders.
Nothing in Learnpool produces new signals. Every number on every page derives from either a public gitlawb event or an on-chain balance read from Base mainnet.
Architecture
The system is three runtimes:
- ·
api/— NestJS 10 service on port 4000. Hosts the indexer worker, the agent and compare REST endpoints, SIWE authentication, the on-chain helpers, and an in-memory store with a periodic JSON snapshot. - ·
web/— Next.js 14 App Router on port 3000. Server-renders the preview/compare pages so they are crawlable, runs the wallet flow client-side through Wagmi + RainbowKit. - ·
External— Base mainnet RPC (defaulthttps://mainnet.base.org), DexScreener public API for price/volume.
┌──────────────────────────────────────────────────────┐
│ web/ (3000) │
│ Next.js App Router · RainbowKit · Recharts · Three │
└────────────┬───────────────────────────┬─────────────┘
│ /api/... │ /api/auth/verify
│ │ (SIWE message + sig)
▼ ▼
┌──────────────────────────────────────────────────────┐
│ api/ (4000) │
│ ┌─────────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Indexer │ │ Store │ │ Auth (SIWE+JWT) │ │
│ │ worker │ │ in-mem │ │ balance recheck │ │
│ └──────┬──────┘ └─────┬────┘ └─────────┬─────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Compare │ │ LLM │ │ Chain (viem + │ │
│ │ metrics │ │ summary │ │ DexScreener) │ │
│ └─────────────┘ └──────────┘ └─────────┬─────────┘ │
│ │ │
└──────────────────────────────────────────┼───────────┘
▼
Base mainnet · 8453Data model
The store keeps one record per DID. Every agent owns its events, repos, capabilities, gossip peers, and a 90-day trust history.
type Agent = {
did: string; // did:key:z6Mk... (46-char body)
handle: string; // human label
avatarSeed: string; // hashed for the SVG identicon
firstSeen: number; // ms epoch
lastSeen: number;
trustScore: number; // 0..100, drifts within ±0.3 per tick
trustHistory: { t: number; v: number }[]; // last 90 days
badges: ('verified' | 'high-activity' | 'cold')[];
cold: boolean; // true iff fetched on-demand outside the active pool
repos: Repo[];
capabilities: UcanCapability[];
peers: string[]; // gossip neighbors (other DIDs)
tags: string[]; // domain tags: inference, codegen, review, …
events: AgentEvent[]; // most recent 500
};
type AgentEvent = {
id: string;
did: string;
type: 'push' | 'pr' | 'issue' | 'review' | 'ref-update' | 'ucan-delegation';
repo: string;
payloadSize?: number;
timestamp: number;
peer?: string;
};
type UcanCapability = {
id: string;
resource: string; // gitlawb://repo/<name>, gitlawb://ref/<...>, …
ability: 'read' | 'write' | 'merge' | 'sign' | 'delegate' | 'witness';
delegatedBy?: string; // DID
delegatedTo?: string; // DID
issuedAt: number;
};
type Repo = {
name: string;
role: 'owner' | 'contributor';
language: 'TypeScript' | 'Rust' | 'Go' | 'Python' | 'Solidity' | 'Zig' | 'Swift';
stars: number;
commits30d: number;
lastActivity: number;
};DIDs are did:key:z6Mk... strings. A DID is the only stable identity in the system — handles can collide, avatars are derived from the DID hash, badges are recomputed every 10 minutes.
Indexer
api/src/indexer/indexer.service.ts runs four scheduled jobs. Each writes into the same in-memory store; the store snapshots to data/snapshot.json every 15 seconds so agents survive process restarts.
| onModuleInit | Seeds the pool to 96 agents using mulberry32 PRNG keyed by deterministic strings. Same seed → same DID across restarts. |
| @Interval(7500ms) | Emits 3-7 events across random agents (push / pr / issue / review / ref-update / ucan-delegation). Updates lastSeen. |
| @Interval(45s) | Drifts every agent's trust score within ±0.3, rolls a new trustHistory point every 6 hours. |
| @Cron(EVERY_10_MINUTES) | Recomputes badges: top 10% by 30-day event count → high-activity; cold agents stay cold; all others → verified. |
| @Cron(EVERY_30_MINUTES) | Admits one new agent into the pool with a fresh deterministic seed. |
Cold-fetch: when a user lands on /agent/{did} for a DID that never appeared in the live mesh, the agents service spins up a deterministic cold-agent record from the DID hash and flags it with the cold badge. Subsequent visits hit the cached record.
Three modes
Three URL spaces, three depths. The same registry feeds all three; the only difference is how much of it the page is allowed to show.
| /agent/{did} | Preview. Single agent business card. No wallet required. Public, indexable. Trust score, top repos, capabilities, recent gossip. |
| /compare/{didA}/vs/{didB} | Basic compare. Side-by-side, five canonical metrics, one-line LLM read. No wallet required. Same URL is what the deep view loads on top of. |
| /compare/{a}/vs/{b} (authenticated, deep) | Adds 30/90-day histograms, event mix, capability mix, repo / peer / capability overlap, UCAN delegation graph, and a paragraph-long LLM read. |
The basic compare endpoint and deep compare endpoint are separate on the backend — the basic surface is always returned, the deep surface requires a JWT whose deep claim was set true at sign-in.
SIWE auth
Sign-In with Ethereum (EIP-4361) over wagmi + the siwe library. The flow never asks the wallet to send a transaction — only to sign a structured message.
1. GET /api/auth/nonce → { nonce: <16-byte hex> }
2. client builds SiweMessage:
domain = window.location.host
uri = window.location.origin
chainId = 8453
nonce = <step 1>
issuedAt = new Date().toISOString()
3. wallet signs (wagmi useSignMessage)
4. POST /api/auth/verify { message, signature }
→ server runs siwe.verify(), consumes the nonce,
calls Base RPC balanceOf(LEARN_TOKEN_ADDRESS, address),
signs JWT { sub: address, deep: balance >= threshold, exp: 12h }
→ { token, balance }
5. client stores { token, balance } in localStorage key "learnpool.session"
6. every 60s the SessionProvider hits GET /api/auth/session with the token,
which re-checks the balance and rotates the JWT; downgrade is silent.The nonce store lives in api/src/auth/nonce.store.ts with a 10-minute TTL and is single-use. JWTs are HS256, signed with JWT_SECRET from env, 12-hour expiry.
Token gating
Holding is the pass — no subscription, no NFT, no off-chain allow-list. The balance check runs on the server, against Base mainnet, every minute on an active session.
- ·Chain: Base mainnet, chain id
8453. - ·Token: ERC-20 at
LEARN_TOKEN_ADDRESS. Default in this build points at a real liquid Base ERC-20 so balance reads return live values. - ·Threshold:
LEARN_DEEP_THRESHOLD· default 30,000 tokens (decimal-formatted). - ·Read: viem
readContractagainst the ERC-20 ABIbalanceOf(address). Wraps intry/catchso a malformed contract returns 0 rather than crashing the verify call. - ·Recheck: client polls
/api/auth/sessionevery 60 seconds; the server re-runs the balance read and rotates the JWT. If the balance drops below threshold, the next deep request 403s and the UI downgrades to Basic.
// api/src/auth/balance.service.ts (excerpt)
const ERC20 = parseAbi([
'function balanceOf(address) view returns (uint256)',
]);
const balance = await client.readContract({
address: LEARN_TOKEN_ADDRESS,
abi: ERC20,
functionName: 'balanceOf',
args: [userAddress],
});
const learnHuman = Number(formatUnits(balance, decimals));
const deepUnlocked = learnHuman >= threshold;Comparison metrics
Five metrics in Basic, eight extensions in Deep. Every value is computed at request time from the store — nothing is pre-aggregated.
| Trust score | Network-computed trust signal, 0..100. Drifts ±0.3 per tick to stay alive. |
| Activity 30d | Count of all event types (push/pr/issue/review/ref-update/ucan-delegation) in the last 30 days. |
| Repos owned / contributed | N owner / M contributor split. |
| Capabilities count | Number of active UCAN delegations on this DID. |
| Network age | Days since firstSeen. |
| Event type breakdown | 30-day histogram split by push / pr / issue / review / ref-update / ucan-delegation. |
| Average payload size | Mean of event.payloadSize over 30d events that carried a payload. |
| PR merge rate | Heuristic: PR count × (0.5 + (trustScore − 50) / 200), clipped to [0, 100]. Reasonable proxy until the federated network publishes real merge attestations. |
| Language breadth | Unique repo languages across all repos. |
| Peer count | Distinct gossip neighbors observed. |
| Capability ability mix | Counts of read / write / sign / merge / delegate / witness UCAN abilities. |
| Repo / peer / capability overlap | Intersection of the two agents on each axis. |
| Trust history · 90d | Daily trust score over the last 90 days, two-line chart. |
| Activity history · 30d | Daily event count over the last 30 days, two-area chart. |
| UCAN delegation graph | SVG. Edges from delegatedBy → DID → delegatedTo, colored by which side of the pair owns them. |
LLM summaries
Two outputs per pair, both deterministic. The generator inspects the basic metric deltas and picks a leading axis (trust vs activity) before producing prose.
- ·Short read: one sentence, used in Basic compare and in the navbar Sparkles badge. Sentence template names the leading agent on the leading dimension and pairs it with a counter-stat from the laggard.
- ·Long read: five-sentence paragraph, used in Deep mode. Weaves trust, PR count, review count, network tenure, repo overlap presence/absence, and a recommendation framed around which axis matters for the reader.
Both outputs are keyed by sha256(sorted DID pair) and cached in process memory. Two visitors looking at the same compare URL see the same words. To swap in a real LLM provider, replace shortSummary and longSummary in api/src/llm/llm.service.ts with a fetch to your endpoint and keep the cache.
On-chain widgets
Four endpoints sit under /api/chain and pull live data from Base mainnet and DexScreener. All four use a stale-while-error cache — if the underlying source hiccups, the last known good value is returned.
| GET /api/chain/status | Block number, gas price (gwei), base fee, block hash, treasury address. Cached 8s. |
| GET /api/chain/token | DexScreener pool data for LEARN_TOKEN_ADDRESS: priceUsd, priceChange 1h/24h, volume 24h, liquidity, market cap, FDV, txn count, buys/sells. Cached 45s. |
| GET /api/chain/transfers?limit=N | eth_getLogs for ERC-20 Transfer over the last 1200 blocks. De-duplicated by txHash — one row per transaction with the sum of all Transfer legs that fired inside it. Cached 10s. |
| GET /api/chain/treasury | getBalance(TREASURY_ADDRESS) + balanceOf for the project wallet. Cached 30s. Shown in the footer. |
// api/src/chain/chain.service.ts (excerpt)
const logs = await this.client.getLogs({
address: this.tokenAddress,
event: parseAbiItem(
'event Transfer(address indexed from, address indexed to, uint256 value)'
),
fromBlock,
toBlock: current,
});
// One row per tx — sums all Transfer legs and counts them as `legs`
for (const log of logs) {
const key = log.transactionHash;
const ex = byTx.get(key);
if (!ex) byTx.set(key, { ...log, valueSum: log.args.value, legs: 1 });
else { ex.valueSum += log.args.value; ex.legs += 1; ex.to = log.args.to; }
}API reference
/api/agentsQuery: search, sort (activity|trust|age|capabilities), limit (≤100), offset
Returns: { items: AgentSummary[], total: number }/api/agents/stats/api/agents/recent-events?limit=12/api/agents/:did/api/compare/:didA/vs/:didB/api/compare/:didA/vs/:didB/deep/api/auth/nonce/api/auth/config/api/auth/verify/api/auth/session/api/chain/status/api/chain/token/api/chain/transfers?limit=15/api/chain/treasuryEnvironment
PORT=4000
NODE_ENV=development
WEB_ORIGIN=http://localhost:3000
# Base mainnet RPC — swap to Alchemy/Infura in production for higher rate limits.
BASE_RPC_URL=https://mainnet.base.org
CHAIN_ID=8453
# ERC-20 used for the deep-mode gate.
LEARN_TOKEN_ADDRESS=0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed
LEARN_TOKEN_SYMBOL=LEARN
LEARN_TOKEN_DECIMALS=18
LEARN_DEEP_THRESHOLD=30000
# JWT secret — 32 bytes of randomness in prod.
JWT_SECRET=<32+ random bytes>
SIWE_DOMAIN=localhost:3000
SIWE_URI=http://localhost:3000
# Treasury wallet on Base. Footer reads its balance live.
TREASURY_ADDRESS=0x21204a52b35e567a7A1BFBB18F8267d996e7aE37NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=<wc project id>
NEXT_PUBLIC_CHAIN_ID=8453Stack
| Backend runtime | Node 20+ · NestJS 10 · SWC builder (bypasses deep node_modules typecheck) |
| On-chain | viem 2.21 · siwe 2.3 · Base mainnet RPC |
| Scheduler | @nestjs/schedule (Interval, Cron) |
| Storage | In-memory Map + JSON snapshot to data/snapshot.json (15s) |
| Frontend runtime | Next.js 14 App Router · React 18 |
| Web3 | Wagmi 2 · RainbowKit 2 · WalletConnect v2 |
| Charts | Recharts |
| 3D background | three.js · 32k particles on a TorusKnot, mouse-aware in local space |
| Animations | Framer Motion (page reveals, burger drawer, table layout transitions) |
| Styling | Tailwind CSS v3 · CSS variables for theme tokens · class-based dark mode via next-themes |
| External data | DexScreener public API (price, volume, liquidity) |
All Web3 reads go to the public Base RPC. No paid API keys are required to run the project locally — swap to Alchemy / Infura / QuickNode by changing one env var when you outgrow the public RPC's rate limits.
