Let’s talkLet’s talk

June 19th, 2026

Indexing a Bitcoin L2 with Goldsky: Boar Finance Case Study

Kamil Pyszkowski

Kamil Pyszkowski

13 mins

A case study on building production-grade on-chain indexing for a BTC yield protocol on Mezo, using nothing but a JSON config file.

Indexing a Bitcoin L2 with Goldsky: Boar Finance Case Study

When we built the indexing layer for Boar Finance, the first instinct was the usual one: set up a Graph Protocol subgraph, write AssemblyScript event handlers, maintain schema.graphql by hand, compile to WASM, fight the toolchain. It's a familiar path, but it carries real overhead - especially when your protocol is live, your contracts are evolving, and your team is small.

We ended up doing none of that. Instead, the entire subgraph that powers Boar Finance's dashboard - APY history, per-user earnings, activity feeds, bribe rewards - is defined by a single 55-line JSON file.

This post walks through why we made that call, exactly how it works, and what we learned shipping it on a custom Bitcoin Layer 2.

What Boar Finance Actually Does

Before getting into the indexing, it helps to understand what we're indexing for.

Boar Finance is a non-custodial BTC yield protocol running on Mezo, a Bitcoin Layer 2. The core product lets users lock BTC into veBTC (vote-escrowed BTC) NFTs, delegate them to Boar's managed position, and earn compounded BTC yield - without handing over custody. Boar automates the governance voting that earns the yield, auto-compounds each epoch, and distributes rewards proportionally.

There's a second strategy on top: MEZO token staking, where users lock MEZO to earn weekly bribe rewards in MEZO.

The dashboard needs to answer questions like:

  • What is the current and historical APY for each strategy?
  • What has this wallet earned over time, broken down by epoch?
  • What BTC did this wallet lock, and when?
  • What MEZO bribe rewards has this address claimed?

These questions have one thing in common: they require historical event data, not just current state. You cannot answer "what did this wallet earn in epoch 34?" by calling a contract today. You need a full event log going back to genesis.

That's why you need a subgraph.

The Indexing Problem on a Custom EVM Chain

Mezo is a production EVM chain (mainnet chain ID: 31612) with first-class EVM compatibility, but it isn't Ethereum mainnet, Polygon, or Arbitrum. At the time we were building, there was no pre-existing public indexing infrastructure to plug into.

The protocol spans five deployed contracts:

ContractRole
VeBTCVote-escrowed BTC - manages locks, delegations
RewardsDistributorEpoch-based reward distribution
BoarBTCRelayAuto-compound logic - emits Compound events each epoch
LockGatewayEntry point for bridged BTC deposits
BoarBoostBribeWeekly MEZO bribe reward claims

Each contract emits events that, together, let you reconstruct a complete picture of what happened to every satoshi that ever touched the protocol. The challenge is doing it without building a bespoke indexing service.

Enter Goldsky's Low-Code Subgraph

The traditional subgraph workflow looks like this:

  1. Write a schema.graphql by hand
  2. Write subgraph.yaml wiring events to handlers
  3. Write AssemblyScript handler functions - one per event
  4. Compile to WASM
  5. Deploy and hope the toolchain cooperates

Goldsky's low-code subgraph approach collapses that entire pipeline into a single step: point it at your ABIs and let it generate everything else.

The config format is a JSON file. You declare:

  • Your contract ABIs (by file path)
  • Your contract instances (address, chain, start block)

Goldsky handles schema generation, handler generation, WASM compilation, and deployment. You get a live GraphQL endpoint and a fully indexed subgraph with zero AssemblyScript.

Note

Low-code subgraphs are distinct from Goldsky's "no-code" wizard (which works from a contract address alone, with no config file at all). The low-code approach requires a JSON config, but in return gives you multi-contract, multi-chain indexing and the option to add enrichments - eth_call lookups that run at index time to augment event data.

The Config: 55 Lines, Five Contracts

Here is the complete subgraph configuration for Boar Finance mainnet, as it exists in the repository:

