A Gnosis Safe Module that autonomously skims yield from a Mamo USDC strategy owned by the Pawthereum Safe and distributes it across a Safe-configured list of recipients, with any unallocated remainder auto-compounded back into protected principal — without ever touching principal.
(strategyValueAfter + safeIdleUSDC) >= protectedPrincipal
If this invariant is ever violated the execution reverts. Principal is sacrosanct.
Once per executionInterval, anyone can poke executeYieldCapture():
- Compute
totalYield = strategy value - protectedPrincipal - For each configured recipient, compute
amount = totalYield * recipient.bps / 10_000. Sum these astotalDistributed. - Withdraw
totalDistributedfrom the Mamo strategy via the Safe and transfer each recipient's share - Verify the principal invariant
- Auto-ratchet: bump
protectedPrincipalby the un-distributed remainder (totalYield - totalDistributed) so the buffer compounds
If recipient bps sum to less than 10,000, the remainder compounds. If they sum to exactly 10,000, nothing compounds and the entire yield is paid out. An empty recipients list means 100% compounds — useful as a "distributions paused, principal still grows" mode (set minimumClaimAmount to 0 in that case).
Only value held by the Mamo strategy creates claimable yield. Raw USDC held directly by the Safe is still counted in the final principal-protection invariant, but it is not treated as yield and will not be redistributed by future claims. This allows the Safe itself to be a recipient without its retained USDC being claimed again in later cycles.
The auto-ratchet means the floor grows monotonically with the strategy.
Before executing, you can dry-run previewYieldCapture() to see expected amounts and whether execution would succeed. It returns a Preview struct. Because balanceOfUnderlying on the Moonwell mToken accrues interest as a side-effect, this function cannot be marked view — but it should still be called as a simulation (no gas, no state change), not as a transaction.
cast call <MODULE_ADDRESS> \
"previewYieldCapture()((uint256,uint256,uint256,uint256,uint256,uint256[],bool))" \
--rpc-url $BASE_RPC_URLThe seven struct fields in order:
| # | Name | Description |
|---|---|---|
| 1 | strategyValue |
Total USDC value held in the Mamo strategy (raw 6-decimal units) |
| 2 | safeIdle |
USDC sitting idle in the Safe itself |
| 3 | totalYield |
strategyValue - protectedPrincipal; Safe idle USDC is reported separately but does not create claimable yield |
| 4 | totalDistributed |
Sum of per-recipient amounts that would be paid out |
| 5 | compoundedAmount |
totalYield - totalDistributed — bumped into protectedPrincipal |
| 6 | amounts |
Per-recipient amounts; amounts[i] corresponds to getRecipients()[i] |
| 7 | canExecute |
true if not paused, interval has elapsed, yield is non-zero, and totalDistributed >= minimumClaimAmount |
Divide any USDC amount by 1e6 for a human-readable value. If canExecute is false, check whether the module is paused, the interval hasn't elapsed yet, there's no yield to claim, or distributions are below the minimum threshold.
Do not send previewYieldCapture as a transaction — return values are discarded by the EVM when called that way, and you will spend gas for nothing.
The Safe owns the recipient list. Each entry is (address addr, uint16 bps) and the sum of bps across all entries must be ≤ 10,000. Whatever doesn't sum to 10,000 is the share that auto-compounds into protectedPrincipal each cycle.
Read the current configuration:
# returns (recipients, compoundBps)
cast call <MODULE_ADDRESS> \
"getDistribution()((address,uint16)[],uint16)" \
--rpc-url $BASE_RPC_URLUpdate via a Safe transaction calling setRecipients((address,uint16)[]). Validation rules:
- Each
addrmust be non-zero - Each
bpsmust be > 0 (omit a recipient instead of giving it 0 bps) - No duplicate addresses
- Sum of all
bps≤ 10,000 - At most
MAX_RECIPIENTSentries (16)
An empty list is allowed and means "100% compound". setRecipients replaces the entire list — there are no add/remove primitives.
Mirrors Mamo's internal _getTotalBalance() exactly:
morphoVault.convertToAssets(morphoVault.balanceOf(strategy))
+ mToken.balanceOfUnderlying(strategy)
+ USDC.balanceOf(strategy)Verified against moonwell-fi/mamo-contracts source — see src/PawthereumMamoYieldModule.sol::getStrategyValue.
The Safe may hold raw USDC directly, including because the Safe itself is configured as a recipient. That USDC is not part of the yield calculation:
totalYield = strategyValue - protectedPrincipalIt still counts toward the post-execution safety check:
strategyValueAfter + safeIdleUSDC >= protectedPrincipalThis distinction keeps the protected-principal invariant conservative without treating treasury USDC held by the Safe as recurring distributable yield.
The module relies on Safe.execTransactionFromModuleReturnData(...), which has been part of the Safe interface since v1.3.0 (March 2021). Deployments must use a Safe ≥ 1.3.0. The current canonical Safe v1.4.1 singleton on Base is 0x41675C099F32341bf84BFc5382aF534df5C7461a. End-to-end integration coverage against a real Safe v1.4.1 proxy lives in test/PawthereumMamoYieldModule.fork.t.sol (the RealSafeForkTest contract).
| USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| Moonwell mUSDC | 0xEdc817A28E8B93B03976FBd4a3dDBc9f7D176c22 |
| Moonwell Flagship USDC vault | 0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca |
| Mamo USDC strategy factory | 0x5967ea71cC65d610dc6999d7dF62bfa512e62D07 |
The per-Safe Mamo strategy address is created via the factory and supplied at module-deployment time.
src/
PawthereumMamoYieldModule.sol # the module
interfaces/
IMamoStrategy.sol
IMToken.sol
IERC4626Minimal.sol
IERC20Minimal.sol
ISafe.sol
script/
DeployPawthereumMamoYieldModule.s.sol
test/
PawthereumMamoYieldModule.t.sol # mock-based unit tests
PawthereumMamoYieldModule.fork.t.sol # Base mainnet fork tests
forge build# unit tests (no network)
forge test --match-path "test/PawthereumMamoYieldModule.t.sol" -vv
# fork tests (requires BASE_RPC_URL)
set -a && source .env && set +a
forge test --match-path "test/PawthereumMamoYieldModule.fork.t.sol" -vv
# everything
forge testCopy .env.example to .env and fill in BASE_RPC_URL and ETHERSCAN_API_KEY for fork tests and verification.
Set the env vars in .env:
SAFE= # the Pawthereum Gnosis Safe
MAMO_STRATEGY= # strategy created by the Mamo factory for the Safe
RECIPIENT_ADDRESSES=0xAAA...,0xBBB... # comma-separated; same length as RECIPIENT_BPS
RECIPIENT_BPS=4500,4500 # comma-separated; sum must be <= 10000
PROTECTED_PRINCIPAL= # initial USDC floor (6 decimals)
EXECUTION_INTERVAL=604800 # 7 days
MIN_CLAIM_AMOUNT=1000000 # 1 USDC minimum to bother executing
RECIPIENT_ADDRESSES and RECIPIENT_BPS may both be empty strings to deploy with no recipients (100% compound from day one).
Then:
set -a && source .env && set +a
forge script script/DeployPawthereumMamoYieldModule.s.sol \
--rpc-url $BASE_RPC_URL --broadcast --verifyAfter deployment, the Safe must enable the module. This module cannot enable itself — that requires Safe-owner signatures.
Both operations are Safe transactions where the target is the Safe itself and the value is 0.
Submit a Safe transaction calling enableModule(address) on the Safe, passing the deployed module address.
Via the Safe UI (app.safe.global):
- New Transaction → Transaction Builder
- Set To to the Safe's own address
- Paste the ABI:
[{"inputs":[{"name":"module","type":"address"}],"name":"enableModule","outputs":[],"stateMutability":"nonpayable","type":"function"}] - Set
moduleto<MODULE_ADDRESS> - Collect required owner signatures and execute
Via cast (encode calldata for the Safe UI or API):
cast calldata "enableModule(address)" <MODULE_ADDRESS>Use the resulting hex as the transaction data field in the Safe UI or when proposing via the Safe Transaction Service API.
The Safe uses a singly-linked list for modules. To remove a module you must supply the address of the module that points to it (prevModule). If the module was the first one enabled (or is the only module), prevModule is the sentinel 0x0000000000000000000000000000000000000001.
Step 1 — find prevModule:
# Returns (address[] modules, address next) — sentinel is 0x0000...0001
cast call <SAFE_ADDRESS> \
"getModulesPaginated(address,uint256)(address[],address)" \
0x0000000000000000000000000000000000000001 10 \
--rpc-url $BASE_RPC_URLWalk the returned array: the entry immediately before <MODULE_ADDRESS> in the list is prevModule. If <MODULE_ADDRESS> is first in the array, prevModule is the sentinel 0x0000000000000000000000000000000000000001.
Step 2 — submit the Safe transaction:
cast calldata "disableModule(address,address)" <PREV_MODULE> <MODULE_ADDRESS>Submit this calldata as a Safe transaction where To is the Safe's own address. Collect required owner signatures and execute.
Only the Safe can call:
setRecipients((address,uint16)[])(replaces the entire recipient list — see Configuring recipients)setProtectedPrincipal(uint256)(manual override of the auto-ratcheted floor)setExecutionInterval(uint256)setMinimumClaimAmount(uint256)pause()/unpause()
The module cannot:
- call arbitrary contracts
- accept external calldata
- delegatecall
- approve tokens
- pull funds out of the Mamo strategy beyond the computed strategy yield
- send funds to anyone other than the configured recipients
- change strategy ownership
All call-targets are immutable, all amounts are derived from the on-chain value calculation, and the recipient list is the only mutable destination set (Safe-controlled).