Reporting Profit and Loss
Description: Operational runbook for the REBALANCER_ROLE holder reporting each epoch's realised P&L.
Reporting P&L
Reporting P&L is one function call. distributeYield is the only function REBALANCER_ROLE gates anywhere in the protocol:
distributeYield(uint256 amount, uint256 feeAmount, uint256 nonce, bool isProfit)Everything else below is preflight work to compute arguments that will not revert — and to avoid the one class of mistake that cannot be undone.
Before you start
YIELD=<yield proxy>
VAULT=<vault proxy>
RPC=<rpc url>Step by step
Confirm the rate limit has elapsed
cast call $YIELD "lastDistributionTime()(uint256)" --rpc-url $RPC
cast call $YIELD "minDistributionInterval()(uint256)" --rpc-url $RPC
cast block latest -f timestamp --rpc-url $RPCRequires lastDistributionTime + minDistributionInterval <= block.timestamp, else DistributionTooFrequent. Default interval is 79,200s (22 hours).
The very first report is exempt — the check is skipped while lastDistributionTime == 0.
Compute the amount ceiling
cast call $VAULT "totalAssets()(uint256)" --rpc-url $RPC
cast call $YIELD "maxYieldPercentage()(uint256)" --rpc-url $RPCamount <= totalAssets() * maxYieldPercentage / 10_000. Default maxYieldPercentage is 50 = 0.5% of recognised assets.
The cap is evaluated against live totalAssets() at execution time, not at the time you read it. A deposit or redemption landing between your read and your transaction shifts the ceiling. Leave headroom on a report near the cap.
Split the realised P&L into the two arguments
This is the step that permanently corrupts vault accounting if you get it wrong.
feeAmount is additive, not deducted from amount. If the strategy realised 5 USDC and the protocol takes 20%:
amount
4e6
5e6
feeAmount
1e6
1e6
Booked
5 USDC
6 USDC against 5 real
The wrong version overstates the vault permanently. Nothing onchain will catch it — the vault has no view of strategy balances.
Both values are in underlying asset decimals — 6 for USDC, not 18. Passing an 18-decimal figure books a 10¹²× overstatement. This is the single highest-consequence input error available here.
Simulate before sending
cast call $YIELD "distributeYield(uint256,uint256,uint256,bool)" \
<amount> <feeAmount> <nonce> <isProfit> \
--from <rebalancer> --rpc-url $RPCA successful call means the guards pass. It does not validate that your figures are economically correct — only that they are accepted.
Verify it landed as intended
cast call $YIELD "epoch()(uint256)" --rpc-url $RPC # incremented by 1
cast call $VAULT "totalAssets()(uint256)" --rpc-url $RPC
cast call $VAULT "getUnvestedAmount()(uint256)" --rpc-url $RPC
cast call $YIELD "treasuryDebt()(uint256)" --rpc-url $RPC # non-zero: loss exceeded treasuryConfirm the DistributeYield event carries the figures you intended.
Every guard, in one place
amount <= totalAssets * maxYieldPercentage / 10_000
(no reason string)
50 bps
feeAmount <= amount * maxFeePercentage / 10_000
(no reason string)
5,000 bps
nonce == epoch + 1
InvalidEpoch
—
trxns[proofHash] unseen
DuplicateTransaction
—
Interval elapsed
DistributionTooFrequent
79,200s
Caller holds REBALANCER_ROLE
AccessControlUnauthorizedAccount
—
The two percentage caps are bare require statements with no message — unlike every other check, which uses a named custom error. Exceeding either cap reverts with no reason string. If a transaction fails with nothing to decode, check the caps first.
proofHash is keyed on (vaultToken, amount, nonce, isProfit) — feeAmount is not part of the replay identity, so the nonce check is what actually prevents a duplicate submission.
Two things this role does not do
It does not move any assets
distributeYield writes accounting only. There is not a single underlying-asset transfer in Yield.sol. Reporting a profit does not bring capital back onchain.
Returning capital is Manager.transferToVault, which requires OPERATOR_ROLE. These are independent systems:
Books
distributeYield
Rebalancer
virtualAssets changes
Assets
transferToVault
Operator
Real tokens arrive at the vault
Reporting profit while the vault stays unfunded raises the share price without making a single redemption payable.
It cannot correct a mistake
There is no reversal path. nonce is strictly sequential, and a bad report can only be offset by an opposing report in a later epoch — itself bounded by the per-epoch cap.
A materially wrong figure could take many epochs to unwind, and throughout that window requestRedeem keeps freezing quotes at the wrong price. Those quotes are final: users who requested during the error are paid the wrong amount permanently.
Simulate every report. A misplaced decimal cannot be undone in one transaction, and the users harmed by it are the ones who exit before you finish unwinding it.
Cadence
There is no required reporting frequency. The interval is a floor, not a ceiling — nothing obliges a report, and there is no staleness check anywhere.
If reporting stops, unvested profit finishes vesting and the share price freezes at its last value indefinitely, while deposits and redemption requests keep quoting against it. Onchain this is indistinguishable from a flat-performing strategy.
The 22-hour default paired with a 7-day vesting period implies daily reporting — each epoch's profit vests across roughly seven subsequent reports, so the schedules overlap and the share price moves smoothly instead of stepping.
Monitoring after each report
Epoch advanced
epoch()
Report was accepted
Vesting in flight
getUnvestedAmount()
Profit not yet in the share price
Treasury debt
treasuryDebt()
A loss exceeded what the treasury could absorb; fees now route to depositors
Redemption backlog
totalRequestedAmount() vs the vault's asset balance
Whether matured redemptions are actually payable
That last row is the one worth alerting on. It is the only onchain signal that separates a healthy vault from one whose books look fine while users cannot exit.
Last updated