{
  "name": "boar-finance-data",
  "version": "1",
  "abis": {
    "VeBTC": {
      "path": "./abis/ve-btc.json"
    },
    "RewardsDistributor": {
      "path": "./abis/rewards-distributor.json"
    },
    "BoarBTCRelay": {
      "path": "./abis/boar-btc-relay.json"
    },
    "LockGateway": {
      "path": "./abis/lock-gateway.json"
    },
    "BoarBoostBribe": {
      "path": "./abis/boar-boost-bribe.json"
    }
  },
  "instances": [
    {
      "abi": "VeBTC",
      "address": "0x3D4b1b884A7a1E59fE8589a3296EC8f8cBB6f279",
      "startBlock": 5043592,
      "chain": "mezo"
    },
    {
      "abi": "RewardsDistributor",
      "address": "0xb58477e074265BdC7F7ca6100eD0f7De264F74A2",
      "startBlock": 5043846,
      "chain": "mezo"
    },
    {
      "abi": "BoarBTCRelay",
      "address": "0x920b1c573F503554E113e4c47A92cd289a3d1625",
      "startBlock": 6821706,
      "chain": "mezo"
    },
    {
      "abi": "LockGateway",
      "address": "0xb4C0eA5E674Cd32D93EbE94F9d2a31CeB0490132",
      "startBlock": 8118646,
      "chain": "mezo"
    },
    {
      "abi": "BoarBoostBribe",
      "address": "0xe805865867A9bdaFc756bf3A42e77d29435A536C",
      "startBlock": 7913000,
      "chain": "mezo"
    }
  ],
  "enableCallHandlers": false
}

That's it. No schema file. No handler file. No yarn codegen. The startBlock values are set to each contract's deployment block - critical for correctness, since Goldsky will start scanning from genesis if you omit them, which on a live chain means waiting through a lot of irrelevant history.

Deployment is one command:

goldsky subgraph deploy boar-finance-data-mezo/1.3.0 --from-abi ./subgraph/mainnet.json

Goldsky parses the ABIs, generates a GraphQL schema from every event signature it finds, writes AssemblyScript handlers internally, compiles, and deploys. You get back a public GraphQL endpoint with no further work.

Important

ABI contents really matter. Low-code subgraphs index entire ABIs, not just the events you point in a config file. If your ABI includes irrelevant events, those events will be indexed and show up in your GraphQL schema. It's worth auditing your ABIs before deploying to ensure they only contain the events you actually want to index.

Tip

The chain field in each instance uses Goldsky's chain slug, not the numeric chain ID. For Mezo mainnet that's "mezo". If you're deploying to a chain Goldsky doesn't have a pre-registered slug for, you can still deploy - check their supported networks list first, and reach out if your chain isn't listed.

What the Subgraph Generates

When Goldsky processes the ABI, it generates one GraphQL entity per event. The naming convention is camelCase plural of the event name. So Deposit(address provider, uint256 tokenId, uint256 value, uint256 ts) becomes a deposits collection, queryable like this:

query {
  deposits(where: { provider: "0xabc..." }) {
    tokenId
    value
    ts
    transactionHash_
  }
}

The auto-generated field names follow a predictable pattern: event parameters keep their names, with a few reserved-word collisions handled by appending an underscore suffix (from_, timestamp_, block_number). Learning this convention once means you can write queries confidently against any low-code subgraph.

Important

Goldsky normalizes hex addresses to lowercase before indexing. If you query with a mixed-case address (e.g., from a wallet connection), you'll get no results. Normalize all address arguments to lowercase in your application code before sending them to the subgraph.

In our SDK we handle this at the client level:

// apps/web/subgraph/sdk/index.ts
const normalizeAddresses = <T extends Record<string, unknown>>(
  variables: T
): T =>
  Object.fromEntries(
    Object.entries(variables).map(([key, value]) => [
      key,
      typeof value === "string" && value.startsWith("0x")
        ? value.toLowerCase()
        : value,
    ])
  ) as T

How the Subgraph Powers Real Features

The subgraph isn't an abstraction layer we added for fun - every piece of data on the Boar Finance dashboard traces back to a specific query against it. Here's how the key features map to indexed events.

APY Calculation

The current and historical BTC APY is computed from Compound events emitted by the BoarBTCRelay contract each epoch. Each event records how much BTC was added to the pool. To compute a per-epoch yield rate, you need the compound amount and the total locked veBTC supply at that block.

query boarBTCRelayCompoundEvents {
  compounds {
    amount
    block_number
    timestamp_
  }
}

