incentive teller with principal checkpointing, IncentivePool, and rewards claiming - #627
incentive teller with principal checkpointing, IncentivePool, and rewards claiming#627bxmmm1 wants to merge 55 commits into
Conversation
…and add paginated incentive history Extract shared logic into TellerWithMultiAssetSupportLib, LayerZeroTellerLib, and PairwiseRateLimiterLib to reduce code duplication across teller variants. Consolidate LayerZero chain management: replace allowMessagesFromChain, allowMessagesToChain, stopMessagesFromChain, stopMessagesToChain, and setChainGasLimit with addChain (full overwrite) and stopMessages (selective disable). Add paginated history retrieval to IncentivePool. Add comprehensive test coverage for LZ teller rate limiting, principal history tracking, and reward routing (2,600+ lines).
Use getRateSafe() (base-denominated) instead of getRateInQuoteSafe(asset) so principal is always tracked in base units regardless of deposit/withdraw asset. Use mulDivDown on deposits and mulDivUp on withdrawals to prevent phantom principal accumulation from rounding dust. Add pool allowlist vulnerability test demonstrating teller calls arbitrary user-supplied pool addresses without validation.
… tracking and fix compliance window semantics PrincipalCheckpoint now stores cumulativeDeposits and cumulativeWithdrawals (uint104 each) instead of a single net cumulativePrincipalInBaseAsset (uint208). This enables the off-chain reward system to correctly attribute principal across transfers without inflating rewards for share receivers. Key changes: - beforeTransfer hook changed from view to state-mutating, pushing timestamp-only checkpoints on share transfers so off-chain can reconcile via Transfer events - complianceDeadline renamed to complianceWindow with relative duration semantics (deadline must be <= block.timestamp + window, not an absolute cap) - depositAndBridge now records principal checkpoints for cross-chain deposits - Added SafeCast.toUint104 for overflow protection on cumulative values - Added fuzz tests for principal invariants (monotonicity, no phantom principal) - Added reentrancy/auth tests for TellerWithYieldStreaming.withdrawWithRewards
… fix withdrawWithRewards ordering IncentivePool.processRewards now returns 0 instead of reverting for invalid/expired/rate-limited claims so that withdrawWithRewards never reverts due to a failed reward. Bridge operations now create withdrawal checkpoints to prevent phantom principal on the source chain.
…d require initial maxDeadline Add nonReentrant + requiresAuth to TellerWithYieldStreaming.withdrawWithRewards to close the reentrancy/auth gap. Require initialMaxDeadline in IncentivePool constructor so the pool is never deployed with an unset staleness window. Extract CrossChainTellerLib to reduce CrossChainTellerWithGenericBridge bytecode. Optimize _checkpointTransfer to overwrite consecutive transfer-only entries instead of unbounded array growth.
…lCheckpoint to lib Adds a `to` field to `DepositParams` so deposits can be directed to an arbitrary recipient instead of always msg.sender. Updates the decoder to include the `to` address in sanitizer output. Moves `PrincipalCheckpoint` struct into TellerWithMultiAssetSupportLib for shared access. Updates all test call sites to pass the new struct shape.
Extends TellerDecoderAndSanitizer with decoders that extract pool addresses from RewardData arrays for merkle leaf verification. Includes integration tests through the full ManagerWithMerkleVerification path.
…imports IncentivePool._checkSignature now uses ECDSA.tryRecover instead of ECDSA.recover so malformed signatures return false rather than reverting, preserving the soft-fail contract of processRewards. Also removes unused SafeTransferLib and ReentrancyGuard imports from TellerWithYieldStreaming.
… tests Add comprehensive BackendRewardSimulation test simulating off-chain reward calculations, and expand PrincipalHistoryFuzz with deposit/withdraw/bridge flow coverage. Remove duplicate _checkpointPrincipal call in bridge deposit.
Blacklisting is handled at a different layer; the in-contract check was redundant and added unnecessary storage/gas overhead. Removes setBlacklisted, the blacklisted mapping, and all related tests.
Change withdraw and withdrawWithRewards in TellerWithYieldStreaming from public to external since they are not called internally.
Record the exchange rate at checkpoint time so the off-chain reward system can compute share value without replaying historical rates.
…ansfer restrictions Delete TellerWithMultiAssetSupportLib and move all structs, errors, events, and logic directly into TellerWithMultiAssetSupport. Add transferAllowedRole to restrict share transfers so at least one counterparty must hold a specified role. Make beforeTransfer view by removing checkpoint writes from transfers. Rename cumulativeRewards to cumulativeOwed in IncentivePool for clarity.
Verify that native ETH cannot be accidentally lost through bridge functions after removing the revertOnNativeDeposit modifier. Covers NATIVE sentinel reverts, ERC20 bridge fee paths, excess msg.value rejection, and DualDeposit guard integrity.
…ccounting Replace _checkpointPrincipal with _checkpointPrincipalAtRate so callers specify the rate used for base-value conversion. This lets refundDeposit record the withdrawal at the original deposit-time rate, ensuring the refund exactly cancels the deposit's principal impact instead of being inflated by subsequent rate changes. Also adds a missing bridge-deposit checkpoint in CrossChainTellerWithGenericBridge and includes depositSharePrice in the public deposit history hash.
Prevents depositWithPermit from being called with the NATIVE sentinel address. The previous defense was indirect (accountant revert on missing rate), which could be bypassed if the accountant ever mapped the sentinel.
…d code disallowBufferHelper only flipped the allowlist flag without clearing the helper from currentBufferHelpers, leaving a disallowed helper actively executing on every deposit/withdrawal. This was a known gap marked as a TODO in the buffer helper tests (line 673). The fix atomically zeros out any active deposit/withdraw helper that matches the disallowed address. Also removes three unused functions from LayerZeroTellerLib (allowMessagesFromChain, allowMessagesToChain, setChainGasLimit) and their associated events that were never called from any contract.
Remove duplicate MessageReceived event from CrossChainTellerWithGenericBridge (only emitted from CrossChainTellerLib). Add natspec clarifying intentional depositParams.to ignore in _depositAndBridge. Fix typos in TellerWithMultiAssetSupport. Add getClaimHistoryPaginated test coverage for IncentivePool.
depositAndBridge was only verifying deposit compliance (depositor, asset, amount) but not the bridge destination address. The standalone bridge() enforced this via _verifyBridgeCompliance, creating a bypass path where a compliant depositor could bridge shares to a non-compliant address. Add _verifyDepositAndBridgeCompliance to CrossChainTellerWithGenericBridge that hashes the bridge `to` into the compliance message, so the signer explicitly approves the full deposit-and-bridge action in one signature.
…aming - Add missing event emission on setTransferAllowedRole for off-chain monitoring - Cache accountant.getRateSafe() to avoid redundant external calls in deposit, withdraw, and bridge paths - Pass currentRate as explicit parameter to _checkpointPrincipalAtRate instead of fetching it internally - Fix misleading mapping parameter name: signatureHash -> messageHash - Replace vm.recordLogs with vm.expectEmit in transfer role tests
Include msg.sender in the compliance hash alongside depositor, so a signature issued for caller A cannot be replayed by caller B even when deposit parameters are identical.
Remove dead using-directives and imports across AccountantWithYieldStreaming, LayerZeroTeller, LayerZeroTellerWithRateLimiting, CrossChainTellerWithGenericBridge, and ArcticArchitectureLens. Add natspec to the four admin functions in LayerZeroTellerLib.
Move ECDSA verification, principal checkpointing, and buffer helper logic into TellerWithMultiAssetSupportLib to reduce bytecode size of all child contracts. This brings both ChainlinkCCIPTeller and LayerZeroTeller under the EIP-170 24,576 byte limit. - CCIP: 26,093 -> 23,945 bytes - LZ: 26,088 -> 23,941 bytes Also fix compliance test signing helper to include msg.sender in hash (missed in 892d36c) and correct version string expectation.
Pass the already-fetched rate through _afterPublicDeposit and _bridge instead of each function calling accountant.getRateSafe() independently. Saves 1-2 external calls per deposit/bridge transaction.
The test expected a revert from share locking but never called setShareLockPeriod, so the default 0 meant shares were never locked.
…transfer allowlist Rename the struct field for clarity and extend _enforceTransferAllowlist to also check the operator address, so approved operators holding the transfer role can move shares on behalf of users.
…ted history Add allowedIncentivePools mapping to TellerWithMultiAssetSupport so _processRewards rejects non-allowlisted pool addresses instead of acting as an unrestricted proxy. Remove the on-chain per-user totalRewardCap (backend controls caps via signed cumulativeOwed). Replace getPrincipalHistory with getPrincipalHistoryPaginated and add a balance check in processRewards for graceful soft-failure. Introduce TellerHistoryHelper to aggregate principal and claim histories across teller and multiple pools in a single call.
Use RolesAuthority role (default 224) instead of a fixed signer address. complianceSignerRole uses type(uint8).max as disabled sentinel, matching the transferAllowedRole convention. Enables multiple signers via role grants and instant revocation without contract state changes.
Move user history view functions into the existing lens contract and delete the standalone helper, reducing deployment surface.
Greptile SummaryThis PR introduces principal checkpointing, IncentivePool, and rewards claiming on the teller system. It is a well-architected, extensively tested PR (~8,500 lines of tests) with clean library extractions and solid compliance binding. Three P1 issues need resolution before merging: (1) missing principal checkpoint on bridge receive in Confidence Score: 3/5Not ready to merge without addressing the missing bridge-receive checkpoint and the withdrawWithRewards authorization gap Two P1 issues directly undermine core PR invariants: bridge recipients receive no _principalHistory entry on the destination chain (contradicts the stated 'every bridge writes a checkpoint' guarantee), and withdrawWithRewards hard-reverts the asset withdrawal when rewards[] contains an unauthorized pool (inconsistent with IncentivePool's soft-fail design). The missing native-ETH guard on depositAndBridge is a third P1 by defence-in-depth standards. Extensive test coverage (~8,500 lines), compliance binding improvements, nonReentrant+requiresAuth pairing, and bytecode reductions are all solid — addressing the three P1s would bring this to 4/5. src/base/Roles/CrossChain/CrossChainTellerWithGenericBridge.sol (missing checkpoint on receive, native-ETH guard), src/base/Roles/TellerWithMultiAssetSupport.sol (withdrawWithRewards revert semantics) Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant Teller as TellerWithMultiAssetSupport
participant Lib as TellerWithMultiAssetSupportLib
participant Vault as BoringVault
participant Pool as IncentivePool
participant Accountant
Note over User,Accountant: deposit() flow
User->>Teller: deposit(DepositParams, referralAddress, ComplianceData)
Teller->>Lib: verifyAndMark(usedSigs, authority, role, window, hash, deadline, sig)
Lib-->>Teller: compliance OK
Teller->>Vault: enter(from, depositAsset, amount, to, 0)
Vault-->>Teller: shares minted to params.recipient
Teller->>Accountant: getRateSafe()
Teller->>Lib: checkpointPrincipalAtRate(user, shares, isDeposit=true, rate, rate)
Teller-->>User: shares
Note over User,Accountant: withdrawWithRewards() flow
User->>Teller: withdrawWithRewards(asset, shares, minAssets, to, rewards[])
Teller->>Teller: beforeTransfer(msg.sender, 0x0, msg.sender)
Teller->>Vault: exit(to, asset, assetsOut, msg.sender, shares)
Teller->>Lib: checkpointPrincipalAtRate(user, shares, isDeposit=false, rate, rate)
loop For each RewardData
Teller->>Lib: processRewards(allowedPools, rewards, user)
Lib->>Pool: processRewards(user, cumulativeOwed, deadline, sig)
Pool-->>Lib: amountSent (0 if soft-fail)
Pool->>User: transfer(amountSent)
end
Teller-->>User: assetsOut
Note over User,Accountant: bridge() source chain
User->>Teller: bridge(shareAmount, to, wildcard, feeToken, maxFee, compliance)
Teller->>Teller: _verifyBridgeCompliance(sender, shareAmount, to, deadline, sig)
Teller->>Lib: checkpointPrincipalAtRate(msg.sender, shares, isDeposit=false, rate, rate)
Teller->>Vault: exit(0x0, 0x0, 0, msg.sender, shareAmount) [burn]
Teller->>LZ: _sendMessage(encodedMsg, options, fee)
Note over Teller,Vault: _completeMessageReceive() destination chain — NO checkpoint written!
Teller->>Vault: enter(0x0, 0x0, 0, m.to, m.shareAmount) [mint to recipient]
|
| /** | ||
| * @notice Sets the reward signer | ||
| * @param newSigner The address of the reward signer | ||
| * @dev Callable by OWNER_ROLE. | ||
| * When rotating keys, do not reuse a previously active signer address. | ||
| * Reusing a retired key would re-enable any unexpired signatures issued under that key. |
There was a problem hiding this comment.
rewardSigner defaults to address(0) — dangerous partial-init window
The constructor leaves rewardSigner as address(0). If setMaximumRewardAmountPerClaim is called before setRewardSigner, the pool enters a partially-initialised state. While OZ's ECDSA.tryRecover returns RecoverError.InvalidSignature for a zero-address recovery (making an actual exploit practically impossible), requiring rewardSigner in the constructor would tighten the invariant:
constructor(address _owner, ERC20 rewardToken, uint32 initialMaxDeadline, address initialRewardSigner) Auth(...) {
if (initialRewardSigner == address(0)) revert InvalidSigner();
rewardSigner = initialRewardSigner;
...
}| ERC20 feeToken, | ||
| uint256 maxFee, | ||
| address referralAddress, | ||
| ComplianceData calldata compliance | ||
| ) internal returns (uint256 sharesBridged) { | ||
| _verifyDepositAndBridgeCompliance( | ||
| msg.sender, depositParams.depositAsset, depositParams.depositAmount, to, compliance | ||
| ); | ||
| { | ||
| Asset memory asset = _beforeDeposit(depositParams.depositAsset); | ||
| sharesBridged = _erc20Deposit( | ||
| depositParams.depositAsset, | ||
| depositParams.depositAmount, | ||
| depositParams.minimumMint, | ||
| msg.sender, | ||
| msg.sender, | ||
| asset |
There was a problem hiding this comment.
depositParams.recipient is silently ignored in _depositAndBridge
The comment on _depositAndBridge notes that depositParams.recipient is intentionally ignored, with shares always minted to msg.sender before being bridged to the separate to argument. Callers who populate params.recipient expecting it to control the destination-chain recipient will be silently wrong. Consider adding an explicit guard or strengthening the natspec to prominently warn that depositParams.recipient has no effect in the bridge path.
| @@ -490,37 +663,39 @@ contract TellerWithMultiAssetSupport is Auth, BeforeTransferHook, ReentrancyGuar | |||
| from = msg.sender; | |||
| } | |||
|
|
|||
| shares = _erc20Deposit(depositAsset, depositAmount, minimumMint, from, msg.sender, asset); | |||
| _afterPublicDeposit(msg.sender, depositAsset, depositAmount, shares, shareLockPeriod, referralAddress); | |||
| _verifyComplianceSignature(to, depositAsset, depositAmount, compliance); | |||
| shares = _erc20Deposit(depositAsset, depositAmount, params.minimumMint, from, to, asset); | |||
| uint256 rate = accountant.getRateSafe(); | |||
| _checkpointPrincipalAtRate(to, shares, true, rate, rate); | |||
| _afterPublicDeposit(to, depositAsset, depositAmount, shares, shareLockPeriod, referralAddress, rate); | |||
| } | |||
|
|
|||
| /** | |||
| * @notice Allows users to deposit into BoringVault using permit. | |||
| * @dev Publicly callable. | |||
| */ | |||
| function depositWithPermit( | |||
| ERC20 depositAsset, | |||
| uint256 depositAmount, | |||
| uint256 minimumMint, | |||
| uint256 deadline, | |||
| uint8 v, | |||
| bytes32 r, | |||
| bytes32 s, | |||
| address referralAddress | |||
| ) | |||
| external | |||
| virtual | |||
| requiresAuth | |||
| nonReentrant | |||
| revertOnNativeDeposit(address(depositAsset)) | |||
| returns (uint256 shares) | |||
| { | |||
| Asset memory asset = _beforeDeposit(depositAsset); | |||
|
|
|||
| _handlePermit(depositAsset, depositAmount, deadline, v, r, s); | |||
|
|
|||
| shares = _erc20Deposit(depositAsset, depositAmount, minimumMint, msg.sender, msg.sender, asset); | |||
| _afterPublicDeposit(msg.sender, depositAsset, depositAmount, shares, shareLockPeriod, referralAddress); | |||
| DepositParams calldata params, | |||
| PermitData calldata permit, | |||
| address referralAddress, | |||
There was a problem hiding this comment.
deposit natspec missing that params.recipient is bound in the compliance hash
The NatDoc comment correctly notes that native ETH deposits must use the nativeWrapper address in compliance signatures. It should also note that params.recipient (not msg.sender) is bound in the hash, since the compliance signer must explicitly approve the intended recipient:
// The compliance hash covers: (teller, chainId, msg.sender, params.recipient,
// nativeWrapperAddress, depositAmount, deadline) for native ETH deposits.|
|
||
| // ========================================= BUFFER HELPERS ========================================= | ||
|
|
||
| /// @notice Execute buffer management before a withdrawal. | ||
| function beforeWithdraw( | ||
| mapping(ERC20 => BufferHelpers) storage bufferHelpers, | ||
| BoringVault vault, | ||
| ERC20 withdrawAsset, | ||
| uint256 assetAmount | ||
| ) external { | ||
| if (address(bufferHelpers[withdrawAsset].withdrawBufferHelper) != address(0)) { | ||
| (address[] memory targets, bytes[] memory data, uint256[] memory values) = bufferHelpers[withdrawAsset].withdrawBufferHelper | ||
| .getWithdrawManageCall(address(withdrawAsset), assetAmount); |
There was a problem hiding this comment.
processRewards return values from IncentivePool are discarded without logging
TellerWithMultiAssetSupportLib.processRewards calls IncentivePool.processRewards(...) but ignores the returned uint256. Because IncentivePool.processRewards is soft-fail, silent zero-value claims are indistinguishable from successful claims at the teller level. Consider emitting a teller-level event per pool with the returned amount to aid off-chain monitoring and user-facing UX.
| * @dev When set to type(uint8).max (255), transfers are unrestricted. | ||
| * Any other value restricts transfers so that either `from` or `to` must hold that role. | ||
| */ | ||
| uint8 public transferAllowedRole = type(uint8).max; |
There was a problem hiding this comment.
should this not just be handled by the roles auth contract?
There was a problem hiding this comment.
this is a way to allow the queue to be the allowed operator without adding extra roles.
| function allowPermissionedOperator(address operator) external requiresAuth { | ||
| beforeTransferData[operator].permissionedOperator = true; | ||
| emit AllowPermissionedOperator(operator); | ||
| function setTransferAllowedRole(uint8 _transferAllowedRole) external requiresAuth { |
There was a problem hiding this comment.
can be handled by roles auth depending on the intended flow of who is allowed to call this.
There was a problem hiding this comment.
the admin/multisig is responsible for defining which role can do transfers, e.g. only addresses that have the BoringQueue role
| * Any other value enables checks; the recovered signer must hold that role in the RolesAuthority. | ||
| * @param _complianceSignerRole The role ID required for compliance signers, or 255 to disable. | ||
| */ | ||
| function setComplianceSignerRole(uint8 _complianceSignerRole) external requiresAuth { |
There was a problem hiding this comment.
same as other role setting function. Depends on intended caller, can be removed or keep if we are not setting this ourselves.
There was a problem hiding this comment.
I think this one should probably be a purely rolesAuth thing tbh. the only consideration that isnt solved by both this and by the rolesAuth route is signer compromise when the rolesAuth has a timelock. but that can be handled by pausing
| /** | ||
| * @notice Per-user cumulative principal history in base-asset value. | ||
| */ | ||
| mapping(address user => PrincipalCheckpoint[]) internal _principalHistory; |
There was a problem hiding this comment.
group mappings and other like variable types together
| assertGt(rB, 0, "bob earns"); | ||
|
|
||
| _claimReward(alice, rA); | ||
| _claimReward(bob, rB); |
There was a problem hiding this comment.
should we check that rewards earned by bob and alice are equal here?
There was a problem hiding this comment.
not really, it is a fuzz test and they probably deposit different amounts at the same time, if we are using the same formula for both they shouldn't receive the same amount of rewards. I'll add that bigger deposit = more rewards assert
| * When rotating keys, do not reuse a previously active signer address. | ||
| * Reusing a retired key would re-enable any unexpired signatures issued under that key. | ||
| */ | ||
| function setRewardSigner(address newSigner) external requiresAuth { |
There was a problem hiding this comment.
I think this is good as is so that separate roles can manage separate pools
Merge main into feature/incentive-teller-2, resolving 7 conflicts in favor of the struct-based deposit API (V0.3) with compliance signatures introduced on this branch. Conflict resolution decisions: - src/base/Roles/TellerWithMultiAssetSupport.sol: kept struct-based deposit(DepositParams, address, ComplianceData) over the old parameter-based deposit(ERC20, uint256, uint256, address) overload. Removed dead _publicDeposit helper from incoming. Version stays V0.3. - test/TellerVersions.t.sol: kept V0.3 version assertions, dropped TellerWithBuffer (no source file exists). - test/TellerWithMultiAssetSupport.t.sol: kept TransferAllowedRoleSet event and COMPLIANCE_SIGNER_ROLE. Re-added deposit-to-receiver and refund tests rewritten for struct-based API. - test/TellerWithMultiAssetSupportBuffer.t.sol: kept buffer helper isolation tests from HEAD. - test/AccountantWithYieldStreaming.t.sol: re-added testDepositToInheritsYieldStreamingFlow using struct-based deposit. - test/TellerWithYieldStreamingBuffer.t.sol: re-added testDepositToReceivesSharesAndLocksReceiver and testDepositToRefundReturnsAssetsToReceiver using struct-based deposit. - test/EtherFiLiquid1Migration.t.sol: kept TODO for struct-based migration of fork test deposit calls. - test/integrations/TellerWithReferral.t.sol: accepted deletion (intentionally removed on this branch). Auto-merged fixups (old API leaked through non-conflicting sections): - Replaced manual keccak256 deposit selectors with TellerWithMultiAssetSupport.deposit.selector across all test setUp functions. - Removed ROUTER_ROLE, DEPOSIT_SELECTOR, DEPOSIT_TO_SELECTOR constants that referenced the deleted deposit overload. - Updated fuzzing handlers (TellerHandler.sol) to use struct-based deposit, fixed BeforeTransferData destructuring (4 fields not 5), added depositSharePrice to DepositRecord and refundDeposit calls, removed references to deleted setPermissionedTransfers. - Updated fuzzing InvariantTestRP/MedusaInvariantTestRP selector arrays to remove permissioned transfer selectors. All 131 tests pass (125 original + 6 re-added deposit-to tests).
Document that depositParams.recipient is ignored in _depositAndBridge (shares mint to msg.sender before bridging to the `to` param), and that the compliance hash binds params.recipient as the approved receiver.
Allow authorized callers (e.g. automated bot) to zero out the reward signer, effectively pausing all claims until a new signer is set.
Update TellerWithMultiAssetSupport deposit flow and cross-chain bridge to propagate the recipient address. Adjust decoder/sanitizer and update tests accordingly.
Use TellerWithMultiAssetSupport.deposit.selector instead of hardcoded keccak256 hash in NonWadScaledVault, NonWadScaledVaultBTC, and AtomicQueue tests. Update poolInsufficientBalance test to expect soft no-op after processRewards balance guard was added.
…ction Merge granular admin setters into single-call alternatives to reduce bytecode and simplify role configuration: - denyAll/allowAll/denyFrom/allowFrom/etc -> setDenyFlags - setComplianceSignerRole + setComplianceWindow -> setComplianceConfig - setTransferAllowedRole -> setTransferRestrictions (now includes depositForOthersRole) - stopMessagesFromChain + stopMessagesToChain -> stopMessages Add depositForOthersRole gate so depositing to a different recipient can be restricted to authorized addresses. Move deposit-and-bridge compliance verification into CrossChainTellerLib to reduce leaf contract bytecode.
…enable type(uint8).max now means disabled (deposit-for-others blocked) rather than unrestricted. Any other value enables the feature for holders of that role. Self-deposits (msg.sender == recipient) are always allowed.
Introduces `principalTeller` state variable and `setPrincipalTeller` admin function so the queue can call `checkpointQueueWithdrawal` on a teller each time a withdrawal request is solved, enabling accurate principal tracking for incentive distribution.
Aligns OnChainWithdraw.nonce struct field, OnChainWithdrawRequested event param, and local requestNonce variable with the uint88 state variable. Updates test event signatures and abi.decode calls to match.
* fix: _checkRecipient and beforeTransfer * docs: natspec * feat: add sender and recipient to Withdraw event - Update Withdraw event signature to include indexed user and recipient - Update emit calls in TellerWithMultiAssetSupport and TellerWithYieldStreaming - Add expectEmit assertion in PrincipalHistory partial withdraw test * rename sharePrice -> vaultSharePrice in PrincipalCheckpoint * safecast in AccountantWithYieldStreaming * fix: allow withdrawals when transferAllowedRole restricts p2p transfers
Introduces the incentive teller system — a set of teller upgrades that track per-user principal checkpoints on deposit/withdraw/bridge, integrate with a new
IncentivePoolcontract for off-chainreward distribution, and add
withdrawWithRewardstoTellerWithYieldStreaming.Core features
cumulativeDeposits,cumulativeWithdrawals, andsharePriceat each deposit/withdraw/bridge so the off-chain reward system can attribute principal withoutreplaying historical rates
processRewards(returns 0 instead of reverting for invalid/expired/rate-limited claims), paginated claim history, andconfigurable
complianceWindowsemanticstorecipient on deposits:DepositParams.toallows directing deposits to an arbitrary recipient instead of alwaysmsg.sendertransferAllowedRolerequires at least one counterparty in a share transfer to hold a specified roleArchitecture changes
TellerWithMultiAssetSupportLib,LayerZeroTellerLib,CrossChainTellerLib, andPairwiseRateLimiterLibto reduce bytecode size (CCIP teller: 26,093 → 23,945bytes, LZ teller: 26,088 → 23,941 bytes — both under EIP-170 limit)
allowMessagesFromChain/To,stopMessagesFromChain/To,setChainGasLimitwithaddChain(full overwrite) andstopMessages(selective disable)TellerWithBufferandPairwiseRateLimiter: Replaced by buffer helper integration in base teller andPairwiseRateLimiterLibSecurity fixes
msg.senderto prevent front-running replaydepositAndBridge(previously only checked deposit params, not bridgeto)disallowBufferHelpernow atomically clears active helpers (previously left disallowed helper executing)depositWithPermittryRecoverfor soft-fail signature checks in IncentivePoolnonReentrant+requiresAuthonwithdrawWithRewardsgetRateSafe()to eliminate redundant external calls in deposit/bridge paths_checkpointPrincipalAtRatewith explicit rate so refunds cancel at original deposit-time rateTest coverage
BackendRewardSimulation: end-to-end simulation of off-chain reward calculationsPrincipalHistoryFuzz: fuzz tests for principal invariants (monotonicity, no phantom principal)RewardRouting/RewardRoutingNegative: reward claim happy path and failure modesCrossChainTellerCompliance: bridge destination compliance enforcementNativeDepositBridgeSafety: native ETH cannot be lost through bridge functionsTellerWithYieldStreamingReentrancy: reentrancy and auth gap coverageLayerZeroTellerWithRateLimiting: consolidated LZ chain management + rate limitingDeleted Contracts
TellerWithBuffer— entire contract removed, buffer helper logic absorbed intoTellerWithMultiAssetSupportPairwiseRateLimiter— abstract contract removed, logic moved toPairwiseRateLimiterLib(library, no longer overridable)Changed External Function Signatures (ABI-Breaking)
deposit:(ERC20, uint256, uint256, address)→(DepositParams, address, ComplianceData)— shares now mint toparams.toinstead ofmsg.senderdepositWithPermit: flat params →(DepositParams, PermitData, address, ComplianceData)refundDeposit: newuint256 depositSharePriceparam inserted — old deposit hashes are incompatible, pending deposits cannot be refunded after upgradedepositAndBridge: flat params →(DepositParams, address, bytes, ERC20, uint256, address, ComplianceData)depositAndBridgeWithPermit: flat struct → nestedDepositParams/PermitData/ComplianceDatabridge: addedComplianceDataparamgetInstantlyWithdrawableAmount(IBufferLens):TellerWithBuffer→TellerWithMultiAssetSupportRemoved External Functions
setPermissionedTransfers,allowPermissionedOperator,denyPermissionedOperator→ replaced bysetTransferAllowedRole(uint8)allowMessagesFromChain,allowMessagesToChain,setChainGasLimit→ replaced byaddChain(...)(full overwrite)stopMessagesFromChain,stopMessagesToChain→ replaced bystopMessages(uint32, bool, bool)setOutboundRateLimits,setInboundRateLimits→ replaced bysetRateLimits(outbound[], inbound[])New Mandatory Compliance Flow
ComplianceData— ifcomplianceSigner != address(0), valid ECDSA signature bound tomsg.senderis requiredtoaddresscomplianceSignerasaddress(0)New Side Effects on Existing Paths
_principalHistory[user]_afterDeposit/_beforeWithdrawnow execute manage calls through the vault when a buffer helper is configuredIncentivePool Integration Notes
_processRewardscalls user-supplied pool addresses, authorization relies on pool-siderequiresAuthprocessRewardsreturns 0 on all failures (bad sig, expired, rate-limited) — never revertsrescueFundsallows owner to drain reward token