SparkLend = Aave V3: The Thin-Adapter Pattern
SparkLend is a fork of Aave V3 — bytecode-identical Pool contract, same ABI, same selectors, different deployment. Our adapter is a 30-line subclass. Sometimes the right amount of code reuse is deep reuse, not abstraction.
Part of the Protocol coverage series.
Conventional adapter wisdom says inheritance is brittle and you should compose. Sometimes conventional wisdom is wrong, in a narrow but important way. SparkLend's Pool contract is bytecode-identical to Aave V3's Pool contract. Same 4-byte selectors. Same storage layout. Same getUserAccountData() return tuple. Same event signatures. Different deployment address, different AaveOracle instance, different reserve list. That's it. That's the difference.
For a protocol relationship that precise, the right adapter is deep reuse — subclass, not compose. This is the shortest post in the protocol-coverage theme because the adapter is the shortest in the codebase.
The whole adapter
mayavi/protocols/spark.py — roughly 30 lines of Python, including the docstring:
"""SparkLend (Aave V3 fork) Pool helpers.
SparkLend is a fork of Aave V3 — its Pool contract is bytecode-identical
to Aave V3's (same ABI, same 4-byte selectors), just deployed at a different
on-chain address with its own AaveOracle instance and reserve set. So this
module reuses every encoder / decoder / borrower-ergonomics method from
mayavi.protocols.aave_v3 and only swaps the resolved Pool address.
"""
from mayavi.evm.chains.mainnet import SPARK_ORACLE, SPARK_POOL
from mayavi.evm.fork import Fork
from mayavi.protocols.aave_v3 import AaveAccount
class UnsupportedSparkChainError(ValueError):
"""Constructing SparkAccount against a chain with no SparkLend deployment."""
_SPARK_POOL_BY_CHAIN: dict[int, bytes] = {
1: SPARK_POOL,
}
class SparkAccount(AaveAccount):
"""SparkLend borrower wrapper — thin subclass over AaveAccount."""
def __init__(self, fork: Fork, account: bytes) -> None:
if fork.config.chain_id not in _SPARK_POOL_BY_CHAIN:
raise UnsupportedSparkChainError(
f"SparkLend is not deployed on chain_id={fork.config.chain_id}"
)
self._fork = fork
self._account = account
self._pool_address = _SPARK_POOL_BY_CHAIN[fork.config.chain_id]Inheriting from AaveAccount gives supply, borrow, repay, withdraw, health_factor, the whole borrower-ergonomics surface — for free, byte-for-byte, with no maintenance cost. Because the inherited methods all read self._pool_address rather than a module-level constant, just rebinding that attribute in __init__ makes every method route to SparkLend instead of Aave V3.
That's the whole adapter. It produces a real bundle:
Why this is unusual
Most fork relationships are not this clean. SushiSwap and Uniswap V2 are bytecode-identical but Sushi has the MasterChef / xSushi staking layer on top. Spark might have looked like Aave V3 in early 2023 but DAI has its own savings rate now; the deployment evolves. Inheritance only works when the surface you're inheriting is the surface, and there's nothing material extending it.
Today, that's true for SparkLend's borrower path. The inheritance works because SparkLend isn't trying to be Aave-V3-plus-something — it's trying to be Aave V3 with a different reserve list and oracle wiring, on the same Pool bytecode. The downstream invariant: if SparkLend ever forks the Pool implementation itself, this adapter breaks loud. The test suite (tests/protocols/test_spark_account.py) re-runs every AaveAccount method's assertions against a forked mainnet at the SparkLend Pool address, so a divergence in the implementation surfaces immediately.
The oracle is wired but unused
SPARK_ORACLE exists in mayavi/evm/chains/mainnet.py — the SparkLend AaveOracle address. A future Spark depeg-cascade scenario would build a Spark-flavoured OracleOverride on top of it (the same bytecode-stub trick from Post 2), but Phase 5 doesn't ship one. Why: budget. The interesting Spark stress scenario is sDAI / DAI depeg, which deserves its own scenario YAML, agent population (Maker DSR rate vs Spark borrow rate dynamics), and an honest validation pass. None of that exists yet. The oracle address sits ready; no scenario calls it.
This is the deliberate pattern of the protocol library: narrow, vertical, scenario-driven. We don't add an oracle override class for a scenario we're not going to write. We don't write a scenario without an agent class that exercises it. The four-pillar discipline — code + tests + validation + verification + docs in the same PR — would catch the half-finished version anyway.
When to subclass and when to compose
Three heuristics from the SparkLend experience:
- Subclass when the upstream is bytecode-identical and the downstream is configuration only. SparkLend is the textbook case.
- Compose when the downstream adds new methods or changes the invariant. Compound V3 is not Aave V3 even though both are lending protocols — different invariant, different liquidation model. No inheritance there; that adapter is its own thing.
- Default to subclass for minor-fork protocols (Spark, Radiant on Aave V3; SushiSwap on Uniswap V2). Default to compose for category peers (Aave V3 vs Compound V3 vs Morpho). The signal isn't "are they similar" — it's "does the upstream's invariant fully describe the downstream, or does the downstream add invariants?"
The mistake the conventional-wisdom-says-compose advice protects against is premature subclassing — making AaveAccount a base class for things that will diverge. The mistake it can introduce is implementing the same protocol twice when one of them is literally a fork.
What's next
Post 7 — Curve StableSwap closes the protocol-coverage theme. It's the only adapter in the codebase that isn't a borrower — Curve is an AMM, the depeg cascade scenario exercises swap dynamics under an oracle shock, and the validation passes look more like the Uniswap V3 Quoter check from Post 2 than the borrower flow we've spent four posts on.