The subgraph gives us the full compounds history in one query. The supply at each historical block comes from a multicall against an archive node. The two datasets are joined in the server action, and the final APY is the geometric mean of per-epoch rates - annualized over 52 epochs per year.

Without the subgraph, reconstructing compounds history would mean scanning the full chain event log via eth_getLogs on every page load. That's slow, rate-limited, and fragile. The subgraph absorbs that cost once at index time.

Per-User Earnings History

This is the most computationally involved feature. When a user asks "how much did I earn in week 7?", the answer requires:

  1. The user's veBTC delegation history (which blocks they were delegated, with what weight)
  2. All compound events during that period
  3. The total managed pool weight at each compound

None of this is a simple lookup. You're reconstructing a proportional share calculation across historical state. The subgraph makes it tractable with two queries:

# User's own delegation/undelegation events
query veBTCUserNormalEventsForEarnings($walletAddress: String) {
  deposits(where: { provider: $walletAddress }) {
    block_number
    value
  }
  withdraws(where: { provider: $walletAddress }) {
    block_number
    value
  }
}

# All delegations into the managed token (to reconstruct total pool weight)
query veBTCManagedEventsForBribes($managedTokenId: BigInt) {
  depositManageds(where: { mTokenId: $managedTokenId }) {
    owner
    block_number
    weight
  }
  withdrawManageds(where: { mTokenId: $managedTokenId }) {
    owner
    block_number
    weight
  }
}

These two queries plus the compounds query give you everything needed to walk the timeline and compute each user's proportional earnings per epoch.

Note

The "managed token" is Boar's single veBTC NFT (ID 1226 on mainnet) that aggregates all delegated user positions. Users delegate to this NFT; Boar votes with it. This one-to-many structure is why the earnings calculation needs to reconstruct the full delegation history rather than just reading a single balance.

Activity Feed

The activity feed shows a wallet's complete interaction history with the protocol: deposits, withdrawals, delegations, undelegations. These are all event types on the VeBTC contract.

query veBTCUserActivityEvents($walletAddress: String) {
  depositManageds(where: { owner: $walletAddress }) {
    tokenId
    mTokenId
    ts
    transactionHash_
    weight
  }
  withdrawManageds(where: { owner: $walletAddress }) {
    tokenId
    mTokenId
    ts
    transactionHash_
    weight
  }
  deposits(where: { provider: $walletAddress }) {
    tokenId
    value
    ts
    transactionHash_
  }
  withdraws(where: { provider: $walletAddress }) {
    tokenId
    ts
    transactionHash_
  }
}

All four event types land in a single GraphQL query, which the application normalizes into a unified timeline sorted by timestamp.

MEZO Staking Bribe Rewards

The MEZO staking strategy runs through BoarBoostBribe, which emits ClaimRewards and NotifyReward events. These power the bribe rewards dashboard:

query mezoUserBribeClaimedEvents($user: String!) {
  claimRewards(
    where: { from: $user }
    orderBy: timestamp_
    orderDirection: asc
    first: 1000
  ) {
    from
    reward
    amount
    timestamp_
  }
}

query mezoNotifyRewardEvents($reward: String!, $fromEpoch: BigInt!) {
  notifyRewards(
    where: { reward: $reward, epoch_gte: $fromEpoch }
    orderBy: epoch
    orderDirection: asc
    first: 1000
  ) {
    from
    reward
    epoch
    amount
    timestamp_
  }
}

The BoarBoostBribe contract was added in a later deployment (start block 7,913,000 vs. veBTC at 5,043,592). Because the low-code config is just a JSON array of instances, adding this contract to the subgraph was a one-line change - add an entry, bump the version, redeploy.

The TypeScript Integration

The subgraph SDK in the repository uses graphql-request with auto-generated TypeScript types from @graphql-codegen/cli. The codegen config points at the live mainnet endpoint to pull the schema, then generates typed query functions from the .graphql files.

// Usage in a server action
const sdk = getSubgraphSdk()
const { compounds } = await sdk.boarBTCRelayCompoundEvents()
const { deposits, withdraws } = await sdk.veBTCUserNormalEventsForEarnings({
  walletAddress: address.toLowerCase(),
})

