
The MORALS token and all voting data live on the Base blockchain. Every moral judgment is permanently recorded onchain and publicly readable by anyone. No one needs our permission to access this data.
The Morals.fyi website is one interpretation of this data. We apply our own scoring formula, our own trust degree system, and our own editorial judgment about who deserves 1st degree status. You might disagree with some or all of those choices, and we think building your own alternative is one of the most meaningful ways this project becomes more decentralized.
The most “centralized” element of our scoring is the trust degree weights, which are rooted in 1st degree status that we assign through airdrops. If you want to strip out our editorial voice entirely, you can simply exclude transactions sent from our project wallets. What remains is raw moral judgment data: organic sends from real people making their own decisions about who is moral and who isn’t.
The MORALS Token
| Property | Value |
|---|---|
| Chain | Base (EVM-compatible, Chain ID 8453) |
| Standard | ERC-20 |
| Contract address | 0x2fb4c7Cd0A786baC17b48677c1Da25A3ebA37e28 |
| Decimals | 18 |
| Total supply | 1,000,000,000,000 MORALS |
The Core Mechanic
Every MORALS transaction that qualifies as a moral judgment follows this rule:
- Transfer of ≥1 MORALS from wallet A to wallet B = A proclaims B has good morals
- Transfer of <1 MORALS from wallet A to wallet B = A proclaims B has bad morals
- All other transfers (DEX swaps, liquidity operations, contract interactions) = not a moral judgment
The amount beyond the threshold doesn’t matter for scoring purposes. Sending 1 MORALS and sending 1,000,000 MORALS are both a single proclamation of good. What matters is how many unique wallets proclaim you good or bad, and who those wallets are.
Getting the Onchain Data
Option 1: Direct RPC Queries (Free)
Every Base RPC endpoint can return ERC-20 Transfer events. The standard event signature is:
event Transfer(address indexed from, address indexed to, uint256 value)
Topic0 (keccak256 hash of the event signature):
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
Use eth_getLogs with the MORALS contract address and this topic to retrieve all transfers. Most RPC providers limit how many blocks you can query at once (typically 2,000–10,000 blocks), so you’ll need to paginate through historical data.
Base RPC endpoints:
- Public (rate-limited):
https://mainnet.base.org - Alchemy, Infura, QuickNode, and other providers offer Base support with higher rate limits
- For a complete list, see Base documentation
Option 2: Blockchain Indexers
Several services index ERC-20 transfers and offer query APIs:
- Bitquery — GraphQL API with Base support. This is what Morals.fyi uses.
- The Graph — Subgraph-based indexing. You can deploy a custom subgraph for MORALS.
- Dune Analytics — SQL-based queries on blockchain data. Good for analysis and dashboards.
- Covalent / GoldRush — REST API for token transfers.
- BaseScan API — The
tokentxendpoint returns ERC-20 transfers for a given address or contract.
Any of these will give you the same underlying data.
Option 3: BaseScan Export (Quickest Start)
For a quick start without writing any code, go to BaseScan, navigate to the MORALS contract, click the “Token Transfers” tab, and export to CSV. This gives you a spreadsheet of all transfers that you can filter manually or load into a script. The limitation is that BaseScan exports are capped at a certain number of rows and don’t include the transaction.to field needed for Filter 1 below, so this works for exploration but not for a production site.
Filtering: Which Transfers Are Moral Judgments?
This is the most important technical detail. Not every MORALS Transfer event is a moral proclamation. DEX swaps, liquidity operations, and contract interactions also produce Transfer events from the same token contract. Here’s how to separate moral judgments from noise.
Filter 1 (Critical): Transaction recipient = MORALS contract
When someone sends MORALS directly from their wallet to another wallet, they call the transfer(address,uint256) function directly on the MORALS token contract. The transaction’s to field (the address of the contract being called) equals the MORALS contract address.
When a DEX or other protocol moves MORALS as part of a swap, the transaction’s to field is the DEX router or intermediary contract — not the MORALS contract. The token contract’s transfer() function is called internally by the DEX, but the top-level transaction is addressed to the DEX.
This single filter eliminates approximately 68% of Transfer events based on our proof-of-concept testing with a comparable token on Base (DEGEN, over a 36-hour window with 10,022 transfer events).
How to check: Compare the transaction.to field (which contract was the transaction sent to) with the MORALS contract address. Only keep transfers where they match. Note that the transaction.to field is a property of the transaction itself, not of the Transfer event log — you need to fetch the full transaction to get it.
Filter 2: Both sender and recipient are user-controlled addresses
The goal of this filter is to keep “real people made a moral judgment” and exclude “protocol plumbing moved tokens as a side effect.” That distinction used to be cleanly captured by “is this an externally owned account?” but the EVM has evolved. Account abstraction and EIP-7702 mean an address can be controlled by a real person and still have bytecode deployed. So the check has three branches.
Get the address’s bytecode with eth_getCode(address) on any Base RPC, then accept the address if:
- It’s an EOA.
eth_getCodereturns0x(empty). This is the classic “wallet with a seed phrase” case. - It’s an EIP-7702 delegation.
eth_getCodereturns0xef0100followed by a 20-byte delegate address (46 hex chars total). Since Pectra (May 2025), an EOA can temporarily delegate to a smart contract for transaction execution without giving up its private key. The owner is still in control. - It’s a recognized smart wallet. The bytecode matches a known account-abstraction wallet implementation. The implementations we currently recognize:
- Safe — v1.3.0 L2 singleton at
0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552, v1.4.1 mainnet singleton at0x41675C099F32341bf84BFc5382aF534df5C7461a, and v1.4.1 L2 singleton at0x29fcb43b46531bca003ddc8fcb67ffe91900c762. - Coinbase Smart Wallet v1 — implementation at
0x000100abaad02f1cfC8Bbe32bD5a564817339E72. - Any EIP-1167 minimal-proxy contract — bytecode starts with
0x3d602d80600a3d3981f3363d3d373d3d3d363d73.
- Safe — v1.3.0 L2 singleton at
Otherwise the address is rejected. This excludes Sablier vesting vaults, DEX routers, LP pools, bridges, and any other infrastructure that holds tokens without being a moral agent.
Exception: Some corporates and crypto projects use custom contract setups that don’t match the patterns above. You can choose to maintain your own whitelist of approved addresses if you want to include them, or simply exclude all unrecognized contracts.
Performance tip: Cache the bytecode lookups. An address’s code rarely changes; only via SELFDESTRUCT (extremely rare and being deprecated in Cancun-era forks) or EIP-7702 re-delegation (occasional but doesn’t change the address’s user-controlled status). For practical purposes, one lookup per address per indexing run is enough.
Filter 3 (Optional, defense-in-depth): Single Transfer event per transaction
A legitimate moral send produces exactly one MORALS Transfer event in the transaction receipt. Transactions containing multiple Transfer events or DEX-specific events (like Uniswap’s Swap event) in their logs are almost certainly not moral proclamations. Check eth_getTransactionReceipt and count the Transfer events matching the MORALS contract address.
Pseudocode
for each Transfer event from the MORALS contract:
tx = getTransaction(event.transactionHash)
# Filter 1: Was the MORALS contract called directly,
# OR was this a Disperse-routed airdrop?
if tx.to != MORALS_CONTRACT and tx.to != DISPERSE_CONTRACT:
skip # This was a DEX/contract interaction
# If Disperse-routed, the effective sender is the EOA
# that called disperseToken, not the Disperse contract itself.
from_addr = tx.from if tx.to == DISPERSE_CONTRACT else event.from
to_addr = event.to
# Filter 2: Are both parties user-controlled addresses?
# (EOA, EIP-7702 delegated EOA, or recognized smart wallet)
if not is_user_controlled(from_addr) or not is_user_controlled(to_addr):
skip # Protocol plumbing, not a moral judgment
# Classify
amount = event.value / 10^18
if amount >= 1:
record as GOOD proclamation (from_addr → to_addr)
else:
record as BAD proclamation (from_addr → to_addr)
def is_user_controlled(address):
code = getCode(address)
if code == '0x':
return True # EOA
if code.startswith('0xef0100') and len(code) <= 64:
return True # EIP-7702 delegated EOA
if matches_known_smart_wallet(code):
return True # Safe, CSW, EIP-1167, etc.
return FalseProject Wallet Addresses
These wallets are controlled by the Morals project. Transactions sent from these addresses reflect editorial decisions (airdrops, ecosystem grants, Bad Actor Identification Program rewards), not organic community judgment.
| Wallet | Purpose | Address |
|---|---|---|
| Farcaster (@morals) | The in-app wallet tied to the @morals Farcaster account. | 0x5BcC4e91623B9b06b47F8722BDc61C4d022187Be |
| Deployer | Deploys contract, receives total supply, distributes to other wallets. Empty after launch. | 0x2EcE6ab0dFEF6FCf84Ba1D2a76e596b968c2Ed49 |
| Airdrop | Sends all airdrop phases (1–9). Also holds a 1st Degree Reserve for granting 1st degree status to approved Bad Actor Identification Program participants. Any send of ≥1 MORALS from this wallet confers 1st degree trust anchor status. | 0xa8681335De73ec2308AC59F849E9737110b9289d |
| Ecosystem Fund | Bad Actor Identification Program rewards, grants, ecosystem development. | 0x89Dee39b5a6C10Ae3b8c0A15daD32deb564A6244 |
| Team | Receives team allocation, locks in Sablier vesting. | 0x215De46A6a76ABB26724ebC493C549C406f3dC4c |
| LP | Deposits MORALS + ETH into Aerodrome pool, locks LP tokens. | 0xc877BDd1e03F2d86cBF0da3bF863C0E0C8eB097A |
To strip out our editorial voice: Exclude all Transfer events where the from address is any of the project wallets listed above. What remains is purely organic — real people sending their own MORALS to express their own moral judgments. This also removes the trust degree system’s roots, since all 1st degree status is conferred through sends from the airdrop wallet. You’re left with a flat, unweighted dataset of moral proclamations.
Narrower alternative — exclude just the airdrops: Every airdrop we run (Phase 1 at launch, then quarterly Phases 2–9) is routed through the Disperse contract on Base (0xD152f549545093347A162Dce210e7293f1452150). If all you want is to ignore the airdrops, drop any Transfer event whose parent transaction’s to (i.e. Transaction.To, not the Transfer event’s recipient) is that address. This is a one-line filter, but it’s narrower than the broader option above: Bad Actor Identification Program degree grants and individual proclamations from @morals still come through, so you keep some of our editorial voice.
To see only our editorial voice: Include only Transfer events where the from address is one of the project wallets. This shows you exactly which wallets we chose to airdrop to and which we chose to shame with dust.
Our Scoring Formula
Morals.fyi uses a Trust-Seeded Bayesian Score. You’re free to replicate it, modify it, or replace it entirely.
Trust Degrees
We assign each wallet a “trust degree” based on its proximity to known moral actors:
| Trust Degree | Who Gets This Degree | Vote Weight |
|---|---|---|
| 1st Degree | All airdrop recipients (Phases 1–9) + approved Bad Actor Identification Program participants (each receives ≥1 MORALS from the airdrop wallet to confer this status onchain) | 1.0 |
| 2nd Degree | Any wallet proclaimed good by a 1st degree wallet | 0.5 |
| 3rd Degree | Any wallet proclaimed good by a 2nd degree wallet | 0.25 |
| Unranked | Everyone else | 0.01 |
Only “good” sends (≥1 MORALS) confer trust degree status. Bad sends (dust) affect the recipient’s moral score but don’t propagate trust. The chain stops at 3rd degree — a 3rd degree wallet proclaiming someone good doesn’t make that person 4th degree.
A wallet’s trust degree is determined by the highest-degree wallet that proclaimed it good. If a 2nd degree wallet praises you (making you 3rd degree) and then later a 1st degree wallet praises you, you become 2nd degree.
One vote per sender per recipient. Each sender gets exactly one active vote on each recipient, determined by their most recent classified send. If wallet A sends ≥1 MORALS to B (good), then later sends <1 to B (bad), A’s vote on B flips from good to bad. This prevents a single wallet from amplifying its influence through repeated sends and allows people to change their minds. All historical transactions are still recorded onchain; the one-vote rule applies to how votes are tallied in the score formula.
The Formula
moral_score = (C × m + weighted_good) / (C + weighted_good + weighted_bad) × 100Where:
- weighted_good = sum of vote weights from all wallets whose most recent send to this address was good (≥1 MORALS). Each sender counts only once per recipient — if someone sends multiple times, only their latest send determines their vote. For example, if a 1st degree wallet (weight 1.0) and two unranked wallets (weight 0.01 each) most recently proclaimed you good, your weighted_good = 1.02.
- weighted_bad = sum of vote weights from all wallets whose most recent send to this address was bad (<1 MORALS). Same one-vote-per-sender logic.
- m = the global average good ratio across organic community proclamations (project wallet sends are excluded). m is itself Bayesian: instead of the raw empirical ratio
totalGood / totalAll(which would peg at 0 or 1 from a single early vote and break every recipient’s score downstream), we shrink toward a neutral 0.5 prior with a Bayesian weight K:With K = 20, the system behaves as if it has already observed 20 weighted votes at a 50/50 split before any real votes arrive. Atm = (totalGood + K × 0.5) / (totalAll + K)
totalAll = 0this collapses to 0.5. At one organic good vote, m ≈ 0.524. At hundreds of organic votes, the prior fades and m approaches the empirical ratio. - C = the confidence constant. This controls how much voting data is needed before a wallet’s score diverges meaningfully from the global average. For example, with C = 2, a wallet needs about 2 weighted vote-units before its score is driven more by actual votes than by the prior. C plays the same Bayesian-prior role at the individual-address level that K plays at the system-wide level — same shape, different scope.
We expect to increase the value of C over time as voting activity grows. The current value of C is 2. The current value of m is 0.698. K is a documented project constant; if we ever change it we’ll note it on the Time Machine pages alongside C and m.
How it works in practice: A wallet with no votes scores m × 100 (the global average). As weighted votes accumulate, C becomes small relative to the vote totals, and the score converges toward the wallet’s actual weighted good ratio. Being proclaimed good by a 1st degree wallet (weight 1.0) moves the needle 100× more than a proclamation from an unranked wallet (weight 0.01).
Alternative Scoring Ideas
You don’t have to use our formula. Here are some starting points for alternative approaches:
- Simple ratio — good_votes / total_votes × 100. No weighting, no Bayesian prior. Transparent and easy to explain, but trivially gamed with sybil wallets since every wallet’s vote counts equally.
- Equal-weight Bayesian — Same formula as ours but with all vote weights set to 1.0. Eliminates the trust degree hierarchy while keeping the Bayesian prior’s stability against thin data.
- PageRank-style — A recursive algorithm where a wallet’s influence depends on the influence of wallets that endorsed it. More computationally expensive but captures transitive trust more elegantly than our fixed 3-tier system.
- Time-decayed — Weight recent votes more heavily than old ones. Captures evolving community sentiment. Someone who was considered moral a year ago but has since been widely shamed would see their score drop faster.
- Balance-weighted — Weight votes by the sender’s MORALS balance. People with more skin in the game have more influence. Trade-off: this favors wealthy participants.
- Quadratic — Apply diminishing returns to votes that come from wallets with shared transaction history. If 10 wallets that all received tokens from the same source all praise the same person, the quadratic approach would count that as less meaningful than 10 votes from unrelated wallets.
Enriching Addresses with Identity
Raw blockchain data gives you hex addresses. Here’s how to resolve them to human-readable names.
ENS Names
Reverse-resolve any Ethereum address to its ENS name via the ENS registry. This is a free eth_call on Ethereum mainnet (not Base, since ENS lives on mainnet). The same EOA address is valid across all EVM chains, so a Base address can be resolved on mainnet.
Contract: ENS Universal Resolver at 0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe
Most web3 libraries handle ENS resolution automatically:
// viem
const name = await mainnetClient.getEnsName({ address: '0x...' });
// ethers.js
const name = await mainnetProvider.lookupAddress('0x...');Farcaster Usernames
The Neynar API provides wallet-to-username lookups:
GET https://api.neynar.com/v2/farcaster/user/bulk-by-address?addresses=0x...Requires a Neynar API key (free tier available). Supports bulk lookups — you can pass multiple comma-separated addresses in a single request.
Neynar also provides a “Neynar score” (0–1) that indicates how likely an account is to be a real human vs. a bot. This can be useful for filtering.
Sample: Minimal Leaderboard Script
Here’s a simplified example using JavaScript (Node.js) with the viem library that fetches MORALS transfers and builds a basic leaderboard. This example uses simple vote counting (no trust degrees, no Bayesian scoring) to keep the code short and focused on the data pipeline.
import { createPublicClient, http, parseAbi } from 'viem';
import { base } from 'viem/chains';
// ============================================================
// Configuration
// ============================================================
const MORALS_CONTRACT = '0x2fb4c7cd0a786bac17b48677c1da25a3eba37e28'; // MORALS token contract address on Base
// Phase 1 (launch) and the quarterly Phases 2-9 airdrops are batched through
// this contract. Recognising it lets us count those Transfer events as moral
// sends from the Airdrop wallet, and gives us a one-line "ignore the airdrops"
// filter (see excludeAirdropContractCalls below).
const DISPERSE_CONTRACT = '0xd152f549545093347a162dce210e7293f1452150';
const PROJECT_WALLETS = new Set([
'0x5BcC4e91623B9b06b47F8722BDc61C4d022187Be', // Farcaster (@morals)
'0x2EcE6ab0dFEF6FCf84Ba1D2a76e596b968c2Ed49', // Deployer
'0xa8681335De73ec2308AC59F849E9737110b9289d', // Airdrop (all phases)
'0x89Dee39b5a6C10Ae3b8c0A15daD32deb564A6244', // Ecosystem Fund
'0x215De46A6a76ABB26724ebC493C549C406f3dC4c', // Team
'0xc877BDd1e03F2d86cBF0da3bF863C0E0C8eB097A', // LP
].map(a => a.toLowerCase()));
const START_BLOCK = 45869932n; // Block number of MORALS contract deployment
// ============================================================
// Setup
// ============================================================
const client = createPublicClient({
chain: base,
transport: http('https://mainnet.base.org'),
});
const transferEvent = parseAbi([
'event Transfer(address indexed from, address indexed to, uint256 value)',
])[0];
// Cache for EOA checks (address → boolean)
const eoaCache = new Map();
async function isEOA(address) {
const key = address.toLowerCase();
if (eoaCache.has(key)) return eoaCache.get(key);
const code = await client.getCode({ address });
const result = code === '0x' || code === undefined;
eoaCache.set(key, result);
return result;
}
// ============================================================
// Fetch and classify moral judgments
// ============================================================
async function fetchMoralJudgments(fromBlock, toBlock) {
// Fetch Transfer events in chunks (Base RPC typically allows 2000 blocks)
const CHUNK_SIZE = 2000n;
const allLogs = [];
for (let start = fromBlock; start <= toBlock; start += CHUNK_SIZE) {
const end = start + CHUNK_SIZE - 1n > toBlock ? toBlock : start + CHUNK_SIZE - 1n;
const logs = await client.getLogs({
address: MORALS_CONTRACT,
event: transferEvent,
fromBlock: start,
toBlock: end,
});
allLogs.push(...logs);
}
console.log(`Found ${allLogs.length} Transfer events. Filtering...`);
const judgments = [];
for (const log of allLogs) {
// Filter 1: Was the call a direct transfer() OR an airdrop batched through
// the Disperse contract? Anything else is contract-intermediated (DEX
// swap, claim, etc.) and not a moral judgment.
const tx = await client.getTransaction({ hash: log.transactionHash });
const txTo = tx.to?.toLowerCase();
const isDirectTransfer = txTo === MORALS_CONTRACT.toLowerCase();
const isDisperseAirdrop = txTo === DISPERSE_CONTRACT;
if (!isDirectTransfer && !isDisperseAirdrop) continue;
// Filter 2: Both parties are EOAs (regular wallets)
const [senderOk, recipientOk] = await Promise.all([
isEOA(log.args.from),
isEOA(log.args.to),
]);
if (!senderOk || !recipientOk) continue;
// Classify based on amount
const amount = Number(log.args.value) / 1e18;
// Effective sender: for Disperse-routed calls the Transfer event's "from"
// is the Disperse contract itself (it pulls tokens via transferFrom then
// transfers them out from its own balance), so use the parent
// transaction's "from" — the EOA that actually initiated the airdrop.
// For direct transfers the two values coincide.
const effectiveFrom = isDisperseAirdrop
? tx.from.toLowerCase()
: log.args.from.toLowerCase();
// Skip Disperse's own intermediate "pull" event (sender pulls tokens to
// the contract's balance before fanning out). The pull's "to" IS the
// Disperse contract; real recipient events have a recipient that isn't.
if (isDisperseAirdrop && log.args.to.toLowerCase() === DISPERSE_CONTRACT) continue;
judgments.push({
from: effectiveFrom,
to: log.args.to.toLowerCase(),
type: amount >= 1 ? 'good' : 'bad',
block: Number(log.blockNumber),
txTo, // preserved so leaderboards can opt to ignore Disperse-routed airdrops
});
}
console.log(`${judgments.length} moral judgments after filtering.`);
return judgments;
}
// ============================================================
// Build a leaderboard from judgments
// ============================================================
function buildLeaderboard(judgments, {
excludeProjectWallets = false,
excludeAirdropContractCalls = false,
} = {}) {
// One-vote-per-sender: for each sender→recipient pair, only the most recent
// judgment counts. Sort by block descending so the first seen is the latest.
const sortedDesc = [...judgments].sort((a, b) => b.block - a.block);
const seen = new Set();
const dedupedJudgments = [];
for (const j of sortedDesc) {
const key = `${j.from}->${j.to}`;
if (seen.has(key)) continue;
seen.add(key);
dedupedJudgments.push(j);
}
const scores = {};
for (const j of dedupedJudgments) {
// Broader: exclude any send from a project wallet (catches BAIP grants
// and proclamations from @morals too).
if (excludeProjectWallets && PROJECT_WALLETS.has(j.from)) continue;
// Narrower: exclude only the batched airdrops (Disperse-routed calls).
if (excludeAirdropContractCalls && j.txTo === DISPERSE_CONTRACT) continue;
const addr = j.to;
if (!scores[addr]) scores[addr] = { address: addr, good: 0, bad: 0 };
scores[addr][j.type]++;
}
return Object.values(scores)
.map(entry => ({
...entry,
total: entry.good + entry.bad,
score: Math.round((entry.good / (entry.good + entry.bad)) * 100),
}))
.sort((a, b) => b.score - a.score || b.good - a.good);
}
// ============================================================
// Run it
// ============================================================
const currentBlock = await client.getBlockNumber();
const judgments = await fetchMoralJudgments(START_BLOCK, currentBlock);
console.log('\n=== LEADERBOARD (all votes) ===');
console.table(buildLeaderboard(judgments).slice(0, 20));
console.log('\n=== LEADERBOARD (excluding the airdrops) ===');
console.table(buildLeaderboard(judgments, { excludeAirdropContractCalls: true }).slice(0, 20));
console.log('\n=== LEADERBOARD (excluding all project-wallet sends) ===');
console.table(buildLeaderboard(judgments, { excludeProjectWallets: true }).slice(0, 20));To run this: Save as leaderboard.mjs, install viem (npm install viem), fill in the contract address and project wallet addresses, then run with node leaderboard.mjs. On first run with full history, expect it to take several minutes due to RPC calls for each transaction. A production site would use an indexer and database to avoid re-fetching everything on each run.
To add our scoring formula: Replace the simple good / total ratio in buildLeaderboard with the Bayesian formula described above. You’ll also need to implement trust degree assignment: start by marking known 1st degree addresses, then propagate degrees as you process “good” judgments.
