How to Build Your Own Site

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.

Jump to a section

The MORALS Token

PropertyValue
ChainBase (EVM-compatible, Chain ID 8453)
StandardERC-20
Contract address0x2fb4c7Cd0A786baC17b48677c1Da25A3ebA37e28
Decimals18
Total supply1,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:

Solidity
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 tokentx endpoint 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:

  1. It’s an EOA. eth_getCode returns 0x (empty). This is the classic “wallet with a seed phrase” case.
  2. It’s an EIP-7702 delegation. eth_getCode returns 0xef0100 followed 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.
  3. 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 at 0x41675C099F32341bf84BFc5382aF534df5C7461a, and v1.4.1 L2 singleton at 0x29fcb43b46531bca003ddc8fcb67ffe91900c762.
    • Coinbase Smart Wallet v1 — implementation at 0x000100abaad02f1cfC8Bbe32bD5a564817339E72.
    • Any EIP-1167 minimal-proxy contract — bytecode starts with 0x3d602d80600a3d3981f3363d3d373d3d3d363d73.
    Detection for Safe and Coinbase Smart Wallet is a substring match: those proxies embed their implementation address directly in the bytecode, so checking for that address as a hex substring is sufficient and reliable. Detection for EIP-1167 is a prefix match on the standard minimal-proxy deployment shape. The list grows as new wallet implementations gain adoption; recheck periodically.

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

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 False

Project 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.

WalletPurposeAddress
Farcaster (@morals)The in-app wallet tied to the @morals Farcaster account.0x5BcC4e91623B9b06b47F8722BDc61C4d022187Be
DeployerDeploys contract, receives total supply, distributes to other wallets. Empty after launch.0x2EcE6ab0dFEF6FCf84Ba1D2a76e596b968c2Ed49
AirdropSends 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 FundBad Actor Identification Program rewards, grants, ecosystem development.0x89Dee39b5a6C10Ae3b8c0A15daD32deb564A6244
TeamReceives team allocation, locks in Sablier vesting.0x215De46A6a76ABB26724ebC493C549C406f3dC4c
LPDeposits 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 DegreeWho Gets This DegreeVote Weight
1st DegreeAll 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 DegreeAny wallet proclaimed good by a 1st degree wallet0.5
3rd DegreeAny wallet proclaimed good by a 2nd degree wallet0.25
UnrankedEveryone else0.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

Formula
moral_score = (C × m + weighted_good) / (C + weighted_good + weighted_bad) × 100

Where:

  • 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:
    m = (totalGood + K × 0.5) / (totalAll + 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. At totalAll = 0 this 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:

JavaScript
// 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:

HTTP
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.

JavaScript — leaderboard.mjs
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.


💬
Reach out on Farcaster (@morals) or X (@morals_fyi) if you have questions about the data or want to discuss your approach. We’d love to see what you build and potentially link to it.
“vitalik.eth” “@dwr” “celebrities” “July 30 2015” “is it moral to freeze stolen funds”