An open score anyone can compute: enter a wallet (+ optionally a GitHub handle) and get a Builder Score computed entirely in the browser from public data — badges and token holdings via RPC, GitHub via its public API — with an optional one-click EAS attestation on Base. No backend, no database of people, no accounts.
Live at the-final-app-wine.vercel.app (Base Sepolia attestations while in POC).
Context docs (internal):
- What It Is (and Isn't) — concept, math, product shape
- Credential Feasibility — what's computable and what isn't
- A browser can compute a defensible score from public sources alone (no Talent infra).
- The result is explainable — per-credential breakdown with the exact math shown.
- The result is attestable — an EAS attestation that anyone can verify by recomputing.
Out of scope for the POC (deliberately): multi-wallet aggregation, GitHub sign-in (OAuth), Tier 2 explorer-backed credentials, verifier view, embeddable widget, percentile context.
- Next.js 16 (App Router, TypeScript, Tailwind v4) — standard runtime, but everything
meaningful is client-side (
"use client"): no server state. The only server code is the three GitHub OAuth routes under/api/github/*, which exist because the token exchange needs a client secret the browser must never see. The engine stays framework-free (src/lib/engine.ts). - viem for RPC, with Multicall3 batching
(
0xcA11bde05977b3631167028862bE2a173976CA11, same address on every chain) - RainbowKit + wagmi for wallet connection — needed only for the attest step; scoring never requires a connected wallet. wagmi is pinned to 2.x (RainbowKit peer dep).
@ethereum-attestation-service/eas-sdkfor attestation- Vitest for the engine's golden tests
- Public RPC endpoints with a fallback list (no API keys anywhere in the repo; the WalletConnect projectId is a public client identifier, not a secret)
- Full rationale:
docs/superpowers/specs/2026-07-24-open-builder-score-stack-design.md
Per credential:
points = min(round(convert(value) * multiplier), max_score)
sum_allcredentials: sum raw values across accounts first, then convert, then multiply. (Single wallet in the POC makes this trivial, but implement the order correctly.)max_valuecredentials: convert × multiply per account, take the best.- Conversions:
no_conversion— identitysqrt— square rootlog— natural log (ln)timestamp_to_year—(now - timestamp) / SECONDS_IN_A_YEAR, rounded to 2 decimals, withSECONDS_IN_A_YEAR = 31_536_000(365 days — production constant, pin it in spec.json)
- Total score = Σ credential points. For determinism, "now" and balances are taken at an as-of anchor (timestamp + block number) that also goes into the attestation.
The authoritative machine-readable versions live in
spec/spec.json(weights + math) andspec/badge-registry.json(contract addresses, extracted from production).
Every credential carries a status: active (scored), excluded (computable, deliberately
not scored) or deferred (wanted, not computable yet). Multipliers started from the
finalized 2025 season's; deviations are deliberate and recorded in the spec's changelog
(0.3.0: finalist re-cut, attainable Base Devfolio caps, github_repositories halved,
sum_all retired from the active set). /credentials renders the scored set only. The
excluded and deferred ones keep their status_reason in spec.json, and the spec
tests still prove every credential is accounted for, but the page no longer argues either
case.
| slug | max | multiplier | conversion | calc | check |
|---|---|---|---|---|---|
| eth_global_hacker | 12 | 12.0 | none | max | Hacker Pack NFT (Optimism) |
| eth_global_builder | 20 | 20.0 | none | max | Builder Pack NFT (Optimism) |
| eth_global_pioneer | 10 | 10.0 | none | max | Pioneer Pack NFT (Optimism) |
| eth_global_partner | 12 | 12.0 | none | max | Partner Pack NFT (Optimism) |
| eth_global_finalist | 30 | 15.0 | sqrt | max | distinct finalist NFTs across 19 per-event contracts (Optimism) — the judged-results curve, mirrors devfolio_hackathons_won |
| devfolio_hackathons_participation | 20 | 10.0 | sqrt | max | distinct event SBTs (Base/Arb/Polygon) |
| base_devfolio_hackathons_participation | 17 | 10.0 | sqrt | max | 3 event SBTs (Base) — cap = round(10×√3), the frozen manifest's true maximum |
| buidl_guidl_batches_graduate | 20 | 20.0 | none | max | 12 batch SBTs (OP + Arb) |
| talent_protocol_verified_builder | 20 | 20.0 | none | max | EAS attestations (Base + Celo, via easscan GraphQL) |
Plus one public-API credential: buidl_guidl_speedrun_ethereum (12 / 1.0 / none / max) —
not onchain; it's BuidlGuidl's public API counting ACCEPTED challenges per wallet.
Formerly deferred, activated once their allowlists could be rebuilt from chain data: scored
from those allowlists
(public/nft-credentials/ shards, generated by scripts/build-nft-credential-allowlists.mjs),
because their checks need token-metadata enumeration a browser can't do. The "won" variants
use the same contracts as participation but filter token metadata (nft_type == "WINNER")
— a win intentionally also earns participation (win ⊃ participate).
| slug | max | multiplier | conversion | calc | check |
|---|---|---|---|---|---|
| devfolio_hackathons_won | 30 | 15.0 | sqrt | max | WINNER tokens across the 11 participation contracts |
| base_devfolio_hackathons_won | 26 | 15.0 | sqrt | max | WINNER tokens, 3 Base contracts — cap = round(15×√3) |
| base_basecamp | 20 | 20.0 | none | max | 2 attendee SBTs (ERC-1155; application-gated builder program, which is why it survives the attendance exclusion below) |
A Builder Score should measure building. These score attendance, membership, a buyable
balance, or testnet activity, so they're carried in spec.json with status: "excluded"
and a reason rather than deleted — the contracts stay documented and the cut stays legible.
| slug | would-be max | why not |
|---|---|---|
| farcaster_farcon_nyc_2025_attendee | 12 | conference attendance is a ticket purchase |
| crypto_nomads_club | 12 | community membership, not building |
| developer_dao_member | 8 | a $CODE balance, buyable on the open market |
| talent_protocol_talent_holder | 8 | a $TALENT balance — buyable, and our own token |
| talent_vault | 8 | a $TALENT deposit — buyable, and our own token |
| base_learn | 13 | completion SBTs live on Base Sepolia; testnet is cheap to farm |
The re-cut that dropped these — because the scan's chain set is derived from the active RPC slugs — retired two whole chains: Ethereum went with CNC and $CODE, Base Sepolia with Base Learn. Four chains instead of six means fewer ways for a scan to come back incomplete, which is what gates attestation.
Encode uses one contract with programme-type attributes, but the only source that serves the metadata in bulk truncates the collection (8,910 of 14,432 tokens) and contradicts its own holder count, so no build of it can be validated:
| slug | max | multiplier | conversion | calc |
|---|---|---|---|---|
| encode_programmes_participations | 20 | 10.0 | sqrt | max |
| encode_programmes_won | 30 | 15.0 | sqrt | max |
Also skipped: developer_dao_og (historical balance at block 13612670).
| slug | max | multiplier | conversion | calc | source |
|---|---|---|---|---|---|
| github_account_age | 8 | 1.0 | timestamp_to_year | max | GET /users/:handle created_at |
| github_followers | 6 | 1.0 | sqrt | max | GET /users/:handle followers |
| github_stars | 6 | 0.5 | sqrt | max | sum stargazers_count over repos |
| github_forks | 12 | 2.0 | sqrt | max | sum forks_count over repos |
| github_repositories | 4 | 1.0 | sqrt | max | public repo count (approximation — production counts repos contributed to; note it in the UI). Halved in 0.3.0: empty repos are the cheapest farm in the set |
Unauthenticated limit is 60 req/hr per IP: 1 user call + paginated repos (100/page) is fine for self-checks. Handle 403 rate-limit responses with a friendly message.
- 1a. Spec data —
spec/spec.json(credentials, weights, math, tiers) ✅ generated from the production scorer dump. - 2. Badge registry —
spec/badge-registry.json✅ generated from productionTrackedNFT+ theapp/services/data_points/*contract maps. - 1b. Scaffold — Next.js 16 app via
create-next-app✅ (spec files get imported as JSON modules in phase 3). - 3. Engine —
src/lib/engine.ts: purecomputeScore(inputs, spec) → {total, perCredential[]}. ✅ No DOM, no fetch, no framework imports; Vitest golden vectors (197/285 across the 18 active credentials). - 4. Chain reads —
src/lib/chains.ts✅: one Multicall3 round-trip per chain across 6 chains, public RPC fallback lists, cross-chain count merging, per-chain failure isolation ("couldn't check" ≠ "not earned"). - 5. GitHub reads —
src/lib/github.ts✅: the five metrics, paginated, graceful on 404/rate-limit. - 6. UI — ✅ form → total + credential cards (points, raw value, exact formula, earned / not-earned / couldn't-check states) + as-of anchor footer.
- 7. Attest — ✅ schema registered on Base Sepolia (schema
#2265,
UID
0x38b1a4ab5bee04789565591b11646eb0f5269096f65ef0b24e817f2b6168d1cd— deterministic:keccak256(schema ++ zero resolver ++ revocable), golden-pinned intest/eas.test.ts). Schema:string spec_version,address wallet,string github_handle,uint16 score,uint64 computed_at,uint64 block_number. E2E verified 2026-07-25 incl. the wrong-network switch path. Base mainnet registration deferred until after Sepolia validation (flipATTEST_CHAIN_IDinsrc/lib/eas.ts). - 8. Deploy — Vercel ✅ 2026-07-25: the-final-app-wine.vercel.app.
The only server-side code is the three GitHub OAuth routes (
/api/github/*), which hold no state;GITHUB_CLIENT_SECRETis set in the Vercel project, never committed. Deployed viavercel deploy --prodfrom localmain(no git integration yet — redeploys are manual). - 9. Aggregate attestation — a second EAS schema so a multi-wallet score can be
attested, with an EIP-712 ownership signature per extra wallet stored in the record.
Schema registered on Base Sepolia 2026-08-04 as schema
#2307,
UID
0x01d83b22aca3881b6673513b0e29fec6659a7def03c69fa41c55a16bcaf192a2— deterministic and golden-pinned intest/eas.test.ts, alongside the single-wallet schema #2265. Re-runnable vianode --env-file=.env scripts/register-aggregate-schema.mjs(preflight; add--sendto register). Still to do: end-to-end attest on a real multi-wallet score. See "Aggregate attestation" below.
SITE_ORIGIN in src/lib/routes.ts is hardcoded to https://talentprotocol.com, and that
origin is now written onchain in schema #2304's description, where it cannot be edited
(only superseded). It is deliberately not read from the environment: these URLs outlive the
deployment that minted them, and a VERCEL_URL would point at a preview that stops resolving.
It does not resolve yet. talentprotocol.com serves the main Talent app, whose root
app/[id]/ dynamic segment catches /verify and /score, and whose /api clashes outright.
Until talent-apps rewrites those paths to this app (or this app gets a basePath), the link
in the schema description is dead. Fixing that is the last step to making the onchain pointer
genuinely useful.
Scoring across up to 5 wallets already worked; attesting the result did not, because the single-wallet schema anchors one address and nothing proved the user owned wallets 2–5. Without that proof anyone could borrow a whale's address into their aggregate.
Schema v2 (ATTEST_AGGREGATE_SCHEMA, Base Sepolia schema #2307) keeps every v1 field and adds
the wallet set with its proofs, plus a pointer back into this app:
string spec_version,address wallet,address[] extra_wallets,bytes[] ownership_proofs,string github_handle,uint16 score,uint64 computed_at,uint64 block_number,string verify_url,string[] badges
verify_url opens the verification view, not a fresh scoring run. Someone reading an
attestation wants to see what was verified, not start a new computation — so it points at
/verify/wallet/<recipient>, which resolves to that wallet's most recent attestation and hands
off to the verify screen.
It is keyed on the wallet rather than on the attestation's own UID because an attestation can
never contain a link to itself, for two independent reasons, both confirmed by recomputing a
live attestation's UID from its fields: the UID hashes the record's own data, and it also
hashes block.timestamp, which isn't known until the transaction is mined. The URL is built by
the app's own router (absoluteUrl(verifyWalletPath(…))) so it cannot drift from real routing.
badges records zero-point achievements, and the verifier says what each can rest on.
Badges never affect the score. Three of the four touch a dated Talent Protocol export, so the
verify screen classifies rather than asserts — the attestation stores the slug but not which
check earned it:
| badge | evidence | shown as |
|---|---|---|
| Launched a Talent Token | public |
re-derivable from public chain history |
| $BUILD Contributor | mixed |
earned by a live onchain read or by a dated export — the record does not say which |
| Builder Score 100+ | export |
rests on a dated Talent Protocol export — recorded, not independently checkable |
| Earned Builder Rewards | export |
(same) |
Only talent_token_launched is fully permissionless: its allowlist is rebuilt from
TalentCreated events on Celo and Polygon, and anyone re-running
scripts/build-talent-token-allowlist.mjs gets the same list. $BUILD Contributor is an OR of
donated(address) > 0 and the pay-it-forward export, so calling it public would overclaim for
anyone who earned it only through the export. classifyAttestedBadges in src/lib/badges.ts
draws these lines; an unknown slug stays visible and is classified at the cautious end.
Note easscan renders these as plain text, not links — it does not autolink attestation values.
Three earlier cuts were superseded: #2304 (no URL field, 0 attestations), #2305
(verify_url_prefix, 1 attestation) and #2306 (score_url, 1 attestation). Both #2305 and
#2306 stay decode-only in verify.ts so their attestations keep verifying — the same rule that
kept the single-wallet schema alive. A #2306 record surfaces verifyUrl: null, so the screen
never offers a link that recomputes instead of showing what was verified.
wallet stays the recipient — that keeps recipient == wallet, keeps isSelfAttested
unchanged, and leaves ownership_proofs[i] a clean 1:1 with extra_wallets[i]. The recipient
needs no proof; msg.sender is its proof. extra_wallets is stored in canonical (sorted,
deduped) order, so the onchain array is the array the verifier reconstructs against.
Each extra wallet signs this, once, on Base Sepolia:
domain { name: 'Open Builder Score', version: '2', chainId: 84532, verifyingContract: <EAS> }
message WalletOwnership { statement, wallet, recipient, wallets[], issuedAt, expiresAt }
- Only the signature is stored. The payload is reconstructed at verify time from fields already in the attestation, which is why the domain must be deterministic — no origin.
expiresAtis derived (issuedAt + 24h), so nothing extra is stored and nothing can be forged. Verification checks the attestation'stimeCreatedfalls inside that window;timeCreatedis recorded by EAS, and the attester cannot pick it.- No nonce is needed.
recipientand the whole wallet set are bound into the message, so a signature cannot be replayed into someone else's aggregate. - Signatures are stored verbatim, never unwrapped — a smart account returns an ABI-encoded wrapper, and while counterfactual an ERC-6492 one, which is exactly what makes it verifiable.
What verification can honestly claim, spelled out on the verify screen:
| signer | check | strength |
|---|---|---|
| EOA | recoverTypedDataAddress |
offline, permissionless, true forever |
| EIP-7702 delegated EOA signing with its key | recoverTypedDataAddress |
same — recovery succeeds even though the account has code |
| smart account (or a 7702 account returning a wrapped signature) | ERC-1271 / ERC-6492 via RPC | depends on the account's current owners or delegation |
Worth knowing: both wallets that have used the single-wallet schema on Base Sepolia are EIP-7702
delegated EOAs (delegate 0x63c0c19a…, ERC-1271 live), so the contract path is not hypothetical
here — and a 7702 delegation can be re-pointed or revoked, which is exactly the mutability the
ERC-1271 caveat is about.
ERC-1271 is a call to a contract whose owner set can change, so it answers "does this account
accept the signature today", not "did it at attest time". Public Base RPCs prune state, so
as-of-block verification needs an archive node; verifyOwnershipProofs takes an optional
blockNumber for anyone who has one, and defaults to latest. An RPC failure reports
unchecked, never invalid — the same "couldn't check ≠ not earned" rule the chain reads
follow.
Ownership is displayed as its own line and deliberately never reaches classifyAttestation
or scoreVerdict: score correctness and wallet ownership are independent facts. The
percentile corpus stays single-wallet only, since mixing 1-wallet and 5-wallet totals is not
like-for-like.
Zero-point achievements shown beside the score, defined in spec/badges.json. They add no
points, so they cannot move a total, change ScoreResult.complete, or alter what an
attestation says — and the verifier screen ignores them entirely, since a snapshot cannot be
re-derived at an as-of anchor.
| badge | source | how it's checked |
|---|---|---|
| $BUILD Contributor | live RPC + dated snapshot | donated(address) > 0 on Base 0x556e…FdB7, or a BUILD pay-it-forward donation |
| Launched a Talent Token | onchain history, frozen | membership in the v1 TalentFactory's TalentCreated history — Celo 0xa902…8246 + Polygon 0xa91b…fde0, 564 wallets |
| Builder Score 100+ | dated snapshot | membership in an export from Talent Protocol |
| Earned Builder Rewards | dated snapshot | membership in an export from Talent Protocol |
A badge can carry more than one check, OR-ed: earned by any one is earned. $BUILD Contributor uses two, because DataPoints::BuildContribution reads the BUILD airdrop
database first and only falls back to donated() — an allocation recorded there,
especially one on a custody wallet rather than the wallet a user would type in, is invisible
to the live read. That database is gone (BUILD_DATABASE_URL points at a Supabase project
that no longer resolves, matching build_contribution being marked Remove in the 2025
credential set), so the snapshot exports build_pay_forward_wallets instead — the
donors who gave their BUILD allocation away. Both sides of each pair ship, because the
donation sits on the custody wallet and custody wallets are not linked accounts, so that
table is the only place they come from.
Neither check subsumes the other: donated() records direct donations to the contract,
while pay-it-forward donations were recorded in the BUILD database. Sampling the export
against the contract, roughly a third of these wallets also show a non-zero donated() —
so the two overlap without either being complete.
The export also reads the stored build_contribution data point, but that population is
empty: the credential row was deleted when it was retired, and Credential has_many :data_points, dependent: :destroy took the values with it. The query stays for the day it
comes back.
The snapshots are exported profile-wide: every EVM wallet on a qualifying profile, not just the one the record sits on. A badge is a fact about a person, so whichever of their wallets they enter should match.
Why the Talent Token badge is not a live read. The obvious call is
talentsToTokens(wallet) on the factory, and on Polygon it works. On Celo it does not: that
older deployment never populated the talent → token direction, so it returns 0x0 even for
wallets whose createTalent succeeded, and hasTalentToken reverts outright. Only
tokensToTalents(token) answers there, which cannot be asked from a wallet address. Polygon
holds 20 of the 564 talents, so a live read would have quietly missed the entire Celo
cohort. Both factories have been dormant since July 2023 and v1 is closed, so the set is
frozen and shipped as data — still public and reproducible, unlike the two snapshots below:
node scripts/build-talent-token-allowlist.mjs # rebuilds spec/allowlists/talent-token-launched.jsonThe two snapshot badges have no permissionless source at all. Builder Score lives in Talent Protocol's database, and rewards are paid from a per-grant wallet through a per-grant multisend contract, so there is no stable distributor address to check. They ship as a dated export, and the UI labels them as such.
Regenerating the snapshots, from the bastion:
cd ../talent-api/terraform
./obs_badge_export # writes ../../open-builder-score/exports/*.txt
cd -
node scripts/build-snapshots.mjsobs_badge_export streams the export back over ssh rather than writing a file on the
bastion and copying it: dr runs docker run -it --rm with no volume mounts, so anything
the script writes inside the container dies with it, and the image is the deployed build —
it won't contain script/export_obs_badge_snapshots.rb until that branch ships. Piping the
script in on stdin and capturing stdout sidesteps both. The same script also runs the
ordinary way anywhere with a filesystem worth writing to:
bundle exec rails runner script/export_obs_badge_snapshots.rb tmp/obs_badge_snapshots
cp ../talent-api/tmp/obs_badge_snapshots/*.txt exports/That writes 256 shards per badge under public/snapshots/<slug>/ — keyed by the first byte
of the address, so the client fetches one small file rather than a multi-MB list — and
updates spec/snapshots.json with the export date shown in the UI. Empty shards are written
too: the client reads a 404 as "couldn't check", so a missing shard has to mean a broken
deploy, never "not earned". Until the first export lands, generated_at is null and both
snapshot badges honestly render as unavailable.
All three README-era unknowns were extracted from talent-api and live-verified (CORS open):
- SpeedRun Ethereum API —
GET https://speedrunethereum.com/api/user-challenges/<address>; count uniquechallengeIdwherereviewAction == "ACCEPTED". - TalentVault —
userBalanceMeta(address)returns(depositedAmount, lastRewardCalculation, lastDepositAt); production uses index 0 ÷ 1e18. - EAS GraphQL —
https://base.easscan.org/graphql+https://celo.easscan.org/graphql, query by checksummed recipient + schema UID, excluding revoked. (Production quirk: a Rubyany?short-circuit means prod effectively only queries Base; this POC follows the spec and queries both.)
Standard OAuth web flow: click, authorize on github.com, land back signed in. One tab, two clicks, nothing to type.
Signing in buys two things: it proves the GitHub handle going into a score is yours (the
attest panel enforces the match), and it lifts the GitHub API limit from 60 to 5,000 req/hr.
The token is scope-less and lives in sessionStorage, so it dies with the tab.
This is the one place the repo needs a secret. GitHub does not support PKCE, so
exchanging the callback code for a token requires GITHUB_CLIENT_SECRET. It is read only in
src/lib/github-oauth.ts, which is server-only — it never enters the client bundle, and it
is never committed. Scoring is unaffected either way: it is fully client-side and works
signed-out, so a deployment without the secret just reports that sign-in isn't configured.
| variable | where | required |
|---|---|---|
GITHUB_CLIENT_SECRET |
server only — .env.local, and Vercel project settings |
for sign-in |
NEXT_PUBLIC_GITHUB_CLIENT_ID |
optional override; the committed default is a public identifier | no |
Setup, once per environment:
- In the GitHub app's settings, add a Callback URL for each origin —
http://localhost:3000/api/github/callbackand the deployed equivalent. GitHub validatesredirect_uriagainst this list, so an unregistered origin simply fails. - Generate a client secret and put it in
.env.local(git-ignored) and in Vercel. - Nothing else — no scopes are requested, and no installation is required.
The flow is three small routes and no server-side state: /api/github/authorize mints a
CSRF state and redirects to GitHub; /api/github/callback verifies that state, does the
secret-bearing exchange, and parks {token, login} in a short-lived HttpOnly cookie;
/api/github/session hands that to the client once and clears it, so the token's resting
place stays sessionStorage.
This replaced an OAuth device flow, which needed no secret but asked the user to copy a code into a second tab — a TV/CLI affordance, not a web one. The secret is the price of the better flow; GitHub offers no secretless redirect.
-
The engine is deterministic: same inputs + same
spec.jsonversion → same score, always. -
spec.jsonis versioned; any weight/credential change bumps the version. Attestations carry the version they were computed with. -
Zero secrets in the repo and zero server-side state.
GITHUB_CLIENT_SECRETis the single exception, and it stays an environment variable read only by server code — GitHub has no PKCE, so a redirect sign-in cannot be done without one. Everything that computes a score still runs in the browser against public endpoints with no keys. -
Wallet ownership is proved by the attestation. Anyone may score any address — that's the point of an open score — but attesting requires the connected wallet to be the scored wallet. EAS records the attester as
msg.sender, so the transaction itself is the proof and anyone can check it afterwards by comparingattesterto the attestedwallet(isSelfAttestedinsrc/lib/verify.ts, surfaced on the verify screen).Attesting an aggregate requires every wallet in the set to be proven: the wallet that sends the transaction is proven by
msg.sender— EAS records it as the attester — and each of the others by an EIP-712 signature stored inside the attestation. Any wallet of the set may be the sender. The first wallet is simply the address the score is issued to (the EAS recipient, where lookups find it); it has no signing privilege. That is the whole difference from SIWE: the objection to a browserpersonal_signwas never the signature, it was that the result never left the browser. A signature written onchain is checkable by anyone, forever, with no server — and for EOAs, with no network call at all. SIWE itself is still the wrong format here: itsdomainanduriare origin-bound, so a message couldn't be reconstructed at verify time across localhost, previews, and production without storing the origin too.