Every query function is typed - return shapes come from the generated types, so TypeScript catches field name mismatches at compile time, not at runtime. When Goldsky auto-generates field names with underscore suffixes (like timestamp_), the query files use GraphQL field aliases to expose clean names to the application:

compounds {
  amount
  blockNumber: block_number   # aliased to match app conventions
  timestamp: timestamp_
}

This keeps the subgraph schema's naming conventions contained to the query layer, invisible to the rest of the application.

Versioning and Deployment Workflow

Goldsky subgraphs are versioned by the deployment slug: boar-finance-data-mezo/1.3.0. The version in the slug is independent of the "version": "1" in the config file - the config version tracks the config format, while the slug version tracks your own deployment history.

The repository keeps a separate config for testnet:

Both point to the same ABIs. Deploying to testnet:

goldsky subgraph deploy boar-finance-data-mezo-testnet/1.3.0 --from-abi ./subgraph/testnet.json
Warning

Goldsky's free tier limits the number of concurrent active subgraph deployments. At time of writing, Boar Finance operates under a 3-deployment limit: 1 testnet, 1 mainnet production, and 1 mainnet staging. When deploying a new production version, the old staging deployment must be removed first. Build that into your deploy script so it doesn't bite you during a late-night incident response.

What Low-Code Trades Away

This approach isn't without constraints. Being honest about the trade-offs is part of what makes a good case study.

You cannot transform data at index time. Traditional subgraph handlers let you compute derived values, update aggregate entities, and maintain running tallies. The low-code approach indexes raw events. Any cross-event aggregation - like "total BTC compounded across all epochs" - happens at query time in application code, not in the subgraph.

For Boar Finance this was fine. Our APY and earnings calculations are complex enough that we wanted them in TypeScript where they're easier to test, version, and debug. The subgraph is a faithful log of what happened on-chain; the business logic lives in server actions.

Entity relationships require enrichments. If you need a Deposit entity to hold a reference to a computed Balance entity, you need enrichments - Goldsky's mechanism for adding eth_call results to indexed events. Enrichments extend the low-code config with a calls block per event handler. We haven't needed them yet, but the escape hatch exists.

Field naming is convention, not configuration. Auto-generated field names follow the ABI parameter names with reserved-word suffixes. If your ABI uses inconsistent naming (e.g., some events use timestamp, others use ts, others use createdAt), your GraphQL schema will reflect that inconsistency. The query-layer aliases help, but it's worth auditing your ABIs before deploying if you care about schema consistency.

Caution

When you upgrade a subgraph to a new version, the old version continues serving requests at its endpoint until you explicitly remove it. If you have code pointing at a version URL (like we do in the Next.js app), a version removal is a breaking change. Keep the versioned URL in sync with your deployed subgraph, and never remove a version that's still in your production .env.

The Result

The Boar Finance subgraph indexes five contracts on a custom Bitcoin L2, powers four distinct data features, and handles a testnet/mainnet split - all from a JSON config file that fits on a single screen. The only code in the loop is the GraphQL queries and the TypeScript that consumes them.

The total implementation time for the initial subgraph setup - from writing the first config to having a live endpoint powering the staging dashboard - was under two hours. Subsequent contract additions (the BoarBoostBribe contract was added after the initial launch) took a single JSON line and a redeployment.

That's the concrete case for low-code subgraphs on a protocol like this: the value is in what you never have to write. No AssemblyScript to debug, no WASM to compile, no schema file to keep in sync with your ABIs. The ABI is the schema. When contracts change, you update the ABI, bump the version, and redeploy.

The pattern works best when:

  • Your indexing needs are event-log shaped - you want historical records of what happened, not pre-computed aggregates
  • Your team is small and wants to keep the stack surface small
  • You're deploying on a chain without existing indexing infrastructure
  • Your contracts are still evolving and you want low-friction updates

If you're building a DeFi protocol on any EVM-compatible chain and haven't considered whether a low-code subgraph fits your read requirements, it's worth thirty minutes to find out. The ceiling is lower than a hand-coded subgraph, but the floor is dramatically higher than parsing eth_getLogs in application code.


Boar Finance is live at boar.finance. The veBTC strategy is open to all users with bridged BTC on Mezo. The MEZO staking strategy requires a MEZO lock. Neither strategy is custodial.

Let’s talk

Bring us your problem, we’ll help design the system. No hype, just engineering.