Onchain savings account
Taffi is a cross-border payments app. Users send money internationally, hold USDC balances, and move funds between currencies. The core product is firmly in TradFi territory: KYC, fiat on-ramps, compliance rails. None of that involves a blockchain.
But we had a problem that DeFi had already solved. Users were holding USDC in Taffi and that USDC was sitting idle. Aave was offering 4 to 8 percent APY on USDC deposits with no lockup and instant withdrawal. The gap between what users were earning (zero) and what was available on-chain was impossible to ignore.
So we built a savings feature. This is the engineering story of how we did it, what broke along the way, and why we ended up extracting the whole thing into an open-source Python library called defi-savings.
The Taffi savings tab. Users deposit USDC and earn yield on Base.The architecture decision
The simplest implementation would have been one wallet address per user, each depositing directly into Aave. Clean separation. No yield distribution problem. But it would not have worked for Taffi.
Taffi's treasury architecture uses a shared Gnosis Safe. A single on-chain position holds the pooled USDC of all savings users, and the application layer tracks individual balances off-chain. This is a standard pattern for custodial fintech products that want on-chain yield without routing every user through a wallet onboarding flow. The tradeoff is that you take on a yield distribution problem: one Aave position earns yield continuously, and you have to figure out how much of that yield belongs to each user.
We also had an immediate signing problem. Our staging environment used a single EOA private key. Production used a Gnosis Safe requiring two signatures. Future integrations with custody providers would need something else entirely. Every signing environment had a completely different execution model.
Those two problems, execution and distribution, are what shaped everything that followed.
The first implementation
The first version worked, in the sense that it ran without crashing. It was not well-structured.
The Aave interaction code lived in a service class that also knew how to drive the Gnosis Safe. It fetched the Safe nonce, computed the EIP-712 transaction hash, sorted signers by address (Safe requires this), packed the signatures, and submitted execTransaction. Woven through the same class were the Aave calldata builders: the USDC approval, the supply call, the aToken balance reader, the RAY-to-APY conversion.
It worked in production. But when we needed to add a withdrawal flow, we duplicated large sections of it. When we added a background yield distribution job, it needed its own copy of the balance-reading logic. When a bug appeared in the APY calculation, we found it in three places.
The code was not reusable because it was not designed to be. It was product code that happened to touch a blockchain.
Separating what from how
The core abstraction we needed was a clean separation between what to call on the protocol and how to sign and submit the transaction.
We introduced a Signer ABC:
class Signer(ABC):
@property
def address(self) -> str: ... # where the USDC lives
@property
def w3(self) -> Web3: ... # Web3 instance for read-only calls
def execute(self, calls: list[Call]) -> str: ... # sign and submit, return tx hashAaveProvider builds the calldata for a deposit or withdrawal and passes a list of Call objects to signer.execute(). It does not know what signer it is talking to. GnosisSafeSigner implements execute by running the full multisig flow with atomic MultiSend batching. EOASigner implements it by submitting sequential transactions with a single key. Any custody provider, any wallet type, any future signing setup implements three things and gets the full protocol integration for free.
# Staging: single private key
provider = AaveProvider(EOASigner(private_key, rpc_url))
# Production: Gnosis Safe 2-of-2
provider = AaveProvider(GnosisSafeSigner(safe_address, key1, key2, rpc_url))
# Future: anything else
provider = AaveProvider(MyCoinbaseWalletSigner(...))This is a straightforward application of the strategy pattern, but it eliminated the entire class of bugs that came from signing logic leaking into protocol logic.
Dropping gnosis-py
The Gnosis Safe Python SDK was our first instinct for the multisig implementation. We dropped it within a week.
The dependency pulled in a large transitive tree. The documentation was sparse. The API surface was much wider than what we needed, and the abstractions it provided did not map cleanly to our use case. For a production service where we needed to audit exactly what was happening at every step, a library that did too much invisibly was a liability.
What we actually needed was narrow. Compute the transaction hash. Sign it with two keys. Submit execTransaction. That is it.
It turned out we could do all of this through the Safe's own view functions. getTransactionHash computes the correct EIP-712 hash on-chain, which meant we did not need to replicate the encoding ourselves and could not get it wrong. From there, eth_account handles the signing, and the Safe's checkNSignatures logic accepts the eth_sign style signatures without additional configuration. The whole implementation is under 80 lines with no external Safe dependency.
The concurrency handling required separate attention. Taffi has background jobs running yield distribution on a schedule, and users can initiate withdrawals at any time. Both paths touch the Safe. If they run simultaneously, they race on the nonce: both jobs read the same current nonce, build transactions with the same nonce, and one of them fails on-chain.
We serialise all Safe interactions behind a class-level lock on GnosisSafeSigner. Any call to execute acquires the lock before reading the nonce. This is not elegant, but it is correct, and correctness matters more when the failure mode is a reverted transaction that holds real user funds.
The yield math
The yield distribution problem is subtle in a way that took us a few iterations to get right.
Per-user yield breakdown in Taffi. Multiple users share a single Aave position.The naive approach: for each user, subtract their last known snapshot from the current Aave balance and credit the difference. This breaks immediately when there are multiple users. If the pool holds $4,100 and Alice's snapshot is $1,000, you would credit Alice with $3,100 of growth that mostly belongs to Bob.
The correct invariant is that after every yield run, the sum of all user snapshots must equal the sum of all user balances. This means growth is always:
total_growth = protocol_balance - sum(last_snapshot for all users)Each user's share of that growth is proportional to their balance. We extracted this into distribute_yield(), a pure function:
from defi_savings import AccountSnapshot, distribute_yield
from decimal import Decimal
snapshots = [
AccountSnapshot("alice", balance=Decimal("1000"), last_snapshot=Decimal("1000")),
AccountSnapshot("bob", balance=Decimal("3000"), last_snapshot=Decimal("3000")),
]
distributions = distribute_yield(snapshots, provider.position_balance())
# [("alice", Decimal("25.000000")), ("bob", Decimal("75.000000"))]No network calls. No database reads. No async. The function takes a list of snapshots and a balance and returns a list of (account_id, yield_amount) tuples. It is deterministic and testable without a blockchain. When we found an edge case in the dust-rounding behaviour (amounts below 0.000001 USDC causing fractional credits that accumulated incorrectly), the unit tests caught it before it reached production.
After crediting each user, you advance their snapshot: last_snapshot = balance + yield_amount. The next run will only measure growth that has occurred since the last credit. This prevents double-counting regardless of how frequently you run the job.
Staying out of storage
An early version of what became the library shipped with a Postgres adapter, migration files, and a schema. We removed it before the first public release.
The on-chain execution and yield math are generic. The storage decisions are not. Taffi stores user balances in Postgres with a specific schema that reflects our product model. A different app might use a Merkle tree for gas-efficient on-chain verification, or a single treasury address with no per-user tracking at all. Shipping a database layer would have forced every consumer of the library into our schema.
distribute_yield() is the right boundary. If you need per-user accounting, the math is there. You pass in whatever snapshots you have stored however you have stored them, and you get back the amounts to credit. What you do with those amounts is entirely your problem.
Protocol selection and the ERC-4626 provider
Aave was our starting point, but the question of which protocol to use became a recurring discussion. Morpho MetaMorpho vaults on Base were regularly posting higher APYs. Compound v3 and Moonwell were reasonable alternatives. We wanted a way to compare live rates without building a scraper for each protocol's API.
We added fetch_rates, which queries DefiLlama's yields API and returns pools sorted by APY:
from defi_savings.rates import fetch_rates
pools = fetch_rates(chain="Base", symbol="USDC")
for p in pools:
print(f"{p.project:25s} {p.apy:.2f}% TVL ${p.tvl_usd:>12,.0f}")morpho-blue 8.14% TVL $ 357,000,000
compound-v3 5.92% TVL $ 82,000,000
aave-v3 4.81% TVL $ 610,000,000
moonwell 4.23% TVL $ 35,000,000
For automated selection, score_pools ranks pools by a weighted composite of APY, gas cost, and TVL, each dimension min-max normalised across the result set. Default weights are APY 60%, gas 25%, TVL 15%:
from defi_savings.scoring import score_pools
ranked = score_pools(pools, gas_cost_usd={"morpho-blue": 0.12, "aave-v3": 0.05})Most modern DeFi protocols expose an ERC-4626 interface. Rather than writing a custom provider for each, we built Erc4626Provider, which handles the full deposit, withdrawal, and balance-reading cycle for any compliant vault:
from defi_savings import Erc4626Provider, GnosisSafeSigner
provider = Erc4626Provider(
vault_address = "0xCBeeF01994E24a60f7DCB8De98e75AD8BD4Ad60d",
signer = GnosisSafeSigner(safe_address, key1, key2, rpc_url),
name = "morpho-sirloin-usdc-base",
apy_fn = lambda: fetch_rates("Base", "SIRLOINUSDC")[0].apy,
)
provider.deposit(Decimal("1000"))
balance = provider.position_balance() # shares converted to assets at current price
apy = provider.current_apy() # from apy_fnAaveProvider remains for teams already on Aave who do not need the generic interface. Both implement the same four-method YieldProvider contract.
Extracting the library
By the time we had a working savings feature in Taffi, the on-chain and yield logic had accumulated enough structure that extracting it into a separate package was mostly a matter of removing the Taffi-specific imports and writing a clean public API.
The extraction forced a few good decisions. We had to decide what the library owned (on-chain execution, yield math, rate discovery) and what it did not (storage, user management, API design). The boundary was not obvious from inside the product code; it became obvious when we tried to describe the library in one sentence.
The result is defi-savings: a Python package for on-chain yield that plugs into any signing setup and has no opinions about your database.
uv add defi-savings
# or
pip install defi-savingsWhere it is now
The library is in production inside Taffi and available as an open-source package at github.com/maxcabd/defi-savings.
It ships with:
EOASignerandGnosisSafeSigneras concrete signer implementationsAaveProviderfor Aave v3 on BaseErc4626Providerfor any ERC-4626 vault on any EVM chaindistribute_yield()for pure proportional yield distributionfetch_rates()backed by DefiLlama for live APY discovery across protocolsscore_pools()for weighted protocol ranking
Adding a new signer means implementing two properties and one method on Signer. Adding a custom provider beyond ERC-4626 means implementing four methods on YieldProvider.
If you are building anything that sits at the intersection of Python and on-chain yield, take a look. The issues tracker is open. Pull requests are welcome. If something does not work the way you expect, file a bug and we will look at it.