From 2ad3d4504a2c51e422434345e9b67da21d938285 Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Sat, 21 Sep 2024 11:40:55 +0200 Subject: [PATCH 01/13] feat: ported the SereneHook into the new forked repository --- .../foundry/contracts/hooks/SereneHook.sol | 302 ++++++++++++++ .../hooks/interfaces/IGaugeRegistry.sol | 6 + .../hooks/interfaces/IQuestBoard.sol | 48 +++ .../hooks/utils/QuestSettingsRegistry.sol | 39 ++ .../foundry/contracts/mocks/GaugeRegistry.sol | 17 + .../contracts/mocks/MockQuestBoard.sol | 69 +++ packages/foundry/test/SereneHook.t.sol | 394 ++++++++++++++++++ 7 files changed, 875 insertions(+) create mode 100644 packages/foundry/contracts/hooks/SereneHook.sol create mode 100644 packages/foundry/contracts/hooks/interfaces/IGaugeRegistry.sol create mode 100644 packages/foundry/contracts/hooks/interfaces/IQuestBoard.sol create mode 100644 packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol create mode 100644 packages/foundry/contracts/mocks/GaugeRegistry.sol create mode 100644 packages/foundry/contracts/mocks/MockQuestBoard.sol create mode 100644 packages/foundry/test/SereneHook.t.sol diff --git a/packages/foundry/contracts/hooks/SereneHook.sol b/packages/foundry/contracts/hooks/SereneHook.sol new file mode 100644 index 00000000..40242626 --- /dev/null +++ b/packages/foundry/contracts/hooks/SereneHook.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { + LiquidityManagement, + AfterSwapParams, + SwapKind, + PoolSwapParams, + TokenConfig, + HookFlags +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { IBatchRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IBatchRouter.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol"; +import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol"; +import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol"; + +import { IGaugeRegistry } from "./interfaces/IGaugeRegistry.sol"; +import { IQuestBoard } from "./interfaces/IQuestBoard.sol"; +import { QuestSettingsRegistry } from "./utils/QuestSettingsRegistry.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +contract SereneHook is BaseHooks, VaultGuard { + using FixedPoint for uint256; + using SafeERC20 for IERC20; + + // Percentages are represented as 18-decimal FP numbers, which have a maximum value of FixedPoint.ONE (100%), + // so 60 bits are sufficient. + uint64 public immutable hookSwapFeePercentage; + + uint64 public constant BPS = 10000; + + // only pools from the allowedFactory are able to register and use this hook + address private immutable allowedFactory; + + // The batch router used to swap the fees + address private immutable batchRouter; + + // Permit2 contract + IPermit2 internal permit2; + + // The token from which the quest are being created + address private immutable incentiveToken; + + address public immutable questBoard; + + address public immutable gaugeRegistry; + + address public immutable questSettings; + + // Pool => token => amount + mapping(address => mapping(address => uint256)) public takenFees; + + mapping(address => address) public gauges; + + mapping(address => IERC20[]) public poolTokens; + + mapping(address => uint256) public lastQuestCreated; + + /** + * @notice A new `SereneHook` contract has been registered successfully for a given factory and pool. + * @dev If the registration fails the call will revert, so there will be no event. + * @param hooksContract This contract + * @param pool The pool on which the hook was registered + */ + event SereneHookRegistered(address indexed hooksContract, address indexed pool); + + /** + * @notice The hooks contract has charged a fee. + * @param hooksContract The contract that collected the fee + * @param token The token in which the fee was charged + * @param feeAmount The amount of the fee + */ + event HookFeeCharged(address indexed hooksContract, IERC20 indexed token, uint256 feeAmount); + + error CannotCreateQuest(); + + constructor( + IVault vault, + IPermit2 _permit2, + address _allowedFactory, + address _gaugeRegistry, + address _batchRouter, + address _questBoard, + address _questSettings, + address _incentiveToken, + uint64 _hookSwapFeePercentage + ) VaultGuard(vault) { + permit2 = _permit2; + allowedFactory = _allowedFactory; + batchRouter = _batchRouter; + questBoard = _questBoard; + questSettings = _questSettings; + incentiveToken = _incentiveToken; + gaugeRegistry = _gaugeRegistry; + + hookSwapFeePercentage = _hookSwapFeePercentage; + } + + /// @inheritdoc IHooks + function getHookFlags() public pure override returns (HookFlags memory) { + HookFlags memory hookFlags; + // `enableHookAdjustedAmounts` must be true for all contracts that modify the `amountCalculated` + // in after hooks. Otherwise, the Vault will ignore any "hookAdjusted" amounts, and the transaction + // might not settle. (It should be false if the after hooks do something else.) + hookFlags.enableHookAdjustedAmounts = true; + hookFlags.shouldCallAfterSwap = true; + hookFlags.shouldCallComputeDynamicSwapFee = true; + return hookFlags; + } + + /// @inheritdoc IHooks + function onRegister(address factory, address pool, TokenConfig[] memory, LiquidityManagement calldata) + public + override + onlyVault + returns (bool) + { + // This hook implements a restrictive approach, where we check if the factory is an allowed factory and if + // the pool was created by the allowed factory. Since we only use onComputeDynamicSwapFeePercentage, this might + // be an overkill in real applications because the pool math doesn't play a role in the discount calculation. + bool allowed = factory == allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool); + + emit SereneHookRegistered(address(this), pool); + + return allowed; + } + + /// @inheritdoc IHooks + function onAfterSwap(AfterSwapParams calldata params) + public + override + onlyVault + returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) + { + hookAdjustedAmountCalculatedRaw = params.amountCalculatedRaw; + + uint256 staticSwapFeePercentage = _vault.getStaticSwapFeePercentage(params.pool); + uint256 hookFeePercentage = (staticSwapFeePercentage * uint256(hookSwapFeePercentage) / 1e18); + if (hookFeePercentage > 0) { + uint256 previousAmountCalculatedRaw = + (hookAdjustedAmountCalculatedRaw * (1e18 + (staticSwapFeePercentage - hookFeePercentage))) / 1e18; + uint256 hookFee = previousAmountCalculatedRaw.mulDown(hookFeePercentage); + + if (hookFee > 0) { + IERC20 feeToken; + + if (params.kind == SwapKind.EXACT_IN) { + // For EXACT_IN swaps, the `amountCalculated` is the amount of `tokenOut`. The fee must be taken + // from `amountCalculated`, so we decrease the amount of tokens the Vault will send to the caller. + // + // The preceding swap operation has already credited the original `amountCalculated`. Since we're + // returning `amountCalculated - hookFee` here, it will only register debt for that reduced amount + // on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenOut` from the Vault to this + // contract, and registers the additional debt, so that the total debts match the credits and + // settlement succeeds. + feeToken = params.tokenOut; + hookAdjustedAmountCalculatedRaw -= hookFee; + } else { + // For EXACT_OUT swaps, the `amountCalculated` is the amount of `tokenIn`. The fee must be taken + // from `amountCalculated`, so we increase the amount of tokens the Vault will ask from the user. + // + // The preceding swap operation has already registered debt for the original `amountCalculated`. + // Since we're returning `amountCalculated + hookFee` here, it will supply credit for that increased + // amount on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenIn` from the Vault to + // this contract, and registers the additional debt, so that the total debts match the credits and + // settlement succeeds. + feeToken = params.tokenIn; + hookAdjustedAmountCalculatedRaw += hookFee; + } + + _vault.sendTo(feeToken, address(this), hookFee); + takenFees[params.pool][address(feeToken)] += hookFee; + + emit HookFeeCharged(address(this), feeToken, hookFee); + } + } + return (true, hookAdjustedAmountCalculatedRaw); + } + + // Alter the swap fee percentage + function onComputeDynamicSwapFeePercentage( + PoolSwapParams calldata params, + address, // pool + uint256 staticSwapFeePercentage + ) public view override returns (bool success, uint256 dynamicSwapFeePercentage) { + return (true, staticSwapFeePercentage - (staticSwapFeePercentage * uint256(hookSwapFeePercentage) / 1e18)); + } + + /** + * @notice Get the gauge for a pool + * @param pool The pool to get the gauge for + * @return The gauge for the pool + */ + function _getGauge(address pool) internal view returns (address) { + return IGaugeRegistry(gaugeRegistry).getPoolGauge(pool); + } + + /** + * @notice Swap the fees taken from the pool to the incentive token + * @param pool The pool from which the fees were taken + * @param steps The swap steps to convert the fees to the incentive token + */ + function _swapToToken(address pool, IBatchRouter.SwapPathStep[][] calldata steps) internal { + // Create path data from steps + IERC20[] memory tokens = poolTokens[pool]; + IBatchRouter.SwapPathExactAmountIn[] memory paths = new IBatchRouter.SwapPathExactAmountIn[](steps.length); + uint256 pathLength = 0; + uint256 length = tokens.length; + for (uint256 i = 0; i < length; i++) { + IERC20 token = tokens[i]; + if (address(token) == incentiveToken) { + continue; + } + + uint256 amount = takenFees[pool][address(token)]; + if (amount > 0) { + _increasePermit2Allowance(token, amount); + paths[pathLength++] = IBatchRouter.SwapPathExactAmountIn({ + tokenIn: token, + steps: steps[i], + exactAmountIn: amount, + minAmountOut: 0 + }); + takenFees[pool][address(token)] = 0; + } + } + // Store the path length in the first slot of the array + assembly { + mstore(paths, pathLength) + } + + // Swap the tokens + IBatchRouter(batchRouter).swapExactIn(paths, block.timestamp + 1, false, new bytes(0)); + } + + function _increasePermit2Allowance(IERC20 token, uint256 amount) internal { + if (token.allowance(address(this), address(permit2)) == 0) { + token.approve(address(permit2), type(uint256).max); + } + permit2.approve(address(token), batchRouter, uint160(amount), uint48(block.timestamp + 1)); + } + + /** + * @notice Create a quest from the fees taken from the pool + * @param pool The pool from which the fees were taken + * @param steps The swap steps to convert the fees to the incentive token + */ + function createQuest(address pool, IBatchRouter.SwapPathStep[][] calldata steps) public { + uint256 lastQuest = lastQuestCreated[pool]; + if (lastQuest == 0) { + // Store the gauge and tokens for the pool to not query the registry again + address gauge = _getGauge(pool); + if (gauge == address(0)) revert CannotCreateQuest(); + gauges[pool] = gauge; + IERC20[] memory tokens = _vault.getPoolTokens(pool); + poolTokens[pool] = tokens; + } else { + uint48[] memory periods = IQuestBoard(questBoard).getAllPeriodsForQuestId(lastQuest); + uint256 lastPeriod = periods[periods.length - 1]; + if (IQuestBoard(questBoard).getCurrentPeriod() <= lastPeriod) revert CannotCreateQuest(); + } + + QuestSettingsRegistry.QuestSettings memory settings = + QuestSettingsRegistry(questSettings).getQuestSettings(incentiveToken); + + // Swap fees taken from the pool and create a quest from it + uint256 amountOutAfterFee; + uint256 feeAmount; + { + _swapToToken(pool, steps); + uint256 amountOut = IERC20(incentiveToken).balanceOf(address(this)); + uint256 feeRatio = IQuestBoard(questBoard).platformFeeRatio(); + amountOutAfterFee = (amountOut * BPS) / (BPS + feeRatio); + feeAmount = (amountOutAfterFee * feeRatio) / BPS; + } + + IERC20(incentiveToken).safeIncreaseAllowance(questBoard, amountOutAfterFee + feeAmount); + uint256 id = IQuestBoard(questBoard).createRangedQuest( + gauges[pool], + incentiveToken, + false, // Allows to create the Quest right now, and check the previous one is over before allowing to create a new one + settings.duration, + settings.minRewardPerVote, + settings.maxRewardPerVote, + amountOutAfterFee, + feeAmount, + settings.voteType, + settings.closeType, + settings.voterList + ); + lastQuestCreated[pool] = id; + } +} diff --git a/packages/foundry/contracts/hooks/interfaces/IGaugeRegistry.sol b/packages/foundry/contracts/hooks/interfaces/IGaugeRegistry.sol new file mode 100644 index 00000000..c1bc5a27 --- /dev/null +++ b/packages/foundry/contracts/hooks/interfaces/IGaugeRegistry.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IGaugeRegistry { + function getPoolGauge(address pool) external view returns (address); +} diff --git a/packages/foundry/contracts/hooks/interfaces/IQuestBoard.sol b/packages/foundry/contracts/hooks/interfaces/IQuestBoard.sol new file mode 100644 index 00000000..eb4f65c8 --- /dev/null +++ b/packages/foundry/contracts/hooks/interfaces/IQuestBoard.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IQuestBoard { + enum QuestVoteType { + NORMAL, + BLACKLIST, + WHITELIST + } + enum QuestCloseType { + NORMAL, + ROLLOVER, + DISTRIBUTE + } + + function createFixedQuest( + address gauge, + address rewardToken, + bool startNextPeriod, + uint48 duration, + uint256 rewardPerVote, + uint256 totalRewardAmount, + uint256 feeAmount, + QuestVoteType voteType, + QuestCloseType closeType, + address[] calldata voterList + ) external returns (uint256); + + function createRangedQuest( + address gauge, + address rewardToken, + bool startNextPeriod, + uint48 duration, + uint256 minRewardPerVote, + uint256 maxRewardPerVote, + uint256 totalRewardAmount, + uint256 feeAmount, + QuestVoteType voteType, + QuestCloseType closeType, + address[] calldata voterList + ) external returns (uint256); + + function platformFeeRatio() external view returns (uint256); + + function getAllPeriodsForQuestId(uint256 questID) external view returns (uint48[] memory); + + function getCurrentPeriod() external view returns (uint256); +} diff --git a/packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol b/packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol new file mode 100644 index 00000000..57626df4 --- /dev/null +++ b/packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "../interfaces/IQuestBoard.sol"; + +/// @title QuestSettingsRegistry +/// @notice A contract to store quest settings for each incentive token +contract QuestSettingsRegistry is Ownable { + struct QuestSettings { + uint48 duration; + uint256 minRewardPerVote; + uint256 maxRewardPerVote; + IQuestBoard.QuestVoteType voteType; + IQuestBoard.QuestCloseType closeType; + address[] voterList; + } + + mapping(address => QuestSettings) public questSettings; + + constructor(address initialOwner) Ownable(initialOwner) { } + + function setQuestSettings( + address incentiveToken, + uint48 duration, + uint256 minRewardPerVote, + uint256 maxRewardPerVote, + IQuestBoard.QuestVoteType voteType, + IQuestBoard.QuestCloseType closeType, + address[] calldata voterList + ) external onlyOwner { + questSettings[incentiveToken] = + QuestSettings(duration, minRewardPerVote, maxRewardPerVote, voteType, closeType, voterList); + } + + function getQuestSettings(address incentiveToken) external view returns (QuestSettings memory) { + return questSettings[incentiveToken]; + } +} diff --git a/packages/foundry/contracts/mocks/GaugeRegistry.sol b/packages/foundry/contracts/mocks/GaugeRegistry.sol new file mode 100644 index 00000000..3c1ba683 --- /dev/null +++ b/packages/foundry/contracts/mocks/GaugeRegistry.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title GaugeRegistry +/// @notice A mock contract to register gauges for pools +/// @dev This contract is used for testing purposes only while waiting from feedback of balancer team regarding the balancer v3 gauge registry +contract GaugeRegistry { + mapping(address pool => address gauge) public gauges; + + function register(address pool, address gauge) external { + gauges[pool] = gauge; + } + + function getPoolGauge(address pool) external view returns (address) { + return gauges[pool]; + } +} diff --git a/packages/foundry/contracts/mocks/MockQuestBoard.sol b/packages/foundry/contracts/mocks/MockQuestBoard.sol new file mode 100644 index 00000000..2f38372e --- /dev/null +++ b/packages/foundry/contracts/mocks/MockQuestBoard.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "../hooks/interfaces/IQuestBoard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockQuestBoard is IQuestBoard { + uint256 public currentPeriod; + uint256 public platformFeeRatio; + mapping(uint256 => uint48[]) periodsForQuestId; + + constructor(uint256 _platformFeeRatio, uint256 _currentPeriod) { + currentPeriod = _currentPeriod; + platformFeeRatio = _platformFeeRatio; + } + + function setPlatformFeeRatio(uint256 ratio) external { + platformFeeRatio = ratio; + } + + function setPeriod(uint256 period) external { + currentPeriod = period; + } + + function setPeriodsForQuestId(uint256 questID, uint48[] calldata periods) external { + periodsForQuestId[questID] = periods; + } + + function createRangedQuest( + address, // gauge + address rewardToken, + bool, // startNextPeriod + uint48, // duration + uint256, // minRewardPerVote + uint256, // maxRewardPerVote + uint256 totalRewardAmount, + uint256 feeAmount, + QuestVoteType, // voteType + QuestCloseType, // closeType + address[] calldata // voterList + ) external returns (uint256) { + IERC20(rewardToken).transferFrom(msg.sender, address(this), totalRewardAmount + feeAmount); + return 1; + } + + function createFixedQuest( + address, // gauge + address rewardToken, + bool, // startNextPeriod + uint48, // duration + uint256, // rewardPerVote + uint256 totalRewardAmount, + uint256 feeAmount, + QuestVoteType, // voteType + QuestCloseType, // closeType + address[] calldata // voterList + ) external returns (uint256) { + IERC20(rewardToken).transferFrom(msg.sender, address(this), totalRewardAmount + feeAmount); + return 1; + } + + function getAllPeriodsForQuestId(uint256 questID) external view returns (uint48[] memory) { + return periodsForQuestId[questID]; + } + + function getCurrentPeriod() external view returns (uint256) { + return currentPeriod; + } +} diff --git a/packages/foundry/test/SereneHook.t.sol b/packages/foundry/test/SereneHook.t.sol new file mode 100644 index 00000000..1f01e80d --- /dev/null +++ b/packages/foundry/test/SereneHook.t.sol @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; +import { + HooksConfig, + LiquidityManagement, + PoolRoleAccounts, + TokenConfig, + AfterSwapParams, + SwapKind +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { BasePoolMath } from "@balancer-labs/v3-vault/contracts/BasePoolMath.sol"; + +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; +import { BalancerPoolToken } from "@balancer-labs/v3-vault/contracts/BalancerPoolToken.sol"; +import { PoolMock } from "@balancer-labs/v3-vault/contracts/test/PoolMock.sol"; +import { PoolFactoryMock } from "@balancer-labs/v3-vault/contracts/test/PoolFactoryMock.sol"; +import { RouterMock } from "@balancer-labs/v3-vault/contracts/test/RouterMock.sol"; +import { IBatchRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IBatchRouter.sol"; + +import { GaugeRegistry } from "../contracts/mocks/GaugeRegistry.sol"; +import { MockQuestBoard } from "../contracts/mocks/MockQuestBoard.sol"; +import { QuestSettingsRegistry } from "../contracts/hooks/utils/QuestSettingsRegistry.sol"; +import { SereneHook } from "../contracts/hooks/SereneHook.sol"; +import { IQuestBoard } from "../contracts/hooks/interfaces/IQuestBoard.sol"; + +import "forge-std/console.sol"; + +contract SereneHookTest is BaseVaultTest { + using CastingHelpers for address[]; + using FixedPoint for uint256; + using ArrayHelpers for *; + + GaugeRegistry gaugeRegistry; + QuestSettingsRegistry questSettings; + MockQuestBoard questBoard; + address owner; + + uint256 internal daiIdx; + uint256 internal usdcIdx; + + uint256 hookSwapFee = 0.05e18; + uint256 UNIT = 1e18; + + uint256 internal constant SWAP_FEE_PERCENTAGE = 5e16; // 5% + + function setUp() public override { + owner = makeAddr("owner"); + vm.label(owner, "owner"); + + gaugeRegistry = new GaugeRegistry(); + questSettings = new QuestSettingsRegistry(owner); + questBoard = new MockQuestBoard(400, 0); + + vm.prank(owner); + questSettings.setQuestSettings( + address(dai), + 1, + 1000, + 2000, + IQuestBoard.QuestVoteType.NORMAL, + IQuestBoard.QuestCloseType.NORMAL, + new address[](0) + ); + + super.setUp(); + + (daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc)); + } + + function createHook() internal override returns (address) { + // lp will be the owner of the hook. + address sereneHook = address( + new SereneHook( + IVault(address(vault)), + permit2, + address(factoryMock), + address(gaugeRegistry), + address(batchRouter), + address(questBoard), + address(questSettings), + address(dai), + uint64(hookSwapFee) + ) + ); + vm.label(sereneHook, "sereneHook"); + return address(sereneHook); + } + + // Overrides pool creation to set liquidityManagement (disables unbalanced liquidity) + function _createPool(address[] memory tokens, string memory label) internal override returns (address) { + address newPool = factoryMock.createPool("SereneHook Pool", "SNKP"); + vm.label(address(newPool), label); + + PoolRoleAccounts memory roleAccounts; + LiquidityManagement memory liquidityManagement; + liquidityManagement.disableUnbalancedLiquidity = true; + + factoryMock.registerPool( + address(newPool), + vault.buildTokenConfig(tokens.asIERC20()), + roleAccounts, + poolHooksContract, + liquidityManagement + ); + + authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), admin); + vm.prank(admin); + vault.setStaticSwapFeePercentage(newPool, SWAP_FEE_PERCENTAGE); + + return address(newPool); + } + + function testRegistryWithWrongFactory() public { + address serenePool = _createPoolToRegister(); + TokenConfig[] memory tokenConfig = + vault.buildTokenConfig([address(dai), address(usdc)].toMemoryArray().asIERC20()); + + uint32 pauseWindowEndTime = IVaultAdmin(address(vault)).getPauseWindowEndTime(); + uint32 bufferPeriodDuration = IVaultAdmin(address(vault)).getBufferPeriodDuration(); + uint32 pauseWindowDuration = pauseWindowEndTime - bufferPeriodDuration; + address unauthorizedFactory = address(new PoolFactoryMock(IVault(address(vault)), pauseWindowDuration)); + + vm.expectRevert( + abi.encodeWithSelector( + IVaultErrors.HookRegistrationFailed.selector, poolHooksContract, serenePool, unauthorizedFactory + ) + ); + _registerPoolWithHook(serenePool, tokenConfig, unauthorizedFactory); + } + + // Registry tests require a new pool, because an existing pool may be already registered + function _createPoolToRegister() private returns (address newPool) { + newPool = address(new PoolMock(IVault(address(vault)), "SereneHook Pool", "SHK")); + vm.label(newPool, "newPool"); + } + + function _registerPoolWithHook(address exitFeePool, TokenConfig[] memory tokenConfig, address factory) private { + PoolRoleAccounts memory roleAccounts; + LiquidityManagement memory liquidityManagement; + liquidityManagement.disableUnbalancedLiquidity = true; + + PoolFactoryMock(factory).registerPool( + exitFeePool, tokenConfig, roleAccounts, poolHooksContract, liquidityManagement + ); + } + + function testFeeSwapExactIn__Fuzz(uint256 swapAmount) public { + // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)); + uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); + uint256 protocolFees = swapAmount.mulUp(protocolFeePercentage); + uint256 amountCalculatedRaw = swapAmount - protocolFees; + uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; + uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; + + BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + + uint256 storedHookFeesBefore = SereneHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + if (hookFee > 0) { + vm.expectEmit(); + emit SereneHook.HookFeeCharged(poolHooksContract, IERC20(usdc), hookFee); + } + + vm.prank(bob); + router.swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); + + BaseVaultTest.Balances memory balancesAfter = getBalances(bob); + + uint256 storedHookFeesAfter = SereneHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + assertEq( + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], swapAmount, "Bob DAI balance is wrong" + ); + assertEq( + balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook DAI balance is wrong" + ); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + swapAmount - hookFee - protocolFees, + "Bob USDC balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[usdcIdx] - balancesBefore.hookTokens[usdcIdx], + hookFee, + "Hook USDC balance is wrong" + ); + + assertEq( + storedHookFeesAfter - storedHookFeesBefore, + hookFee, + "Hook taken fees stored is wrong" + ); + + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + swapAmount, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + swapAmount - protocolFees, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + swapAmount, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + swapAmount - protocolFees, + "Vault USDC balance is wrong" + ); + } + + function testFeeSwapExactOut__Fuzz(uint256 swapAmount) public { + // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)); + uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); + uint256 protocolFees = swapAmount.mulDivUp( + protocolFeePercentage, + protocolFeePercentage.complement() + ); + uint256 amountCalculatedRaw = swapAmount + protocolFees; + uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; + uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; + + BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + + uint256 storedHookFeesBefore = SereneHook(poolHooksContract).takenFees(address(pool), address(dai)); + + if (hookFee > 0) { + vm.expectEmit(); + emit SereneHook.HookFeeCharged(poolHooksContract, IERC20(dai), hookFee); + } + + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), dai, usdc, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + ); + + BaseVaultTest.Balances memory balancesAfter = getBalances(bob); + + uint256 storedHookFeesAfter = SereneHook(poolHooksContract).takenFees(address(pool), address(dai)); + + assertEq( + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], swapAmount + hookFee + protocolFees, "Bob DAI balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[daiIdx] - balancesBefore.hookTokens[daiIdx], + hookFee, + "Hook DAI balance is wrong" + ); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + swapAmount, + "Bob USDC balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[usdcIdx], balancesBefore.hookTokens[usdcIdx], "Hook USDC balance is wrong" + ); + + assertEq( + storedHookFeesAfter - storedHookFeesBefore, + hookFee, + "Hook taken fees stored is wrong" + ); + + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + swapAmount + protocolFees, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + swapAmount, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + swapAmount + protocolFees, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + swapAmount, + "Vault USDC balance is wrong" + ); + } + + function testNoGaugeWhenCreatingQuest() public { + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](0); + + vm.expectRevert(SereneHook.CannotCreateQuest.selector); + SereneHook(poolHooksContract).createQuest(address(pool), steps); + } + + function testQuestAlreadyCreatedForThisEpoch__Fuzz(uint256 swapAmount) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, 1e19); + gaugeRegistry.register(address(pool), makeAddr("gauge")); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + ); + + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); + steps[0] = new IBatchRouter.SwapPathStep[](1); + steps[0][0] = IBatchRouter.SwapPathStep({ pool: address(pool), tokenOut: dai, isBuffer: false }); + SereneHook(poolHooksContract).createQuest(address(pool), steps); + + uint48[] memory periods = new uint48[](1); + periods[0] = 0; + questBoard.setPeriodsForQuestId(1, periods); + + vm.expectRevert(SereneHook.CannotCreateQuest.selector); + SereneHook(poolHooksContract).createQuest(address(pool), steps); + } + + function testCreateNormalQuestSecondEpoch__Fuzz(uint256 swapAmount) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, 1e19); + gaugeRegistry.register(address(pool), makeAddr("gauge")); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + ); + + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); + steps[0] = new IBatchRouter.SwapPathStep[](1); + steps[0][0] = IBatchRouter.SwapPathStep({ pool: address(pool), tokenOut: dai, isBuffer: false }); + SereneHook(poolHooksContract).createQuest(address(pool), steps); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + ); + + uint48[] memory periods = new uint48[](1); + periods[0] = 0; + questBoard.setPeriodsForQuestId(1, periods); + questBoard.setPeriod(1); + + vm.expectCall(address(questBoard), abi.encodeWithSelector(IQuestBoard.createRangedQuest.selector)); + SereneHook(poolHooksContract).createQuest(address(pool), steps); + + assertEq(SereneHook(poolHooksContract).gauges(address(pool)), makeAddr("gauge"), "Gauge not set"); + assertEq(SereneHook(poolHooksContract).lastQuestCreated(address(pool)), 1, "Quest not created"); + assertEq(usdc.balanceOf(poolHooksContract), 0, "Usdc balance is wrong"); + assertApproxEqAbs(dai.balanceOf(poolHooksContract), 0, 1, "Dai balance is wrong"); + } + + function testCreateNormalQuest__Fuzz(uint256 swapAmount) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, 1e19); + gaugeRegistry.register(address(pool), makeAddr("gauge")); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + ); + + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); + steps[0] = new IBatchRouter.SwapPathStep[](1); + steps[0][0] = IBatchRouter.SwapPathStep({ pool: address(pool), tokenOut: dai, isBuffer: false }); + vm.expectCall(address(questBoard), abi.encodeWithSelector(IQuestBoard.createRangedQuest.selector)); + SereneHook(poolHooksContract).createQuest(address(pool), steps); + + assertEq(SereneHook(poolHooksContract).gauges(address(pool)), makeAddr("gauge"), "Gauge not set"); + assertEq(SereneHook(poolHooksContract).lastQuestCreated(address(pool)), 1, "Quest not created"); + assertEq(usdc.balanceOf(poolHooksContract), 0, "Usdc balance is wrong"); + assertApproxEqAbs(dai.balanceOf(poolHooksContract), 0, 1, "Dai balance is wrong"); + } + +} From caedef602d0b1907cb5ced9b7eb74cd2eb0860f8 Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Sat, 21 Sep 2024 11:53:03 +0200 Subject: [PATCH 02/13] chore: update balancer dependency to update base fee --- packages/foundry/lib/balancer-v3-monorepo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/foundry/lib/balancer-v3-monorepo b/packages/foundry/lib/balancer-v3-monorepo index 67337944..d4523f67 160000 --- a/packages/foundry/lib/balancer-v3-monorepo +++ b/packages/foundry/lib/balancer-v3-monorepo @@ -1 +1 @@ -Subproject commit 67337944430569fb8bd1fe071e0ed808a0e19209 +Subproject commit d4523f67a9d6040dcfc58d5a98d47f69c92c58e2 From 974c5152d4d521173685ee91293850df21b116ca Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Sat, 21 Sep 2024 23:50:50 +0200 Subject: [PATCH 03/13] feat: improve documentation and readibility of contracts --- .../foundry/contracts/hooks/SereneHook.sol | 338 +++++++++++------- .../hooks/utils/QuestSettingsRegistry.sol | 57 ++- 2 files changed, 259 insertions(+), 136 deletions(-) diff --git a/packages/foundry/contracts/hooks/SereneHook.sol b/packages/foundry/contracts/hooks/SereneHook.sol index 40242626..e2c03bba 100644 --- a/packages/foundry/contracts/hooks/SereneHook.sol +++ b/packages/foundry/contracts/hooks/SereneHook.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.24; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; @@ -27,43 +26,26 @@ import { IQuestBoard } from "./interfaces/IQuestBoard.sol"; import { QuestSettingsRegistry } from "./utils/QuestSettingsRegistry.sol"; import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -contract SereneHook is BaseHooks, VaultGuard { +/// @title SereneHook +/// @notice A hook contract that charges a fee on swaps and creates quests from the fees taken from the pools +/// @author 0xtekgrinder & Kogaroshi +contract SereneHook is BaseHooks, VaultGuard, ReentrancyGuard { using FixedPoint for uint256; using SafeERC20 for IERC20; - // Percentages are represented as 18-decimal FP numbers, which have a maximum value of FixedPoint.ONE (100%), - // so 60 bits are sufficient. - uint64 public immutable hookSwapFeePercentage; - - uint64 public constant BPS = 10000; - - // only pools from the allowedFactory are able to register and use this hook - address private immutable allowedFactory; - - // The batch router used to swap the fees - address private immutable batchRouter; - - // Permit2 contract - IPermit2 internal permit2; - - // The token from which the quest are being created - address private immutable incentiveToken; - - address public immutable questBoard; - - address public immutable gaugeRegistry; + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ - address public immutable questSettings; - - // Pool => token => amount - mapping(address => mapping(address => uint256)) public takenFees; - - mapping(address => address) public gauges; + error CannotCreateQuest(); + error InvalidHookSwapFeePercentage(); + error InvalidAddress(); - mapping(address => IERC20[]) public poolTokens; - - mapping(address => uint256) public lastQuestCreated; + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ /** * @notice A new `SereneHook` contract has been registered successfully for a given factory and pool. @@ -72,7 +54,6 @@ contract SereneHook is BaseHooks, VaultGuard { * @param pool The pool on which the hook was registered */ event SereneHookRegistered(address indexed hooksContract, address indexed pool); - /** * @notice The hooks contract has charged a fee. * @param hooksContract The contract that collected the fee @@ -81,30 +62,118 @@ contract SereneHook is BaseHooks, VaultGuard { */ event HookFeeCharged(address indexed hooksContract, IERC20 indexed token, uint256 feeAmount); - error CannotCreateQuest(); + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + uint64 public constant BPS = 10000; + + /*////////////////////////////////////////////////////////////// + IMMUTABLES VARIABLES + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The factory that is allowed to register pools with this hook + */ + address public immutable allowedFactory; + /** + * @notice The fee percentage of the staticSwapPoolFee to be taken from the swaps + * @dev Percentages are represented as 18-decimal FP numbers, which have a maximum value of FixedPoint.ONE (100%), + * so 60 bits are sufficient. + */ + uint64 public immutable hookSwapFeePercentage; + /** + * @notice The batch router contract used in batch swaps + */ + address public immutable batchRouter; + /** + * @notice The permit2 contract used in batch router swaps + */ + IPermit2 public immutable permit2; + /** + * @notice The token to be used as incentive for the quests + */ + address public immutable incentiveToken; + /** + * @notice The quest board contract to create the quests + */ + address public immutable questBoard; + /** + * @notice The gauge registry contract to get the gauges for the pools + */ + address public immutable gaugeRegistry; + /** + * @notice The quest settings contract to get the settings for the quests creation + */ + address public immutable questSettings; + + /*////////////////////////////////////////////////////////////// + MUTABLE VARIABLES + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The fees taken from the pools + * @dev Pool => token => amount + */ + mapping(address => mapping(address => uint256)) public takenFees; + /** + * @notice The gauges of the pools + * @dev Pool => gauge + */ + mapping(address => address) public gauges; + /** + * @notice The tokens of the pools + * @dev Pool => tokens[] + */ + mapping(address => IERC20[]) public poolTokens; + /** + * @notice The last quest created for the pool + * @dev Pool => questId + */ + mapping(address => uint256) public lastQuestCreated; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ constructor( - IVault vault, - IPermit2 _permit2, - address _allowedFactory, - address _gaugeRegistry, - address _batchRouter, - address _questBoard, - address _questSettings, - address _incentiveToken, - uint64 _hookSwapFeePercentage - ) VaultGuard(vault) { - permit2 = _permit2; - allowedFactory = _allowedFactory; - batchRouter = _batchRouter; - questBoard = _questBoard; - questSettings = _questSettings; - incentiveToken = _incentiveToken; - gaugeRegistry = _gaugeRegistry; - - hookSwapFeePercentage = _hookSwapFeePercentage; + IVault definitiveVault, + IPermit2 definitivePermit2, + address definitiveAllowedFactory, + address definitiveGaugeRegistry, + address definitiveBatchRouter, + address definitiveQuestBoard, + address definitiveQuestSettings, + address definitiveIncentiveToken, + uint64 definitiveHookSwapFeePercentage + ) VaultGuard(definitiveVault) { + if (definitiveHookSwapFeePercentage > 1e18) revert InvalidHookSwapFeePercentage(); + if ( + address(definitiveVault) == address(0) || + address(definitivePermit2) == address(0) || + definitiveAllowedFactory == address(0) || + definitiveGaugeRegistry == address(0) || + definitiveBatchRouter == address(0) || + definitiveQuestBoard == address(0) || + definitiveQuestSettings == address(0) || + definitiveIncentiveToken == address(0) + ) revert InvalidAddress(); + + permit2 = definitivePermit2; + allowedFactory = definitiveAllowedFactory; + batchRouter = definitiveBatchRouter; + questBoard = definitiveQuestBoard; + questSettings = definitiveQuestSettings; + incentiveToken = definitiveIncentiveToken; + gaugeRegistry = definitiveGaugeRegistry; + + hookSwapFeePercentage = definitiveHookSwapFeePercentage; } + /*////////////////////////////////////////////////////////////// + HOOKS FUNCTIONS + //////////////////////////////////////////////////////////////*/ + /// @inheritdoc IHooks function getHookFlags() public pure override returns (HookFlags memory) { HookFlags memory hookFlags; @@ -118,15 +187,14 @@ contract SereneHook is BaseHooks, VaultGuard { } /// @inheritdoc IHooks - function onRegister(address factory, address pool, TokenConfig[] memory, LiquidityManagement calldata) - public - override - onlyVault - returns (bool) - { + function onRegister( + address factory, + address pool, + TokenConfig[] memory, + LiquidityManagement calldata + ) public override onlyVault returns (bool) { // This hook implements a restrictive approach, where we check if the factory is an allowed factory and if - // the pool was created by the allowed factory. Since we only use onComputeDynamicSwapFeePercentage, this might - // be an overkill in real applications because the pool math doesn't play a role in the discount calculation. + // the pool was created by the allowed factory. bool allowed = factory == allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool); emit SereneHookRegistered(address(this), pool); @@ -135,19 +203,16 @@ contract SereneHook is BaseHooks, VaultGuard { } /// @inheritdoc IHooks - function onAfterSwap(AfterSwapParams calldata params) - public - override - onlyVault - returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) - { + function onAfterSwap( + AfterSwapParams calldata params + ) public override onlyVault returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) { hookAdjustedAmountCalculatedRaw = params.amountCalculatedRaw; uint256 staticSwapFeePercentage = _vault.getStaticSwapFeePercentage(params.pool); - uint256 hookFeePercentage = (staticSwapFeePercentage * uint256(hookSwapFeePercentage) / 1e18); + uint256 hookFeePercentage = ((staticSwapFeePercentage * uint256(hookSwapFeePercentage)) / 1e18); if (hookFeePercentage > 0) { - uint256 previousAmountCalculatedRaw = - (hookAdjustedAmountCalculatedRaw * (1e18 + (staticSwapFeePercentage - hookFeePercentage))) / 1e18; + uint256 previousAmountCalculatedRaw = (hookAdjustedAmountCalculatedRaw * + (1e18 + (staticSwapFeePercentage - hookFeePercentage))) / 1e18; uint256 hookFee = previousAmountCalculatedRaw.mulDown(hookFeePercentage); if (hookFee > 0) { @@ -177,10 +242,10 @@ contract SereneHook is BaseHooks, VaultGuard { hookAdjustedAmountCalculatedRaw += hookFee; } - _vault.sendTo(feeToken, address(this), hookFee); - takenFees[params.pool][address(feeToken)] += hookFee; - emit HookFeeCharged(address(this), feeToken, hookFee); + + takenFees[params.pool][address(feeToken)] += hookFee; + _vault.sendTo(feeToken, address(this), hookFee); } } return (true, hookAdjustedAmountCalculatedRaw); @@ -188,15 +253,76 @@ contract SereneHook is BaseHooks, VaultGuard { // Alter the swap fee percentage function onComputeDynamicSwapFeePercentage( - PoolSwapParams calldata params, + PoolSwapParams calldata, // params address, // pool uint256 staticSwapFeePercentage ) public view override returns (bool success, uint256 dynamicSwapFeePercentage) { - return (true, staticSwapFeePercentage - (staticSwapFeePercentage * uint256(hookSwapFeePercentage) / 1e18)); + return (true, staticSwapFeePercentage - ((staticSwapFeePercentage * uint256(hookSwapFeePercentage)) / 1e18)); } + /*////////////////////////////////////////////////////////////// + QUEST FUNCTIONS + //////////////////////////////////////////////////////////////*/ + /** - * @notice Get the gauge for a pool + * @notice Create a quest from the fees taken from the pool + * @param pool The pool from which the fees were taken + * @param steps The swap steps to convert the fees to the incentive token + */ + function createQuest(address pool, IBatchRouter.SwapPathStep[][] calldata steps) public nonReentrant { + uint256 lastQuest = lastQuestCreated[pool]; + if (lastQuest == 0) { + // Check if there is a gauge then store it and tokens for the pool to not query the registry again + address gauge = _getGauge(pool); + if (gauge == address(0)) revert CannotCreateQuest(); + gauges[pool] = gauge; + IERC20[] memory tokens = _vault.getPoolTokens(pool); + poolTokens[pool] = tokens; + } else { + // Check if the last quest is from last epoch + uint48[] memory periods = IQuestBoard(questBoard).getAllPeriodsForQuestId(lastQuest); + uint256 lastPeriod = periods[periods.length - 1]; + if (IQuestBoard(questBoard).getCurrentPeriod() <= lastPeriod) revert CannotCreateQuest(); + } + + QuestSettingsRegistry.QuestSettings memory settings = QuestSettingsRegistry(questSettings).getQuestSettings( + incentiveToken + ); + + // Swap fees taken from the pool and create a quest from it + uint256 amountOutAfterFee; + uint256 feeAmount; + { + _swapToToken(pool, steps); + uint256 amountOut = IERC20(incentiveToken).balanceOf(address(this)); + uint256 feeRatio = IQuestBoard(questBoard).platformFeeRatio(); + amountOutAfterFee = (amountOut * BPS) / (BPS + feeRatio); + feeAmount = (amountOutAfterFee * feeRatio) / BPS; + } + + IERC20(incentiveToken).safeIncreaseAllowance(questBoard, amountOutAfterFee + feeAmount); + uint256 id = IQuestBoard(questBoard).createRangedQuest( + gauges[pool], + incentiveToken, + false, // Allows to create the Quest right now, and check the previous one is over before allowing to create a new one + settings.duration, + settings.minRewardPerVote, + settings.maxRewardPerVote, + amountOutAfterFee, + feeAmount, + settings.voteType, + settings.closeType, + settings.voterList + ); + lastQuestCreated[pool] = id; + } + + /*////////////////////////////////////////////////////////////// + UTILS + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Get the gauge for a pool * @param pool The pool to get the gauge for * @return The gauge for the pool */ @@ -205,7 +331,7 @@ contract SereneHook is BaseHooks, VaultGuard { } /** - * @notice Swap the fees taken from the pool to the incentive token + * @dev Swap the fees taken from the pool to the incentive token * @param pool The pool from which the fees were taken * @param steps The swap steps to convert the fees to the incentive token */ @@ -215,7 +341,7 @@ contract SereneHook is BaseHooks, VaultGuard { IBatchRouter.SwapPathExactAmountIn[] memory paths = new IBatchRouter.SwapPathExactAmountIn[](steps.length); uint256 pathLength = 0; uint256 length = tokens.length; - for (uint256 i = 0; i < length; i++) { + for (uint256 i = 0; i < length; ++i) { IERC20 token = tokens[i]; if (address(token) == incentiveToken) { continue; @@ -242,61 +368,15 @@ contract SereneHook is BaseHooks, VaultGuard { IBatchRouter(batchRouter).swapExactIn(paths, block.timestamp + 1, false, new bytes(0)); } + /** + * @dev Increase the allowance of the permit2 contract + * @param token The token to increase the allowance for + * @param amount The amount to increase the allowance by + */ function _increasePermit2Allowance(IERC20 token, uint256 amount) internal { if (token.allowance(address(this), address(permit2)) == 0) { token.approve(address(permit2), type(uint256).max); } permit2.approve(address(token), batchRouter, uint160(amount), uint48(block.timestamp + 1)); } - - /** - * @notice Create a quest from the fees taken from the pool - * @param pool The pool from which the fees were taken - * @param steps The swap steps to convert the fees to the incentive token - */ - function createQuest(address pool, IBatchRouter.SwapPathStep[][] calldata steps) public { - uint256 lastQuest = lastQuestCreated[pool]; - if (lastQuest == 0) { - // Store the gauge and tokens for the pool to not query the registry again - address gauge = _getGauge(pool); - if (gauge == address(0)) revert CannotCreateQuest(); - gauges[pool] = gauge; - IERC20[] memory tokens = _vault.getPoolTokens(pool); - poolTokens[pool] = tokens; - } else { - uint48[] memory periods = IQuestBoard(questBoard).getAllPeriodsForQuestId(lastQuest); - uint256 lastPeriod = periods[periods.length - 1]; - if (IQuestBoard(questBoard).getCurrentPeriod() <= lastPeriod) revert CannotCreateQuest(); - } - - QuestSettingsRegistry.QuestSettings memory settings = - QuestSettingsRegistry(questSettings).getQuestSettings(incentiveToken); - - // Swap fees taken from the pool and create a quest from it - uint256 amountOutAfterFee; - uint256 feeAmount; - { - _swapToToken(pool, steps); - uint256 amountOut = IERC20(incentiveToken).balanceOf(address(this)); - uint256 feeRatio = IQuestBoard(questBoard).platformFeeRatio(); - amountOutAfterFee = (amountOut * BPS) / (BPS + feeRatio); - feeAmount = (amountOutAfterFee * feeRatio) / BPS; - } - - IERC20(incentiveToken).safeIncreaseAllowance(questBoard, amountOutAfterFee + feeAmount); - uint256 id = IQuestBoard(questBoard).createRangedQuest( - gauges[pool], - incentiveToken, - false, // Allows to create the Quest right now, and check the previous one is over before allowing to create a new one - settings.duration, - settings.minRewardPerVote, - settings.maxRewardPerVote, - amountOutAfterFee, - feeAmount, - settings.voteType, - settings.closeType, - settings.voterList - ); - lastQuestCreated[pool] = id; - } } diff --git a/packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol b/packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol index 57626df4..b7dd21d4 100644 --- a/packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol +++ b/packages/foundry/contracts/hooks/utils/QuestSettingsRegistry.sol @@ -6,7 +6,12 @@ import "../interfaces/IQuestBoard.sol"; /// @title QuestSettingsRegistry /// @notice A contract to store quest settings for each incentive token +/// @author 0xtekgrinder & Kogaroshi contract QuestSettingsRegistry is Ownable { + /*////////////////////////////////////////////////////////////// + STRUCTS + //////////////////////////////////////////////////////////////*/ + struct QuestSettings { uint48 duration; uint256 minRewardPerVote; @@ -16,10 +21,46 @@ contract QuestSettingsRegistry is Ownable { address[] voterList; } + /*////////////////////////////////////////////////////////////// + MUTABLE VARIABLES + //////////////////////////////////////////////////////////////*/ + mapping(address => QuestSettings) public questSettings; - constructor(address initialOwner) Ownable(initialOwner) { } + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + constructor(address initialOwner) Ownable(initialOwner) {} + + /*////////////////////////////////////////////////////////////// + VIEW FUNCTIONS + //////////////////////////////////////////////////////////////*/ + /** + * @notice Get the quest settings for a specific incentive token + * @param incentiveToken The incentive token address + * @return QuestSettings The quest settings for the incentive token + */ + function getQuestSettings(address incentiveToken) external view returns (QuestSettings memory) { + return questSettings[incentiveToken]; + } + + /*////////////////////////////////////////////////////////////// + OWNER FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Set the quest settings for a specific incentive token + * @param incentiveToken The incentive token address + * @param duration The duration of the quest + * @param minRewardPerVote The minimum reward per vote + * @param maxRewardPerVote The maximum reward per vote + * @param voteType The vote type + * @param closeType The close type + * @param voterList The list of voters + * @custom:require Owner + */ function setQuestSettings( address incentiveToken, uint48 duration, @@ -29,11 +70,13 @@ contract QuestSettingsRegistry is Ownable { IQuestBoard.QuestCloseType closeType, address[] calldata voterList ) external onlyOwner { - questSettings[incentiveToken] = - QuestSettings(duration, minRewardPerVote, maxRewardPerVote, voteType, closeType, voterList); - } - - function getQuestSettings(address incentiveToken) external view returns (QuestSettings memory) { - return questSettings[incentiveToken]; + questSettings[incentiveToken] = QuestSettings( + duration, + minRewardPerVote, + maxRewardPerVote, + voteType, + closeType, + voterList + ); } } From ae7edc52ef5d4dbc0b7191870c1b027b1d6ccf3c Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Sat, 21 Sep 2024 23:55:23 +0200 Subject: [PATCH 04/13] test: improve readibiltiy of serneHook tests --- packages/foundry/test/SereneHook.t.sol | 217 ++++++++++++++----------- 1 file changed, 125 insertions(+), 92 deletions(-) diff --git a/packages/foundry/test/SereneHook.t.sol b/packages/foundry/test/SereneHook.t.sol index 1f01e80d..8d4723d6 100644 --- a/packages/foundry/test/SereneHook.t.sol +++ b/packages/foundry/test/SereneHook.t.sol @@ -34,8 +34,6 @@ import { QuestSettingsRegistry } from "../contracts/hooks/utils/QuestSettingsReg import { SereneHook } from "../contracts/hooks/SereneHook.sol"; import { IQuestBoard } from "../contracts/hooks/interfaces/IQuestBoard.sol"; -import "forge-std/console.sol"; - contract SereneHookTest is BaseVaultTest { using CastingHelpers for address[]; using FixedPoint for uint256; @@ -49,7 +47,7 @@ contract SereneHookTest is BaseVaultTest { uint256 internal daiIdx; uint256 internal usdcIdx; - uint256 hookSwapFee = 0.05e18; + uint256 hookSwapFee = 5e17; // 50% uint256 UNIT = 1e18; uint256 internal constant SWAP_FEE_PERCENTAGE = 5e16; // 5% @@ -78,53 +76,11 @@ contract SereneHookTest is BaseVaultTest { (daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc)); } - function createHook() internal override returns (address) { - // lp will be the owner of the hook. - address sereneHook = address( - new SereneHook( - IVault(address(vault)), - permit2, - address(factoryMock), - address(gaugeRegistry), - address(batchRouter), - address(questBoard), - address(questSettings), - address(dai), - uint64(hookSwapFee) - ) - ); - vm.label(sereneHook, "sereneHook"); - return address(sereneHook); - } - - // Overrides pool creation to set liquidityManagement (disables unbalanced liquidity) - function _createPool(address[] memory tokens, string memory label) internal override returns (address) { - address newPool = factoryMock.createPool("SereneHook Pool", "SNKP"); - vm.label(address(newPool), label); - - PoolRoleAccounts memory roleAccounts; - LiquidityManagement memory liquidityManagement; - liquidityManagement.disableUnbalancedLiquidity = true; - - factoryMock.registerPool( - address(newPool), - vault.buildTokenConfig(tokens.asIERC20()), - roleAccounts, - poolHooksContract, - liquidityManagement - ); - - authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), admin); - vm.prank(admin); - vault.setStaticSwapFeePercentage(newPool, SWAP_FEE_PERCENTAGE); - - return address(newPool); - } - function testRegistryWithWrongFactory() public { address serenePool = _createPoolToRegister(); - TokenConfig[] memory tokenConfig = - vault.buildTokenConfig([address(dai), address(usdc)].toMemoryArray().asIERC20()); + TokenConfig[] memory tokenConfig = vault.buildTokenConfig( + [address(dai), address(usdc)].toMemoryArray().asIERC20() + ); uint32 pauseWindowEndTime = IVaultAdmin(address(vault)).getPauseWindowEndTime(); uint32 bufferPeriodDuration = IVaultAdmin(address(vault)).getBufferPeriodDuration(); @@ -133,28 +89,15 @@ contract SereneHookTest is BaseVaultTest { vm.expectRevert( abi.encodeWithSelector( - IVaultErrors.HookRegistrationFailed.selector, poolHooksContract, serenePool, unauthorizedFactory + IVaultErrors.HookRegistrationFailed.selector, + poolHooksContract, + serenePool, + unauthorizedFactory ) ); _registerPoolWithHook(serenePool, tokenConfig, unauthorizedFactory); } - // Registry tests require a new pool, because an existing pool may be already registered - function _createPoolToRegister() private returns (address newPool) { - newPool = address(new PoolMock(IVault(address(vault)), "SereneHook Pool", "SHK")); - vm.label(newPool, "newPool"); - } - - function _registerPoolWithHook(address exitFeePool, TokenConfig[] memory tokenConfig, address factory) private { - PoolRoleAccounts memory roleAccounts; - LiquidityManagement memory liquidityManagement; - liquidityManagement.disableUnbalancedLiquidity = true; - - PoolFactoryMock(factory).registerPool( - exitFeePool, tokenConfig, roleAccounts, poolHooksContract, liquidityManagement - ); - } - function testFeeSwapExactIn__Fuzz(uint256 swapAmount) public { // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); @@ -183,11 +126,11 @@ contract SereneHookTest is BaseVaultTest { uint256 storedHookFeesAfter = SereneHook(poolHooksContract).takenFees(address(pool), address(usdc)); assertEq( - balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], swapAmount, "Bob DAI balance is wrong" - ); - assertEq( - balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook DAI balance is wrong" + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], + swapAmount, + "Bob DAI balance is wrong" ); + assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook DAI balance is wrong"); assertEq( balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], swapAmount - hookFee - protocolFees, @@ -199,11 +142,7 @@ contract SereneHookTest is BaseVaultTest { "Hook USDC balance is wrong" ); - assertEq( - storedHookFeesAfter - storedHookFeesBefore, - hookFee, - "Hook taken fees stored is wrong" - ); + assertEq(storedHookFeesAfter - storedHookFeesBefore, hookFee, "Hook taken fees stored is wrong"); assertEq( balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], @@ -233,10 +172,7 @@ contract SereneHookTest is BaseVaultTest { uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)); uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); - uint256 protocolFees = swapAmount.mulDivUp( - protocolFeePercentage, - protocolFeePercentage.complement() - ); + uint256 protocolFees = swapAmount.mulDivUp(protocolFeePercentage, protocolFeePercentage.complement()); uint256 amountCalculatedRaw = swapAmount + protocolFees; uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; @@ -252,7 +188,14 @@ contract SereneHookTest is BaseVaultTest { vm.prank(bob); router.swapSingleTokenExactOut( - address(pool), dai, usdc, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + address(pool), + dai, + usdc, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") ); BaseVaultTest.Balances memory balancesAfter = getBalances(bob); @@ -260,7 +203,9 @@ contract SereneHookTest is BaseVaultTest { uint256 storedHookFeesAfter = SereneHook(poolHooksContract).takenFees(address(pool), address(dai)); assertEq( - balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], swapAmount + hookFee + protocolFees, "Bob DAI balance is wrong" + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], + swapAmount + hookFee + protocolFees, + "Bob DAI balance is wrong" ); assertEq( balancesAfter.hookTokens[daiIdx] - balancesBefore.hookTokens[daiIdx], @@ -272,15 +217,9 @@ contract SereneHookTest is BaseVaultTest { swapAmount, "Bob USDC balance is wrong" ); - assertEq( - balancesAfter.hookTokens[usdcIdx], balancesBefore.hookTokens[usdcIdx], "Hook USDC balance is wrong" - ); + assertEq(balancesAfter.hookTokens[usdcIdx], balancesBefore.hookTokens[usdcIdx], "Hook USDC balance is wrong"); - assertEq( - storedHookFeesAfter - storedHookFeesBefore, - hookFee, - "Hook taken fees stored is wrong" - ); + assertEq(storedHookFeesAfter - storedHookFeesBefore, hookFee, "Hook taken fees stored is wrong"); assertEq( balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], @@ -318,7 +257,14 @@ contract SereneHookTest is BaseVaultTest { // Swap to get USDC fees vm.prank(bob); router.swapSingleTokenExactOut( - address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") ); IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); @@ -341,7 +287,14 @@ contract SereneHookTest is BaseVaultTest { // Swap to get USDC fees vm.prank(bob); router.swapSingleTokenExactOut( - address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") ); IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); @@ -352,7 +305,14 @@ contract SereneHookTest is BaseVaultTest { // Swap to get USDC fees vm.prank(bob); router.swapSingleTokenExactOut( - address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") ); uint48[] memory periods = new uint48[](1); @@ -376,7 +336,14 @@ contract SereneHookTest is BaseVaultTest { // Swap to get USDC fees vm.prank(bob); router.swapSingleTokenExactOut( - address(pool), usdc, dai, swapAmount, MAX_UINT256, block.timestamp + 1, false, bytes("") + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") ); IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); @@ -391,4 +358,70 @@ contract SereneHookTest is BaseVaultTest { assertApproxEqAbs(dai.balanceOf(poolHooksContract), 0, 1, "Dai balance is wrong"); } + /*////////////////////////////////////////////////////////////// + UTILS + //////////////////////////////////////////////////////////////*/ + + function createHook() internal override returns (address) { + // lp will be the owner of the hook. + address sereneHook = address( + new SereneHook( + IVault(address(vault)), + permit2, + address(factoryMock), + address(gaugeRegistry), + address(batchRouter), + address(questBoard), + address(questSettings), + address(dai), + uint64(hookSwapFee) + ) + ); + vm.label(sereneHook, "sereneHook"); + return address(sereneHook); + } + + // Overrides pool creation to set liquidityManagement (disables unbalanced liquidity) + function _createPool(address[] memory tokens, string memory label) internal override returns (address) { + address newPool = factoryMock.createPool("SereneHook Pool", "SNKP"); + vm.label(address(newPool), label); + + PoolRoleAccounts memory roleAccounts; + LiquidityManagement memory liquidityManagement; + liquidityManagement.disableUnbalancedLiquidity = true; + + factoryMock.registerPool( + address(newPool), + vault.buildTokenConfig(tokens.asIERC20()), + roleAccounts, + poolHooksContract, + liquidityManagement + ); + + authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), admin); + vm.prank(admin); + vault.setStaticSwapFeePercentage(newPool, SWAP_FEE_PERCENTAGE); + + return address(newPool); + } + + // Registry tests require a new pool, because an existing pool may be already registered + function _createPoolToRegister() private returns (address newPool) { + newPool = address(new PoolMock(IVault(address(vault)), "SereneHook Pool", "SHK")); + vm.label(newPool, "newPool"); + } + + function _registerPoolWithHook(address exitFeePool, TokenConfig[] memory tokenConfig, address factory) private { + PoolRoleAccounts memory roleAccounts; + LiquidityManagement memory liquidityManagement; + liquidityManagement.disableUnbalancedLiquidity = true; + + PoolFactoryMock(factory).registerPool( + exitFeePool, + tokenConfig, + roleAccounts, + poolHooksContract, + liquidityManagement + ); + } } From 63a21c4b168a57cf65b9f52880d0ae1f762b1e0a Mon Sep 17 00:00:00 2001 From: Kogaroshi Date: Mon, 23 Sep 2024 18:24:18 -0400 Subject: [PATCH 05/13] feat : new variations of Serene Hooks based on given Balancer examples --- .../hooks/SereneDirectionalFeeHook.sol | 432 ++++++++++++++++++ .../hooks/SereneVeBalDiscountHook.sol | 416 +++++++++++++++++ 2 files changed, 848 insertions(+) create mode 100644 packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol create mode 100644 packages/foundry/contracts/hooks/SereneVeBalDiscountHook.sol diff --git a/packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol b/packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol new file mode 100644 index 00000000..086058c3 --- /dev/null +++ b/packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { + LiquidityManagement, + AfterSwapParams, + SwapKind, + PoolSwapParams, + TokenConfig, + HookFlags +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { IBatchRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IBatchRouter.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol"; +import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol"; +import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol"; + +import { IGaugeRegistry } from "./interfaces/IGaugeRegistry.sol"; +import { IQuestBoard } from "./interfaces/IQuestBoard.sol"; +import { QuestSettingsRegistry } from "./utils/QuestSettingsRegistry.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @title SereneDirectionalFeeHook +/// @notice A hook contract that charges a fee on swaps and creates quests from the fees taken from the pools +/// @author 0xtekgrinder & Kogaroshi +contract SereneDirectionalFeeHook is BaseHooks, VaultGuard, ReentrancyGuard { + using FixedPoint for uint256; + using SafeERC20 for IERC20; + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error CannotCreateQuest(); + error InvalidHookSwapFeePercentage(); + error InvalidAddress(); + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice A new `SereneDirectionalFeeHook` contract has been registered successfully for a given factory and pool. + * @dev If the registration fails the call will revert, so there will be no event. + * @param hooksContract This contract + * @param pool The pool on which the hook was registered + */ + event SereneDirectionalFeeHookRegistered(address indexed hooksContract, address indexed pool); + /** + * @notice The hooks contract has charged a fee. + * @param hooksContract The contract that collected the fee + * @param token The token in which the fee was charged + * @param feeAmount The amount of the fee + */ + event HookFeeCharged(address indexed hooksContract, IERC20 indexed token, uint256 feeAmount); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + uint64 public constant BPS = 10000; + + /*////////////////////////////////////////////////////////////// + IMMUTABLES VARIABLES + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The factory that is allowed to register pools with this hook + */ + address public immutable allowedFactory; + /** + * @notice The fee percentage of the staticSwapPoolFee to be taken from the swaps + * @dev Percentages are represented as 18-decimal FP numbers, which have a maximum value of FixedPoint.ONE (100%), + * so 60 bits are sufficient. + */ + uint64 public immutable hookSwapFeePercentage; + /** + * @notice The batch router contract used in batch swaps + */ + address public immutable batchRouter; + /** + * @notice The permit2 contract used in batch router swaps + */ + IPermit2 public immutable permit2; + /** + * @notice The token to be used as incentive for the quests + */ + address public immutable incentiveToken; + /** + * @notice The quest board contract to create the quests + */ + address public immutable questBoard; + /** + * @notice The gauge registry contract to get the gauges for the pools + */ + address public immutable gaugeRegistry; + /** + * @notice The quest settings contract to get the settings for the quests creation + */ + address public immutable questSettings; + + + /*////////////////////////////////////////////////////////////// + MUTABLE VARIABLES + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The fees taken from the pools + * @dev Pool => token => amount + */ + mapping(address => mapping(address => uint256)) public takenFees; + /** + * @notice The gauges of the pools + * @dev Pool => gauge + */ + mapping(address => address) public gauges; + /** + * @notice The tokens of the pools + * @dev Pool => tokens[] + */ + mapping(address => IERC20[]) public poolTokens; + /** + * @notice The last quest created for the pool + * @dev Pool => questId + */ + mapping(address => uint256) public lastQuestCreated; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + constructor( + IVault definitiveVault, + IPermit2 definitivePermit2, + address definitiveAllowedFactory, // must be StableSwap Factory + address definitiveGaugeRegistry, + address definitiveBatchRouter, + address definitiveQuestBoard, + address definitiveQuestSettings, + address definitiveIncentiveToken, + uint64 definitiveHookSwapFeePercentage + ) VaultGuard(definitiveVault) { + if (definitiveHookSwapFeePercentage > 1e18) revert InvalidHookSwapFeePercentage(); + if ( + address(definitiveVault) == address(0) || + address(definitivePermit2) == address(0) || + definitiveAllowedFactory == address(0) || + definitiveGaugeRegistry == address(0) || + definitiveBatchRouter == address(0) || + definitiveQuestBoard == address(0) || + definitiveQuestSettings == address(0) || + definitiveIncentiveToken == address(0) + ) revert InvalidAddress(); + + permit2 = definitivePermit2; + allowedFactory = definitiveAllowedFactory; + batchRouter = definitiveBatchRouter; + questBoard = definitiveQuestBoard; + questSettings = definitiveQuestSettings; + incentiveToken = definitiveIncentiveToken; + gaugeRegistry = definitiveGaugeRegistry; + + hookSwapFeePercentage = definitiveHookSwapFeePercentage; + } + + /*////////////////////////////////////////////////////////////// + HOOKS FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IHooks + function getHookFlags() public pure override returns (HookFlags memory) { + HookFlags memory hookFlags; + // `enableHookAdjustedAmounts` must be true for all contracts that modify the `amountCalculated` + // in after hooks. Otherwise, the Vault will ignore any "hookAdjusted" amounts, and the transaction + // might not settle. (It should be false if the after hooks do something else.) + hookFlags.enableHookAdjustedAmounts = true; + hookFlags.shouldCallAfterSwap = true; + hookFlags.shouldCallComputeDynamicSwapFee = true; + return hookFlags; + } + + /// @inheritdoc IHooks + function onRegister( + address factory, + address pool, + TokenConfig[] memory, + LiquidityManagement calldata + ) public override onlyVault returns (bool) { + // This hook implements a restrictive approach, where we check if the factory is an allowed factory and if + // the pool was created by the allowed factory. + bool allowed = factory == allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool); + + emit SereneDirectionalFeeHookRegistered(address(this), pool); + + return allowed; + } + + /// @inheritdoc IHooks + function onAfterSwap( + AfterSwapParams calldata params + ) public override onlyVault returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) { + hookAdjustedAmountCalculatedRaw = params.amountCalculatedRaw; + + uint256 staticSwapFeePercentage = _vault.getStaticSwapFeePercentage(params.pool); + // We assume the balances given here reflect the same calculations made in onComputeDynamicSwapFeePercentage + uint256 calculatedSwapFeePercentage = _calculatedExpectedSwapFeePercentage( + params.tokenInBalanceScaled18, + params.tokenOutBalanceScaled18 + ); + uint256 directionalFeePercentage = calculatedSwapFeePercentage > staticSwapFeePercentage + ? calculatedSwapFeePercentage + : staticSwapFeePercentage; + uint256 hookFeePercentage = ((directionalFeePercentage * uint256(hookSwapFeePercentage)) / 1e18); + if (hookFeePercentage > 0) { + uint256 previousAmountCalculatedRaw = (hookAdjustedAmountCalculatedRaw * + (1e18 + (directionalFeePercentage - hookFeePercentage))) / 1e18; + uint256 hookFee = previousAmountCalculatedRaw.mulDown(hookFeePercentage); + + if (hookFee > 0) { + IERC20 feeToken; + + if (params.kind == SwapKind.EXACT_IN) { + // For EXACT_IN swaps, the `amountCalculated` is the amount of `tokenOut`. The fee must be taken + // from `amountCalculated`, so we decrease the amount of tokens the Vault will send to the caller. + // + // The preceding swap operation has already credited the original `amountCalculated`. Since we're + // returning `amountCalculated - hookFee` here, it will only register debt for that reduced amount + // on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenOut` from the Vault to this + // contract, and registers the additional debt, so that the total debts match the credits and + // settlement succeeds. + feeToken = params.tokenOut; + hookAdjustedAmountCalculatedRaw -= hookFee; + } else { + // For EXACT_OUT swaps, the `amountCalculated` is the amount of `tokenIn`. The fee must be taken + // from `amountCalculated`, so we increase the amount of tokens the Vault will ask from the user. + // + // The preceding swap operation has already registered debt for the original `amountCalculated`. + // Since we're returning `amountCalculated + hookFee` here, it will supply credit for that increased + // amount on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenIn` from the Vault to + // this contract, and registers the additional debt, so that the total debts match the credits and + // settlement succeeds. + feeToken = params.tokenIn; + hookAdjustedAmountCalculatedRaw += hookFee; + } + + emit HookFeeCharged(address(this), feeToken, hookFee); + + takenFees[params.pool][address(feeToken)] += hookFee; + _vault.sendTo(feeToken, address(this), hookFee); + } + } + return (true, hookAdjustedAmountCalculatedRaw); + } + + // Alter the swap fee percentage + function onComputeDynamicSwapFeePercentage( + PoolSwapParams calldata params, + address pool, + uint256 staticSwapFeePercentage + ) public view override returns (bool success, uint256 dynamicSwapFeePercentage) { + // Get pool balances + (, , , uint256[] memory lastBalancesLiveScaled18) = _vault.getPoolTokenInfo(pool); + + uint256 calculatedSwapFeePercentage = _calculatedExpectedSwapFeePercentage( + lastBalancesLiveScaled18[params.indexIn] + params.amountGivenScaled18, + lastBalancesLiveScaled18[params.indexOut] - params.amountGivenScaled18 + ); + + uint256 directionalFeePercentage = calculatedSwapFeePercentage > staticSwapFeePercentage + ? calculatedSwapFeePercentage + : staticSwapFeePercentage; + + // Charge the static or calculated fee, whichever is greater. + return ( + true, + directionalFeePercentage - ((directionalFeePercentage * uint256(hookSwapFeePercentage)) / 1e18) + ); + } + + /** @notice This example assumes that the pool math is linear and that final balances of token in and out are + * changed proportionally. This approximation is just to illustrate this hook in a simple manner, but is + * also reasonable, since stable pools behave linearly near equilibrium. Also, this example requires + * the rates to be 1:1, which is common among assets that are pegged around the same value, such as USD. + * The charged fee percentage is: + * + * (distance between balances of token in and token out) / (total liquidity of both tokens) + * + * For example, if token in has a final balance of 100, and token out has a final balance of 40, the + * calculated swap fee percentage is (100 - 40) / (140) = 60/140 = 42.85% + */ + function _calculatedExpectedSwapFeePercentage( + uint256 finalBalanceTokenIn, + uint256 finalBalanceTokenOut + ) private pure returns (uint256 feePercentage) { + // Pool is farther from equilibrium, charge calculated fee. + if (finalBalanceTokenIn > finalBalanceTokenOut) { + uint256 diff = finalBalanceTokenIn - finalBalanceTokenOut; + uint256 totalLiquidity = finalBalanceTokenIn + finalBalanceTokenOut; + // If `diff` is close to `totalLiquidity`, we charge a very large swap fee because the swap is moving the + // pool balances to the edge. + feePercentage = diff.divDown(totalLiquidity); + } + } + + /*////////////////////////////////////////////////////////////// + QUEST FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Create a quest from the fees taken from the pool + * @param pool The pool from which the fees were taken + * @param steps The swap steps to convert the fees to the incentive token + */ + function createQuest(address pool, IBatchRouter.SwapPathStep[][] calldata steps) public nonReentrant { + uint256 lastQuest = lastQuestCreated[pool]; + if (lastQuest == 0) { + // Check if there is a gauge then store it and tokens for the pool to not query the registry again + address gauge = _getGauge(pool); + if (gauge == address(0)) revert CannotCreateQuest(); + gauges[pool] = gauge; + IERC20[] memory tokens = _vault.getPoolTokens(pool); + poolTokens[pool] = tokens; + } else { + // Check if the last quest is from last epoch + uint48[] memory periods = IQuestBoard(questBoard).getAllPeriodsForQuestId(lastQuest); + uint256 lastPeriod = periods[periods.length - 1]; + if (IQuestBoard(questBoard).getCurrentPeriod() <= lastPeriod) revert CannotCreateQuest(); + } + + QuestSettingsRegistry.QuestSettings memory settings = QuestSettingsRegistry(questSettings).getQuestSettings( + incentiveToken + ); + + // Swap fees taken from the pool and create a quest from it + uint256 amountOutAfterFee; + uint256 feeAmount; + { + _swapToToken(pool, steps); + uint256 amountOut = IERC20(incentiveToken).balanceOf(address(this)); + uint256 feeRatio = IQuestBoard(questBoard).platformFeeRatio(); + amountOutAfterFee = (amountOut * BPS) / (BPS + feeRatio); + feeAmount = (amountOutAfterFee * feeRatio) / BPS; + } + + IERC20(incentiveToken).safeIncreaseAllowance(questBoard, amountOutAfterFee + feeAmount); + uint256 id = IQuestBoard(questBoard).createRangedQuest( + gauges[pool], + incentiveToken, + false, // Allows to create the Quest right now, and check the previous one is over before allowing to create a new one + settings.duration, + settings.minRewardPerVote, + settings.maxRewardPerVote, + amountOutAfterFee, + feeAmount, + settings.voteType, + settings.closeType, + settings.voterList + ); + lastQuestCreated[pool] = id; + } + + /*////////////////////////////////////////////////////////////// + UTILS + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Get the gauge for a pool + * @param pool The pool to get the gauge for + * @return The gauge for the pool + */ + function _getGauge(address pool) internal view returns (address) { + return IGaugeRegistry(gaugeRegistry).getPoolGauge(pool); + } + + /** + * @dev Swap the fees taken from the pool to the incentive token + * @param pool The pool from which the fees were taken + * @param steps The swap steps to convert the fees to the incentive token + */ + function _swapToToken(address pool, IBatchRouter.SwapPathStep[][] calldata steps) internal { + // Create path data from steps + IERC20[] memory tokens = poolTokens[pool]; + IBatchRouter.SwapPathExactAmountIn[] memory paths = new IBatchRouter.SwapPathExactAmountIn[](steps.length); + uint256 pathLength = 0; + uint256 length = tokens.length; + for (uint256 i = 0; i < length; ++i) { + IERC20 token = tokens[i]; + if (address(token) == incentiveToken) { + continue; + } + + uint256 amount = takenFees[pool][address(token)]; + if (amount > 0) { + _increasePermit2Allowance(token, amount); + paths[pathLength++] = IBatchRouter.SwapPathExactAmountIn({ + tokenIn: token, + steps: steps[i], + exactAmountIn: amount, + minAmountOut: 0 + }); + takenFees[pool][address(token)] = 0; + } + } + // Store the path length in the first slot of the array + assembly { + mstore(paths, pathLength) + } + + // Swap the tokens + IBatchRouter(batchRouter).swapExactIn(paths, block.timestamp + 1, false, new bytes(0)); + } + + /** + * @dev Increase the allowance of the permit2 contract + * @param token The token to increase the allowance for + * @param amount The amount to increase the allowance by + */ + function _increasePermit2Allowance(IERC20 token, uint256 amount) internal { + if (token.allowance(address(this), address(permit2)) == 0) { + token.approve(address(permit2), type(uint256).max); + } + permit2.approve(address(token), batchRouter, uint160(amount), uint48(block.timestamp + 1)); + } +} diff --git a/packages/foundry/contracts/hooks/SereneVeBalDiscountHook.sol b/packages/foundry/contracts/hooks/SereneVeBalDiscountHook.sol new file mode 100644 index 00000000..1c5cc039 --- /dev/null +++ b/packages/foundry/contracts/hooks/SereneVeBalDiscountHook.sol @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IRouterCommon } from "@balancer-labs/v3-interfaces/contracts/vault/IRouterCommon.sol"; +import { + LiquidityManagement, + AfterSwapParams, + SwapKind, + PoolSwapParams, + TokenConfig, + HookFlags +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { IBatchRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IBatchRouter.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol"; +import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol"; +import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol"; + +import { IGaugeRegistry } from "./interfaces/IGaugeRegistry.sol"; +import { IQuestBoard } from "./interfaces/IQuestBoard.sol"; +import { QuestSettingsRegistry } from "./utils/QuestSettingsRegistry.sol"; + +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @title SereneVeBalDiscountHook +/// @notice A hook contract that charges a fee on swaps and creates quests from the fees taken from the pools, and applies a discount of the user holds veBAL +/// @author 0xtekgrinder & Kogaroshi +contract SereneVeBalDiscountHook is BaseHooks, VaultGuard, ReentrancyGuard { + using FixedPoint for uint256; + using SafeERC20 for IERC20; + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error CannotCreateQuest(); + error InvalidHookSwapFeePercentage(); + error InvalidAddress(); + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice A new `SereneVeBalDiscountHook` contract has been registered successfully for a given factory and pool. + * @dev If the registration fails the call will revert, so there will be no event. + * @param hooksContract This contract + * @param pool The pool on which the hook was registered + */ + event SereneVeBalDiscountHookRegistered(address indexed hooksContract, address indexed pool); + /** + * @notice The hooks contract has charged a fee. + * @param hooksContract The contract that collected the fee + * @param token The token in which the fee was charged + * @param feeAmount The amount of the fee + */ + event HookFeeCharged(address indexed hooksContract, IERC20 indexed token, uint256 feeAmount); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + uint64 public constant BPS = 10000; + + /*////////////////////////////////////////////////////////////// + IMMUTABLES VARIABLES + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The factory that is allowed to register pools with this hook + */ + address public immutable allowedFactory; + /** + * @notice The fee percentage of the staticSwapPoolFee to be taken from the swaps + * @dev Percentages are represented as 18-decimal FP numbers, which have a maximum value of FixedPoint.ONE (100%), + * so 60 bits are sufficient. + */ + uint64 public immutable hookSwapFeePercentage; + /** + * @notice The batch router contract used in batch swaps + */ + address public immutable batchRouter; + /** + * @notice The permit2 contract used in batch router swaps + */ + IPermit2 public immutable permit2; + /** + * @notice The token to be used as incentive for the quests + */ + address public immutable incentiveToken; + /** + * @notice The quest board contract to create the quests + */ + address public immutable questBoard; + /** + * @notice The gauge registry contract to get the gauges for the pools + */ + address public immutable gaugeRegistry; + /** + * @notice The quest settings contract to get the settings for the quests creation + */ + address public immutable questSettings; + /** + * @notice Only trusted routers are allowed to call this hook, because the hook relies on the `getSender` implementation + * implementation to work properly. + */ + address private immutable trustedRouter; + /** + * @notice The gauge token received from staking the 80/20 BAL/WETH pool token. + */ + IERC20 private immutable veBAL; + + + /*////////////////////////////////////////////////////////////// + MUTABLE VARIABLES + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The fees taken from the pools + * @dev Pool => token => amount + */ + mapping(address => mapping(address => uint256)) public takenFees; + /** + * @notice The gauges of the pools + * @dev Pool => gauge + */ + mapping(address => address) public gauges; + /** + * @notice The tokens of the pools + * @dev Pool => tokens[] + */ + mapping(address => IERC20[]) public poolTokens; + /** + * @notice The last quest created for the pool + * @dev Pool => questId + */ + mapping(address => uint256) public lastQuestCreated; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + constructor( + IVault definitiveVault, + IPermit2 definitivePermit2, + address definitiveAllowedFactory, + address definitiveGaugeRegistry, + address definitiveBatchRouter, + address definitiveQuestBoard, + address definitiveQuestSettings, + address definitiveIncentiveToken, + uint64 definitiveHookSwapFeePercentage, + address definitiveVeBAL, + address definitiveTrustedRouter + ) VaultGuard(definitiveVault) { + if (definitiveHookSwapFeePercentage > 1e18) revert InvalidHookSwapFeePercentage(); + if ( + address(definitiveVault) == address(0) || + address(definitivePermit2) == address(0) || + definitiveAllowedFactory == address(0) || + definitiveGaugeRegistry == address(0) || + definitiveBatchRouter == address(0) || + definitiveQuestBoard == address(0) || + definitiveQuestSettings == address(0) || + definitiveIncentiveToken == address(0) + ) revert InvalidAddress(); + + permit2 = definitivePermit2; + allowedFactory = definitiveAllowedFactory; + batchRouter = definitiveBatchRouter; + questBoard = definitiveQuestBoard; + questSettings = definitiveQuestSettings; + incentiveToken = definitiveIncentiveToken; + gaugeRegistry = definitiveGaugeRegistry; + veBAL = IERC20(definitiveVeBAL); + trustedRouter = definitiveTrustedRouter; + + hookSwapFeePercentage = definitiveHookSwapFeePercentage; + } + + /*////////////////////////////////////////////////////////////// + HOOKS FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IHooks + function getHookFlags() public pure override returns (HookFlags memory) { + HookFlags memory hookFlags; + // `enableHookAdjustedAmounts` must be true for all contracts that modify the `amountCalculated` + // in after hooks. Otherwise, the Vault will ignore any "hookAdjusted" amounts, and the transaction + // might not settle. (It should be false if the after hooks do something else.) + hookFlags.enableHookAdjustedAmounts = true; + hookFlags.shouldCallAfterSwap = true; + hookFlags.shouldCallComputeDynamicSwapFee = true; + return hookFlags; + } + + /// @inheritdoc IHooks + function onRegister( + address factory, + address pool, + TokenConfig[] memory, + LiquidityManagement calldata + ) public override onlyVault returns (bool) { + // This hook implements a restrictive approach, where we check if the factory is an allowed factory and if + // the pool was created by the allowed factory. + bool allowed = factory == allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool); + + emit SereneVeBalDiscountHookRegistered(address(this), pool); + + return allowed; + } + + /// @inheritdoc IHooks + function onAfterSwap( + AfterSwapParams calldata params + ) public override onlyVault returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) { + hookAdjustedAmountCalculatedRaw = params.amountCalculatedRaw; + + uint256 staticSwapFeePercentage = _vault.getStaticSwapFeePercentage(params.pool); + uint256 discountedSwapFeePercentage = _getDiscountedSwapFeePercentage(params.router, staticSwapFeePercentage); + uint256 hookFeePercentage = ((discountedSwapFeePercentage * uint256(hookSwapFeePercentage)) / 1e18); + if (hookFeePercentage > 0) { + uint256 previousAmountCalculatedRaw = (hookAdjustedAmountCalculatedRaw * + (1e18 + (discountedSwapFeePercentage - hookFeePercentage))) / 1e18; + uint256 hookFee = previousAmountCalculatedRaw.mulDown(hookFeePercentage); + + if (hookFee > 0) { + IERC20 feeToken; + + if (params.kind == SwapKind.EXACT_IN) { + // For EXACT_IN swaps, the `amountCalculated` is the amount of `tokenOut`. The fee must be taken + // from `amountCalculated`, so we decrease the amount of tokens the Vault will send to the caller. + // + // The preceding swap operation has already credited the original `amountCalculated`. Since we're + // returning `amountCalculated - hookFee` here, it will only register debt for that reduced amount + // on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenOut` from the Vault to this + // contract, and registers the additional debt, so that the total debts match the credits and + // settlement succeeds. + feeToken = params.tokenOut; + hookAdjustedAmountCalculatedRaw -= hookFee; + } else { + // For EXACT_OUT swaps, the `amountCalculated` is the amount of `tokenIn`. The fee must be taken + // from `amountCalculated`, so we increase the amount of tokens the Vault will ask from the user. + // + // The preceding swap operation has already registered debt for the original `amountCalculated`. + // Since we're returning `amountCalculated + hookFee` here, it will supply credit for that increased + // amount on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenIn` from the Vault to + // this contract, and registers the additional debt, so that the total debts match the credits and + // settlement succeeds. + feeToken = params.tokenIn; + hookAdjustedAmountCalculatedRaw += hookFee; + } + + emit HookFeeCharged(address(this), feeToken, hookFee); + + takenFees[params.pool][address(feeToken)] += hookFee; + _vault.sendTo(feeToken, address(this), hookFee); + } + } + return (true, hookAdjustedAmountCalculatedRaw); + } + + // Alter the swap fee percentage + function onComputeDynamicSwapFeePercentage( + PoolSwapParams calldata params, + address, // pool + uint256 staticSwapFeePercentage + ) public view override returns (bool success, uint256 dynamicSwapFeePercentage) { + uint256 discountedSwapFeePercentage = _getDiscountedSwapFeePercentage(params.router, staticSwapFeePercentage); + return (true, discountedSwapFeePercentage - (discountedSwapFeePercentage * uint256(hookSwapFeePercentage)) / 1e18); + } + + function _getDiscountedSwapFeePercentage(address router, uint256 staticSwapFeePercentage) internal view returns (uint256) { + // If the router is not trusted, do not apply the veBAL discount. `getSender` may be manipulated by a + // malicious router. + if (router != trustedRouter) { + return staticSwapFeePercentage; + } + + address user = IRouterCommon(router).getSender(); + + // If user has veBAL, apply a 50% discount to the current fee. + if (veBAL.balanceOf(user) > 0) { + return staticSwapFeePercentage / 2; + } + + return staticSwapFeePercentage; + } + + /*////////////////////////////////////////////////////////////// + QUEST FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Create a quest from the fees taken from the pool + * @param pool The pool from which the fees were taken + * @param steps The swap steps to convert the fees to the incentive token + */ + function createQuest(address pool, IBatchRouter.SwapPathStep[][] calldata steps) public nonReentrant { + uint256 lastQuest = lastQuestCreated[pool]; + if (lastQuest == 0) { + // Check if there is a gauge then store it and tokens for the pool to not query the registry again + address gauge = _getGauge(pool); + if (gauge == address(0)) revert CannotCreateQuest(); + gauges[pool] = gauge; + IERC20[] memory tokens = _vault.getPoolTokens(pool); + poolTokens[pool] = tokens; + } else { + // Check if the last quest is from last epoch + uint48[] memory periods = IQuestBoard(questBoard).getAllPeriodsForQuestId(lastQuest); + uint256 lastPeriod = periods[periods.length - 1]; + if (IQuestBoard(questBoard).getCurrentPeriod() <= lastPeriod) revert CannotCreateQuest(); + } + + QuestSettingsRegistry.QuestSettings memory settings = QuestSettingsRegistry(questSettings).getQuestSettings( + incentiveToken + ); + + // Swap fees taken from the pool and create a quest from it + uint256 amountOutAfterFee; + uint256 feeAmount; + { + _swapToToken(pool, steps); + uint256 amountOut = IERC20(incentiveToken).balanceOf(address(this)); + uint256 feeRatio = IQuestBoard(questBoard).platformFeeRatio(); + amountOutAfterFee = (amountOut * BPS) / (BPS + feeRatio); + feeAmount = (amountOutAfterFee * feeRatio) / BPS; + } + + IERC20(incentiveToken).safeIncreaseAllowance(questBoard, amountOutAfterFee + feeAmount); + uint256 id = IQuestBoard(questBoard).createRangedQuest( + gauges[pool], + incentiveToken, + false, // Allows to create the Quest right now, and check the previous one is over before allowing to create a new one + settings.duration, + settings.minRewardPerVote, + settings.maxRewardPerVote, + amountOutAfterFee, + feeAmount, + settings.voteType, + settings.closeType, + settings.voterList + ); + lastQuestCreated[pool] = id; + } + + /*////////////////////////////////////////////////////////////// + UTILS + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Get the gauge for a pool + * @param pool The pool to get the gauge for + * @return The gauge for the pool + */ + function _getGauge(address pool) internal view returns (address) { + return IGaugeRegistry(gaugeRegistry).getPoolGauge(pool); + } + + /** + * @dev Swap the fees taken from the pool to the incentive token + * @param pool The pool from which the fees were taken + * @param steps The swap steps to convert the fees to the incentive token + */ + function _swapToToken(address pool, IBatchRouter.SwapPathStep[][] calldata steps) internal { + // Create path data from steps + IERC20[] memory tokens = poolTokens[pool]; + IBatchRouter.SwapPathExactAmountIn[] memory paths = new IBatchRouter.SwapPathExactAmountIn[](steps.length); + uint256 pathLength = 0; + uint256 length = tokens.length; + for (uint256 i = 0; i < length; ++i) { + IERC20 token = tokens[i]; + if (address(token) == incentiveToken) { + continue; + } + + uint256 amount = takenFees[pool][address(token)]; + if (amount > 0) { + _increasePermit2Allowance(token, amount); + paths[pathLength++] = IBatchRouter.SwapPathExactAmountIn({ + tokenIn: token, + steps: steps[i], + exactAmountIn: amount, + minAmountOut: 0 + }); + takenFees[pool][address(token)] = 0; + } + } + // Store the path length in the first slot of the array + assembly { + mstore(paths, pathLength) + } + + // Swap the tokens + IBatchRouter(batchRouter).swapExactIn(paths, block.timestamp + 1, false, new bytes(0)); + } + + /** + * @dev Increase the allowance of the permit2 contract + * @param token The token to increase the allowance for + * @param amount The amount to increase the allowance by + */ + function _increasePermit2Allowance(IERC20 token, uint256 amount) internal { + if (token.allowance(address(this), address(permit2)) == 0) { + token.approve(address(permit2), type(uint256).max); + } + permit2.approve(address(token), batchRouter, uint160(amount), uint48(block.timestamp + 1)); + } +} From 52fab25626f358fd56a2c2eddd386166f66f3642 Mon Sep 17 00:00:00 2001 From: Kogaroshi Date: Thu, 26 Sep 2024 11:22:14 -0400 Subject: [PATCH 06/13] feat: SerenHok veBalDiscount tests --- .../test/SereneVeBalDiscountHook.t.sol | 665 ++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 packages/foundry/test/SereneVeBalDiscountHook.t.sol diff --git a/packages/foundry/test/SereneVeBalDiscountHook.t.sol b/packages/foundry/test/SereneVeBalDiscountHook.t.sol new file mode 100644 index 00000000..9c49b469 --- /dev/null +++ b/packages/foundry/test/SereneVeBalDiscountHook.t.sol @@ -0,0 +1,665 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; +import { + HooksConfig, + LiquidityManagement, + PoolRoleAccounts, + TokenConfig, + AfterSwapParams, + SwapKind +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { BasePoolMath } from "@balancer-labs/v3-vault/contracts/BasePoolMath.sol"; + +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; +import { BalancerPoolToken } from "@balancer-labs/v3-vault/contracts/BalancerPoolToken.sol"; +import { PoolMock } from "@balancer-labs/v3-vault/contracts/test/PoolMock.sol"; +import { PoolFactoryMock } from "@balancer-labs/v3-vault/contracts/test/PoolFactoryMock.sol"; +import { RouterMock } from "@balancer-labs/v3-vault/contracts/test/RouterMock.sol"; +import { IBatchRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IBatchRouter.sol"; + +import { GaugeRegistry } from "../contracts/mocks/GaugeRegistry.sol"; +import { MockQuestBoard } from "../contracts/mocks/MockQuestBoard.sol"; +import { QuestSettingsRegistry } from "../contracts/hooks/utils/QuestSettingsRegistry.sol"; +import { SereneVeBalDiscountHook } from "../contracts/hooks/SereneVeBalDiscountHook.sol"; +import { IQuestBoard } from "../contracts/hooks/interfaces/IQuestBoard.sol"; + +contract SereneVeBalDiscountHookTest is BaseVaultTest { + using CastingHelpers for address[]; + using FixedPoint for uint256; + using ArrayHelpers for *; + + GaugeRegistry gaugeRegistry; + QuestSettingsRegistry questSettings; + MockQuestBoard questBoard; + address owner; + + uint256 internal daiIdx; + uint256 internal usdcIdx; + + uint256 hookSwapFee = 5e17; // 50% + uint256 UNIT = 1e18; + + uint256 internal constant SWAP_FEE_PERCENTAGE = 5e16; // 5% + + address payable internal trustedRouter; + + function setUp() public override { + owner = makeAddr("owner"); + vm.label(owner, "owner"); + + gaugeRegistry = new GaugeRegistry(); + questSettings = new QuestSettingsRegistry(owner); + questBoard = new MockQuestBoard(400, 0); + + vm.prank(owner); + questSettings.setQuestSettings( + address(dai), + 1, + 1000, + 2000, + IQuestBoard.QuestVoteType.NORMAL, + IQuestBoard.QuestCloseType.NORMAL, + new address[](0) + ); + + super.setUp(); + + (daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc)); + } + + function testRegistryWithWrongFactory() public { + address serenePool = _createPoolToRegister(); + TokenConfig[] memory tokenConfig = vault.buildTokenConfig( + [address(dai), address(usdc)].toMemoryArray().asIERC20() + ); + + uint32 pauseWindowEndTime = IVaultAdmin(address(vault)).getPauseWindowEndTime(); + uint32 bufferPeriodDuration = IVaultAdmin(address(vault)).getBufferPeriodDuration(); + uint32 pauseWindowDuration = pauseWindowEndTime - bufferPeriodDuration; + address unauthorizedFactory = address(new PoolFactoryMock(IVault(address(vault)), pauseWindowDuration)); + + vm.expectRevert( + abi.encodeWithSelector( + IVaultErrors.HookRegistrationFailed.selector, + poolHooksContract, + serenePool, + unauthorizedFactory + ) + ); + _registerPoolWithHook(serenePool, tokenConfig, unauthorizedFactory); + } + + function testFeeSwapExactInNoVeBal__Fuzz(uint256 swapAmount) public { + assertEq(veBAL.balanceOf(bob), 0, "Bob has veBAL"); + // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)); + uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); + uint256 protocolFees = swapAmount.mulUp(protocolFeePercentage); + uint256 amountCalculatedRaw = swapAmount - protocolFees; + uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; + uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; + + BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + if (hookFee > 0) { + vm.expectEmit(); + emit SereneVeBalDiscountHook.HookFeeCharged(poolHooksContract, IERC20(usdc), hookFee); + } + + vm.prank(bob); + router.swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); + + BaseVaultTest.Balances memory balancesAfter = getBalances(bob); + + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + assertEq( + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], + swapAmount, + "Bob DAI balance is wrong" + ); + assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook DAI balance is wrong"); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + swapAmount - hookFee - protocolFees, + "Bob USDC balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[usdcIdx] - balancesBefore.hookTokens[usdcIdx], + hookFee, + "Hook USDC balance is wrong" + ); + + assertEq(storedHookFeesAfter - storedHookFeesBefore, hookFee, "Hook taken fees stored is wrong"); + + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + swapAmount, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + swapAmount - protocolFees, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + swapAmount, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + swapAmount - protocolFees, + "Vault USDC balance is wrong" + ); + } + + function testFeeSwapExactOutNoVeBal__Fuzz(uint256 swapAmount) public { + assertEq(veBAL.balanceOf(bob), 0, "Bob has veBAL"); + // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)); + uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); + uint256 protocolFees = swapAmount.mulDivUp(protocolFeePercentage, protocolFeePercentage.complement()); + uint256 amountCalculatedRaw = swapAmount + protocolFees; + uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; + uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; + + BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(dai)); + + if (hookFee > 0) { + vm.expectEmit(); + emit SereneVeBalDiscountHook.HookFeeCharged(poolHooksContract, IERC20(dai), hookFee); + } + + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), + dai, + usdc, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") + ); + + BaseVaultTest.Balances memory balancesAfter = getBalances(bob); + + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(dai)); + + assertEq( + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], + swapAmount + hookFee + protocolFees, + "Bob DAI balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[daiIdx] - balancesBefore.hookTokens[daiIdx], + hookFee, + "Hook DAI balance is wrong" + ); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + swapAmount, + "Bob USDC balance is wrong" + ); + assertEq(balancesAfter.hookTokens[usdcIdx], balancesBefore.hookTokens[usdcIdx], "Hook USDC balance is wrong"); + + assertEq(storedHookFeesAfter - storedHookFeesBefore, hookFee, "Hook taken fees stored is wrong"); + + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + swapAmount + protocolFees, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + swapAmount, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + swapAmount + protocolFees, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + swapAmount, + "Vault USDC balance is wrong" + ); + } + + function testFeeSwapExactInWithVeBal__Fuzz(uint256 swapAmount) public { + // Mint 1 veBAL to Bob, so he's able to receive the fee discount. + veBAL.mint(bob, 1); + assertGt(veBAL.balanceOf(bob), 0, "Bob does not have veBAL"); + // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)) / 2; + uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); + uint256 protocolFees = swapAmount.mulUp(protocolFeePercentage); + uint256 amountCalculatedRaw = swapAmount - protocolFees; + uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; + uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; + + BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + if (hookFee > 0) { + vm.expectEmit(); + emit SereneVeBalDiscountHook.HookFeeCharged(poolHooksContract, IERC20(usdc), hookFee); + } + + vm.prank(bob); + router.swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); + + BaseVaultTest.Balances memory balancesAfter = getBalances(bob); + + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + assertEq( + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], + swapAmount, + "Bob DAI balance is wrong" + ); + assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook DAI balance is wrong"); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + swapAmount - hookFee - protocolFees, + "Bob USDC balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[usdcIdx] - balancesBefore.hookTokens[usdcIdx], + hookFee, + "Hook USDC balance is wrong" + ); + + assertEq(storedHookFeesAfter - storedHookFeesBefore, hookFee, "Hook taken fees stored is wrong"); + + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + swapAmount, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + swapAmount - protocolFees, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + swapAmount, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + swapAmount - protocolFees, + "Vault USDC balance is wrong" + ); + } + + function testFeeSwapExactOutWithVeBal__Fuzz(uint256 swapAmount) public { + // Mint 1 veBAL to Bob, so he's able to receive the fee discount. + veBAL.mint(bob, 1); + assertGt(veBAL.balanceOf(bob), 0, "Bob does not have veBAL"); + // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)) / 2; + uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); + uint256 protocolFees = swapAmount.mulDivUp(protocolFeePercentage, protocolFeePercentage.complement()); + uint256 amountCalculatedRaw = swapAmount + protocolFees; + uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; + uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; + + BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(dai)); + + if (hookFee > 0) { + vm.expectEmit(); + emit SereneVeBalDiscountHook.HookFeeCharged(poolHooksContract, IERC20(dai), hookFee); + } + + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), + dai, + usdc, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") + ); + + BaseVaultTest.Balances memory balancesAfter = getBalances(bob); + + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(dai)); + + assertEq( + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], + swapAmount + hookFee + protocolFees, + "Bob DAI balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[daiIdx] - balancesBefore.hookTokens[daiIdx], + hookFee, + "Hook DAI balance is wrong" + ); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + swapAmount, + "Bob USDC balance is wrong" + ); + assertEq(balancesAfter.hookTokens[usdcIdx], balancesBefore.hookTokens[usdcIdx], "Hook USDC balance is wrong"); + + assertEq(storedHookFeesAfter - storedHookFeesBefore, hookFee, "Hook taken fees stored is wrong"); + + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + swapAmount + protocolFees, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + swapAmount, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + swapAmount + protocolFees, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + swapAmount, + "Vault USDC balance is wrong" + ); + } + + function testFeeSwapExactInUntrustedRouter__Fuzz(uint256 swapAmount) public { + // Mint 1 veBAL to Bob, so he's able to receive the fee discount. + veBAL.mint(bob, 1); + assertGt(veBAL.balanceOf(bob), 0, "Bob does not have veBAL"); + // Swap between POOL_MINIMUM_TOTAL_SUPPLY and whole pool liquidity (pool math is linear) + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, poolInitAmount); + + // Create an untrusted router + address payable untrustedRouter = payable(new RouterMock(IVault(address(vault)), weth, permit2)); + vm.label(untrustedRouter, "untrusted router"); + + // Allows permit2 to move DAI tokens from Bob to untrustedRouter. + vm.prank(bob); + permit2.approve(address(dai), untrustedRouter, type(uint160).max, type(uint48).max); + + uint256 staticFeePercentage = vault.getStaticSwapFeePercentage(address(pool)); + uint256 protocolFeePercentage = staticFeePercentage - ((staticFeePercentage * hookSwapFee) / UNIT); + uint256 protocolFees = swapAmount.mulUp(protocolFeePercentage); + uint256 amountCalculatedRaw = swapAmount - protocolFees; + uint256 reconstructedAmount = (amountCalculatedRaw * (UNIT + protocolFeePercentage)) / UNIT; + uint256 hookFee = (reconstructedAmount * ((staticFeePercentage * hookSwapFee) / UNIT)) / UNIT; + + BaseVaultTest.Balances memory balancesBefore = getBalances(bob); + + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + if (hookFee > 0) { + vm.expectEmit(); + emit SereneVeBalDiscountHook.HookFeeCharged(poolHooksContract, IERC20(usdc), hookFee); + } + + vm.prank(bob); + RouterMock(untrustedRouter).swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); + + BaseVaultTest.Balances memory balancesAfter = getBalances(bob); + + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + + assertEq( + balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], + swapAmount, + "Bob DAI balance is wrong" + ); + assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook DAI balance is wrong"); + assertEq( + balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx], + swapAmount - hookFee - protocolFees, + "Bob USDC balance is wrong" + ); + assertEq( + balancesAfter.hookTokens[usdcIdx] - balancesBefore.hookTokens[usdcIdx], + hookFee, + "Hook USDC balance is wrong" + ); + + assertEq(storedHookFeesAfter - storedHookFeesBefore, hookFee, "Hook taken fees stored is wrong"); + + assertEq( + balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx], + swapAmount, + "Pool DAI balance is wrong" + ); + assertEq( + balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx], + swapAmount - protocolFees, + "Pool USDC balance is wrong" + ); + assertEq( + balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx], + swapAmount, + "Vault DAI balance is wrong" + ); + assertEq( + balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx], + swapAmount - protocolFees, + "Vault USDC balance is wrong" + ); + } + + function testNoGaugeWhenCreatingQuest() public { + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](0); + + vm.expectRevert(SereneVeBalDiscountHook.CannotCreateQuest.selector); + SereneVeBalDiscountHook(poolHooksContract).createQuest(address(pool), steps); + } + + function testQuestAlreadyCreatedForThisEpoch__Fuzz(uint256 swapAmount) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, 1e19); + gaugeRegistry.register(address(pool), makeAddr("gauge")); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") + ); + + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); + steps[0] = new IBatchRouter.SwapPathStep[](1); + steps[0][0] = IBatchRouter.SwapPathStep({ pool: address(pool), tokenOut: dai, isBuffer: false }); + SereneVeBalDiscountHook(poolHooksContract).createQuest(address(pool), steps); + + uint48[] memory periods = new uint48[](1); + periods[0] = 0; + questBoard.setPeriodsForQuestId(1, periods); + + vm.expectRevert(SereneVeBalDiscountHook.CannotCreateQuest.selector); + SereneVeBalDiscountHook(poolHooksContract).createQuest(address(pool), steps); + } + + function testCreateNormalQuestSecondEpoch__Fuzz(uint256 swapAmount) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, 1e19); + gaugeRegistry.register(address(pool), makeAddr("gauge")); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") + ); + + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); + steps[0] = new IBatchRouter.SwapPathStep[](1); + steps[0][0] = IBatchRouter.SwapPathStep({ pool: address(pool), tokenOut: dai, isBuffer: false }); + SereneVeBalDiscountHook(poolHooksContract).createQuest(address(pool), steps); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") + ); + + uint48[] memory periods = new uint48[](1); + periods[0] = 0; + questBoard.setPeriodsForQuestId(1, periods); + questBoard.setPeriod(1); + + vm.expectCall(address(questBoard), abi.encodeWithSelector(IQuestBoard.createRangedQuest.selector)); + SereneVeBalDiscountHook(poolHooksContract).createQuest(address(pool), steps); + + assertEq(SereneVeBalDiscountHook(poolHooksContract).gauges(address(pool)), makeAddr("gauge"), "Gauge not set"); + assertEq(SereneVeBalDiscountHook(poolHooksContract).lastQuestCreated(address(pool)), 1, "Quest not created"); + assertEq(usdc.balanceOf(poolHooksContract), 0, "Usdc balance is wrong"); + assertApproxEqAbs(dai.balanceOf(poolHooksContract), 0, 1, "Dai balance is wrong"); + } + + function testCreateNormalQuest__Fuzz(uint256 swapAmount) public { + swapAmount = bound(swapAmount, POOL_MINIMUM_TOTAL_SUPPLY, 1e19); + gaugeRegistry.register(address(pool), makeAddr("gauge")); + + // Swap to get USDC fees + vm.prank(bob); + router.swapSingleTokenExactOut( + address(pool), + usdc, + dai, + swapAmount, + MAX_UINT256, + block.timestamp + 1, + false, + bytes("") + ); + + IBatchRouter.SwapPathStep[][] memory steps = new IBatchRouter.SwapPathStep[][](2); + steps[0] = new IBatchRouter.SwapPathStep[](1); + steps[0][0] = IBatchRouter.SwapPathStep({ pool: address(pool), tokenOut: dai, isBuffer: false }); + vm.expectCall(address(questBoard), abi.encodeWithSelector(IQuestBoard.createRangedQuest.selector)); + SereneVeBalDiscountHook(poolHooksContract).createQuest(address(pool), steps); + + assertEq(SereneVeBalDiscountHook(poolHooksContract).gauges(address(pool)), makeAddr("gauge"), "Gauge not set"); + assertEq(SereneVeBalDiscountHook(poolHooksContract).lastQuestCreated(address(pool)), 1, "Quest not created"); + assertEq(usdc.balanceOf(poolHooksContract), 0, "Usdc balance is wrong"); + assertApproxEqAbs(dai.balanceOf(poolHooksContract), 0, 1, "Dai balance is wrong"); + } + + /*////////////////////////////////////////////////////////////// + UTILS + //////////////////////////////////////////////////////////////*/ + + function createHook() internal override returns (address) { + trustedRouter = payable(router); + + // lp will be the owner of the hook. + address sereneHook = address( + new SereneVeBalDiscountHook( + IVault(address(vault)), + permit2, + address(factoryMock), + address(gaugeRegistry), + address(batchRouter), + address(questBoard), + address(questSettings), + address(dai), + uint64(hookSwapFee), + address(veBAL), + trustedRouter + ) + ); + vm.label(sereneHook, "sereneHook"); + return address(sereneHook); + } + + // Overrides pool creation to set liquidityManagement (disables unbalanced liquidity) + function _createPool(address[] memory tokens, string memory label) internal override returns (address) { + address newPool = factoryMock.createPool("SereneVeBalDiscountHook Pool", "SNKP"); + vm.label(address(newPool), label); + + PoolRoleAccounts memory roleAccounts; + LiquidityManagement memory liquidityManagement; + liquidityManagement.disableUnbalancedLiquidity = true; + + factoryMock.registerPool( + address(newPool), + vault.buildTokenConfig(tokens.asIERC20()), + roleAccounts, + poolHooksContract, + liquidityManagement + ); + + authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), admin); + vm.prank(admin); + vault.setStaticSwapFeePercentage(newPool, SWAP_FEE_PERCENTAGE); + + return address(newPool); + } + + // Registry tests require a new pool, because an existing pool may be already registered + function _createPoolToRegister() private returns (address newPool) { + newPool = address(new PoolMock(IVault(address(vault)), "SereneVeBalDiscountHook Pool", "SHK")); + vm.label(newPool, "newPool"); + } + + function _registerPoolWithHook(address exitFeePool, TokenConfig[] memory tokenConfig, address factory) private { + PoolRoleAccounts memory roleAccounts; + LiquidityManagement memory liquidityManagement; + liquidityManagement.disableUnbalancedLiquidity = true; + + PoolFactoryMock(factory).registerPool( + exitFeePool, + tokenConfig, + roleAccounts, + poolHooksContract, + liquidityManagement + ); + } +} From 177f558ca993fe2fceeca0deaca253dd36a8f444 Mon Sep 17 00:00:00 2001 From: Kogaroshi Date: Fri, 27 Sep 2024 09:04:45 -0400 Subject: [PATCH 07/13] remove SereneDirectionalFeeHook : logic does not work correctly with the desired system --- .../hooks/SereneDirectionalFeeHook.sol | 432 ------------------ packages/foundry/remappings.txt | 1 + 2 files changed, 1 insertion(+), 432 deletions(-) delete mode 100644 packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol diff --git a/packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol b/packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol deleted file mode 100644 index 086058c3..00000000 --- a/packages/foundry/contracts/hooks/SereneDirectionalFeeHook.sol +++ /dev/null @@ -1,432 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; -import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; -import { - LiquidityManagement, - AfterSwapParams, - SwapKind, - PoolSwapParams, - TokenConfig, - HookFlags -} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; - -import { IBatchRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IBatchRouter.sol"; -import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; -import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol"; -import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol"; -import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol"; - -import { IGaugeRegistry } from "./interfaces/IGaugeRegistry.sol"; -import { IQuestBoard } from "./interfaces/IQuestBoard.sol"; -import { QuestSettingsRegistry } from "./utils/QuestSettingsRegistry.sol"; - -import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; -import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; - -/// @title SereneDirectionalFeeHook -/// @notice A hook contract that charges a fee on swaps and creates quests from the fees taken from the pools -/// @author 0xtekgrinder & Kogaroshi -contract SereneDirectionalFeeHook is BaseHooks, VaultGuard, ReentrancyGuard { - using FixedPoint for uint256; - using SafeERC20 for IERC20; - - /*////////////////////////////////////////////////////////////// - ERRORS - //////////////////////////////////////////////////////////////*/ - - error CannotCreateQuest(); - error InvalidHookSwapFeePercentage(); - error InvalidAddress(); - - /*////////////////////////////////////////////////////////////// - EVENTS - //////////////////////////////////////////////////////////////*/ - - /** - * @notice A new `SereneDirectionalFeeHook` contract has been registered successfully for a given factory and pool. - * @dev If the registration fails the call will revert, so there will be no event. - * @param hooksContract This contract - * @param pool The pool on which the hook was registered - */ - event SereneDirectionalFeeHookRegistered(address indexed hooksContract, address indexed pool); - /** - * @notice The hooks contract has charged a fee. - * @param hooksContract The contract that collected the fee - * @param token The token in which the fee was charged - * @param feeAmount The amount of the fee - */ - event HookFeeCharged(address indexed hooksContract, IERC20 indexed token, uint256 feeAmount); - - /*////////////////////////////////////////////////////////////// - CONSTANTS - //////////////////////////////////////////////////////////////*/ - - uint64 public constant BPS = 10000; - - /*////////////////////////////////////////////////////////////// - IMMUTABLES VARIABLES - //////////////////////////////////////////////////////////////*/ - - /** - * @notice The factory that is allowed to register pools with this hook - */ - address public immutable allowedFactory; - /** - * @notice The fee percentage of the staticSwapPoolFee to be taken from the swaps - * @dev Percentages are represented as 18-decimal FP numbers, which have a maximum value of FixedPoint.ONE (100%), - * so 60 bits are sufficient. - */ - uint64 public immutable hookSwapFeePercentage; - /** - * @notice The batch router contract used in batch swaps - */ - address public immutable batchRouter; - /** - * @notice The permit2 contract used in batch router swaps - */ - IPermit2 public immutable permit2; - /** - * @notice The token to be used as incentive for the quests - */ - address public immutable incentiveToken; - /** - * @notice The quest board contract to create the quests - */ - address public immutable questBoard; - /** - * @notice The gauge registry contract to get the gauges for the pools - */ - address public immutable gaugeRegistry; - /** - * @notice The quest settings contract to get the settings for the quests creation - */ - address public immutable questSettings; - - - /*////////////////////////////////////////////////////////////// - MUTABLE VARIABLES - //////////////////////////////////////////////////////////////*/ - - /** - * @notice The fees taken from the pools - * @dev Pool => token => amount - */ - mapping(address => mapping(address => uint256)) public takenFees; - /** - * @notice The gauges of the pools - * @dev Pool => gauge - */ - mapping(address => address) public gauges; - /** - * @notice The tokens of the pools - * @dev Pool => tokens[] - */ - mapping(address => IERC20[]) public poolTokens; - /** - * @notice The last quest created for the pool - * @dev Pool => questId - */ - mapping(address => uint256) public lastQuestCreated; - - /*////////////////////////////////////////////////////////////// - CONSTRUCTOR - //////////////////////////////////////////////////////////////*/ - - constructor( - IVault definitiveVault, - IPermit2 definitivePermit2, - address definitiveAllowedFactory, // must be StableSwap Factory - address definitiveGaugeRegistry, - address definitiveBatchRouter, - address definitiveQuestBoard, - address definitiveQuestSettings, - address definitiveIncentiveToken, - uint64 definitiveHookSwapFeePercentage - ) VaultGuard(definitiveVault) { - if (definitiveHookSwapFeePercentage > 1e18) revert InvalidHookSwapFeePercentage(); - if ( - address(definitiveVault) == address(0) || - address(definitivePermit2) == address(0) || - definitiveAllowedFactory == address(0) || - definitiveGaugeRegistry == address(0) || - definitiveBatchRouter == address(0) || - definitiveQuestBoard == address(0) || - definitiveQuestSettings == address(0) || - definitiveIncentiveToken == address(0) - ) revert InvalidAddress(); - - permit2 = definitivePermit2; - allowedFactory = definitiveAllowedFactory; - batchRouter = definitiveBatchRouter; - questBoard = definitiveQuestBoard; - questSettings = definitiveQuestSettings; - incentiveToken = definitiveIncentiveToken; - gaugeRegistry = definitiveGaugeRegistry; - - hookSwapFeePercentage = definitiveHookSwapFeePercentage; - } - - /*////////////////////////////////////////////////////////////// - HOOKS FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /// @inheritdoc IHooks - function getHookFlags() public pure override returns (HookFlags memory) { - HookFlags memory hookFlags; - // `enableHookAdjustedAmounts` must be true for all contracts that modify the `amountCalculated` - // in after hooks. Otherwise, the Vault will ignore any "hookAdjusted" amounts, and the transaction - // might not settle. (It should be false if the after hooks do something else.) - hookFlags.enableHookAdjustedAmounts = true; - hookFlags.shouldCallAfterSwap = true; - hookFlags.shouldCallComputeDynamicSwapFee = true; - return hookFlags; - } - - /// @inheritdoc IHooks - function onRegister( - address factory, - address pool, - TokenConfig[] memory, - LiquidityManagement calldata - ) public override onlyVault returns (bool) { - // This hook implements a restrictive approach, where we check if the factory is an allowed factory and if - // the pool was created by the allowed factory. - bool allowed = factory == allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool); - - emit SereneDirectionalFeeHookRegistered(address(this), pool); - - return allowed; - } - - /// @inheritdoc IHooks - function onAfterSwap( - AfterSwapParams calldata params - ) public override onlyVault returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) { - hookAdjustedAmountCalculatedRaw = params.amountCalculatedRaw; - - uint256 staticSwapFeePercentage = _vault.getStaticSwapFeePercentage(params.pool); - // We assume the balances given here reflect the same calculations made in onComputeDynamicSwapFeePercentage - uint256 calculatedSwapFeePercentage = _calculatedExpectedSwapFeePercentage( - params.tokenInBalanceScaled18, - params.tokenOutBalanceScaled18 - ); - uint256 directionalFeePercentage = calculatedSwapFeePercentage > staticSwapFeePercentage - ? calculatedSwapFeePercentage - : staticSwapFeePercentage; - uint256 hookFeePercentage = ((directionalFeePercentage * uint256(hookSwapFeePercentage)) / 1e18); - if (hookFeePercentage > 0) { - uint256 previousAmountCalculatedRaw = (hookAdjustedAmountCalculatedRaw * - (1e18 + (directionalFeePercentage - hookFeePercentage))) / 1e18; - uint256 hookFee = previousAmountCalculatedRaw.mulDown(hookFeePercentage); - - if (hookFee > 0) { - IERC20 feeToken; - - if (params.kind == SwapKind.EXACT_IN) { - // For EXACT_IN swaps, the `amountCalculated` is the amount of `tokenOut`. The fee must be taken - // from `amountCalculated`, so we decrease the amount of tokens the Vault will send to the caller. - // - // The preceding swap operation has already credited the original `amountCalculated`. Since we're - // returning `amountCalculated - hookFee` here, it will only register debt for that reduced amount - // on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenOut` from the Vault to this - // contract, and registers the additional debt, so that the total debts match the credits and - // settlement succeeds. - feeToken = params.tokenOut; - hookAdjustedAmountCalculatedRaw -= hookFee; - } else { - // For EXACT_OUT swaps, the `amountCalculated` is the amount of `tokenIn`. The fee must be taken - // from `amountCalculated`, so we increase the amount of tokens the Vault will ask from the user. - // - // The preceding swap operation has already registered debt for the original `amountCalculated`. - // Since we're returning `amountCalculated + hookFee` here, it will supply credit for that increased - // amount on settlement. This call to `sendTo` pulls `hookFee` tokens of `tokenIn` from the Vault to - // this contract, and registers the additional debt, so that the total debts match the credits and - // settlement succeeds. - feeToken = params.tokenIn; - hookAdjustedAmountCalculatedRaw += hookFee; - } - - emit HookFeeCharged(address(this), feeToken, hookFee); - - takenFees[params.pool][address(feeToken)] += hookFee; - _vault.sendTo(feeToken, address(this), hookFee); - } - } - return (true, hookAdjustedAmountCalculatedRaw); - } - - // Alter the swap fee percentage - function onComputeDynamicSwapFeePercentage( - PoolSwapParams calldata params, - address pool, - uint256 staticSwapFeePercentage - ) public view override returns (bool success, uint256 dynamicSwapFeePercentage) { - // Get pool balances - (, , , uint256[] memory lastBalancesLiveScaled18) = _vault.getPoolTokenInfo(pool); - - uint256 calculatedSwapFeePercentage = _calculatedExpectedSwapFeePercentage( - lastBalancesLiveScaled18[params.indexIn] + params.amountGivenScaled18, - lastBalancesLiveScaled18[params.indexOut] - params.amountGivenScaled18 - ); - - uint256 directionalFeePercentage = calculatedSwapFeePercentage > staticSwapFeePercentage - ? calculatedSwapFeePercentage - : staticSwapFeePercentage; - - // Charge the static or calculated fee, whichever is greater. - return ( - true, - directionalFeePercentage - ((directionalFeePercentage * uint256(hookSwapFeePercentage)) / 1e18) - ); - } - - /** @notice This example assumes that the pool math is linear and that final balances of token in and out are - * changed proportionally. This approximation is just to illustrate this hook in a simple manner, but is - * also reasonable, since stable pools behave linearly near equilibrium. Also, this example requires - * the rates to be 1:1, which is common among assets that are pegged around the same value, such as USD. - * The charged fee percentage is: - * - * (distance between balances of token in and token out) / (total liquidity of both tokens) - * - * For example, if token in has a final balance of 100, and token out has a final balance of 40, the - * calculated swap fee percentage is (100 - 40) / (140) = 60/140 = 42.85% - */ - function _calculatedExpectedSwapFeePercentage( - uint256 finalBalanceTokenIn, - uint256 finalBalanceTokenOut - ) private pure returns (uint256 feePercentage) { - // Pool is farther from equilibrium, charge calculated fee. - if (finalBalanceTokenIn > finalBalanceTokenOut) { - uint256 diff = finalBalanceTokenIn - finalBalanceTokenOut; - uint256 totalLiquidity = finalBalanceTokenIn + finalBalanceTokenOut; - // If `diff` is close to `totalLiquidity`, we charge a very large swap fee because the swap is moving the - // pool balances to the edge. - feePercentage = diff.divDown(totalLiquidity); - } - } - - /*////////////////////////////////////////////////////////////// - QUEST FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /** - * @notice Create a quest from the fees taken from the pool - * @param pool The pool from which the fees were taken - * @param steps The swap steps to convert the fees to the incentive token - */ - function createQuest(address pool, IBatchRouter.SwapPathStep[][] calldata steps) public nonReentrant { - uint256 lastQuest = lastQuestCreated[pool]; - if (lastQuest == 0) { - // Check if there is a gauge then store it and tokens for the pool to not query the registry again - address gauge = _getGauge(pool); - if (gauge == address(0)) revert CannotCreateQuest(); - gauges[pool] = gauge; - IERC20[] memory tokens = _vault.getPoolTokens(pool); - poolTokens[pool] = tokens; - } else { - // Check if the last quest is from last epoch - uint48[] memory periods = IQuestBoard(questBoard).getAllPeriodsForQuestId(lastQuest); - uint256 lastPeriod = periods[periods.length - 1]; - if (IQuestBoard(questBoard).getCurrentPeriod() <= lastPeriod) revert CannotCreateQuest(); - } - - QuestSettingsRegistry.QuestSettings memory settings = QuestSettingsRegistry(questSettings).getQuestSettings( - incentiveToken - ); - - // Swap fees taken from the pool and create a quest from it - uint256 amountOutAfterFee; - uint256 feeAmount; - { - _swapToToken(pool, steps); - uint256 amountOut = IERC20(incentiveToken).balanceOf(address(this)); - uint256 feeRatio = IQuestBoard(questBoard).platformFeeRatio(); - amountOutAfterFee = (amountOut * BPS) / (BPS + feeRatio); - feeAmount = (amountOutAfterFee * feeRatio) / BPS; - } - - IERC20(incentiveToken).safeIncreaseAllowance(questBoard, amountOutAfterFee + feeAmount); - uint256 id = IQuestBoard(questBoard).createRangedQuest( - gauges[pool], - incentiveToken, - false, // Allows to create the Quest right now, and check the previous one is over before allowing to create a new one - settings.duration, - settings.minRewardPerVote, - settings.maxRewardPerVote, - amountOutAfterFee, - feeAmount, - settings.voteType, - settings.closeType, - settings.voterList - ); - lastQuestCreated[pool] = id; - } - - /*////////////////////////////////////////////////////////////// - UTILS - //////////////////////////////////////////////////////////////*/ - - /** - * @dev Get the gauge for a pool - * @param pool The pool to get the gauge for - * @return The gauge for the pool - */ - function _getGauge(address pool) internal view returns (address) { - return IGaugeRegistry(gaugeRegistry).getPoolGauge(pool); - } - - /** - * @dev Swap the fees taken from the pool to the incentive token - * @param pool The pool from which the fees were taken - * @param steps The swap steps to convert the fees to the incentive token - */ - function _swapToToken(address pool, IBatchRouter.SwapPathStep[][] calldata steps) internal { - // Create path data from steps - IERC20[] memory tokens = poolTokens[pool]; - IBatchRouter.SwapPathExactAmountIn[] memory paths = new IBatchRouter.SwapPathExactAmountIn[](steps.length); - uint256 pathLength = 0; - uint256 length = tokens.length; - for (uint256 i = 0; i < length; ++i) { - IERC20 token = tokens[i]; - if (address(token) == incentiveToken) { - continue; - } - - uint256 amount = takenFees[pool][address(token)]; - if (amount > 0) { - _increasePermit2Allowance(token, amount); - paths[pathLength++] = IBatchRouter.SwapPathExactAmountIn({ - tokenIn: token, - steps: steps[i], - exactAmountIn: amount, - minAmountOut: 0 - }); - takenFees[pool][address(token)] = 0; - } - } - // Store the path length in the first slot of the array - assembly { - mstore(paths, pathLength) - } - - // Swap the tokens - IBatchRouter(batchRouter).swapExactIn(paths, block.timestamp + 1, false, new bytes(0)); - } - - /** - * @dev Increase the allowance of the permit2 contract - * @param token The token to increase the allowance for - * @param amount The amount to increase the allowance by - */ - function _increasePermit2Allowance(IERC20 token, uint256 amount) internal { - if (token.allowance(address(this), address(permit2)) == 0) { - token.approve(address(permit2), type(uint256).max); - } - permit2.approve(address(token), batchRouter, uint160(amount), uint48(block.timestamp + 1)); - } -} diff --git a/packages/foundry/remappings.txt b/packages/foundry/remappings.txt index 8abd1432..46c6f3c4 100644 --- a/packages/foundry/remappings.txt +++ b/packages/foundry/remappings.txt @@ -3,6 +3,7 @@ @balancer-labs/v3-pool-utils/=lib/balancer-v3-monorepo/pkg/pool-utils/ @balancer-labs/v3-interfaces/=lib/balancer-v3-monorepo/pkg/interfaces/ @balancer-labs/v3-pool-weighted/=lib/balancer-v3-monorepo/pkg/pool-weighted/ +@balancer-labs/v3-pool-stable/=lib/balancer-v3-monorepo/pkg/pool-stable/ @balancer-labs/v3-vault/=lib/balancer-v3-monorepo/pkg/vault/ permit2/=lib/permit2/ forge-gas-snapshot/=node_modules/forge-gas-snapshot/src/ From cf4eef7c32bae044229bdbe81249d04cbba40d40 Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Fri, 27 Sep 2024 15:42:51 +0200 Subject: [PATCH 08/13] feat: deploy serene pool script --- .../foundry/script/04_DeploySerenePool.s.sol | 138 ++++++++++++++++++ packages/foundry/script/Deploy.s.sol | 7 +- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 packages/foundry/script/04_DeploySerenePool.s.sol diff --git a/packages/foundry/script/04_DeploySerenePool.s.sol b/packages/foundry/script/04_DeploySerenePool.s.sol new file mode 100644 index 00000000..ed0440da --- /dev/null +++ b/packages/foundry/script/04_DeploySerenePool.s.sol @@ -0,0 +1,138 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { + TokenConfig, + TokenType, + LiquidityManagement, + PoolRoleAccounts +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import { IRateProvider } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IRateProvider.sol"; +import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { PoolHelpers, InitializationConfig } from "./PoolHelpers.sol"; +import { ScaffoldHelpers, console } from "./ScaffoldHelpers.sol"; +import { WeightedPoolFactory } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPoolFactory.sol"; +import { ExitFeeHookExample } from "../contracts/hooks/ExitFeeHookExample.sol"; + +import { GaugeRegistry } from "../contracts/mocks/GaugeRegistry.sol"; +import { MockQuestBoard } from "../contracts/mocks/MockQuestBoard.sol"; +import { QuestSettingsRegistry } from "../contracts/hooks/utils/QuestSettingsRegistry.sol"; +import { SereneHook } from "../contracts/hooks/SereneHook.sol"; +import { IQuestBoard } from "../contracts/hooks/interfaces/IQuestBoard.sol"; + +import { DeployWeightedPool8020 } from "./03_DeployWeightedPool8020.s.sol"; +import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol"; + +/** + * @title Deploy Weighted Pool 80/20 + * @notice Deploys, registers, and initializes a 80/20 weighted pool that uses an Exit Fee Hook + */ +contract DeploySerenePool is DeployWeightedPool8020 { + struct SereneHookConstructorParams { + IVault vault; + IPermit2 permit2; + address factory; + address gaugeRegistry; + address batchRouter; + address questBoard; + address questSettings; + address token1; + uint64 fee; + } + + function deploySerenePool(address token1, address token2) internal { + // Set the pool initialization config + InitializationConfig memory initConfig = getWeightedPoolInitConfig(token1, token2); + + // Start creating the transactions + uint256 deployerPrivateKey = getDeployerPrivateKey(); + address deployer = vm.addr(deployerPrivateKey); + vm.startBroadcast(deployerPrivateKey); + + // Deploy a factory + WeightedPoolFactory factory = new WeightedPoolFactory(vault, 365 days, "Factory v1", "Pool v1"); + console.log("Weighted Pool Factory deployed at: %s", address(factory)); + + // Deploy a hook + GaugeRegistry gaugeRegistry = new GaugeRegistry(); + QuestSettingsRegistry questSettings = new QuestSettingsRegistry(deployer); + MockQuestBoard questBoard = new MockQuestBoard(400, 0); + questSettings.setQuestSettings( + address(token1), + 1, + 1000, + 2000, + IQuestBoard.QuestVoteType.NORMAL, + IQuestBoard.QuestCloseType.NORMAL, + new address[](0) + ); + + address sereneHook = _deploySereneHook( + SereneHookConstructorParams( + vault, + permit2, + address(factory), + address(gaugeRegistry), + address(batchRouter), + address(questBoard), + address(questSettings), + address(token1), + 5e17 // 50% of fee + ) + ); + console.log("SereneHook deployed at address: %s", sereneHook); + + // Deploy a pool and register it with the vault + /// @notice passing args directly to avoid stack too deep error + address pool = factory.create( + "80/20 Weighted Pool", // string name + "80-20-WP", // string symbol + getTokenConfigs(token1, token2), // TokenConfig[] tokenConfigs + getNormailzedWeights(), // uint256[] normalizedWeights + getRoleAccounts(), // PoolRoleAccounts roleAccounts + 0.001e18, // uint256 swapFeePercentage (.01%) + sereneHook, // address poolHooksContract + true, //bool enableDonation + true, // bool disableUnbalancedLiquidity (must be true for the ExitFee Hook) + keccak256(abi.encode(block.number)) // bytes32 salt + ); + console.log("Weighted Pool deployed at: %s", pool); + + // Add a fake gauge to the Gauge Registry + gaugeRegistry.register(address(pool), makeAddr("gauge")); + + // Approve the router to spend tokens for pool initialization + approveRouterWithPermit2(initConfig.tokens); + + // Seed the pool with initial liquidity using Router as entrypoint + router.initialize( + pool, + initConfig.tokens, + initConfig.exactAmountsIn, + initConfig.minBptAmountOut, + initConfig.wethIsEth, + initConfig.userData + ); + console.log("Weighted Pool initialized successfully!"); + vm.stopBroadcast(); + } + + function _deploySereneHook(SereneHookConstructorParams memory params) internal returns (address) { + return address( + new SereneHook( + params.vault, + params.permit2, + params.factory, + params.gaugeRegistry, + params.batchRouter, + params.questBoard, + params.questSettings, + params.token1, + params.fee + ) + ); + } +} diff --git a/packages/foundry/script/Deploy.s.sol b/packages/foundry/script/Deploy.s.sol index 9efde461..78379fe7 100644 --- a/packages/foundry/script/Deploy.s.sol +++ b/packages/foundry/script/Deploy.s.sol @@ -7,6 +7,7 @@ import { DeployMockTokens } from "./00_DeployMockTokens.s.sol"; import { DeployConstantSumPool } from "./01_DeployConstantSumPool.s.sol"; import { DeployConstantProductPool } from "./02_DeployConstantProductPool.s.sol"; import { DeployWeightedPool8020 } from "./03_DeployWeightedPool8020.s.sol"; +import { DeploySerenePool } from "./04_DeploySerenePool.s.sol"; /** * @title Deploy Script @@ -18,7 +19,8 @@ contract DeployScript is DeployMockTokens, DeployConstantSumPool, DeployConstantProductPool, - DeployWeightedPool8020 + DeployWeightedPool8020, + DeploySerenePool { function run() external scaffoldExport { // Deploy mock tokens to use for the pools and hooks @@ -32,6 +34,9 @@ contract DeployScript is // Deploy, register, and initialize a weighted pool with an exit fee hook deployWeightedPool8020(mockToken1, mockToken2); + + // Deploy, register, and initialize a weighted pool with an serene hook + deploySerenePool(mockToken1, mockToken2); } modifier scaffoldExport() { From bd875a010066e98774e7d19b48fe965949bb0a13 Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Fri, 27 Sep 2024 15:46:09 +0200 Subject: [PATCH 09/13] style: prettier tests --- .../test/SereneVeBalDiscountHook.t.sol | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/foundry/test/SereneVeBalDiscountHook.t.sol b/packages/foundry/test/SereneVeBalDiscountHook.t.sol index 9c49b469..d37d0481 100644 --- a/packages/foundry/test/SereneVeBalDiscountHook.t.sol +++ b/packages/foundry/test/SereneVeBalDiscountHook.t.sol @@ -114,7 +114,10 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(usdc) + ); if (hookFee > 0) { vm.expectEmit(); @@ -126,7 +129,10 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(usdc) + ); assertEq( balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], @@ -183,7 +189,10 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(dai)); + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(dai) + ); if (hookFee > 0) { vm.expectEmit(); @@ -263,7 +272,10 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(usdc) + ); if (hookFee > 0) { vm.expectEmit(); @@ -275,7 +287,10 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(usdc) + ); assertEq( balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], @@ -334,7 +349,10 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(dai)); + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(dai) + ); if (hookFee > 0) { vm.expectEmit(); @@ -422,7 +440,10 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { BaseVaultTest.Balances memory balancesBefore = getBalances(bob); - uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + uint256 storedHookFeesBefore = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(usdc) + ); if (hookFee > 0) { vm.expectEmit(); @@ -430,11 +451,23 @@ contract SereneVeBalDiscountHookTest is BaseVaultTest { } vm.prank(bob); - RouterMock(untrustedRouter).swapSingleTokenExactIn(address(pool), dai, usdc, swapAmount, 0, MAX_UINT256, false, bytes("")); + RouterMock(untrustedRouter).swapSingleTokenExactIn( + address(pool), + dai, + usdc, + swapAmount, + 0, + MAX_UINT256, + false, + bytes("") + ); BaseVaultTest.Balances memory balancesAfter = getBalances(bob); - uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees(address(pool), address(usdc)); + uint256 storedHookFeesAfter = SereneVeBalDiscountHook(poolHooksContract).takenFees( + address(pool), + address(usdc) + ); assertEq( balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx], From 540b57f7858f2a4de8c25fcfae2280fb3cdbccfa Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Mon, 30 Sep 2024 00:24:14 +0200 Subject: [PATCH 10/13] doc: add README --- README.md | 296 ++++-------------------------------------------------- 1 file changed, 21 insertions(+), 275 deletions(-) diff --git a/README.md b/README.md index c2628111..a9cc2d0f 100644 --- a/README.md +++ b/README.md @@ -1,286 +1,32 @@ -# 🏗︎ Scaffold Balancer v3 +# Serene Hook -A starter kit for building on top of Balancer v3. Accelerate the process of creating custom pools and hooks contracts. Concentrate on mastering the core concepts within a swift and responsive environment augmented by a local fork and a frontend pool operations playground. +## What the hook does +### Abstract -[![intro-to-scaffold-balancer](https://github.com/user-attachments/assets/f862091d-2fe9-4b4b-8d70-cb2fdc667384)](https://www.youtube.com/watch?v=m6q5M34ZdXw) +The Serene Hook provide a simple and easy way to incentive in a substainable manner the pools that use this hook. It takes a portion of the trading fees generated by the pools and create [Paladin's Quests](https://doc.paladin.vote/) to reward the liquidity provisioners with BAL emissions to the associoted pools's gauges. This enable bigger APYs for the liquidity providers and a better capital efficiency for the pools as it uses the balancer flywheel. -### 🔁 Development Life Cycle -1. Learn the core concepts for building on top of Balancer v3 -2. Configure and deploy factories, pools, and hooks contracts to a local anvil fork of Sepolia -3. Interact with pools via a frontend that runs at [localhost:3000](http://localhost:3000/) +### Components +- `SereneHook` : The main contract that will be used by the pools to use the trading fees to incentive the liquidity providers. It implements the `onComputeDynamicSwapFeePercentage` function to keep a portion of the initial pool swap fee, and then the `afterSwap` function to take the remaininder of the initial swap fees. The last core function of the SereneHook is the `createQuest` function that will create a quest inside the Paladin's Quests contract in a permissionless way. The quests are created by swapping the fees tokens to a defined incentive token (ex WETH). +- `SereneVeBalDiscountHook` : The same hook as `SereneHook` but with a discount on the BAL emissions for the pools that use it to show the flexibility of the hook by taking an already existing example hook. +- `QuestSettingsRegistry` : A utility contract to store specific settings for the quest creation that will be fetched by the `SereneHook` contract. These settings are stored in a permissioned way with one settings per incentive token (ex WETH). +- `QuestBoard` : The external contract from Paladin's Quest product used to create incentive for veBAL voters to vote for the pool(s gauges.) +- `GaugeRegistry` : A mock contract used mainly to store the gauges of the pools while the balancer v3 gauge registry is being worked on. -### 🪧 Table Of Contents +![alt text](.github/assets/image.png) -- [🧑‍💻 Environment Setup](#-environment-setup) -- [👩‍🏫 Learn Core Concepts](#-learn-core-concepts) -- [🕵️ Explore the Examples](#-explore-the-examples) -- [🌊 Create a Custom Pool](#-create-a-custom-pool) -- [🏭 Create a Pool Factory](#-create-a-pool-factory) -- [🪝 Create a Pool Hook](#-create-a-pool-hook) -- [🚢 Deploy the Contracts](#-deploy-the-contracts) -- [🧪 Test the Contracts](#-test-the-contracts) +First the users make some swaps in the pool, generating some trading fees. Then any user can call the createQuest function to create a quest in the Paladin's Quests contract. The quest will be created with the trading fees tokens swapped to the incentive token (ex WETH) and the liquidity providers of the pool will be rewarded with BAL emissions to the pool's gauge. -## 🧑‍💻 Environment Setup +## Example use case -### 1. Requirements 📜 +An example use case of the Serene Hook is the following: +A protocol decides to create a balancer V3 pool, he wants to attract liquidity providers to deposit into his pool. He decides to use the Serene Hook when creating his pool. To make it works, he deploy a gauge to his pool and get approved by governance. Then over the time The liquidity providers of the pool will be rewarded with BAL emissions to the pool's gauge by using the swap fees. -- [Node (>= v18.17)](https://nodejs.org/en/download/) -- Yarn ([v1](https://classic.yarnpkg.com/en/docs/install/) or [v2+](https://yarnpkg.com/getting-started/install)) -- [Git](https://git-scm.com/downloads) -- [Foundry](https://book.getfoundry.sh/getting-started/installation) (>= v0.2.0) +## Feedback about DevX -### 2. Quickstart 🏃 - -1. Ensure you have the latest version of foundry installed -``` -foundryup -``` - -2. Clone this repo & install dependencies - -```bash -git clone https://github.com/balancer/scaffold-balancer-v3.git -cd scaffold-balancer-v3 -yarn install -``` - -3. Set the necessary environment variables in a `packages/foundry/.env` file [^1] - [^1]: The `DEPLOYER_PRIVATE_KEY` must start with `0x` and must possess enough Sepolia ETH to deploy the contracts. The `SEPOLIA_RPC_URL` facilitates running a local fork and sending transactions to sepolia testnet - -``` -DEPLOYER_PRIVATE_KEY=0x... -SEPOLIA_RPC_URL=... -``` - -4. Start a local anvil fork of the Sepolia testnet - -```bash -yarn fork -``` - -5. Deploy the mock tokens, pool factories, pool hooks, and custom pools contracts [^2] - [^2]: The `DEPLOYER_PRIVATE_KEY` wallet receives the mock tokens and resulting BPT from pool initialization - -```bash -yarn deploy -``` - -6. Start the nextjs frontend - -```bash -yarn start -``` - -7. Explore the frontend - -- Navigate to http://localhost:3000 to see the home page -- Visit the [Pools Page](http://localhost:3000/pools) to search by address or select using the pool buttons -- Vist the [Debug Page](http://localhost:3000/debug) to see the mock tokens, factory, and hooks contracts - -8. Run the Foundry tests - -``` -yarn test -``` - -### 3. Scaffold ETH 2 Tips 🏗️ - -SE-2 offers a variety of configuration options for connecting an account, choosing networks, and deploying contracts - -
🔥 Burner Wallet - -If you do not have an active wallet extension connected to your web browser, then scaffold eth will automatically connect to a "burner wallet" that is randomly generated on the frontend and saved to the browser's local storage. When using the burner wallet, transactions will be instantly signed, which is convenient for quick iterative development. - -To force the use of burner wallet, disable your browsers wallet extensions and refresh the page. Note that the burner wallet comes with 0 ETH to pay for gas so you will need to click the faucet button in top right corner. Also the mock tokens for the pool are minted to your deployer account set in `.env` so you will want to navigate to the "Debug Contracts" page to mint your burner wallet some mock tokens to use with the pool. - -![Burner Wallet](https://github.com/Dev-Rel-as-a-Service/scaffold-balancer-v3/assets/73561520/0a1f3456-f22a-46b5-9e05-0ef5cd17cce7) - -![Debug Tab Mint](https://github.com/Dev-Rel-as-a-Service/scaffold-balancer-v3/assets/73561520/fbb53772-8f6d-454d-a153-0e7a2925ef9f) - -
- -
👛 Browser Extension Wallet - -- To use your preferred browser extension wallet, ensure that the account you are using matches the PK you previously provided in the `foundry/.env` file -- You may need to add a local development network with rpc url `http://127.0.0.1:8545/` and chain id `31337`. Also, you may need to reset the nonce data for your wallet exension if it gets out of sync. - -
- -
🐛 Debug Contracts Page - -The [Debug Contracts Page](http://localhost:3000/debug) can be useful for viewing and interacting with all of the externally avaiable read and write functions of a contract. The page will automatically hot reload with contracts that are deployed via the `01_DeployConstantSumFactory.s.sol` script. We use this handy setup to mint `mockERC20` tokens to any connected wallet - -
- -
🌐 Changing The Frontend Network Connection - -- The network the frontend points at is set via `targetNetworks` in the `scaffold.config.ts` file using `chains` from viem. -- By default, the frontend runs on a local node at `http://127.0.0.1:8545` - -```typescript -const scaffoldConfig = { - targetNetworks: [chains.foundry], -``` - -
- -
🍴 Changing The Forked Network - -- By default, the `yarn fork` command points at sepolia, but any of the network aliases from the `[rpc_endpoints]` of `foundry.toml` can be used to modify the `"fork"` alias in the `packages/foundry/package.json` file - -```json - "fork": "anvil --fork-url ${0:-sepolia} --chain-id 31337 --config-out localhost.json", -``` - -- To point the frontend at a different forked network, change the `targetFork` in `scaffold.config.ts` - -```typescript -const scaffoldConfig = { - // The networks the frontend can connect to - targetNetworks: [chains.foundry], - - // If using chains.foundry as your targetNetwork, you must specify a network to fork - targetFork: chains.sepolia, -``` - -
- -## 👩‍🏫 Learn Core Concepts - -- [Contract Architecture](https://docs-v3.balancer.fi/concepts/core-concepts/architecture.html) -- [Balancer Pool Tokens](https://docs-v3.balancer.fi/concepts/core-concepts/balancer-pool-tokens.html) -- [Balancer Pool Types](https://docs-v3.balancer.fi/concepts/explore-available-balancer-pools/) -- [Building Custom AMMs](https://docs-v3.balancer.fi/build-a-custom-amm/) -- [Exploring Hooks and Custom Routers](https://pitchandrolls.com/2024/08/30/unlocking-the-power-of-balancer-v3-exploring-hooks-and-custom-routers/) -- [Hook Development Tips](https://medium.com/@johngrant/unlocking-the-power-of-balancer-v3-hook-development-made-simple-831391a68296) - -![v3-components](https://github.com/user-attachments/assets/ccda9323-790f-4276-b092-c867fd80bf9e) - - -## 🕵️ Explore the Examples -Each of the following examples have turn key deploy scripts that can be found in the [foundry/script/](https://github.com/balancer/scaffold-balancer-v3/tree/main/packages/foundry/script) directory - -### 1. Constant Sum Pool with Dynamic Swap Fee Hook -The swap fee percentage is altered by the hook contract before the pool calculates the amount for the swap - -![dynamic-fee-hook](https://github.com/user-attachments/assets/5ba69ea3-6894-4eeb-befa-ed87cfeb6b13) - -### 2. Constant Product Pool with Lottery Hook -An after swap hook makes a request to an oracle contract for a random number - -![after-swap-hook](https://github.com/user-attachments/assets/594ce1ac-2edc-4d16-9631-14feb2d085f8) - -### 3. Weighted Pool with Exit Fee Hook -An after remove liquidity hook adjusts the amounts before the vault transfers tokens to the user - -![after-remove-liquidity-hook](https://github.com/user-attachments/assets/2e8f4a5c-f168-4021-b316-28a79472c8d1) - - -## 🌊 Create a Custom Pool - -[![custom-amm-video](https://github.com/user-attachments/assets/e6069a51-f1b5-4f98-a2a9-3a2098696f96)](https://www.youtube.com/watch?v=kXynS3jAu0M) - - -### 1. Review the Docs 📖 - -- [Create a custom AMM with a novel invariant](https://docs-v3.balancer.fi/build-a-custom-amm/build-an-amm/create-custom-amm-with-novel-invariant.html) - -### 2. Recall the Key Requirements 🔑 - -- Must inherit from `IBasePool` and `BalancerPoolToken` -- Must implement `onSwap`, `computeInvariant`, and `computeBalance` -- Must implement `getMaximumSwapFeePercentage` and `getMinimumSwapFeePercentage` - -### 3. Write a Custom Pool Contract 📝 - -- To get started, edit the`ConstantSumPool.sol` contract directly or make a copy - -## 🏭 Create a Pool Factory - -After designing a pool contract, the next step is to prepare a factory contract because Balancer's off-chain infrastructure uses the factory address as a means to identify the type of pool, which is important for integration into the UI, SDK, and external aggregators - -### 1. Review the Docs 📖 - -- [Deploy a Custom AMM Using a Factory](https://docs-v3.balancer.fi/build-a-custom-amm/build-an-amm/deploy-custom-amm-using-factory.html) - -### 2. Recall the Key Requirements 🔑 - -- A pool factory contract must inherit from [BasePoolFactory](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/contracts/factories/BasePoolFactory.sol) -- Use the internal `_create` function to deploy a new pool -- Use the internal `_registerPoolWithVault` fuction to register a pool immediately after creation - -### 3. Write a Factory Contract 📝 - -- To get started, edit the`ConstantSumFactory.sol` contract directly or make a copy - -## 🪝 Create a Pool Hook - -[![hook-video](https://github.com/user-attachments/assets/96e12c29-53c2-4a52-9437-e477f6d992d1)](https://www.youtube.com/watch?v=kaz6duliRPA) - -### 1. Review the Docs 📖 - -- [Extend an Existing Pool Type Using Hooks](https://docs-v3.balancer.fi/build-a-custom-amm/build-an-amm/extend-existing-pool-type-using-hooks.html) - -### 2. Recall the Key Requirements 🔑 - -- A hooks contract must inherit from [BasePoolHooks.sol](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/contracts/BaseHooks.sol) -- A hooks contract should also inherit from [VaultGuard.sol](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/contracts/VaultGuard.sol) -- Must implement `onRegister` to determine if a pool is allowed to use the hook contract -- Must implement `getHookFlags` to define which hooks are supported -- The `onlyVault` modifier should be applied to all hooks functions (i.e. `onRegister`, `onBeforeSwap`, `onAfterSwap` ect.) - -### 3. Write a Hook Contract 📝 - -- To get started, edit the `VeBALFeeDiscountHook.sol` contract directly or make a copy - -## 🚢 Deploy the Contracts - -The deploy scripts are located in the [foundry/script/](https://github.com/balancer/scaffold-balancer-v3/tree/main/packages/foundry/script) directory. To better understand the lifecycle of deploying a pool that uses a hooks contract, see the diagram below - -![pool-deploy-scripts](https://github.com/user-attachments/assets/bb906080-8f42-46c0-af90-ba01ba1754fc) - - -### 1. Modifying the Deploy Scripts 🛠️ - -For all the scaffold integrations to work properly, each deploy script must be imported into `Deploy.s.sol` and inherited by the `DeployScript` contract in `Deploy.s.sol` - -### 2. Broadcast the Transactions 📡 - -To run all the deploy scripts - -```bash -yarn deploy -``` - -🛈 To deploy to the live sepolia testnet, add the `--network sepolia` flag - -## 🧪 Test the Contracts - -The [balancer-v3-monorepo](https://github.com/balancer/balancer-v3-monorepo) provides testing utility contracts like [BasePoolTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/test/foundry/utils/BasePoolTest.sol) and [BaseVaultTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/test/foundry/utils/BaseVaultTest.sol). Therefore, the best way to begin writing tests for custom factories, pools, and hooks contracts is to leverage the examples established by the source code. - -### 1. Testing Factories 👨‍🔬 - -The `ConstantSumFactoryTest` roughly mirrors the [WeightedPool8020FactoryTest -](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-weighted/test/foundry/WeightedPool8020Factory.t.sol) - -``` -yarn test --match-contract ConstantSumFactoryTest -``` - -### 2. Testing Pools 🏊 - -The `ConstantSumPoolTest` roughly mirrors the [WeightedPoolTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-weighted/test/foundry/WeightedPool.t.sol) - -``` -yarn test --match-contract ConstantSumPoolTest -``` - -### 3. Testing Hooks 🎣 - -The `VeBALFeeDiscountHookExampleTest` mirrors the [VeBALFeeDiscountHookExampleTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-hooks/test/foundry/VeBALFeeDiscountHookExample.t.sol) - -``` -yarn test --match-contract VeBALFeeDiscountHookExampleTest -``` +In our experience developping the Serne Hook, we found the DevX to be pretty good overall with a decent documentation and a good community support. +The cherry on top was the scaffold repository already had base tests for the hooks, which made the development process much faster and easier. +The pain points were: +- That we didn't knew exactly what were the implication of changing the onComputeDynamicSwapFeePercentage to the amounts on the afterSwap hook. +- We also had some issues to setup our tests which required a fees for the pool so we made a fork of the balancer-monorepo to change the default fees in the MockFactory. \ No newline at end of file From dd932448085e9147ea26b43a9d29b9b7d033176d Mon Sep 17 00:00:00 2001 From: Kogaroshi Date: Mon, 30 Sep 2024 09:38:24 -0400 Subject: [PATCH 11/13] doc : small changes in Readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a9cc2d0f..802f5d61 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The Serene Hook provide a simple and easy way to incentive in a substainable man ### Components - `SereneHook` : The main contract that will be used by the pools to use the trading fees to incentive the liquidity providers. It implements the `onComputeDynamicSwapFeePercentage` function to keep a portion of the initial pool swap fee, and then the `afterSwap` function to take the remaininder of the initial swap fees. The last core function of the SereneHook is the `createQuest` function that will create a quest inside the Paladin's Quests contract in a permissionless way. The quests are created by swapping the fees tokens to a defined incentive token (ex WETH). -- `SereneVeBalDiscountHook` : The same hook as `SereneHook` but with a discount on the BAL emissions for the pools that use it to show the flexibility of the hook by taking an already existing example hook. +- `SereneVeBalDiscountHook` : The same hook as `SereneHook` but with a discount on the protocol swap fees for veBAL holders. The discount is applied by the `onComputeDynamicSwapFeePercentage` function, and also only impacts the fees taken after the swap by the Hook. - `QuestSettingsRegistry` : A utility contract to store specific settings for the quest creation that will be fetched by the `SereneHook` contract. These settings are stored in a permissioned way with one settings per incentive token (ex WETH). - `QuestBoard` : The external contract from Paladin's Quest product used to create incentive for veBAL voters to vote for the pool(s gauges.) - `GaugeRegistry` : A mock contract used mainly to store the gauges of the pools while the balancer v3 gauge registry is being worked on. @@ -25,7 +25,7 @@ A protocol decides to create a balancer V3 pool, he wants to attract liquidity p ## Feedback about DevX -In our experience developping the Serne Hook, we found the DevX to be pretty good overall with a decent documentation and a good community support. +In our experience developping the Serene Hook, we found the DevX to be pretty good overall with a decent documentation and a good community support. The cherry on top was the scaffold repository already had base tests for the hooks, which made the development process much faster and easier. The pain points were: - That we didn't knew exactly what were the implication of changing the onComputeDynamicSwapFeePercentage to the amounts on the afterSwap hook. From e07984b6571530dc9f35863aa2788bff3ca6de6c Mon Sep 17 00:00:00 2001 From: 0xtekgrinder <0xtekgrinder@protonmail.com> Date: Mon, 30 Sep 2024 21:43:43 +0200 Subject: [PATCH 12/13] doc: serene hook schema --- .github/assets/image.png | Bin 0 -> 109982 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/assets/image.png diff --git a/.github/assets/image.png b/.github/assets/image.png new file mode 100644 index 0000000000000000000000000000000000000000..23988caacc92449972fa63c1657665cc9754e080 GIT binary patch literal 109982 zcmd43bySqw8#YV{1E@42DX9!0Bi%5Rq{Ix;DGf?UNQ(m~sURT|lETnPigb)1sdU4L zh=hccgx{VMoZq|F_x}H`bzH|8XP#$2d*A!Mulu^LJeIXZKYhD@ABTi-sVDsX zUmp6Z^nvj|FSyk8lNj3n{Ucrj;6cmPp3Zm_R1^8MCYwehG$AzIpg$n zgV)MT{!UG|UlCRqmH78nM#?DhsO-02J?E9DgmQm(hARKK!A_$H*F=%_lZuc0hLVQx z>GiK@2LxR<+zI46aL{;kwmk1ZcDo^X(k>-7WpwA_;y>dVW#(BRy&Q3gY0Ul6_o3{D zpK%F1uQW4s@872!PS<^tNf54I^EF7k8?W+VDcv0RXkg~Zl2_S?SM$qhI;r?C+r1&B zeJMd3>!0&APo}KO?9P`7O{$efarG9L%-)DWt~Q6TVtNLb{pWfMo|xB6TnJ8Ory`-; zd=opd0{SPfObj&`5Za@z4$m}dTV|fu)?f+|>1#57j?$qKNAHakQY>s6eShA+c30(m zb*Hpif=|gJ?#l-3$D{=8{h*WAOh7+Th#NPD#*$y~S{0obpa1sXgrQUM3YJPA%r`f< zoW1&0`W7m#j=sw zc78%Va7g!1{lKs890FO;M+!ecFNn^H$}l;cfAq)iy|oqPQZ8?W)jbx&F3|x>oxg)x zA30nVv-jk^&jw>z%h~UlnuNSK4yMi{6U&*2hQWUVf5cU*kGgd@__MmP`j1rqU*D-Z z#D@wJ#)D&fH2xhf^2J!_J19IsO0HcTn)Sub)F7_s{3h>U_>Ct!pD>~enSM*p{<;Z& z(|(7j0_L8y#y`*7@6(D;BBIr*k?=#we|ANfjfBf$$%sDC$Op8ZuBKX)^d0Wyd> zTD}Bd1CK5Jn}@zY08M*6A&#%eKcX#LGevOw z9hW+33zJ_iF`Y+8#HCN>rdj8MPU6-^@2^h^2Pbp!uFf=gow!Q>H*7l_{ZTxf1jKD0 z^LqdPZgr2yU8`g&N1_=c17Rh7sfQvSg1_T_5&AuS4ABd{09SYTLnM!7ncU_tD2SLFqUKUo%0<*V33&zo`<9lW z`0a$WrJrQ~Jrbj&=e_S!H7=Wc>IowZ|Gl5hVz)Ao;bp0wfJkq-c|JVw(vDL#=A${< z@_c-_YVtpYp$P409rMZ|Q!=U1P$ANM+c(*|EM!)=f9Hn-lu<`4D3DR z-NCiCw*Pqpee1vRU4_{wZ-NgwpKlP5fYCp&@$v)z*RergC0$C$)9;tr?Q787JKvw+ zD*tok1g@gq|9RXz=y{CdZqWDtKb-&bOZtgt$Mb(8KHY4QDXE=uUY!4P=6kqNH}5(B zqFZFQPk2Re^yYt0a(sx5_evQ_K07=9x%KPIoyh2$%@TpXhSeHeOy?{^PU%$Rn6Kgz zhkL*1IBF#;M9yNIQSRt#qI(Z_3JSv-z`q&dRXuuG<4Ylb;x6$m)wW&w=LecNx!>O_ zCTya^OKYc~WH)%q8|XM#%@Nxt2o^4U3&`u^rg zdxdp}=QvvSzwDCWY$zJIH+nbwPL>?3_0dMn_>%X}rUMI!MHEeY!Rz~s_Zyb!O=pw7 z$BNHEECaVyHj96-8wGrO<a%H=7RTW<+y2W2SxQgF5B{e!3O6QVV5{l1VszxjmubJ<-co&WtN7Wgm8WjUoq8A4HhWgZDXpVGsrZ1dA_{mEbSP5E9NGhJqfeBf>H3% zatGMsSNYPal8xmtx6BKju~-o?jEgVv9(yZl-9JXGP%*-{9=*3|Lm^0H&W&% z&#QY0GqR||^mz{q3-*0*&0IlMavaRoj&eNsJncA(R;PXd@!Gz%*x@yfVptE{Y9rZx zGUoC&E7)JD>k`s~NU1Cu!TcD1)TP8_fAwuvx9EF_l;DT0cK6f0aSUZ*r2s95P}|@E zDHBBdJ{bRuuPVFp>ReMm{c6GMrJ&OHKhV}E+nB+ix{ZoK`O{wphu>a5XxF^iL|Yeo zYS+RRuwJ>f^XX>F%s}X32aP#3wXn_9_+p7 z2z?qI32U<}n=@X6u`aLm*wvPq4*Ck$a%Q_)Z%ZzqL0*N??U~EWD8DhERfOS5`J+f zNsGR1P+wCZ>Dc*Y-JCz4&WLbZ3;<93h7KU9m~F55tF9@-v!77NNywc3`qJRSRQEMO zvs;%doc-DY*EMz9^sWs@ado_u(^s|a@4Xzdf_xZ+tQ0oW3aIk*YLdzuuxbk)W5y2Q zF6B+N*&Ok^?<`w_t@kG#;w^ne%0KLAl-OvxthiOQ5ih{Wvd%R_7T$|m!@%NA7 z4wWnBBSQAm_Z!_L)+;(ROdQH;YL;O<3@w}Q*V;jBCpH#1l}z7m95hkt;%66Y^J)5@ zjsFeh4z`N1#AZF##rde9@5(e{xyMvAVJmyxkFeypeM3Ogr|N)73hou9m@}pxDXd2&_+6 zP#!_Ff>?{Fy#d6V)XgE)I)eVHd~IUy4LJ9J(`LDD>Wm&`^WNsHw9MHdb{lNJIVooE z5k0#M&X~#8$Alg1k<8RB1-%J-#4g@_CRm1+?QW*UVKuhcL9c;5kXWT7u*+|pQ-3U) zFsmv359UCNpg8fe^|2st^qbSztaZ%#ObhT=v=UgaS4Nko?S4(X)BWZtrPce@g6Je& zL|(N6!W*_I_wf7BgxHJZC>m`My^CA{00a$fzIb*79#_E3e>H!5O`pXs z)TI4u0WDBcE@0|)A!-U$p9C9GV6%z)YOisuUvtcNu0oIt)>|3bB_Ws6ymtqrAEer+ zIko#?W5<`UDPUKyZF`x<8aD+0IXz(bMHxp45_O)`^A(P@EN$tH>IT!ZCzV{vE#J~y zmd2A%?r%!h?O(ekA45C6ze)UbF)zZ@r&s0^Bx#Iz!o>;1Eh6uld35<@%~%#;LN^v_ z9KuL>9BV>nDgQ%T&!D7>%^-Fr3~w>DW<30?Zv9E1&Q)aMcIwIIblrpW5)zupCjUJ$ zY>cqZ$@?0;OC9g6ErXBjE;@Y64A^8i6bV1Q7Fxd)#b+a`m#x81a19>Ub;VL9So>2a zNY>J|1g>OsPhLN0ew6<5rkz<^Fr{B*8Dgxgag*_h1bon$PAM5LqQ#|b-Asxy#v z7eT>b%Qbgm2MNqjhJumlPcm!EK8hiRf(I}pv-P(In$SshA+ubmg(I=dHIM=?L}=8 zu&Mow?!8dyy*%hQ+W=O=mNEkx1?!?UK;cq+^JT9;leXPP1~94Byu@zG_b*pEvO>GZ)AR(J50~nj zs!8o`H8)^?gGzwe>v;cgmDYtIC5by!<`|7(QcD~aaKW9~7P=(S9Lu{m<3!%iVmfxUudMFlGOMhAr&op- zXZGiJpYh_hV!dRUS|4kCQG7z&HEv@;_*sIwfa>zC?hl(d$QM^`+vUEw^SgGdogCAv zxB0GedYqH*UuqyJZ$?9cCy!Ld@e?uhrViL|6M_}YTAc>?YR9Ty%Rjv|myT1nBZz~$ z^x44#et)?m-$nVhvd4|5%4zy`xS_28^(&dT9FD!bZ~H(Z!0RFM%N~k-<_!1^!4NPoL$gE9qy@%20ZJ{J|Eg3Q*d79uQR>f*^WTQww*F-=*6#wxcNGD9wzWOgz zr4a;HVf}E7L<@D?_B;T3Hk7J|h|6K(1OTvEG&+7a?rAU}8@Neu{LOF}Y3n+vHYT6P zEwI8*?O=~ZgFJ|OUy?UNFn=ch=FE-$fR!8 z$Fv@RXj{J9JQbwkU-E*^?ujo_(M;nYm{KSa^hNKhmk%yJCYn|>QV|lJ58PdD7Vaga zkfFpl<;IGUfo-A5oKm1GtLE3Rk{J+3pF{ao&#ohwJ6`U$V|w9r22PYw(B3F^pR(kh z5@UOeXU(resvneoUjr7yuh_i41I?ccI+*oiSu_R=j;i#XRyV&gkq8ZLXk6h0rx1I6 zzMG@1JjOrjh@A?bVY;=}qog4odDmPtGX!xfNtlOd0#Rs}MWUVE9=+20eyz+@B((U~ z3~$77?gjp&!WbqjMuIwlT2L&12v*iTv18`Iq68Pr^qSPIX;`am0?%F9d#mZJ!VT3l zZV}+dAmMIx@<#MVJOx}n%nCZ`_l59q4kSoRkx;^$*CNq`-yoh{CR8lj~j(A3+B{few1mXFlj4!m5Ze?#h9pX_JWJU=Y;~>?mZjVWoR-yD&!s z|FW%ml96q_K;p?Y$J5h;dF--=sP9RkNRH=IFLrq%jU+={Pa&fwnFSFdo!RkV#jVJN zCtL(~g5$95^>&1|nS4YNU19a`Bu^r~YQ|E-P)#chZx=BJO-L{#yD(z;k@5{~QFB`) zdQ{HWuRn(|`dV`BD^l$3B$hR8F&|bs>23kp+I3U1SV{3ulH*sE(2ycZ;sX^G=JI0R zt(7MVFMCznrQ+zY{f|CMlF!j9c;APidQttPNqN=Z1!$*|b{lhZ|09FEi6}~fk}B+V z4GQP1sxn3M9b;9$Ke;1+D)Ir;g6&&tH!72j;Ypf4VQKUc05Z637Ps#_X@oD5&>SOr zxky+Z>ZTvQm9>FSsHtefYxJk+;S6JK9H@^u=s4XsjESFlQ3|z;`3;WVA0&NbYSH{6qC?SVNc+ZF(x63aC?=Dg$l&Vf@hc!i%cAL~gvx|vdACF}0$EOe$4L|1n z@aKuGl@5cM+ujT%MH5CyrWGYI6z7eBGLN@<=k!5My}k*@R@;3S;g;jsF zeib_t%>e2l>HLF|AVvs@Qwwnz!I*QejsBk>#pU-4A=z|TdQ(@JNoXsVeB$UzhOfnD z8YSU-*TZ&{hyW2OK^5eJWBkXnd`zLTmr`LtMGK;XIq26gZ0L;??}fW4c=eY&C~q`Y zi{V9gN^c8*i*7MJfpJ%DQ^E{+PRNl4cCc^Z@D^MI>*sS~gjy2fcEz$USfST#P9S52 zmu;H_rqaT;`!dOiTu{w_&JJ2YLsjuTC%W7FxNs(qjzd5jG#~4Jm*3qi!Pflxtceke zE8$i(>aq)~Sf6MHZ&coLZ8_bK9!2+Git%gKGgfhYU7E?&y3E{T_*1kXZRPR`_hVnO zn^%3Ol=RuVC-iE1_wRq$?%qCYxYImeYFcN+k_<1m!PMttlCz3`oqU~9f6SYdTm6zB z(U8*hnpmO*vaHdzD`e`1N%LVoF0cq#dvVT^(qpC+wXNHwO*53eiIcdAD6Fb7d>kg9 z+3<85__e*)&@Pq4eEqF0K1-VkaLJ*lz=%Ew;b|FG>17pYFJ zt*weTFFQ3mCEYzh3I%^)8!&Ibwq=&R6t{Mm9u%y1q|Bg&TBPTXVA`$B zLeWj4xuD{SB0LD^f>1()wZ7=MQU0~~EOf$iw@EUzh|`{iHzT`ot=qMDl?m-3OmMP( zP46R9$BbL;Q@GwD!fF*(D3hCSU%8)O_^mzd6iONO zYxT=@{@Mu}pv|=9iHtgE?KtR-CQKQ84*LmUUt9{gb-#d#f|%i+GPxwri6S?-8xd*G z4xc%7nCe(bjz&pQ@`J!EnB-c?yCovAwYhxK0G=a~ON_J~kxCS@53BcnMe$b49^t_A zQ1RXJiQ28iJ95v)JuXhz6I?`JBu(p8q*PU*D*UXDdAaw_Po^S^ge}m$9%K-k8wNKN zmZZ8vLwWzy)5BHn1m|1J?Y|IIBzNtbA zkY*EauoW5haPar&lo-?`Lfs%bg{syZS-q@qg0UA;U!N=4JXJ{|KTzY=F~B#I+e|U^ zKEZX|^SYuRZ4F?UgEZ&G6j^%2)-nl^Rxb@S>?h4;=vX+42tm){)dySb6CQM}(txqM z+cwF-FAVhxBiDyKMPg5(#U<2)s_oQaNOYo~7F-)0QRr_oE4crz$Vz~nQaxMnSpUXQ zJ{>zdN!rv~uq^Bqx2>>m#&N_Sh zW8Dy(JpAnRI1*%LkznmT<|LbHk$#FL^rOvtmK1(+G~ei^49jdyA+BGxZQL5yjcBF2 zV|jgD$*oJi9Q8a(m(8L^6Msqb{SZf2oXkh^m^=ICeNf#Q@(Nb2PD}f3Ekx{HsytkZ z#*9Cr`%mgo5mFpLd8Lh9a6#%(wJS>_`L9^`SxbP{d1=FF?b*+KLr>%C13r-+Eot?s zY&+9=n7Wpx`WSnT%?iM@@dAtbOd4V7wBNRjQ2Oe%%T~2JB!Sut^?;M_T!=ljX>WoN zT}Q!*E{4};QF=6P)JBhTPxeaeKM9S|Enen+w%Dj^IldRIb?t|VZe(;JMT02TIa#4Eu5q;FTf>JG>?o|w1~X-`-b5ORQ`)JOz;#P!2M8R$J4Nh`6^HBv9h3o) z1TQT<%~}jBaRa3qbLaPh3qd=(QiMqL&Q}Mgo42Ri;H76q!sSD$hUCYZj!8nQ2&e_f zXxlFERCrQi*Gu7G_1atUgGJufJ{j;ZlQF!!CM$L#0YJa|e&{>8;~3*TZZ2LXoJx5% z15(-cQ9H>79;x%LN2Z+;7y7s`51XEo4;7Fi&$#1yQ;{F5=14q18YhW-a3kN&ksW0u z!}jjDWB*F=FW1cmrPe+#x#_Xw$|cyBLs;_BW5&7_QO*FlE7IMC-zLPuqcH11!ag!$ z6Zhf0aK9j1mOlN;Qd$3B3~uXw12hA>{;6Gl4_7~PA0V-e0m6!E=bLDhcS@;Ngr}NR zIn~{sLyqZ$_8kr@lG?Y6K)+J+guO$$kj5*jP_A(_S z$+K}AMc%y>bi5xs&*_&}1q~;kk$0Ie2h=94n)eDO(xcCkBtEK))=2kR>eyH@J^YO; zl6x&8!@ZrfG9f|89Vc%*#>b>x%Pu0z_^axC)(clYBsTf2SEF)6i?4fK5(!CClObNY zhK#f_^PO^fJAoDn|DM|H{cXhI_@Qy6X(NX|}K9$)?vIbu@fr zx>Wcrm+^UWE-z}ihJV^5X``=sz`kQye<(N+YL>YYYyCl=?rjv)TFA|H?Uq>hKU{xg zyRzUYsnh2cS`@j~e)6ucv6jkZHaVHHNM)o7S-sKSr!`&lX6|hg+n>gZE-=%4RAcc* z)Pq4SjhB2;6;;pPt5G->ow`%yLzTFwMN8vJN%XBQC2!}c^L5rILXkreS=VRO>2wjP z%)T=oxuwI%%TRM8FQ{9H+o6SsoZ(KphRHOY(rc-1zD7YL{XyHA zaP}Wiemb!R5IWQ9qug0`3@=kNHzK~Ww1_dbA~7^3tC$`d2DxRRV!@7boGCEcGuDsY zwiT$gX!7r>NY%iMWWGGck;=A(1{xQxu05!nyP9np?A|GnFE za?5<4dgXH46~!yleGF9TRJ#3uMB5gP6rG-s*M0HY1ZFSl6H_c&8@`pMP~()n|AEv^ zjC94(CGDQ27liWaPggtBBEMZc3~x>U*GGdA`laMkqy|mX6Oyfd+a_9msnEc+*neRf z!a*NT%}6Dj(It?|zX?#+4W_hXGn8feC}l0E@YTya9Tb3>jGD%ZhL5G9B%C%l3Gl~W z*#^CJ<<_F7nJ+n2r5gm~FGI1O(Vb_Mkv*i3uv_uAdXcKz265v*$9gRQFjq5!yM%k z9=+o;+qV808Y-Fc+NBf|44aYh`gPJ*#axYC!yyC|-E#7@+0baiXE9f?X3%zMcf^~w zy5CLQ1cEld=0?Rvl|LpV>0V*Xw#wF+>W=f!1jUhG(M)d4CKvtOt9fXGQh#?!am{$F z#Rd9Y@WP<1#TSNo{SMjxl#Ze@%2q1s%CWo7btF9RU!m@6nTESt*<_Nv9SMtlW*1h4 zfZf$b4C=n{`cd>&jL5@k>9xkyu?9~5MIXd2)zosldbWDd)TKGkPXdek(+?g@#KTi3 zc<`p&J1(i2K0z~#1#;ZZ54xTdqUB>yX{giK>TLb~l>!P~;9A3I+byZO%$7~Kcwy9> zh@qObFHX3y)!LGb{o@B9gDt(cz;Ph17>vg&kM66V%KG2}v6c;0?3Zk-x*1(MKZWbM z?E{_^JX5et~p3TiIlpE%qWdyEl9~zd1+>41k0#85far*B%Ob4X0ajbkAEvkn}!RCjJivvv`je= zB?gTitJQa^i4tH;Sf;-%#S(rf*tRD?uNN-4Xpk-XmD&gX*MN^G!br6m)p-=((XTBPdXCp8C#3En>Pv7M>uP>V z99xp1oi~@0b`cOwrE1CeBU(|FYrv$!+z9CB(ph4<(r0@MxHj4dA9J|3HvXV`Z(-`) zn1rvzqvfQD&o6YJY1|&GH?XeG`Fg_f%gtG!k^g@Jt;4$N*|e@ z@44~q!hNFD%l8p2>TvcTKUaLO-XbaXDl$iwh2V{#pgR`X(U&NnNUgNb zKc2sd@_zI=?}i)m@xf2aov)4p;qD2l6sj6-6onxbn^@~e7W(4vgKI#QoF%|w>0Lt@;vWijHX@|){#{Ku#ugceJjnccsz$Vi# zEf8O3W+0UK1uS8~$s^6WFJ6t&=5c5(XOHq1qFsKj3Dt6$JedP5-1d%5v;aM>%J>=Q zpRKAdTaZXP5Ij6Cb*}%=m%+e*cx>8}6dAe$r0QpD%iD^L1Le)Xp$D^iqvlR0JXwJ| zm+O)(YTTP;cRq41X86uMTVW`bc6w0qKVwip2L^yVWi!gOIQ{VjryBWuedaT}v8NpF zpC}d%)4Z_?x@ya24JGM`CM1JDQDcd3GyR?G0sU_y1b^DO%Zzd-QvZzsh91f%^?vVtat=uj!r22;g1pC;IUK&41i8N78cgwbnQ4T>}K)9e~B0UOPmis5xb!tOd7sK zG*UI|jV80zdq^IO;+vzxin&4)NBj+e#5zpC0dD|ww){FE4;Dc*UmiF2HUAI{g!~$6 zK?(tyqN&~-j{C)>wPe@R-QhTmy&X7Ys4%hH2G(Fooc%x}inT)*UI&5^So-t+*O?Z7 z22hw(gd%V;0@3z5Do)kT1%Xn+NH{+8cz(GD&gzJ$nvBv%yG8R?-L3~ow%xh7G`Z0p zp)v!Mb4KDzj4vB`P{0`=q6IuW4dp1%@ya)!u{`?vBDXX}?ZOPOBQc^E=mGC8^ojpn z=O@NlkY(v{EQsGgTWv`Lh6P)Kk<3x!o zggSr^9&XP=tsDR^PWrKiF;OCLIC<4?)K0gXxRfR}`aA@FjNnwCDJL&W-sr6jP+~s? z(Ou-@J9kiyGQdR$>QO`M9Tf60qb^4-Rl{jjMR=EaJ9D}1;#p-q$2HL22=DEgCLn{W z7DweGxywm~`-GG>8R_7Doon}kku$Z^_nE||9CL-njBJ<7^yo{kziXS05gtfw%=0`q z4T?t8WI^WiKlRv@a+1y%VM~kf;z%7S7xF38H%JtRaiLJWba?HhG?2%XQUuLC>vU}) zFAJj@0qAQNY+R?l43sPPkL;24-Nu|aV^Kvr8DHn8lJb?GvAr>S2YFTyuU+j{2`~i=3}$p?#8NVh-Tn(f8q8wG&r5;BqfL_Zy=qBfJv1~{SE2;D>%;ahQm;E07?v%Teb+{To7O&GBZH(K$boY*y*|6 z0seXg#{aiS>96C@Z(V-ibjV4m?6^rW`Sr=mu%u( z6iW{BT`9|x!C{n$l)o-i`GML6c@qO z;**M;P4ZXrFgf1U<9L@5;qOipxJ0|e-TsbZ{Y21mBOe@n0~O01*h?e9U17wY)o9nU z>V4)UUf_RsN&m$oa}3Zk7K1Czho>49!7K;cKrlBH6)I6f(XATAFiYMFJ_ALADUOCM zuyjd7c07`Tja4ZNcu{Ve0R*%NyiC#KE)V-9uglVxM&hl75?*#p!DlYSS;7B72YL1; zHqW@*iJ4p}=N_$O*ds~uLf64H<1H<{N0HW2|;wUD|QbLLS&xQ?`Ia+%=7e z+D~)(Y`)C-<`A?pOmnjVpfe9QcE}Lz0|uB&m}eAY7f*nN1nc%I-gD5q3`ENw){W#M z1EpZg$xakslN4HGP(5B{O#`2K$`))Rl&yS8L?W1{c{=u(y}AcMF99&hQ=JXm?XL{y zOuAok;qJ$GgK|tQAt0Pmklb59jR|iKI*tG?O<`akByVuRi1ctJc0O|_ke4g3>V;a* zcwqX2>K-C$fBhKc(`kQKFbh^E>o`s!iBgeJkr?xWOwA;I3DmhL%nuN+7ysh_%AWHU zq;l{#d`;Mv&|9qnGMe~|nykxa!@#dDP}P`NgAW&lzGw-D*NmAIXUn<JkCM0VC(StltKApy89 z+1zImm*ZlcwieoO#?6a&WVV%O4fX#{CHho|Np@dITwE+DFApo zbQx~lyB)a6Lw@svyK7?4ZtmDu5_jH@&JXa-;7R$QHWl&mqKa4Muh!=N6)+rv@4x3r zuxsOy@O=65{&v1iP7UxPS29N7p@dM*@2_VC-qIN|$oO1j@b<8+Q@nKcv$DYGb(gXR z`w|l;XO8q_PhbxAHG0sr2gkXsBbe?p{28``zaJ9Yov1m?)FLRjYw?IXxQy22u+kn0 z$~t3rV65mE1m_;PSVbLoeeI|eIO42720BYWB0ZTw$s8>J^iu=fVlu}rK`3%uSt8A zE=%5v>G2gh>i;T|_yb`>wUd|iw#M#Ya~4Y^m#uCfum*wS{Jrk$m6$ks4(>=6AES$} z9Is&VB$C__e%r#D0yT&am;Nx~STw`1N^JlywG#0aYha3ZjFeyZb?=`7t%@|o+{gGs z|N6VZ%tjyD?zEvWSRaN+_TWydf>GxNWKLnB7DRajur)~&ON6tgz-$6>AUUE^y;S3s z)jU{htoJBlpz=cEu`9*#pd4)Yj>0ZTypfbbp_UGZ8{;ppLi^>{L*0Q;8CzA zYr%?I14P?*+O2NpF1sN+wKA2vHt3wWW$QE^oxa&A{_IsXg~^e$)o|=N^Ngd;p{m+u zd|mpB>+5X5&!!G=7J;FqAS%TzpU%GY?j4+_vfZHWI6*}qQE`W;X2Xs}T3vs7;Q@h( zgX!OE!$?T;0qA{O3<6BX0jp!C4`P#)fcENA@=zVAq{U+k4Co&4G}4FAl+?geP+8i6 z4~%{cs2zu8x`Izgf59JL$VM3aeEn4nQJMEVLj55D9%~5B4PUod83L48zXr-MR)QGd z%R|xJk%2|%fwKi6n~nWS-_=CNsMMILlps8P#)Qv4nW-@x&ic-GO;=gaCvK$z%o$Y% zQyHa8JocS?7>PT1XVC%+jpt+C|=9xD>`euBik zt`vjwz*`s&2HwOz#8I($V#5#F#sLfoRno!1=?5)Mi$`XH@HtdyFmO6QrAsNfYp8*D z_s5!=*FcXmTeL>^%dBO|!AhJ@&&EnEx*xPKg3Y|wqQ#12?!zIS8_?2BR{<7SC?Qry z2&%4cdpJuH{9uIdAB-5L!C_Kk?%Bg#nfBDjJ;lP`De(FVu*Nb!A>l#1I&`Q?M?-m{ zUWFFuN2@!u7Z}`;6%|w-Kpo)@D#*9q^R-Rw8>sc+Ln%Ce6M+}j`x+SB*=vXLF0jyy z@Rd}+Hm;+qt- z0kYKET(A(KkqQS&%cSXpCY*`(gkO;Zc?;pT9cKz{V%RMzNQ3r?e9_wWA-b(~;&stc zN|+nBCG*8Q4r*-{CoJRM;wmm5AxYVWOq3P_hlxT;MB>WJsX zpY?+_0=m;CYL^xi)}}-%!7{>5W+~2+i|+p2Ba9I7v>c@d?(|?yG^+KpbD|d>M{)4v z$e(O=FyOf}p+D-sRvN?NEW$SVm+|pv25>GPEK1~H)C{GnHAa);K2Ib-T6zE(ca-lT z@|EMMC#5Sgw6=Mz5dPy9*nKZ?*vI$Nj*#R$FaXrc@?K+a&68!tcD+YxsjhIk=7c?M z6~3Tw1X*A(4STvCqojEXDYJbp5RTAYnzT*gY*E+u4eAkIm zTT?a6pj~7$dfUpZDAi8-D#pfScQ3c03X?MfTRy>_*zrT=Cmw`ZO)=@iF#bMfb-?3| zK}!#~Sp<**V`%WP1To4NQAypc*JP||ZKig4p|#SgmxVkW3RyUB6|Wr4p*5!f8$N z7;jqXS`7|OiADO(df&~pWeW>ZHS`hwJWB2%2C)xHBrzxkX zhCHV=;>_D^Z>=g*V%`UrXhza@Q`y{tB_U(S<243*zOhDocE8k+$}|OxxHzJ-VDC3| zrf>B!k+ln180q(-s@$nKybN(lN|T+y)0RKSEevEe-VEczgc4hU*e715;3Oq61uL`s zxEG(sKRpjk;eF0u)>TI!arT>=GL)|1m|Te-9aEXH<($tk-WjQ`1@IVkcc=+jDTat= z_|b}`N>H6KqWG)WpH_{H$~8;I*&=NdJjL?@Zp zzp}G=^AH?A>voo&nxYcad(rt-eC>%{aZi<>E7~Ph_nSk*%`g(E>!HEzi=Po6j3E0IV4|<= z10-F~Rvl=U4w?MiVDP_-lXd+U;dGKEtOrX&O~EK~BUsx78TJ8n%!$XLaJ^4@CEaU@ z2?F7LWM=V2AL;0#iO)%H;voPN%YnL}3mOZY@drdpZr z1h3ExYK75A(JJ{RT~aB0$nmtt1oVtHni{Op82&3XM{nrt&{8%#nOm{7p1dFN zdc{m`a@44BK{#JZfU|^4B**==u%j&KUTWvcgb=$7?%af7C|_Bh3&e zyOptsvHaevaKhl^Z7pkNfuBb6#k#4TQ%F||d^fTSFrc(dNF}fQib&{pi`H-7jkI{q_VtuiW9+dhx3{e_v((ija;@@lj_YQY}OlphmZa*DUg#uYmIk+8C_Gv*qSQsQdH8Ju0D)JG|!Ww(2PPYD%igegeR0);~w~VL@dL-=fx3wu8#< z2ynM?9fQV^+O+TSGgku0*iH^Swx(x5{?CcgC8Drv@L^hr?&`?Mc8?9eAZVT|rksnr z@UBT)XSGW}BMZOVQa=_FWWz*FQ{UIU&|SGSVPLEp+FI$1hL)y!#tR_RibNOq%0Ez> zp3#BrL@OWx_>IjcUVQQ>;`v;gxKo?g&^D77 zCElvKZ4ECj>0S+Jml&X_2xi5UnUu37r6=~w(l5vHx=gWneq@JDa9(_ykYKB_@Dan zxutAw^NyBk0i7bogPo72c%WKxpjSVnxS9A}Yn|qnIgr>E0a|`Gs)Kk&X*L}7{5fi` zdk02!XHA@Ev*AH-0khT!O{$(`GW1P7#aL@jPRxE{37u-v<-~83&w|@GL{^4wNwTDkif z#`YNEIB~O`vYoY78u@%!ClT-QPlr0mMOYCfmk`I+qGto}9co@(!5sw(`g&_nwmakW z!w+-E%eeeg28rw>ySRDa@ah@PkjDVWU8iE?#6DbL(TK} z)65T$W!N+$gbo#RMFDLJ>Fp?X)SE62I*TL6@Y^nJ2)%AtP7^Egu671rK6G!wbD%Q5 zgxBx}qzC=552E*_^1_PV?^ke%aRyU@k@fc$>J2MWZmbQ>L$+1@G5BdyWn&5wxbvo6 zlqC$%wm4wfH}dG&tz-n7C^KN>=J_ z=i0H<^;6t%jK94F*HvD=TiIH|a5jtG{=%qLJ>t4bx~gAcUY&`}@|+qRwyB&6`h*G6 zZ}S2?gBZ%m=~M#k01?oaAcR)=1$NW(PFzdnkXSUL#MA9p5mMP2G4fLkkPRceKaMy= zUK*9C?Pq!C?7Z1v@rl7<49PjM#LWJkqa=IjC7B+ywQBJ6@7`goz4A>4l$~+Ik(7%Z zxk=Tnx83bFc?W9Kz%t$MRV!MXv5@Bbd)x(&WWdFuL)w2$8wd@DrfDrqirm3Xf9rL5 z^zHI*N_c&+vEp%hm5R}dV@5Vh35NdRXf3H*V}UuhB1yf_e$dj|PikB4v)kx(HFt5b%>^O@$#L<uf)l7mjU%k5H6>jKPzsy67}gWkQNtz34Vmkn~{o@A~o5gH0lf|*(W>^oP%IQ zuOo+nHvL>HMii<6gRvuO+IvqGE=2+`t%M+(r4^AI4_}h|itV+wR?_GDIXp`CFeULw z7_P*8O8y7@=f6O^fR?ufzi1%-Ln+|p{Uljp2Vi_h#vq4*34ntOG%Krd4>YKnHGh6@ z_-@u?r7{$*yd~?e_r17izqL}d+tw%cRC;K#crUlzpR0P?J^QMU|D)xlFO6P0G1p3e zbQQoDO2AhkU^^CpM~y`6@(A!N$nm-JLv=ubZOqW;JvNV+CFlf_qQUiNoZ;-OuGH$B zWV%~++)alvFpy%AIP(@T0j?X?;fM1Vuj@Rv`3nlc8@vFi3<2$xbLXIk}lpSBn<_Pe+IYAzI-hf^IQ<9J9LG=Yi;-G z?SgLEhAXTmJ#jS?=+m&nXL7f6L{C%x18XMcR=aMoJ{S9e#+ly6pT+(oZaB~VS{Nut zk8Nk83jdR)V{z_aXo04rD|Y1C+JfL~7Y$YrDu(ts1Ejb45KW!Z9WDhw zm=X1P361YWlib7u0YIQi55!kQh#OYi>i4pHe6@%Iu6DRe&JzDg3!5=h4n?v9ACR?p zEm)D1DJn0m&gz;|E1{JCu#}psoDy90c}4Zr>0|#*hq5@Q80vZ5Jv=pFyfUMiaFKBe ze81z0?$mDy-b7l8PLG<*hYs5Y|0j8u7DiOB5H;kgOg(rtKo>PpF}PTy_V1m*9cCK! z*op5{M2a;gyGRzn^o>be>{_or?cpV%*>=^!&r+c2s zyHvji76mEY!Ae}p;&~dEbCzVMroSau?if;_HZZ50e=>$z~{y(Y(BBY>w|wQ#3OfQ`B(k$Uz(EF6ec zUr8yDY+;X*4q|Q;o&bfC<4tB|48YPCetiV4MF7>%C1BvBYJH^gc@_q~f)^}uXz1JW zEO=ZcFY4D^({+-N>>=JyJxw=qvZQl+h7IM2MY?xwS@0f;`9(_2c01mG)fnC+v!D&C z#{+T?5-Rh*!UmNJbS}_TvdESu@p{9;djfXRod_~lfex_&p;@a#p2Z~~gS-WbI;oI9 z!1=O(B-b8AmNWpTDZEwfCcG$47GuFQ+OvDe__noKg#}*CL&53%om?Yy<#S1oy63^V z!3g$bXIjt;RPA>+%rNq`>rMmbQ_mr-%H5YP_fXD72DB%Rz%{m?du*#4sOC@?>H|J% znrA)(aI#YN{V}b_Yqi|oW%J~(e*L7iuk4Lz)mJ0f9|X6eFK0#R2u^_#D;kQO!=Uby zEA<|Oe&F8A%lA+Zv)zY5wQy+>7|B~mw;$<`h><|pmi%4_2!^3-l>d;>6>)1Silut2 z2yh{QRjKlW*L4vHSGiURm7`N788l9@Y;}$x9g63QL#9^mFr>ml=JL6-$@}7V z2);$ZIfDHsJQMQmf}o^ofG3s>5rLBZSI>aBZNeLCc3LW)^#qb_u+=SXm+$j6q8k~l zzsU?D35x<+WQe8S6bG029ztuR4-^pB*Ws9JfO{tVB;A8FQt>zzqC?OcxlK0$BDBVB z+-F$}S`#@xvd#U1^T9W+44tn*@R%+=mKzc(W1f@eP%H1RnJarc{=2K}E>@T_2EvG) z1<$HI3iq6G2;1hTH5l^FDv9wZiQ{F!>~2}^2L~XKC=%ekeO8TZAKAv?XsCF?SyvA+ zWa1SPXc^(S2redGxmNd{w;eF1LAX;EvFZzzj&Mw}pyLugLkLrO{sA%P3D1)Kg9tzU zoZ;V5@3G<9t_6ABR+aH7w(@=!NLMG^HQ$@EP0A^pe0ZQJlU)dWdo|s-A!WBXw;VK*oMYIps%l`MnA+n_sJi?782irNH zz-k?-m~u2Ng-fM?LX#XJ+a_Ubm&h>4v;1DH??}E}_!2$SP2_}RO&19}IHeY$$)~(! zFk|pF`Q}yLb=+52EgA(3Am?#uwKrsayg4^~=)!B@o15`Xd{{4Kc@%}1tB!IYL`WA@hrEz_UeB4>+hbKzM1JPb_h+gIQekHUvD^QwNE=&03 zJLR6arsJ?p@ufhES^^&V=9%)jpB~s89xhj+rGxFCm{EY7%oqWj*Q=?O9=Y=8_a^@Q z0D?Sr+q&hT7^3e(z>$rw^q=)N^^$9uG$`HU1<_xzblk!~+qzc5;^6<-Wnz4^&wdsp zRxM+!3C-zVkW6%`kR&GXXnqR#O4Z{Jjy5O@C1rnw$QfqXvK;>DFSZ{k2AWqU2#$%R z%Og}cNl%<@}r6da#pHUer%yvw#9yg0(1$FvTZzxKzG|HvZ?|mjvqa0 zOhxh)CVu680+-(Dl=hB8In4be!>_dj;LTg5)cwCR1sA4K@)}bvhMKmRojwn%JwA+8 z4vUD`^#I#`W`>)%fG*MEC9nKt9j`WE$zlW~@f}vS) z6Vwk**;R-|MuBkg%vn|OB}uGg#X0o}V^+gDPzhy4+!DIB_nB+zkrOJb8v8etKxNU) zdg!H==@_|OdP)H0^|X4pLy3=*L7OwK09BAgK@{oI01ICTi8>-z;~1==4KgmAarzdv zC+bcN7TaefRwvT)C^0rWcJX!sNSyu4t!^RK^ejiAA{5moh1sHBpu2h40GwMA9Jx^Q z2hcXF9hPSwc#{X+X30wfV6wkdD5?%+0e+F(V`1F9;dp(J5=01zv%G(v?GT%ZpM5M{ z(gEO#bpp|j4&f90Ysqi1pPoL74Xy$cQ3Kk6SAhz(;kp6|0t7g{4g~%D@>EeO3{mZr)hz#n~| zn9V$LJ(A|c&1j}X)y2)!&Xs=FTsN3*K@8xH%$0eI^I|$4RYeNe?`|Iqx6D?9h>j>h zds=N#P_Bjg@fhv!Ji?vGcelwg1P2q%cRv?Fuz^8ac45flpVBd-5e{pYHiX0i7N_Uo48jscan+&tXf&GyB2T?HPS@t>Z!w6HBP z-qHltecK%3a=a&@mf5W*wy@E2>I5^o8HH`4JmXu5$3%yXSNJf8+|S)VCc&MI$2AH9 z)QZOy$3gj5+C2*!U>t`w$n0(~3GiOA_3b{u9;?sDjeUv|)dE#tEA6c`S-%1yB9w`G zp67y3GyIGSHP31|L7h;m!7b$ce$fFc`uEIuE*+)^F!;`r6e|c4tn_hhTy`xuilcMA|5?HG}g) z_5AagQ$9)b`S)GYr8QZEO)@i(m+RYp%c(Ax(hTygAAxPK9ZK1`d~%yMNDF{sFM?K! z*)UiYYV8PbEdR(Kb}C0kL*b*eiY&ofa_(_>Jfjxnp+ZurQ(KvcfVVc5$fHLZ=>x%U ztOu|b4j$X6 zbFx_1Q{qjwAGErSvn>5+^Ydyrcdk+CK6)jYBFug5sUdLZbL>8Msf&jb?KY*Gd$>+B zC?mUBk~jAQT_sDB-CO+u{cX0SJ7Q;Z1;ujMHky9Ac*$}(6T)%N^SPs=XpJckQu3z*?ISv^jZK|Nt!*Hysn$*mcXHLwOmH8uMJ-~Si6-) zWslI;eY)zp)r5fYve`wEqCY9-!Ee&9V7WqYwPA*G7dmTM%NC%H@QuxPF`1Wn@C`N@ zc(8t6#ks)IXsO3Iiqh|R>bfGkY3H~oDkz2&=@Watl`0Zc3ps8`ZLG#o<|(lTQM3t_bok7mh!heCdE;=Ix=CxjofALvud!7 zvkE;?>a8P}9_LSzcb`|%)gnhJL~S|+A10>;jA8guvT?u9W6}7T;-DWzlFKKLQPteR zlHs)u`=MWdd{n6@L9}w*SH+Cs%AiA1sfnYc8=YbPoAisgciwOe`D&?j#Bx);NW>?M z2$5fqI-V2005xPnLUXkbuJYv4arHJ_1co9nc%|gR9uAub=QB6THi3Q&E=-wJIb~`1 zCiT_VpgFDjJ;U{ZO1oJBFG`l-uUi_VT@+ft>5!sK6NsPLK;B3Ew@fQ6(1)TkWtQUss!Sm#%&LjvI&dGsG(lL&6+C2LFhV%z*?b(lh(@{co-ngx2kU5qT2$9H_Q;4Fm z0OoBjYfYm|LXy?&2dz=123wZ&LhfJVm@Y-Puh3GR5kn+XrZO72J)1? z7~So&5DOJu6x-)?V{ki7>dEXmk#{+1IrVJZxn!EKD4ScYx6DV!~|8^ya z^>!%*#3pNcTa37PCz8SsvGy!2|5>~7hkL7q?Nvzz9}rXrG3jy0eD4EDkVj>8wqO34 zYk!4jf8|HPgvQdTQkB~BVw2fQwkEW_KentZf|$_KyuDoh3?VpuRE#-BNu*$qWJN<> zf`Uyh5N6O=rhxSHl4`zuF9M6^fA}Q#~%j94xF*rBj7651c z*naxhHxD_K+3|%54l7Uv{0UG?)rw?`^@xg1CC3U|_e zlp|7jU>MkD{)WSvuK^7hgL)=zj8lhe_yf;ER@PN0N*h72Akdia#G@XruPXya5=EI` z-L{^wQl2wiMu{XHvV6saa~nnldyH4gHo(I4_!iXr669b1I zs}LcaB6~FZlP8;#f$THB*JVW9J6jYTKGcH7?eNFQ#e~Ha|Hx<+9~QREE{~*&?~IN& zRIdzhlW#iQ>&pXW^F^j#HdhjqQv_}yITcc=o08W-_vyhD#($%6@v{^<@_I63SFVs< zli{D&+_>IUyEAA>r+Mt}8j4ELX;E)8UZYi;c4IF;LMDy#!(cYQWokMh$3DoE(nTrPv-P zGf-}YCMstUtbYvKF_N>$8Iag>t=ZNyoTL+eBA&ng11RIUi?vY2ys~GwF<4P~DntI4 zK+fJUL6N<^VW22IUr2bIm@uQIJ4zv&!m}aGaN!d3o=YeS+;E;|UL~pZF^DaSxOtKo ziv$SAaCTjNi894bbh~tfXWw^q-!+miQRbcPVrx6en{up|?}fy>nKy1P?+Z65aeGd7 z$&{O`TR@rrS%LP750R;3rX;Pf3iQp55XL*eN|7iZca~EfvMj5$mEdxpH#H`pi34)9 zwljbM9g)Pw$H8Lr`|j5{3aXL*A@!1;AEBw}=flu3IOMQnL$v@#By~vhYnAr@l-#O!Z1VpDCK@3J2HH1XDIgSDumLWp z5VBy>Qm7H*;|1cAs;`&!q1d_sSlEp>elKUOD|yxk!WQf}m26F}$Zh=wyBz_k=i$o8 zUopkd@-nk9ff9xuSlYMFjD5HPe09&W&9&EmnBn^x)|gnH(Ekonnp*&BREvZ*!kXn? z1^UAWkRqy#DiMdQXgr79%ohqy!b-~w)O-a@OO+>7SVCxB;x59qAPPD(JZxnkalw*! zF;XFSEBX-(j!^9JE4}2I5asa+S?v(dJz_1$F*XiW9%sQl)Q2u2Q7-WN5lEdc02}Za zn6wIpmRDP(vY0?rd(P`CG5!l6d9+w*uTN@T110~(0g>ZzEc+%4IZ(#C{3)(AhYnrv$ae-mb;!6xTvH`u6b5f*sJ0jT%TQsKl7|^cF{k2<~@p%fo(t% zr521qC*2YJ^k><$tmP2~OF>iH*?7{6Ahb5Uy41kaM$vv&m0T#DEiy1!b$qmaS>^~e zUzwqk2+)&+=Iiys)v!D1CD(rcnBH~DRLK&&s1(RPf-GbHrY#pBmXc;;04&{q;UyGv z9kh>(m`;+f$))#B387(zzP6q1=$r;U6DCHEZ1aoeY=P}BZ6oS4o?RHXTk(gTr;H#6 z{Y%b$`2oLX?f)=>8o})B^Eq@X>YQavB+2o!)0VLD%6T~MZ;xS<3zN5JN|!(RAo~n_ z(+bd*6X01ms9D_Y?*;WU);@T}vW7YEmBTsB049X`>kXgk*ha5znAl3&dysQc=@!nJ9yQ8?y{--LRyI+&fy4b1EocLFA5kO z_avP#F_;sx=DV{ifV<_9yNymW*{ zfyQ=fshyuem zpa5*%fZnx&&vv7akDPS*9uf;9Ax0x?^*jmwRWiYwV?m#p*?tMkb_F!`l_ugkF{6R+ zS?JCd+r6S))^pE+2=rj0qqp2^15ntw^9B@iLMY)qP_Sq-jS2SS7GuWIw8`8};d(x~ z8JqV}*mr(iGYKB$4=)c%U+72F&5L=kHnn~j7uNWQK&Hm%9zMT+tt!V7&J`&-iqOI<(xpukD!4QOv303%VABlP{zc;v8;tiQSA7Nn&rc%k;INRBL0 zkoSU^=JD`^1AC^zp0NY7RJQ05cojt(S;FF;qK#A`^`eUrV2)5Fo9)ZZ@PD2-VZqXI zj8w`3vjc1@42>;iy3%r;yq-_bfWMeikDXqHb?9so3+~P)3@1B$&<6bKTUVySPQN}S~;nUJKv<`=ja2!LzL*|~IQC{^b3xGyNP$uJ439g06c9~(7@=|gtO5aiC}vb^JK=cCcx`J_Kg zm#g%Lg@nMgxQPJBP{Oscx-LO}&A^Fa2H)^F&l5b&c zGZ2{E{ezxN(>rzMb8ZRoQM0m8NtwQqAyOiS2?ouEe}Am*PV;B37SG=WDo3!!8=lmi z+($`Yj`DXW54yzU$jh0!>)j8Q%m2P|YdK~bM1m2E8skC5&RI}e(h6I56VSt2Fgvkp zM40|CA|7(oO^bNv{{GFRJV$}aQDH#SOT*{kfViFX5bfg&QjGji;AEIT-Fo) zI_r%L6ez!JyOhrXN#`p{7~O%}P6MJmBK+xK>`six?&)@96nP;_r2M!9;Y`i`H@9rT z;krm)0Vs(ZZr-$*O7f{?`^{bxzWKREZ1T+l|1@9H&vFHmX*x&UTEEVAzXW4dw}RHI zV8{^yBZZ147}*qApD2lTDhc5cv$SE%N=f(rQG~;8#ooXiz6@$48@XheH{Aot(bLvB zi}Aiw0(*D7klG?hvX;{njM=4396CG^f3Ypwfq5>0V$7?a)*(m@!XT^6~lJ6dNVD$<0DXy zKd9OfKu{re-`#V&Whs|$nqywf{Z*Q=C12H$7cZJRuF|y3=(Tk=i!J=TubF=q;iSM0nR*rZ2g?~_BOyR8 z_}NDc{Jyswx&dX_^YN>CZl=R;jeJ;^G$xc-k#tleb$wJ3lZj=~x&7vMc6a~LkE3<7 zEW_=q{%6;ZGDnjJ!NC(2Mmgl;TLWa}=`#g zpXcxLuI4+V;Ft+u#J$AHk$uyZ zoFpMnlWMI@JR!cIu*|5Yc;7k0-l3^_XEm9 zibJuDdUwjv?4zLum3kFo(-O%iGpup1PL8FNQ#`j%%W%1ASgSl$c`~Ba8y*{6x~vr8 zgA4CkNUFOtFCs4PW1Q?)V+PisJ0?CANUB10Tkwx;ExV-Y&*JwQftebsUe~V)bi_TO z@22E#iZ4!{9ltp}3hFmjdv4X#^*y&Gdx|DkZf$1srDlz}5}$m&nKj{yd59I{9iDq2 z0U!qe(|4Fh|Jl6qk>n8Veyu>NLGut=p7dLvdHHJ_U zP7e5Cxzpt1!6>5&<~2IWx2Mz_gS@uNvD|k)ubWKv!yQp*Y2PutRnqT)?HM9&D%Nm! zuX5q}rlHe>6NIg}h}^{}L+5Y?Qg#}_g(=M@TQp`geltfj!b40~qk&$sMYcIDO#2B@ z;c3-yQxnE=9|Ngyg`t7}*<<#o0BU}vj#^)@N|yFECXADIvoSeYh)Xwve?*IpbC^2d zW6$8hz|D}yU^eJGZUcv2u&?@nt7g7|5#8~;X%|&$9z@(ARAj^&FY4o{0h&D-hXO>q z@4a^6HS~9iZ5_KpG4$@qZz|c!6tL7E$9Ec-tG?+h|LnPa!l2H?tttIZx#LmB2@KPH z?R|v0dkS~W_gUILAxCAr#&tfK-qk5jY9K9T7>cNh8}% zjAdZ8Y!13X zXB@>bV^UJ~nns~!`PNUMVxFBRs zUkW+>GG<0rrpV2cdh{FeV;4AKBvIFW<5^YycaeR2BZKoP%8IHTzBZ6LIMGkA#Jyg8 zUalw3=@f+7nXg?y`9Z*8@9FX^Xl-Nm(n?a6o^ukgkYI-43uM=VZXS-l504H+RTN;e;{;T_JrChSs zG_)|oDu259?7sYHR^}j~$lpwB_qo7lzBi-~dBpTHb;whoX|(Ia5CR273?4^_JK8cv zKF{HCX2F~nkL9R0IUt4WSNM@t z0pgdY(wyiB2NBcU=N)W1^c%0C(W4q(lKl7r1`F|+<8~%}`Qd4LUQy9L&U;*W(9mz; zW>iwo_W10WR&AM1n?Izg=~Ic48>ikF2JlbwteNt^gaT7JM9Wb(^Xtj5d-QA_rVCkk zlf6w>4Ymp9?8=eZXm0wAgls|(NyKAzXl=x6rV;65$@cc81&Q|Hj>YxCiZ00O`!?eE32)2f|#R-q(AhDeh+Ji%K z8V{-bOgg#xwDR-1_gW80YTqCEycLl?g+|#^+(p}nmD!s^XkNv|MlGuGzS5%^-zT$N zhVm78JB}Wz=i{g$mW240xXuGR!4B#&`7rO156z9M%q9Dj%MoOQLL+uLa3o=k^|-3T}JpqBO?Cb2ut zFA*r`l668lf2uU?w$!2Q>R75PdzMn?x2-1^kC0wtgwozjulvLj8^&C1^y63~LSuuo z6Qi&p^x+piR5x!S7!??Gwp%0^OXRzpL0KSa=8ne-C}=#>_5)t(6NUwvy3QnW8TD4y zSXy>omM4QlhC0Iq+#PlnKOt7i@_jrcb!jKj>(K9RNc&|TG0axRw)Z6SV0tlcVG_0K zZI#}Jet4mHgfDKK7F;uI;Lud-llcjnbH$^%78g+;`(i6KJYdc(^**ye0W=YXkBt(h z<<^>m{X+bYOfz?~Wp)xpE7=RT>2oR29o{%+l2@!%({aFzAyQy=ZU8jB@_oCezKU%{ zNb!?6f2Pf9aYLnqC=;0)x7SwySB*rnq!Gwr^YyzY!zg*>KIVyEq@gYSy`J${4=&r< z(p9#kN^DXc{F?JGsq{TDbvLy`TP{nwBI9x(|3d0zKZ2-<+bL{g?xCcH!cWcQQXy83 z5nSaykcHq)8eR}!uf)3O^3p#5AVKBIu0Nx$1F4{*5sk;<4OVuB(akEB{uzu~^>A!> zNZL*6#H8k%NE7BKmShJd!)0`(DTL6t4O_@-Xhf(>0++p%yFj2o$F}h7&lx8Wdc?Gv ze`(yt*N% zg1Tg-5*N*QiV_`q*F%ev;vwF^Oul<1^_2TD)_X@NUxGp)=LUaAukO7Crj_R)q#oO$ zm<}Gl*8mVJe$Qo$o@h!tb?lg|(U1W<-&9s=&=FK$D@2iuFTA%c=EZ&U{k@ArjM)v$&JY)~&wo`-QYB=qD>bTf2Xru<^N0pW*Q$uw8$&<>d&rTBlcZ z{;c&G?+Tt0@eC^Qm0PK|(@X4_cJ?P&dM3O5Oo)?mk^Q^+l#G+Taod&!#w+$zc*^PL zb@Ec~SS2%ra-a3lVWO*FKhQB^t<MZhn6Jw6(;5m(ZC@bFKpp3$hh?z2&i@f5Hs(_QQ&akDnM?x<>-I7 z5m)_tkG?q!yo3VCo^9?XH8UKrv?wRioD%7-a;FuOd&ZS3b_mXo%dnIrgxR-IZ zo{wd21-#5>asqo~Z1>>;8hoe&OFM*%fl=U20MsUO9B^FQXq(|@&%xebpaT$3 z&I7T2Y3m%w-8;|0nBCa}v=2zrrHd0I--Rcx^rCZm5xACVAmNAsNX;c~`8Q(@r|z0#1Wl4n zN-X{TJ8J}Vj<7|Kg07npjT}PkTH^qIqgEBJ9qpre@FHFl{Z@dCRw#-#tpxo(FmTXM z`md91(NBODq#e;cW$4`lCSc!)N@4=?Bw&FLeooYc;8XeE-X_C_w2P5~Jk=zAq|ys| z@fE}ne7`}KLSr*6f<=PF0zt0*75{&Jbtg>%4Pf@f6o`TMhseQ;_VUxKgdhId%f1Xz{S=E!$s(MS@&-E%>Q?55`)Lm-7cxZ5=rY!7Z#fL?+hncB z5ow#8hXVf(GlV=1jxaJkMi`m5n3&ZQ0k6|2g#KyifZUx6karuXI=0TbPeB>6DK@}| zH-gG&H&P)-`hGz{cxsp27NlAmdEop0T>6G40s}qpk(7L!K^Lumxs!wafLW@v{fFyv z8_?$UlD}X35970Gu0?Z2cctC{7ffjLJ_F%+_Wm&@%-^h)B z_VPRh<8u65i>uw?QtOcUVEsBm@!8XUo#=}$_DZ>Cdj^;E3gj83mVAU0?R4u zWyQCXf|n8DJyX@gmwp|-?casGN?XWdz$jo*>Qqe-ybuA6zqNbD$uWtPj!bJX^YmGl zIAjeB5s{b!<-+;P(XJ2IQ=ME(Tm*hGDV`(ojPReg=s?R^iIo;wq0Ehyl)n}#jJC-i5JY5LckaoVS9jmf?$ zAk)D(f5Mp5SJf0^E=cuHpJt{LtlwSC1>yo20Q?hk7MqK;eO|n^{Rlj}D}SpQ<$-vM z#GdiAk>7B|8UY{{94020U0~BnYPdNG)LIgM~^B{V?#9Df6>V?nKA zP?bA&9pjEmm0g0ri~yzkAtd?orO(iof2})Z|L3Rmf(2N~O-7e>MvwC!m08k0*6|-6 zXT*7n{xQyZS$N!*c_dMVtN71LU|lBG7;~;gMy!DqVX~4u@Hjz}50#1~;MHhq2@X#R z!f|~-wjZOt6>pr?PXO_ETsF3~SreepZ;G6QkTQ<1=m#2tAJSc)(IRQ7PuuN47(zHq zu*0T8RI@M{Mse}H!F7}UfXRx%@1V~{vI(J$^C_n#?OuS8t7$O+*t&~{G{#<8B-hwS zPq(8psWbJG0BqsA%F}X%+jqHXKkzz^!I0xT0 zq|~*4khX6sfTdRr-7YC~BGa~R@3-J5w1^qAW=hsT6i^@}b%SD3*VDjOceiKNo1=7G z=S}{(h-UnvOyn`Hd8SeX*|m~!In8**F>RZBLNSbNwNGYZqJAK%bi4J)2?=k^RVLjR zC?@KP>FuQ7Q14l0Ov+ou=^7m8vKX=+Qatd*Y^4Ll1HEp^yc|D~*Q@Z!Tj55+rg+*} zuyeqRJZ<5)j$y=~DW^YZ{S}kbheiDEz~w zARgasv+bNJ#t?h5`!`31pB?hzwv&F#>_})6fRGRW{Xtb z2W#p?KZ6181=}j9{F&oEx|R<6v2^?04UO}O)0PxmCQv2zqST(9R=3!U*xJ}X;-aYz zF9R=dUt`LAfb+XZFGJ(Dv{Rv3M2H$cZ1Aj{~28Q-5`O`VN!<=eMfJ)F6r>aVRC( zCoy-oPT*0A7SL()ND=RE82}vKX^Kh3+G7QkD0^uAzQ=wrq;tYdJIU{#V2=&F zk6nW8Mat}st5nndxu+hH*dMs|Ch_3B+0fDx$ejZ6njAWg1zg!7tGEV+YdP4fNs_F>Oa0U>lvIfjX72 zGVAJmv%4dUv4=5qZg(M=o{);0Qn>p*Lq;a#GMq*+o>#(VUhC3{5tde>8-sc8LqoD7PVno6~@>mwZNPUcS_~6v_?y62i4EV3;a5uU0kYa^p}l0-{7sX zOC?1Gvaj%NRNKJ0!@Z6f+|A)77A5mIXXvlA??r z3V`Qqp>S79u1{QR3+^h#HC%~uXsq%*`A|eLPW}mN+RBhya%-oOR9#MV;`E`l#^tcO z=!)&2!K3h$bz3RMOm8pZ;~)(~DnvdE?vi{~wfgnOAN9-_ufx;6j00U6_*1P_P3yku z;Vz`LfhUD%Fs|lDM2Vg`r952`SGNfDTYT|3hDU_)8keQC^0Yh>8E5(ss>z>DG&t-5 zI-)K0d5&+xVwi2#q$}F+x(}oL6{1d9?oK}NqjHymW4rj_dj1^x`Tn%>?Q~o^TwDhT zyUJtmk1`cP-?@XSGIn8TjI@X?Ip^smQ z)9tJ0s_%qNnYYtBT!_R~uX$3iMRC%`qSt^+jCz;V9jcMrom+Je=nfl|hVUoUt9J5v z3>p<71XOd{{eD`9Wo=fUp|hN>=C@8mJ7G}&mbo1<-rH8{7Kd+Kjws1;crY&?`7Vy? z(c#J8n{5*7XOC;_x^^%|adevJ%)dfy+)LWUMV+Y4mwEz_v&EXWT)UIj7>;3kZOZfB zrWW;xc&V(9-sGp>6Mh>Y8hNYpn?OgGHVY;V;ew}LT)?YimM{!3d;Ez_L~>CpkztH$ zwwM`O5G&D>?#H#Y9V%L73Ytm?GcTRoDhzu~MR5IWl^}a3MibL|oRVgED1=>{p664W z*Ev3$yBlchn(GDXHM7-=ZXAF20)iA$F5PH;SC*80bMeq{-$BQ-s__vDnWzFQJV4^C z-$f@k?Z9|J9&sDTnrV2(c=Bau-krB;pwge}Jxn!XSejO4*x0b{F)hp`do>Uck*U*Lb@6k1< z$lr*idG4T<;ttNVL{+xh{*PymZqc72(~9lCJI=ZET&eG^W|bVDS+^@hwGX+VImI=Z zGlHbYiHD>!-+j9P{dOA+?aHQ1{sD{dfO%I_?kXDeCNG7<SJC1`vzu+-(! z71TXU8cz#)s+*8YSzftsH^D{rkHzh@cV^$g zsvP4vW*J>NshB@w+>6J_=ecRiIb6fsc@jq9h1vP4tFw9S<(W zKWUW?ccb6WN@3LvYdK7bDIE(@T~i6k&@VT2erd8bM+xrBsit^`I)F_!ISO#YA>lfEmx9r;wIU%{N0!LPfsxEYu`zh zOmlC1lYVkAnvLX;_Qpk5qi9F7^O1V+ECekELOXQ5kX`hSgGC0Ti0xA81zZ2VQ%-=j zKE@TL(9=n-p5oD$*yU;8RV^<(wk6qMrgB#*+^P(1QkYlr*{^|& zfuEjx$d?`GOeK{@Yp|LtxHkr(o_NXibV0O2{Air^UHrQY#!A9w4tpH_E&lV{XE#%r z9kDW49*jboGYLngSBy}P)dD&B`)7);1x4z`K3#`;w19o&$@KP()Od-kQ!5rfHK(nV za|fYyXarSG(uT*qW4lj_TSfVGu-?w|SF_A1trhO1BR$T)4YMuRhXn}k8VNicnQs|x z#-7h9QN0iK{P;E3J^Re?StZ5gS-JDlbgI(_bI$n3qq`916;hI140nNW1@ROkl9~9! zA1;I3~5JQ{jSjl*9$2|ncY4*dwghu?WcJ=MO-uKC( zEgjE4I>pp`mO--c&%IBPcNHS6E|_@j64tB{84&TYLBs+6TVVO8$FCs62v?*K)wz~$ zbrmWGzcnjO8B@35DoDL(+Onr5(szz~xJmrFvYQ@>NmL`JH7Y8oK{q766W5<7lZiYk z@x1+~+EK=GQ%M}7k}i+t!OMm1&AFP6ry2X|)=NHHD#q>%W5634w^ADJeQvcn>D$I= z$aRXqO(?{*ope`$i~^PY)B87uM)0R*s_q3pbI$6F zIJ8y~*RPcMRKS%?v@W(=`t2s%+Vlm&|35mj+ROl&GE+(835ODV<8v;?!2#PesnLro zk23b&abLl;;Xdh(mlBJdIJO7;s6{p{_4D9Nn6lZVFer=Qa#CW^WauOof~v_PN99r= zjuMP4_)F_t`djoZnJ`<|RV80nwGocxY+PWp)WQ3P7H6+Z9+$^)h25iE0aQ$nS?OcbwX6E2&Ok*RJ7)iU%aA|*5HoSh`yU9zb=9V zhgpP4PWbedIB$`?*=kazBhwRP!lk)gbWJd1qo>g0IYpt5fr%hUi29Li>X3nv!$_Ux z{|ff|nBGjx3#bgMKm5DNTP$G~Ul!@YhTLJW6x0*vNP-DQ?1G zy=ghgKazAJGe+P!m~2!RI5t+n7tpjE(@(E^zCn$wI?p|=*c-BQf2BiEX6B`CLq!4n zr!LwZZ2Ik^5%?R;Bih#a7VT>$X?|VJHgberei#xUt@E6 z5SpiWerx6Tzwq-*%FmY)pT6xkBzcxcm{{7F2uxdlb7r$)%ROnBqV?r0W8V`e(Fay3 z4A!_RPR|^JWS03^OruPFu~|4vpOM&6*~rUR8Jr{fMn!`h#xtWXnSarHtD}1`JjMxA zn(sHR9P6%#`}6@sd5Ugcq$7eNTRd--3UNCMJJO3-sKgnkmZu;Kv6S zs@)42-tN-k-I>WPFzuAX_(Eh(RAqb`TZRey!8@eRe&IYdE+qyux@R>G2iCIwT!37a zs;?;t)TfVT6 z$=`XT?+Jb`{~rDqdq*6KN#ApzAOl^~Qr5bYN7|>qr(dL%qQj_KBC&ETm4`o$6XRHC z*jZR`Tw<4{E-qPVw*s&wOfn()X`XLqxWbblA3%A#FSh76hA00`%XV3lpKAaPUz+Wc zyJ8x^mT7*309LH0rVV8&O;`2uL`7JV&3c(~@>84zwfdHg^ACw#a%zZI*gH1BUfaF9 z%y#{GqPKy!;ORewHas8shK8$}9$+kv$rodB?OfN+xgk6k_u%i6mI7Qfk@KXZ{&`{V zjr`UY$f%l^k(qVlRs$>6WbI>HJ#9VNZn~}8V^0zd7b$%H<$<}T(zB_9)#h+Ux}a~` z6GDq~=~{eRk6akC-uZ3!!9(tZ>F<*1o}nw!A{IWHNvwGH1f0K-O8tNu&eQx!9-q(^ zLP)vCJJ2-=LFNzNOSU>1AR5f8zIOUA!miXn`2L!t!iNKx{sr{C#rgmB zxDqF5UK#jZkF9^-^TEIGDNFkXGwc7pr)^i*4k0(%ecwU(xpBiU74aJI-r<;0lxZuE zqO8UMRlbdW(6jV!u2v5t?KaHB{QJT?X)jFr?+k9aH?(iBiDtRLD-zq$NSP|*cKu3` z-gM+^0W%H-LYv^tR+?#JY%EM=m2!C%osKS8_?mv`hv`-RseIP0%E@I%rt)2EdhhiG z6|Ryhw!ZezvI7p(KQ&r*N>M#v=H5%(S?RS?Mf1dC&pXsSf3>aBkn>*dzn6I{0KZkUM@y*$tAy{DzDkJ9OmoeDFkXuM+)o)Hv5&Qa=oYO!_LgSc87&@=4=y7^Nt zJm*yFI$b&k=Yr7=T4nqPNyaBsXqUZLnD*kt_+0EVD&kORI8q*mJd`(;S+2Ur$Q5TX zKl8$`+=a)&vpzu70w*dfR<)G;P(T59$`() z=W8o7$uF(Y1+Al9(3#LQ&L>02Pqs9Bh_20*{)MjGdvU||)Q`Wk_fn_5+}b=9E{33? zL^a@i%8hUT`&Zg&i?7Ba`5Pd@hF|h}QO%uPa+0pfb# z_jF8Izgk)NFf6mvkfc*%ht_}XNzt*2pAcQMrgR2w{Z?8A4tTCfvOpi~EcghfsFoYc zgyJ#uGJnao9u~#0>oA_Hsm}$)Sy^%>=C0U_JG3Y;VOu;!kF?0V9iz)ZlY3 zjzGZKe}5uYC~>rzA?Z2FOtXQZE&iYGidVP}GP>GLWT9BME9jB7L(3hMcSjjVy|kna z1y7NWNO(3ax$K00t*{qVBTOV%+p5t9&2;=bGl~=O3t$SJgL@Tp*XD2%scKm}xB0twS4nGJAhuQ&<$lYZzU1jy zKs6<8Qt0hA0*hp(R-%JX6z359Yw*^IxO38>6DxhI&WSi-wC-2{WV(sU`OAeLZuyi? zj_o=n8U6mK0%YZ~n(4qWGs7_$0i|TJd%5bBd&`#Mp1PA2MMI7z{^iu^G|D}@EB`~) zb%#^k{{PB4LRKVXl&E9No<#_eamYxLlsz*eDO6;1WMm{oImgHzC7V-rWm8rtd!&f? zz3=mU|M^{4*Y!N*IOlWj&*#4H_x*amhOwq(Z}TORvpfG!nF;`Aa&RMpeQ457e# zwG0>0@e$|z$oDBh+vdI)8bPnzt@50frmo$pK?u5H{ zA6oYXDZzr79~hs-NlF#`Y{@?6Gi;`a?~z7{RXXLIoweDpj19%692nm?7XNLgdN`_E zG*~t$KI`}W{$DP7w%^QcP16L}w0kL%T@nO7I-y(e`}K7gALlPk6dOZ+d-dS)Bc|Hk z($&F=lwE}+L>*(i^{dAJUTHf&GLwRLEs5L7xw%gruSl&f$NpqyJzXNk%o(5cX(fW{ zve3V+i})wYN}-FV+n+AqLV3DnZv-{uEe-F=(p@R;qs8dV$|svoe|?8*D4Kp8kHJeT z?i?sLHpf2^|L67`8zX@%^UCt4)hl}@xzBgLrH&BrIS;NzN0%)xXr{r0iEc<`txHkE z3)uUBb=_^jJ9h8w;SujGPuV~2FLZJLnEABtw@%|{zruQpwi&7w zEm=$BhT3!(CnuYZFb~I2+hv+;Rz^Hd?Q^P_Id5mVB7Qdg%{_cpOV*U)b}&DXxwf1H z!|W#59Mk#Wo}PR2@4P^FqB(x{u)6_T;}*d`Oe^WO_Hso@FIw-*@&(o9E#tENJU&Gj z<8dLFiH#$5OZR@Y(CZMjs}H=w(&*qTChRuO#(6+M2*l8hd0Yc2D z$YjRRPr`)DE7Q`;EtER={T~bHgVa=agXNEB2dQY%F~eT=PLOVG7JK5xD%-#X9Y^ZYWivD5q#tW=ljT3iP4p_;gg#S z_!$)K?@Uy}X9<`#9&XYh%E6-W!5W_R%3l!-JJ-uO5l_aq%n{?)$RO9$h3wP7J#ycI zPfkP$icrH#3yyw3tAuK)BSt%%B0jHxN=T=*|D`q~1iJMeu`<#HPXQ)Gi`yC!3Db)w zo-fvg+x`B`vHT-gxZIz-Kcs2=VY>XBQe;Oy+H+(yF2wKbewRI;xjRWvJt!BC3vl$Jp_|Q^nXsK zslbAk*`RqTK#(LfAL#vp{&i=ZE0z2(q=tFm>O*Di@B(a&P5Z#PHUfT{Wps%Zef{5M zI+&!s-z(POhW~2$U4zX3N+f|YIaihj)W!TYKX~d75Dl7*Wb`pNn1l#Ju*lUFg7BZz z1r4H3>*4ZC6ef6_Sco4??A`*|d?=gA1C}YcMc? zG{wl$jt3=yS{XmYx!^`SQ2=PO2iR&gy#e=1MN2+-a@6igl3$B^46nVUY!0?W{fr4c zpZ(J_kH$#2jn##&NY!(NUK*_lw&NpdJ5zbpECP7=__oc)b!PaAL%o9Jy$%;;ejiV) zL=3cFi(hY*(i-ejAWm8q+h?;9(6tEoaFg$oF;9b>r725G_N;u?{;Hm0e3xrg#D7H} z(fYqv94qei3|a*KMfJ1OF?+25t`IHbYZvZQ`=2I0VuL|iKIldfme1Li8d1VW1}*=CI3HMp>W93H`(kEsT5y=CaP zM<9LRdB8iyL4w>4GP|Ga1>mrm2`BF==i@q9Hz1&`qi4dH0UpH|9rmF=aJb;ms4Tx2 zeTAZf!5nW%YuP>GN@z*O*(no!9t~TTT|Sm_OKq@B#02vvV|QEVb_-HZqA*>5g{aB8 zIqUK=m_E_uuS;|#4@;t8b}Cmx!Vq-tcA)*LjP8)Q)eSu45J zz>&{DGNSsr7g-vZtBZJ8EuGabJGm2&+?IJI!<)-lfAwVXR?}z&&O#OLThUOotN%h- z|HL26$Xnk9PI0j1<*|E2qelAci7wihTzK@#Ld|Hb?`(h}bx9GdqbyH7GF&g!EPOfC zGS?2ytLpIhO?tLP+_3We{_9_8n0w_i%Lu;<(FGSn1*CWa1D$YxiW2Te0D?)(JU}u{w@+<{DEy za@|h;IAd+{yJWph$PWZ#BKPf-Sy|%HhN9I<;*dT|b=9|2^2?ke$un>EGor<4$Tr9_ zRc9yS2}99l@hAODzGjhMguVaYi&z$T9TlE33~xZQ?^K?zN%L(`J1v79RXA!eni(NP zv}y?!7odUs7mz;7lHeNQN{k?z69C~i6Wl9Tf>3sMBXrL=$qN)SMk&9B|?(p zu@Pumj)R>*yS2$XOkfvq#5U^D{;E@!tFK;(@)pI=-;B4`fIM&?wYX8g*cONzjHxcg zI>;~p89;hfaM*wU@R<6z>lnO_5hyw01eO&f%7;nkk%v4X?VNY~Q6B;^EiyxPcaXCV znrbkikAg50{!2KEcv9pPOfVXKT?lSx;Tnu8(#U9`YAHhEBjf|>Rw8bT2#^YjSlmD+ z*cN;-Ka?q2!WEv_0|?@!SLa%To=5~@uUy_id?>^#67MIHZ6`t1Jg&C8d11u2_YDYV z<^9NCmDr&!zia-)k^~pbi(KT~fX=iLgZ$tVVz1oStpGZzkPH0VIGis(1mAdqYz#mT z#WN0cP&#BCJpiPbMwGdWeDftB#^}3Sb{|kjdBcz+=;c>ZkH<23Y+N9e&BGJrj@#zfD_8nEm5xyL!y$omj&pQ1}JJQx^6PxIjQ-bRSS zmTeibf)Ha%%t{TJd5+Y~*rp(cM&_FA+LZ5!=jbbreVV5f4? zJXxl?Pu{8*P>**(@s_Tez_DqB3uEs8s_URog;=9=kjweG5SmZqpyEH)Wfup1pDg72 zoUmO1#$Lh}lrdngTlAkBbix(5{dB19LVq4P?~as}h4+PxhU~ttKY!TaXEH|wauFv& zF^cYS>#4zO@!GuCFvNS zIrCUIV97ARkKD!P)f=>Rw!HlSk%$q;>`Ims}S`y{oW$ipw~>=>^< zy6Gp;V=@40VblP zw(wApk{G5hZxL8SmsQO9MlsI z{dtdC2p$6OcDOOUC;`Fa`(thP?X3j4aZUksE)Yn&58YJ}kyi`r!r({;dHAt>kl)9PhC!!_Oov3IhT z_S_*hK+$|E`3pt-aAFHn)D1ynd8qmMNvh)`OJHNljR=09c<$X1QS+E^SGLr@{^qsQ zJgm6~C}<8m;)S@j0D5K8CUF}PZ-fxHFgzl1H&cvmuk7#i}5ZrnN-^{}^v6vDkq zlQzLrL9t!^cQu9l`;sJC)$uXpp-5RFKF>a*$NE3cBP>L+~0TKqjp4Tp*UJT8)5rP-Ez8#`^bEar7v{P!{vAh zBH(4?u=(#t#L(^GBb3*|d^f?HICVg_lxe3^==r3+Kkm6TZ0S`?VU>xO8*X=>v4b`B z068If$Ow5u`{xZ_=E;~X0YzEOxVtNs)sb13=_yJ`umwL+dhin*cc>RoYYe=-O+Vb| zU-BkziN@^~I$fwcyxt@kFJeD}T_-i3f#|aSzx-XwP6=xg1ZL z=Xpgw5APP>_g93pPAz9`U55rvTiTGMSJ+dam@16)i0ghmFmz)?{LBG1XWWZ?;5|ZE zg$s?T>7Z^22-c;|>{V7j99wbTCp={MstzwPi-K~JN{rh`r57k&tIxQ-jFxrU;PQp1 zC8pP7FtSoTYzosIM_I53H>-ewc>Fwc2R)AU0i$^In0D-!&nA=bpva^`2?gpF@!jSJZD>W8nquvBdv(@YJ zJBim-6nDxEDy?NdP?)*`5!$)>yb#@@(C+4Gy%t#7#0q3+i0@b3HP#Q0TdU`E1g|AW z^E0)XMKKp<%RZxZpGSB|{Vs3aIwGrzs@3lvgv!*8zH`8AUtJhh4a;!r*AJsY6@hxY zzcvDH1yBa8ImthF;2BcPM`KJ%+U2q_<8N{eQ(AI;8t1^;&cYK(JM!i<0B>N8J$OiM+LNmeo?U7-8e&nR8 zBf}tW%904E~9y?>l3eh>$VgFmo3= z_p~Zqsd9LO$upe5W) z?&sI`?X`9#_W+W|t%3(IPmCuq85>-2rxB60%i4<5QU;aiRd2MyB~E$-uJ>NFOQuvj zg*?eT^To00IZ|!fZR&k#b{&N-+7#FtL_;09IF;My%l9ujeR&O6H+3;UQVa=utG9tS zEmQjQ`x-hnh_97ENnqtN`7w2(*woD;FTfX35ZPBlJtAi-U52&$9F9G?8nU_dtnP7Q z8bsr2mcOyIKK2a&p#Qe9?Ioi;r~HGDdclJ`Y0C+hYp`?ZV*!J&0>z%U0rn#v@wLt`zYIRTT6ViA$P6Px>51C@4oeOB_JbkaPt8wM=s$*E zdOzM_MRwMK)5=w)kBA5zA&-&*7TKeZ)xDewXc$dDIg*JLGu6!Qq=%RblS_Z1NaS|9 zr32+Q{KlitT zq}0(3d9bY}$Ajng5BN4nmsc>wm<$Q3SA#(#mippg*`jWOtBOt(33&kiF}2-Z_y_0P z<^|B=h{`!h|mv$k2&q@x6ANM$yOgr^i>QO zWZ8KABs~AiyAZhZb@Gnt!bf&=4E$+k|KlOu+&@4%q^Y9YvJtRDTY_bI)= zjl?m1FNtr>4@H-C@8`evF~YW^KDA?YSUr(gHT-y?^z@#Oqjk+p2SwpTva*VVC0h1_ zpq=9e$wB$3>r z=^gjXD4JMC;wsr%UGrSqr-KL3fzwF^ok?=8-vU*Xw@vs)tXQbo{tw6_zLKX2>`1tf zDe;EE4a;w+rmqHGUsVQY_?|_mCwV51G?qs4S5ET&1euxwSWU7rWb%qWMEWE0(GL-r zzlr6C8e!s@tL=PX5sYS)+g|HXTf1^=K#VXo2v_ZGO5d?)TQ9d+V7_Ey#{K(OGo5Gr@^**W&=UeBqd!+EbxW zcEw3jY*+dz(IMuQF421u+o$x0Q^Mk+>Y{bF4ol?Ze2yk%9(6-`>J*LkAO+(8KYZM`Hz$=%dyWwo8hGrVqsphkDocFOs@MZI*m+V7mPNz0Zj zNriX?SDRcLwMbKXC@WvUW?&*1B+TsCE%l4P@6Tp~J*NC^+o-u4Gpu?W+&Y>^)PlqW zTpWkX7jPNk!uR+h62oy9K1JY}9*N&G{jGOVri^d+J(;xz?FKJqE?fVWHa@aYG)T7bT-yJAZ%omTp^v z^50%Uh}E#%3f2~()5p{)0E!w z-H8!%pN>9w_-~Wur0Iv1jvsI?;wz7?hcV;W^}LWI6qK9nD~p?3VU)ecPH)Vlo3&pd z@$u|?z_EOZAsbaFr`^;Q#-vknsi#}$Xc8>*^v zE5}8?&9%~{=iKCij4Xd7jn7dZKzn@zvTs8kdPS^_*xWq)JKw3E9m#vPYdwyRypGyg@kV^Ul=Gu=9pb= zY*ddauTUm!Q}=oeww4l)cBe|%sw;N{&cHUoDySUNXIs=AzjeK)0k|7YW|?8FQ?6Dr z2JJdJ9H|XvsH{_t@NilM5nsCe8_N`>(B-J$xcYrNAE&HpgG$PZSx1GoO>RNEqnTaG zf?#i-S&yl)Kkwh7wsqyMkD%2J^>ySjcjl4EbO>TsSZqBN!A>E8-N3&4^{hOxTOT@Y zu3}!l0-A=gH_(lKF+W@0e9R=eH6^@{1)F8u%+~)>Z=dbqrBDmX>o*NPxcgbKG1x1x z?31}G&m%M*bse|K9GC6nulj{m)tD-INLH}@bxYC`@ovBm%vKIh?s<%mi~zI`1auf| z((ilx)OZ0^;Qj?p$1nQ?jb}2p)E7dDk?zYZDJWC5%3Ga&%i)&E{0{9BqTG3aysD-4 z@Co_9KVI5BZwd0Dr8p24G^IbyNUgciB5~%(J)zQq3E7`3j&B-AxSv~JC%%p6jQ>>w zjT3@`M3^&A&Wjf%d%dGpvI)d5^0xWFB+u4+DYt>RR=-DFR z{&_8vrvglClZAT8jIOMRuFD6VR+BFtw*WPWlfWH{1)E}u1MU20=vpHeGlrR{Sd3rMXH zAD%9HWhHfe=^b{#y?CuBTUNES!S<-1grg$ct%SopV^;5wnP!^z0ebbU#3yDAgp&?G z5kPup@cMQAqU62ytfJ9r#6 zv5)CW+52KQmr!gE=ayG`k2yJ|uV)!u$^t;lCsOGO_&1ju87D9Kn1eQQGU!nD)#ovL z7%2l0rlp*|1crI7>4;L|KgqSRy{y=t@x=(Q+bpToLC0oJsfWjI(kDt!_09=B}>cc;W>%lbC&H?HQ<@8vEc>z;K ziX%7zQ?=xqV`~z|ncB^fYrSY+fj_NKp~q{*CFJipHbT^aqT@E%+{kfAx;i8BaU|PceU< zHScAUZzJx|mg|IZVI#~&-${jNZZ$;KciHlW6^X0ne;0!Nz+|A$Y3eb!zYy}H@-%PCPT4yVjd7vTR0anlJ(~R(%bcN^-7}vlr(yt z(&3K=r!ORQAhm(}g%f|kz`IG|2WAC&zlVfkXEyIFM{l*cx1B;nKi?$2F!E9*N~qlS zjj!gR9rjl--qhL<1pCG-t9sBul0SQjo$sk6$8dnaD*_lq-aX95NyboND zoMCOX24}@_@J;w=t(ZL#9a)JS7m-b6mG|rRTSWk7HZzIaUJY^!f%K%X0_%4zvWbXc zI(T;TJpO`&AFR2D(k{kSDJ2UX7`do_ai6X}gQ?q~Z~XJw=&@+tp$n+30Dp|_&&xg4 zAbxR0`MZrrer9I|Z ze;6)Xl^OA?_aeUVMfSHV9M#%o?xU(V=LaO82@zH%WG+85dZ{&Kpr1RifOT3)3mGN` zO;44y0=~=@3n61{jayVUd@uFE*apuDZ4a4%_GVwWk@t~tIfDB&DGF0g)3vWml1QV^ zEo1f+9;{Y=x+`kBR0O>X)mwQfxgz`2_o+}*f}XbxwPC)CJ?6YW1h!hsEp?ilA{WL} z^96e^9i)3|sC(M9*8`h7Fqv6BZ1zi;b05}8m}?&OoUv_)ct;pVL{S+B8MX#VLz|>} zD_1HCHY&F4oP7KI6LBX^T}9ko^ZK*O``lf>y4#tW@CWnDc#fipM@o%E$)x}PZL)#6 z!^DNG7Sb4{IvoeET=sfC&Uy_MBf!>@zcBbzYnk{e*lrQ z!4>=+$fwMIrth#N%52u8F&$%c6@>q~$(*XF#eL7LmuV3ti6OfQf9UQu?xPc7LXc}A z92c5LH=T~|iO)(w7Slv>d~H^Q``;0F9ehd{Izayda^it282~CuK2U;Xa5CsH%G2uq zIZ$M`&3{e!H)SCvt)!@x?ZLUo%K_C4W;`mVtxAi5bm_Jcghl@|SNS^F*EaFuxsrgmsKH zKaVu|UIQkQQ{}93*jgiMq~KJi!~c7EbOvaH;ZdSMBSw6bsd4|W<_)+M>dybYi)FdX z;SjDSxGeRptElss6_I~gU0zK9tubG8Gvd<^!4tz=Q}_vu2wDT39?a&mbh`gRF9_eX zuz;VA3d4tTs*;=HS=2{Or62JC!kQ>Rh=Y!b!j5l{CMub|4dCqs2%$x9&){+A12fKf zKO~pZCI12$b11;c%T!~gR!lVj&Lz8nQ$>QI2fX=RkRUfrvYM&u2u4#=LkU^ zAlqVcgsszQ@M`APsFWU!70}uIoFtP*W<}ZNRxow419Pl(`JcM6cRZ2vF9`DC7oQ{$ z#~f@B?KYwV?uqfe>w2;}9So)n+lXO}y#~wSN`*-#@PbH<(%(YTubt#$#T zSZmTZuAvHd^iHsFpiAg^9*w@TZw;MJ2klw|zp2tWJf)@4D{-0EznjsZRnCfrA(w63 z;ToHBPBEYBh)A@ZH=lHB8=r#a`o%X19tVIT2cv%ViVw*9A7&)|YZuYc{hV~lKNjvU zdI|qXj?qi6*8)1&_CNb|I(lI)9-*iN4U&jo3?#f9yQ62*?xBkAA{w~LL{Y101Gwxo zHa>hxE{-AT$jt-@BN!C7Clkqg}WB$CA?p^uXCPAGJ?ehHxI%#0)^;Gs)VUlXzPx0M_r%{?Wje-F$J=KO8EPqn& zla%!;*6tsizj@Rc*9JFUM2WRpzi63;jv51EGXDUTUb4LB)x}G_35aD5T@xYXu2tm? zja;R5_WzB^9(bD15ucRrH^9Fd^Z&3%BqPIb*i}wVThExaAG{jOsZ^YLayEv=U&~yk zbk7Lc2|)WW$EoY*(HkRSk}}}=sEyy64kkul3H`{9<>Oc=ur;%Se!i~{5C;ZgX|^(C zuS04B;b9xk`~jd0?l+6j9vy3?cw32CJh&N)EwmWx?EuAj3-`Z<)+3H5RPwTXa zS))dshTbQ1gqCoCwmt@i?f^1?Bs?<>YcoYMD-i?AZAIgW+ahr>>B(yu_^B=25s z8_Tg0>Ceye*qZP?^7bU{oIAqO2I>PXBOfU}xc`(aQ3WX+OMmlMV|fh@$Q5%pOT!OI9au7`uCytLoLNZS)@u;e`)pN0cR18^P=p^OgyLAWVLY#70C z&6Wf5hx`vJ~##;3Hc(tCVK zA7_pet~z$hjt^A9F=MOB6321CM&1{l+E#^ z(7?D3A6tGm?+r3s)-6Gn=@FI?)@b1{SZu#&8E%xImHGko@7huNgP%UxkqH5#==?pR zV4?)A^ag}0Q&ggy3B*9G(upTsi@bJqf!K2?( zZ2>IZY}@Vxami(+qND0~&YJldo7?g+7he&dNj@Cb~#5W%iZxoAx%sV^4w3>HUDnv=+FfA7x= zL#2+a1^{AK*0jN zn_rvy7yDuTu$2Jr*b~G%o7@DHIM^Ic;0;jMbBO^36qy2rLl@xvN6KWhxSt902zsE? z>oFBPtcUOUl?^L8jFu_s#;nUNT?tIvofiq7_@2Upiq|i$M|~XJ`ViqZxW@J6kQAl@ zb(1{cs5ITVNn`2?7G}?|)q$W6n)E()?pTi9rH5nFfSet0f4^>4t%l9o;5|mASh_0U z(q?9KT)71`Drfe$9i@EX&B|}EpSnKyH_&;In^cwe&Y9C67NAQW(p*cMj}po_IL(fS z9qXR!&AnI&rzG#0^U+o9=nUt0w`Hj1cIza|BNm5FFlwmM+d1=G*K`O`jD)+2iL2Gr z44ena@z-LsE0Ti+rH{XR(wRzl?T*NnC86Zn$s7Erx(m*EYPsb!(a1N>#St`>dIkK! z!tFwckzmV~MVve(bMJAGo^o*cb-qM9VN-T`11YATH(YgyYTWS(JZtg$Dy0RDdpl1L zPO4^PDD-)!X)9*fsV00TI2C06g?*s`7G+Kf`G$OiZktIPoh*ZIK?>*vk9_>Q-%sKo z17J@~-2L@Hkj^*FTwptrU;=#(XBVptIG?l(XUS$zdZ4hNOKPPtK>)Z-1i2_vzb?Vx zJAs5VFv|qFJw87GtlCcXF&O@ch?MJtD_Fk$%lt@2fCC*6OQ1kQ$*(^;V?{sDi zx_2uI(T7h7`bB*%+77x7%_Xh|{`=f48C;^Lpyipm=LuG|;1A4!CuKufUisAlJvnYK zTcf)l4-1$EUj}ND$#yi#i{|znu(gE~I|FjGsuDeG@h3bn%4c+s6EjGZ&fmj~*U_B) z_B{YHPdJb3bWo6XdLl^WO?aHjrY(2KOG7@=D3syND@o7!kKEeiIvF(S-{JWVH#oTi z1xl-W#jnAXBKCc1Jhl9{neQ2X2NoiR+@!vOalqVm7)O-67KNVGVb$C4`I6tq|6W3d zuQOs#@6H%x_rhFezjsPqRh{>)?A_y*=3YmJPI&Qd^Kr#7vl~72gI^)>#Z4W<9rafN z#?qve(P2O6H#cqN=OGso7&eGNk&1XgX^JRi15j_+`5ZF61RF|B?1knd-UL&cHiI7@ zcg~c31C^ECm{#|1@QowY(vm94riSr?`dH`5Du4`IHh;lcUpR}{71ee@@n@F*4#HO6 z-qN>@y8 zM&=++u}B#xA2(gtbc7pR*jO`ZL%H_qJKZ{W`X-WVIi0vGIb_5hKO}T_`MzQFXqX$N z0YVb!15k26{G({M>p${l@ASs-)hN-4&1_Zi#Dp$M7iXSBB6Fzms9{=e zFG)CJU-bJaln#m1Yz?7|5kDyy-P;*{-pdpqOe1yD*VYIx-1+Z}OgFOkUXAU-|6+_u z-;^(3jgOr?K{C9}YSz=cA|37)J{>i=CdaAEp?&KE+EswfTl;w}JKNA6h0h@Sp-)VL zRBFg`&(wSSRfBNUo|r2SvBLZ6Xjuir$c9w+!errz^Ic3axi<5X;*52XMBJ<-aTZA# z9M$Eqix}DfF-O(62RT_D7%}+6q)AXQYY2QX^ybB$V&&3_EMn~Gc)mxnu?s_G!gMt) z(OidwyZs9Quvq^C{Dq`&55Vk)rM)MNnzfQYu4($Fn*9mz9EHm7dNpriR9%d#c8>n~ zwZ9{H@$hn&*V{}nn-qf?$>N<=kkM6^Ss(5(mMTs-o_!`wK*l;qBPn!m>^gv~9B~~7 zPP{(zy~k2G#IUNmekLfq)0w`Nt4<>z$w=mej6nG1*PGbDXxYAg56#wFNBVo_Qn&b- zCj2xu_E8K=hI>+q;%@SDbu&9W`LjLO0B3Lw2ws{@Qs3CUbB9= z2iE$TshO`yWgLD@Te?yEs#@W*xj~=Wf-G$iOU=Q?Z6DP1oa48W(azN)R;U7PS^|;P zIaV$WR+Iy#gU62HT{fQDe9|)IoQo`fjA}s$?0!Qt?S}vajoRD)t{xvk)r)B}=Qww$ z^S&N6%q+i{8Msx4kfC^FH44LdFp^Lsme5t&yB>Xnh$?$aMMp8En*g5+}=bMdju zm8RA1)i|eev@@vkfc-evpG_1vf^^?C7qZv(ho5s)s8qX++2+8%RE((;HvN#jCvLN= zpyu!F2Dh_@>~>cAG|JvCiq%S%Dj)=?Y_N;xiyjGmQOKVRODC?Wla z6M@ua?vvc`5iqx;?dlwM0g+2UbA2@^zS<~{T{i#b5@&Ter&Tg->R8(3?iVN#<(`Ry ztiq{~pL@e2dHTU5#m(RlB|SXulrf#IFeuuhH8uIq**7LfF20W2-cy`xyjY9NrZz0g zFHk@CP%d&bE#_9i&<1vhM4p=;f|MT zyL@@2Pd_*fz0cw+$Ehs&nqIW#E90t}58CHeVeKFpd_@uyP>k8{`1QkxPpSAd^JtHl zzUVMxWMbh`RSAai-`yms*4{90818LRb_vq~l-b4Q^+zAtaer49GtVvX$dfi3DVk?9 zrB1xNxoqx-o1U6fFjm3rE7{%?UmoW|ft|qA6c3oSFI0@(rE6h->>{^#Z^nFftu>;6 zGO={Ay;ih0x|CHEcjRfhU))R^w@>x`ub^n~95$7Hoq=c`&o$(iFI+3vR}a3821fq(OX?yTqP=Q~C;fm*kUHM&Hww8XM|+wC*&y57Dv+NYd zrLF~K%!nJ5CBSKGyxE?lAE$YxYm!x@mm0g1!>2cwwoHb95yVd~%u8z0TgI z0Q^;R3$5gHAF7Z$qA$L^szs5Xrs^R>pj)Pr*=E+K@ zFdf+BWg@oesa}H{)+@5=xAF`Duv44tGyU^HMWR0suG8d(5)XBh6qmZ(SaF z3Co-ha_cgyx3|K*@+c_s6PT!yWE=p`*jJ7LI!et*&)~cGe8FBLCng236@LixX#ge` z$7K+0j8vI086qUwGvMis0>OR7LCUJ$IiFe|>&j2p=jO~7O3iKs0JM?BX^%--9)Eb| zvg*N_)mGt^Mm}D8Xl5>*ek0Huk6Uk%Olg(d32>j^%|k$H2UW(D3EQRple6zM?e$-- zA2r1(zoue0=QGnZdf00yc@yo@q*?LA^vzC8uG3MbT#T#Q)5)0bRj3LXQT-lm8rM4m zf=)t#^emHD6r(}Vxh$14`@dr*FyB8r>KJ!SZnP!wZx~aOY&giBtmun>A7!MA$86SP zeq2e4n!ab-2sAda^0=(@W4+EONM{k^adD3YSsUzEY#-N;&C{G!m0s0&<8Zg?=4J2A z?hlFtSIXxMrq;^45jm1neATjZ>UNew4EljbRxD>WQW(CPzX=j%@7xqbL(VWL5!1DG zo~mJYpFTkooT)(85$W9|3bajIprwCE2VofYvw%jWh^oY^y^}XnMja&%2VMF8+8kq; ze&^(txvA0Yf^`1$-wUBzhF^1j{>y!}^ybN(Q%BZTW0PpxKB#>y-v#L?K1Fp$=tFtd z_uV~jnX_yE9eoDKovprVnD7NooI&OYblclHbMs~?y_<193fSE8#f!TR1-3RmRqkfi z_J7J+FTF85m*m!8Y~NP=a8pxK-;Wgr38p@38o}8_}b)FKOVOitMWOm2}Qnc zi|YKyc)DhM2b^xzXs%(|Qm$2%cyL~Zq$`|%a2MJ5=6{dN(*4kR zD2ZX**fdQPdtArZq<^A)H%SS;2Jt8C-n8&I268Juvx`w^fYXAh>~oP`Sx)Y4kUn_U z5RrLD1Si?nb6~T-c887K`sOFaVP3-3qo=_~YrmikP3s>3M8aW~sv(5O7A1cIiQm>7 z;AHQl2t=(4Lx9>N4#}g{fXaC8E&?OR;nNGvMNXd?$klzs&rR_WdiVaxph+OF#7WwC zToJ;v%xV!ab46a)R8o!G3Ywfpx@Ht%Q1qi0PQbyp1!?gDf6pIgH@_0<0qxqRy*(+i zP@{T)D${I z@@l={7Rrp^jmAiI%qgTR>4qJ1M4@aB6tavcI5%jdGhK-sIW7WH5zjjv{b!~aI3)O> z?{{h(XrYn(P9_a}BAI>rh4Yxi;N}sq0>xyjW(Wgr34Tbdgk$E67(of=d}m4p+;Jz0 zjcJo?2KjY}=1xWdFno!oY~CVenxO7=0?my8f%Y$A=hBH+Gu4{5K0m%YvIFJdINYB% z31ft!EAN@&kY~ClpPZGakx9;9GHKg69cm{Lzr}-LOsLn6N^|hR9%g~=Wl^A zV3&L*!Mqr;f!tyCbm77gmtVvKvM)y!3cwcyc{*7eU2hILerHYj;c&63=EPibS+Uw+;2b2Px)>5KG zY!`x^j(XE)%h+4jB(3o1V*CKuZ0bMAKZ3v|8Sz0A za+vS>Q-H0fI2w*Xbq4^F+k?H__nLuu$+mwIrf_cy>~uwI&Kvlivl4ZkclboxFk^@> zvFmyK=AML8VJwJ-z5Pz%Xx(#$aR5Z@*r+ii%*@8PGM3tTA4MzTkI*L}=~j2{#W&r6 zyKqP|wSIcG6vk?RLCFY<>>Z30L$D*dZC3$*WFqK7Z~cNr24I}7h?hHAr@EKB_j#By zoaI9*ftX{r+%l$?@Gv@mf7HMDWhq-8QKF~6Tj?Bdbf)%GdyG}}*>inB#-WG`vLZcv zhwou<#@=$UXlY)&z`dO=Q({#q=j4EE6xCD{Yp#=)Op z|B|4K*q-*YnXpFyc>z2pF!KniN-M6h!aPX zAb>zLW$XQSPY(6w16ynyJ~7Hwh*p_am6m`BG=tDkEt`OI=0ylDKAvcP6zyLtG6u_i zh8g`~w5${E<#hEwpdWK2RvY#}>%0@Qz`dqtq&?tRajtE|bF!s*SlTbB=a!8IJzv3OgO0NTp^!=6eeb8-#@w&ow1!5pO!Q& z`XW#Rj|&d-EM=PU=6|iww^~at8oD_GdutB|bl#0wm!V({L$=8gdg!2$_g9Ba756fW zX%W&A(uguPCR)wihzdzTH_dr8=gNB&oLS|(eed+6y?sDJ$^TJ(w)c#)YPZ+pvFI~a zkgD?GTee;j0uMNI8=OU^Ve8|lHf(ObBD>{8Ku0A4^^H!DMl8dIMH-7gllC@mPp&KK zU(ioFhz@pZXc80awr_&e@}hG3vRv;A+!9Nj@exuIEygI-S#ty>!^hI$oW)=}^;G4K zvD=)6!}~yAV%$@r=egks0l*+qX6Z6Dw8tB89%2MYX#}kmnV1 zyc0X=VuO5*3uS}+&_xqA61mHbditCSmR?5VEzbq=eF0i1TEkPxl#`=CLRz`UcJ#&$a+WS6}1|H!@1Hyl6}?w%FXu8uBG(750)?idkF< z)7kGAo3i`7ql>04*)ttA%JfB>n7hKEhQL?Nvj9M|_20@Qp6R>c8&gZe$OiR{FzBnTRYr z{UJ8t>Ye68;+Ut7DkI(M*ZcO)-Ssj4p>lsY|6tHV{sp1D`)@JMCc>>*rnzQSCMHZz zibW3W*E8;z7)Z6my^@F_%A+zbtEB}-a(0iYAyO^pxjj!GKxWq9ieBkO6Lz@t{Mw$- zFZ6<^AXoB9I^F&KeAA>%&BLZP^v2UxS3;?-cGq*CZ?`kS+R`Gkx2yOm~bzTLMw$LThj}U?lf}2_v*qfVua#)QN)a7=7KTI~{jq9Gtb3&JsPb z@E5PoTE(!i+vf{EB}Ep#Huo^fE8~f6TdM)z$E!?!h=mK3hiS3&yL^vHc1PIuQ{}i^ zb33(ozpeRt|YoRDD-t+H$=y{Pb@oc}X=e-o9Y;Fv2)#{aYu_%X%~ z3c7`@WBNs|XU)F=QD^{LTI_meP@C2up#7k9=+s|y7N0Cxprc}%vSr$BV!iO_O)bo2 zRkAR<=>%h@e&!@iS7`sap+w5$@NzrCmUP=7G=~a7F`Mu;#z)`Y*!SP3NWA@?1r~*l zwQ)~coXI-^YKe9Cg-T2L_}dF^#Dzc-bX_9WVUH2#VV9V=cO ztj$Tz?iCuX41pH0cmIjJ;I`QDxtG7E)PpNLgp(foaqz-vqY(6nDc-TJ(wVx0?J~LH%t)$?r zVF)szU2*?y`rG37*ZIj+bXL!JXTND~g*SxD^7rpP%upT3x#DvjHc8?`|rZgsoqQ+_Jdxh!hNUrdNNO@+QhE|9-@8 zq-e~C?w`jNh@ACzpd|MCvm8rQ7TelEieT}{s(DxxSJn}$<0CmfIAbdi)HM?|AG}sm zK7PCLcYLF%C@YRA!n!3jgM6P?mbUFi!B3stW=VvRG&ukJI+u6)RZ?8g2n}g|yPt8Z z1HvDY-kEudP>_~T2Ybgu@!h3O|BltSB1zo-dWMdUFNDD%&_uXMGxgl>+ZR5qWWl{{H9MW3lc`&Mw?|U^VR;yqJz=zB=6gDoJ8Tnv8%}}_}!+qEQ zq>dlZeI)DK?y3>qu$PA0urCt1H9c}2&Uy6QD-wGsr7+OsKKQZvn#*fk2s zU&z_cBtA@Z!$ln=(l^;?VjGJ650utr7C-d$Xt!wgz76$$12h#mg!OZZH{k-jDsmLD z6AndMR19-z3eN`)RbKc_LA<)ZEX^)GtK4VgnG=aPue7nXbkcjKh_P7Q*tVhiE{>{* zA^FV$=s=?r&W9UzG*!e0D0>7C?G}w@ZPE4ESI~uv$Uf+2 zUW-dHWG3BiDh~a(1e(T}*ChkBPeby+k7;FdtvW~QnuP&4N>#>Q&wb**Gw*)IW^(Cj zQHSSZ5u-BRP02I5iXqLV7B`?H{B;_e$$K|1f4J52ic`Bq!@LtMP zIAI}+jluPK z9q#qw)*#S--v|PRK+8hg%ENxm8lsqqQ|$-Y$ucjP>;Fg8TZToozVF{O0}Rq7Nap|& zN(qvpgb2b6QqqHT3WCxMpmc*YO3lzID3Zb`ZGe|+&EG+kIY0YwzLBZlNZM@IA_4kyWWMQnFt^NzzLA>PpVxzyK5hs z+0_H~ohr>1Z=f9mZLhUdMExY83z<2EJ&gCi5F?-aOH#UOH_<%+XXtqXhE3mR__fdV z@nQFtCLo%}JhuwrhdYYvjO42nw^%(Fx7e;epv@sfl2vsPD9^!`KRQTJU%j$9!X46W z{m?Hd`}o0VmRf*2UiCU5eHew~%Z%_s=z#OWyCu8(#Rm1fBq*jispl`BE!Toe!iQ{A z__GQZfEB~QQX(G)>*$$#jA*Wc8BiE?KDIqC^5t3$0Pd+6umL8B4GIei58DH;t5Ckx zWKi@f-9|1TIB!Wei%%zaJ(~Ji1vOvYxQH)_m<-(#y~P>3D&?sH#7_RoJS5YxJN~X` zWcxdyg7tj{yZ7;0t`c9Z+V}Of5~uqe=hVPEb_baHm~7dVi4=*-Vrt`A4^#lY#a2~) zQ$6Pv5mR^rV~p2Z#$T5KF%<2yUzfQ2iiTv4bR_yTb+=Es^JuZ@c7^J=P>oZ#9Fh3A zXpKbp)5Z(iTQdz2`+zdw&!J`eoI;}Ow|bay_X4eLLpAJ(;v=jb5@>Y>CSmRf`xXew z^ES>ajB+NAW8vXmibPDoN#F33d#*i@_Pd&)`T8-}K;MzA%1bw%%!cn&r1~XFmq+69 z0$&7kmtIq>h8Y)d44B~8$T7)lqL_z)6rl?{P|E1^7PDc$v_PBXM2Nq|9t)9HGjR=J z#!FVqS4@$of!g7lnai($f8gEe7L*cK^B|lzA&10fEpd_5isVJxO%vHY(QzKD7HdnD z@1G|m{Hc9Ze(!c&6Ixq5A}LsV6i65@p)Lk`F5mq9ZVCP4<03D8hNdii7Lvfp=V6SZqRxAroU95gw0_82T_T<& zNjn)T6lXFZA1!LdwWZ9k~(LZdS(7vAqD~!Z3Z4sD_YHlZq z7!A{nMd~RZ1)Ix0Bj^Z{Vf|&mivAUPa@eA}dtsUl(;&tk@fZ0BUBNcVVhSufCo$OV z&1?MrJQI9ZZCYVXka(@7qhxvTY-q+?iDjIPcy*HU`na!I4M;XqZ_BlQHk-`-tTS;v zllEqr&ZTie8+1`2kNJ=W?Hzx0lN+I3-n6x)3mSWSYs*LuI?uY0dhvi@lGDW@HsclL zA-kO^B9t9c$%{SQrE>d2ltGfirpkO zSQpPlJ6gh>iEC6J^9)o7-^>q6L1?Qh;qC<_R8l1jkW31Cm zH}ThaJd33>k`nBYd%Im*+^FX5$k-U%&WhRNJjwStiCsS`6D}$C?MxJKoTL}OP6fM3 z&-8A2WkpjaaJ+nx9x98(;7s&(EAhxAZ770*%%-7>itrK-^H-kRg%`uZh8Z2`yrq(K z$B5Eli;=QSMfK5YtJ|9gCz7@K)OM^|f^7?XBDD6M8EPmZQnJzpCN=D?mF&|wKd|l@ zK4r2+(-U_>fzSs4j<&F)XQDFc7ehhZ1-bR%qOAUcqXYQUPyO|@?)qA(;X-GnO}vSE zDA<6;M7PhPP-H@*;%y!~9;eX&uuLcIDZ|jlSU+8*tuEg4wKiYAXMa~5zk_~jRHtE( z&NYQ<{3H2GPyXGm?6-%Xv4|*E10e!E&^a_ok0zwuQ)y;PBu3=PCw7M6#Bi6A&F+O8 zM6GKQs0pjnW)e{m%`wsoiSaRIBfl0e3IrB9i?5q6&UwK`%9CtWcv^<%>sln#RmZ7T z5lv|R&cgX;l3}x<2G7s}X%0U&#;eRNO2x)Kn!!^8sj)n_nD@Jy^&Q!@N3&>o>2xB( zN*Iyh%RPQ}riQatx50L)NPO=dSU78!Rl+?`I&wC-9$EWkcAFjj@S} z;7r~B8vA$VZrKvbrbYe-4w@8)9P<))hKwj*{R&do2whOTO?TpWqF1~ zdfr!8?dIJm*i1f6K7J4~81=;>b2R^y#g%;zzve@r;!%t!a`B~5EJG&xGFQP58DqhK<#d}FYv?1a8#&+OHa%$1HnB{@V^dGL$yNP~E{ z;h=gwQ%kZ|iU>p6TcYya`efA8QBOY~MCN=mmV&vK4?J%}P-7%Kcf-v9ayOcDJue3dpbj?PO+nJj9 zN-ieX(ySfBe?1_+qfUVEryrL&7OW9%rdiDi3SD$id08dm#^J=4-Mho%Z?Cd&v_?Eb zV-w)_1FbUDe6xhfIw1Y3g#>#y3I&GJG2=0}4fmLSW96cW2C3j`o|;5BIj-|l@Vh;< z3NFErQ?8<|^SX@{|GPO>BiD{$1p4`Q;-kuC*OuP1;Z?qKDspY-D+5n7{C@B}wEKQr zU2V_F@}3e)Li%r6Yc3%UdrrgrD@81LHsDZS8EhPG>9~6SpnDa=oi8C8+OL+86O^%7 zYMUB*1onOLVHZ~;cDq*#d{d|e81}A=v(<=pU&FD=lRd~3;j%KeNlM>mCOVwzCU8fa z-4(QuqdqaC24L!l1<3;R!31wfzeegG#k&J^h;kkm@)6!`GcBKasHeq*%Z zQCC#^X9XfMFeGqCX%ijx8ds3-(a;j7DulXc(A2Hu3}2$>-jqY3NMIyZ$Wz)EAC2SC zA1+kWU)W5YT0~b2vceV%jET6+a=hLGX-u=&dmUI(y^lm)Ao6;#=TkKsvyXIzcLkDx zibhp0oz88zC2lqnAy%!w*#}OY;a|d+-_v}3pqgv^{ABY4J3H%#^kKaCUQdlN@%iQD zN4QYk%qyLGj@>Fd$al1ifSLGstKc!h>JH#HOxPyZHnFpJ{3uWp>}lxt>f3okKNM6hwZr6e~OonAXWLIEkVI4X7L4@ zvkUIVb!hYLiU*sGOR&tn?)7@&b@<0@*k%)KS5n-RHa z8(@fL2_v1(WDX(WX{(0y!yFvH;$>e@i?1dcgzu`gd`3=KfyFfy zS=6`JA7!@~IkYI^Tj21fHrO+)cZBK zXK!V%vLw?dm29UlQb1{hNPYA0x8eBBbURo_Ks;U2mOv=u##VlB@Nj+cAuBjo4(?VS z@`@aQb^2d!yKM9{7iVND2g$81xGGr^s5p8CdRCvI=T8%l9ZUmMgc;|zbIYA)W%Q0` zPF6l=cl(jyYqpD5?Mp@z7E}4hxDxPzdkaC&CFrJJ7y@M?>O zx^tdl%CN#vk4=VQ$%#AiowI`J4DeFvryqRFO+Mh!jaq!6IuV0P%=;lghxsn-E}1me zo5yi>OAgQ+{mCIN@?1m6E!$JgFRhoT^u+%y63vkO~b@UB2B^wZ|6mgO3B}r zPa`W%p4u%5b0B+;&E_Vso}_nw0*PF^?h#RuHLxKU1JCX*wHkiA@e3cc4j_MCwHzem z76rREeCY_8({DMArddDhl&~^$C&N+Xv#OSM#}e92J>Dkn<{@iz5_-Rjh(%D7HLlgx03hRYOIrhjtrkFAgCI!ksl@_i_|oS5+5rCmD^YQD) zLIa($wkmVpd&CGf3UXHd2c!A;m%NF4=@=stEvF!F6<5)5_JJ+}l~*64yfNVrA8=;b zRR4XHHO6bU-EkQoMC(q+==B405BZ^XsGdo!k)HqOe6UHuOjOYG>0+)kK}X)TpRM%r zTWuz1UntY}^4Fr*-VBClYGrQDJlR__Z9b1MPM&sc(3{bpu$lzSCJZNu#9Bogv;X~C zPQy(#TD*n3NE^Yl`y=zAw zFEkz?!W0NL8C_PCx8%PU*8=j7s4Ji44o9ZEU+tfdhRI=yQ|U`JKMXUTEnF)STFqDM zJQ*)?W!bZ7esCnM%3LJ853(&s2OlqPDKysxB83_x3{U&E!gmfnksMz*X0mFZ^}WeD z=#_VD%k|*L&)0)>?}P4Y<5%;(l7(HzKZo)-76>wTvM7#)#sb@d{oh6`7!?_JrFYeV zW$x~XwAaZ`R(%t_(OaZKZiWpt%HoZKpkAJEqO_dIBN+{Rh3C?@K6a zY$>FSvfcvo~<_FtjMgT-=ZIITbLx#ZGT5~67*flEiDe*Uf|N3T9ESB-lZa*SK^GwfVW6X=7e*S`BU6%$t85brLh(Y z3EQ+%h`}hZceHpYTCShJGbF`#iiBL*zcfdk&NdoFevOQG3B~KZb{+Q%SWkUA_oSw8T%J?#v;tgvVX8owDn%Q`yU(lHK{3-lz&xUE-ON7Fd(;Wh)2vi5 zT$MDnt|Tw7KmIH1V)K4A+gWEL2QO@lPD6o>&ANZ^Ou|>8Rle&r+qG3#+F}%s4`Jz7 z6e?}IWsL?IvNg4^+m%UPe{8Ls@Nq46&8O0b=rW+C%h31JulZzum9)y`)~Z;MuFIpa znJiEU-ZD8XXLR6taBf}j-j;pqB05Lq@2E!IrxXcJPtIc{JWWQ}?tm9&wINRAz`lT2 z)!|%!H5@&=JGh#;&;_8{^L7n!92xxZUXJY2>ePBV@_Hy_DDkHNxbGsF5nbry|kf}1Z>5|=G={uFSKAA}R8GaUY*(9X6JHouhRshrtR!JTu#}^@Fs4Eja|by<8!G8<`91WqM>2BlZsd(k60!xhKnG3< zvMIKw`m8PJ(e#+cscvgolru(t@YRjZuer5)<%};cl9xm|P?Mrm4tXrx5WWSaL~+)b z{-G}y(t{HFMXL*_D^+WUd@I<$l&Y3ibFpBYkU4jKb<$Z;KuEsZbQ>c~NyA7$P!+Cy zLk)@Hcm*p#n4g-Y!jMij+UYXszLN;oapGfbphovz#vBNcWTdJMH`as|ChXNAx0p1Z zzGq>HA&qDXoBab;)Z(nQ0+_EB(WftIHzJxJ^oA>evOV|q84jH=#!&_|m4Jc>S)#{U zk1ovNV!Aw*cwTgin@5sn#htY~($A;R}hI(uNwula=Gu`bK+lO+`(jMm1urj!=iNR#-^URE#f2Pr6&j8#OWb}e76M9 z%w5v}j>B$V_=z{Iv%VRZR}nkg`l4$hMqM7c6bx4GqAGC-2O0$qt+F`{w)A^MgRy4zR~ z(*OcKF482NW5+y?h$8XsKsaf%(%cctuYI1lmpF))@&5UK!m_T;`nwp}#5B;UOmlxq zsJdLC0O>EFPNaP1z3fEoc=jC)>Gu%&@aup}5x8Bdk)Dx-wt%-&-7t!6=X{kF)pENj ztaZWQxtjJk;W5K8)A2n!*sQ7yiSr9eUh3J><1~I53yzvUK++>WB5GC7owP6jskUXh zz}5ySk)`p>`56Sbew2Xd4!qy^(gJwsR@!WJnj#cx%JUzC6LnjeY~+u=giGwpc~G;^ zZu%3Zxuh`}$k`Qqmakq1w0dt>939pIVlb%PF0JV93zFB@za6ac!EJy7yHIS%B`v%mAiL8|MAkg;O= zQ|Z$^yGFhWSXDo3mT$Y9w(}HB(NT1eMWR#c99X<@$<4jEOOu`PKWf^xbv9C*&_8+< zzA=&wyVN(_^zBxT=*D3aM^c^GWqyv-Z`>*oY1WIqpAiE8>EA6Y+F#jMG-jcRvR<8AW3n!{qL^$IcheK>HUWcZ`A@6hJtEoxJpg z#{8`KYTT9Kr%XA%3M>v9kS=tH>Q5|E&F5sfMPJhb__l*+993a66E`PEyMf0sSV{$y(idjw1;(ST|m$t-U6^YUFI zNgz6%@J?&49J)Sh(9@}`?a~j4_8`e-kx|*5IY=1|IR`f`4rM|jD1DA|ipQ*>KM(X- z@7<-c8>?naaCk)#G2eV4y?6>=muU?VW=Ja%6)sN?2T+1-Rm#OMKzEk|K`x5J9~%KZ zWN{dLvR5wXUn_k^Dl~^T((}^unqq%GlV?yHc|n-EP8q%CFnWC z|=FEG!**W>hoWEiFWfP-cPeHP^L1XZ}(pSsKp%6 z)+Uq*Xj7X9*l^&tmi}7){^|e~)DOVl#|UW#>j^FZ*{$g>klB>$5sOg3)>{IBE0#ze z;-)+VP6WvJ3b`c=@AG)tac==J!LyJn?^{66y_fLT7VeNcUt(*?BKN9S`HXl@RA93 zn?MwS4ZD#4%$BhNT;o{&OkIExFpzE$P}whT|MVi6C!Q}VjUq=M~Org2)?MuAsnqfHv~9%W-chM(Juc1VCU!azkzu+ z8nUxO%`vaMu^Y6@M;=q4vL}NeF}HN=?CsRfXdQ! zybbxg#|Z2w7qUOUI&~ph7c%v58oq9(Du-9VfJg&vD>LmssV76n{GUay`a+Q~7k7ZW zane%XOGS7I;wpUwD!P-ohemxImY6RP*d3A`D&agMnIGoaU8N2-6K!Nid~s+ zjIp~^vX|<4!C2eb?9;LRWBs482)(zw5Xc4T248`sDswUhwxgE z^mSV{PWKNx(zoPr9=^~INhW4Qh(UY~&flZ>I{I-U|I?S0ud&cV2St_6);N6(N=grq zvoZh$M?@{KqeesG;7Y*k-HMhC#HK&Z&IfLmE5lUs5KZF|sP!7F<5fLC%Xq(;X0wOG z5!v44HZAM=c6WU1_28;1lZj6gXk|qM4UsHRR0;{u^dWsug{%U%BG>J5l0r^bt!w~g zg#t2TC~>kwmwf(-+b%e^pRv!$S)|bZtyD)oA!NDyeVv};ILF%@Dl~@aw{zvCEPw#E zPE#vK)~!26w3-!4GnpwdGIuIT?}p}3^7DJ3yE@bAOsNbgex3l&3dwvyUfmbNEiU^d zz5zM#4o2(APVxu8Z1PziwgIXRYM^Tgfruk5;6X0PVU|IZ#7t9;kopP`;H!v1AxS<6 z+62M7tuqTCE&QWuXIno z`A_rn!6y!^a->YOBx%26ot&(Vf#Y$D^FwCC$Fzu+--6Rzw^OpOl1))4LV2p94zM1e z%y|opYE%u|p7(JxSqt%DPnLMPA<8hIQ87sj;0q9B-j51q^7`Q%V+RHhV!cxklW46I=h=#k&F+1^aX1A zNN_SJ(eCc7p8dJ%+G+>TQp`cY43OGn=W}|@0>Mb?*KZs?gLmy}RFv}#hcsO@Emzx? z7{X71Ak+;P@KHy`VK8eG>_DQxE3X(_R9N&PJh=e!O1g(M{{Y#|X>S_0+EULc~dt1ErbA;!j3k_BN?hHUT0-M2BHixg#V@W9Z3p^Wwwe9`Cw+6E)hdw_5$nlqF4glJ%GkLuO9NJLHu1WV6R^5!rm*#<(sh8iy0e zw2*dR09j+oXF))F*XtYGG;Sv93K_Dh8w|--O$!1UGS+i(;$ZlWw473+@@uP&YLnW) zgepb50ZQ=!SY~=J8|$M0N6*C z*fqzp7ZMj}x+KCDA)Tg5-~&K_Y}@HkCRzXoVo|_zQvWG&ME0dlrs@t@%MRr!qC;#8 z0w4%_tiI*QWKacb0 z({=7c;;Hm#JQgYi5)U`#f(Z2L)Mo%R%J~JWyz=kjR9gXxh;ihbO^wZ)=C7bwKkh|n zX@|0JEaywI>D&bi+re)GUm$;uCy)-}SEc<6a0{?d7(2B3^#mfq-ed8okZY}6oco}m zO7pL{ri6fCK8ZQthk%1Z5Rj_@KQZcCp!NIckSLi%I}b)tLJ?KOuF|b)4Vbz~SB~6B z0pa8D8;wAf6%{>(CD37QQvo-i2ebjw6(3NNy`dllujt;4f=q-*l(c8QO6|xsA;w8y z&+jLqB>PUGnZ*}}>r$uuR$YG#@LRdyMK*7Ar1QAd^5L}*;|fs!E7jm8`XGmCx>6W?2x4Lu2FkNrq|goi4EkDYd1H zN_&tzZ^nw}GVvU*70Il8GFLl~Iota{$2IanT~j#z8jhiL2m%XORUl93xA%X2w9EcV zwVLj0=Bl{(=Me=SG5o-~%4F9o05%7XV}0BmygEqUR=SY=k!P;A0@_52R+rmqCVo(U zq}%=S@aGbDXq%URB&-kAg)O)#?>$PW;wZ=wh4^MNVI!7-cV^|f7_129ANo8=oNIW1 z3qJ-A%V$|9C7a><`GE?RUKQ`-sbsjeVXCU|E`$QX4uj`2WRf9Y?ueCS)K8e zudWIY{eTx=>C|c2g_Js7N2Dwkl4nLY#@>MeWFk4r&07BJ%_VqNS(klARh!y6*l2K* zJ;rd(kPW`<43!+X^}cUWNeHJ0i*p3!6Nb7tdo`Vd#NI-Pr}Hsb&)bdZVg2)wnwKD8 z;~B+07!6DaMq&~iUIcXhi|%)WKtz@A%;iaf1j2{Gtdh$)4UoIQLN=d048f!+Vo<2Z zvl>+xMspP@T{=x?SlXY_zoGbO#_JZ8O}OAz4|5cqqM1kpYpVj2)-=!T<&^O1My~p{ zMqmCw`yYJvAF8LnWF$FXX_m9mkcB~>q4_;wl*mBuW1-?fnqmq`tg8LAWG3&JA-298 zam=SCZ(~(tfRkQT;rc2>@Nz!pH1jlj<@L$&9Bb#R)JYE@>CvO7e+wo#ui$HYdUrBo z2;{-u`)d8^;*Ysuq9u>x)UZ(h?BU86k#<3bglUcl zd1mi(_&DABI+g7aDt{%*X^v^N#UGGqqeH24HJj4Ga`i9_5ym_Hd_f#folxVt46&6t zfWJm+@Z~kxOF20ejrky@k;$rfjtWiU-d3~oy*psYsa-x_)PDne4e$p}HqSD@lV?vI z7eE`TyKu5A20H0f5wGA=LL|bCQufyAVRi@v>|)lFXQ5#+TvLkP*U8F0pSUwodony^ zbcuzL6&uO)K_@xO$q3A*ged!WI?quuiP#u!vd>Om`?#-;nZlbHQVb@E5OXjU|I3X% zCjcg}a9C1!@u^4Er;7brC~H)1UY16S|Y|m$mS*r z_FG3LMeo*OHZYYMU}It3tSzXOpbw>`lv*~{D<3?1kRwzXGHx0ixdZaz?Sf`fa^y0@%56bH+Avqu+WA%zGI>yF+5p9nG$nN$KQ(;l=)mm z+{c&3-dh(Y1U=)<9Ze&+OxljYA}hncO55;_#H!{^`&9lMpg~gA7i^o2xL>i%`4=-I zUc+^?4bC0|6F^#zmCAFf8jSETeZx{2EyM-!v+ekpZ}F&9UxGg zgR4McIr2d4XWP=teTBGg#>%(W6}}XKh*K{=ur=Ty@kL&{E0E@n5B0nFaO9Sz;o~#R zUAPiMp+T>D?0YD-&A8mtv@Yz_wtU5gH}2>5LZ#-DzYJHTKS2ha zgPhaw&a_Rw9}q;eVhD1WiPBQs`gBy{9lME{4P77hc}Y(};>1L11e(sRoxr1L95VR3 z*BSue2x$l@Z79Sa!LII16ONOrGg`Vd+Wl>r@(3Q&r|VN3GuiND2_>x{66i2` z$lY&%b1tmHN$B){Q8pxf+S&GW@e&u<>W|U2ZE3P!qYmx5bv80vh8|o~RY@1g6Ml!c6cr($}uTTZm zmL^2K_PIUvv=sPQj02`4xg$oS!cH4GVLIg|ykZ|boV4GYiLIf7z-q7eXt>&Z={>K7 z_N%gMPjwoz%crm_-eXO;%5w~Nl5x~bMp7ir8xp0)P?Gg4pe2_zQPawq9)e7~Ym<)C z#j5IM(z&9@Z5Yz6FgKA`O1WhK+y=c!t?Jb_duW*6=aTp}z&S@N#{jn?-+uR*R_tBQ z*nauy95X9#OZ8Aq&y3C2%l^+Lo&?%)6+f1r+tt*)H7?3ydM#nBd9Eiq8bX4Q?IG8< z(Nwst2?vrgr1ka@w}~z)j^q(Xi0RGtz%Fkio*}n`xx&U(p&O5IaBlcqg84|KzZW9^q&>h6>ibj&pYW(05|WJ)w>85@@n#K`7goSoioJ?8zwL-MkOr;3i^VHQimS5~eRio^1!x;0HJ z^TDulio<;W`DX{Dgb6Z(=$oN^Ug66hy!b#2UKa<^8_ak!GFjSR^NAVmoxfY@;_Wft zM$^H|imQ>eh@sd%yYQ8ISMFh#D{pAFO(t<;V(n>wdXT}*IyqWHJ5GUzHD6+?=NMrF zp+bVwvFkTA*{8+Wv$xx_7*b-?)#b?uu#)JYx(oh6AUWI9d@RW_g&)OP;AMp3_|tQe z*!Mu@&QAV!B*0L$hA#K|Wbi)&&yARUuRzx4M+4yaN z>pEF0TT5x4O&#Gq^*SU^+NC%_7_YsJq{^w0ri(UNmqOxr7<`+aWK=zV&ks2*b?^;% z#QlTR<6R3wWAZ*yW8ZFwVs@jciOcE_ok6-Ryr(k9KC1^1hK{znnMvd&w z1r$WDeA%Vp7o?o21Et*cj2QSN8l6-i>*XYw@0)@{!q_MZ9Iud1@zFO*OBU4LG5zD@ znZL+iIcQ9XYRun}vSJ*`3*w$WN=}NWP1(%|zrGit$!yB+UdAX$Z?UcMOU)#uD$Rbf zm9vV(so=w|BWeF>iD&$2T9WWFM>&E04?)Kd8#$=E{oWd075r2cUQLVmDqQNp>1<{PfMOy+A=} z#7b^fS^tMVx5T(&?XznvV`bP*7Cvc#dOBL?1JWjzu^*g!^=viu2kRH=@tK7@og-EpxKd0d4AY8PJiHHd?F>Y`N#Yt96k;ckhN*S-VU|8(T^PL;` zu{~3qpMwKAA!9OeaE7?$T?z8qyi{|-c7TIS`HbO%g&TCv2q!v>PN9t@d_Y#?lyh@5 zU7a!f`cjZ6jT$lWu;N;+M&+;fcr+#uo^nsdk;sOaki=~r3O<#u)gMsmz8p?PRtXja zyR68~uCTBt&BSwr#HWWvwkSz`=5$phK{Z0+pN;JcTQE4x#-sqF0`QZ@@OvhAG9H0c zq_{Au%o<=m+mfp~D!1f)hz_Wz|MSz%I{)<9Y*c5A??5P_nkeCgV>O&@@e2`BArgi{ zdM6OR8}k(%ER&J9_zmh$5fH1aNnm(M#4)`}oHm5VlmyVfh6wC~_l{!x${9%5zILUTfb@}XkfPJF2 zNZf)#n8K8QKK670S_|$!!i~4Yso^kR;i%Thb>e2OF`Kr0Ss=kWFyn1oKIm*NrRGz+ z+;xeNbH8UbYA>B6P$YDr3Gb46tW%>vBssjCv%>KR_PXNAKG!~efyH?tyLU^T^gBl; zWz(5Be`H@9%a^?~1c0g}N+vizRPHE~OgL9K$QjuR%-1fMV~=fYI~fjkNClsb)yg!t+hgb%!< z5e0v?v$UCL)2zvDLrDA?zWq5nUM;9w8u6jr-G;5)(ysIQa+gZF7=sM~@n4J!JS{VN z6ch3jc1(%NqZOu|k$gYQVKB)Ym#!B2ZaWcJ(8)Nt4PFGX@1W{tDCYVggsqDF=x`$b z=i$$L%P5R-rjt7kz)~fmh>>vmv@pDoJk;8ne7#g>N^a5TOHt$~p{3RD$Mcihx==@$ zdg31X#|Qj^*ZClR7rZjPv>6I-GTuYN+>2Ga8za zey0tCl~0KHr>N*JF5au%l1ul+Xd_#_FIk3QY)NJVS?^5Om)6x7qIC}LJRJ%2$t~3b ziep7BT>S(yCK5B#wlvY9kU`r?x4m<1HhRaz(oA@X+;i5y^RTiEIeEkSX6T*S%rT)AL- zkze`8j^<5euB{NGfC4(5-Y1rNKZM2(+X})ZtaVb-dY`bLf8PH7bz=EvzPmS8fCd;= zJ#6}YS@R{&w}X=A+i|C|O=WN4_Je^L+a+co? z&Y$cKk~j;Jgi#14jZJk;xSDz0D)hKB5od->)PzhvPwCPY$?e5d2HtUp6m7;4@Lg94 z|D^sEOM|*!9h3h+54Uzy_Ba-8t0L_2+{9g7-aw&0*Sm4%ryr)Xv5{H+FP9_rxlD0)2Mxo02 z-L75ODQq6f8LK}eFx8(C!bin)*Wj$Lf6i`x(JEzn)9l1B55F^~OEh@={QB9Z0L(08 z`|$(XID*um5|^*lTU?xoCx`T;b2esN90TQIizb6lh?oM9N9c~gQI5@A>`5s?4YY_l zk7@sW=FVE6@_QJi6;ftwDHA5~cXP5v@Xr;HLQ#p_)dr91nB$wh^U-OUGL!?sOJ;$6 z!TVH~)&Id}2I0NS`m84o1l}N#wI8rOA{wyLu9N6Y@oP`3a3YM|GDys85(PhAL;R~A;4FWxRzZlFz=RInv`Ju%d2%;etZWVcYd<0Qg@WJAMsEDABYQ~IER@{ zey1RIR3c+~KSc2-tdZ!YW(AOVMDFiEdyrtZ6iWH;sfW_YY@zDXZuyPG&Kf~-btA>C zFUEj^B=&vbAS6}dsob7~%0=*7`wav%eTl2|98sCQ++yOt{depvrOm!zH?Ed~7#DUf zjFW;Q@TApCUM~73=riJ#TSzU6%GA5BabZqXhBLwo7~|vd`(oj*fZJKFIj?wEEX)}< z#W#q~{{ThT;3RB9m$fvdr=J%rP1L7!X@$!}Ef|f?zGiZ^2--dw8Q<)M|QBXs3a?AQWqk3Jl;cD>0N8`& z28Rqel1MA%%P4KeU2F#R_wp#fcnrjiX4#dcNhTbZHVOO*L)N4A-`0ZX2B28{dZH)~ zxxMqZ;4DJhnWS8)XbLvC`tBqsV%l&I~P+lVb1NpZ~ zIF?aOFXAu5pa38aT0Ng#trVoW|DXPXvbH)X{{)+C0^FFkwELiu8x4|c_#aglAKetN z05+K(=qNvmnmt4D9?X#v( zT9cm9?`UxufyNjhI=a7b;^}O8ve&_nm9i#iFo^9x*};8!k#eqk?cp!lA5_?KQPLeM za2S)kGN>^{3%%4|v<4-6;B&yV0Ea3tr+#saddTMvW25*-xiI@1c&K-5$= zGf-O@rKm&HVhGlQ`@J6F^0?nPoCs1*E5(_`Es0$bjLeja$4h`q}Ky%f6&6cIrBp%EBT z91js}v87j+umu0R27XQOxpc4h#sf^*5(%__AX8?C5!C$ldGH@A4Ls!AxEQ{N8qJ#M zYaOG3UKV#k?}eO?@F_8QjXP&BM$HXI$`W98R$eW4)K9+()`3C zcN1{ELga4#KT=zG-$`O2KQ_RwT^9ER_Wl!17@JDuk~g?qv1x_r%@;v;|+L2 zw%WikKqS!Aq(^%Gew)r);s=?`|plmV^~$~ z4WxqxtbjTqW;0H07{EdLIu!0#n#HTSg@No+KmmXQ`33BM?P60Cyjug$B|}O4aVdBV z*m^@)=68*Ye9!;`@)cgFU>bo9vl4*N=IWLmiqOnil-%QdViL=-Qr3|PXFB}Fvz^=s<#Bvkjb5)zW_RqZTZO?z)tCgP+T?pV5#0$xB<@N zpAm8Zg+ZC5-`a#E@cH!u_@48H|GOiYpVQ|l{7(JH^Wa@$fxydWBGa zy`Anr5SHkM@Pa%SKOnN*e@_}bD`%29_9{V&?^)!4gO)%AGRP7_AS&k8HE^Wt0!Vv? zQoT#OZess8BFBWMj6YSUjHg3^LM=efm-EgV$B*5RS=y)h@1`Qch@XK{4}9p3ceO!Y zxuK8Ue`6Juw$up1OqlrM2ArXAg*1SX%lYr!fD}q{K)3*93T2c3?|UG`9C%8-|2Kre zUt|WsnZds|5B!(f&~8H=itYh_M*aO0a;|vR3{WPZAo+t`E{zRvP|ktd`T$5 z{X6GL(2VAUpf{d?4-yIScAy^Z9FQ^%-p5VJI(;f0Ez>UmEm%biXxV+0_=3Ij|6c-f z0G#?%d4ee+3dC>l=7KGx4TNLU6>maLI9F74gCl3Io>q`dp!iQ(2>w6?WFU_=GH?{g zjv$B!bamDL@2`(rjmVt;yRF(dw76O0`@HO_r*wSXpqG28rxkQDmDhmW=wEo7+jKMa z{nrmEAQveVj*_Jm;Jv|&AgvPa07Vfr|4x8Fq}9l99hxA(?Lkw45;owk4VGR$oRkB$|QG z1C?dXj6k}^uLd9rj{;rj%aCjf!t2<89)3b57}t7pfXfC4sws0ubo;s+1p-06c^9}T3~imHv&QDZ4PWh zC?NhI=roac1G@CWAz}l}hgE*GH(@t?nPGpSPzy-P;R#`*3gem62k(zJJQ%43>wEm_ zDY)n&{Q_mFa?z5DHLWimOLoMPt{URp{$Hi!C1(IQM>{o9Ot}+PT7N**!Bvl7=^!osnNZJjw4a^&WP|4Z`$>O%^35m%tK;bds zT&;5iG`MAlYwl06|NpPl*5l}!g1Xhm6@d}tG#9{l^?=Yac~2^z0;9$hmM_)T^BRSMoAOhkD7=gzS6whd}IctcH$0As<|LRD?{{|m6<_z4xWx(15VNMv$ z#Or$|{s3kO!YG~tnhi)XRe?zCzd%OCB!@k+*LQVX9DeEA)w3$6c(7lNx`wR(xRf-W zJh%%*)8U)*FlY5e*93G!)VFGI093n_?b0`J znCk;zIR92bXWwQ%4ySTQ#~=*4n!zSLI^Ebz1R}iIfZsy{;q)Lppso1`7y#G73&og5R0PdPe`{@eO0k=Ui~eGU_o3zBzlu>T_>>Y0C0sxQ_R%mS&=b0ufW&F< zkW)LbSE2rg+5~jR`5(yl-#}?VcNzJpc|saoJgylG_RIgo9N3Z!N@yUO9>0$n1bH!a z&hfG-GHxK*2KMh*TLMM4uF-J36;5@T)d6s5W~~1ADnoCxU=+ayz!Om=Ds5|RKdT#9~p88+if2Xp3@ZYf|nJvAJlpRzMn0~XGt6fBb`a(#gTfby7}!T zQ*ibB5S7q7lkrxT#;+F8JMLXkHoN)Bdk3`IX&|PoKi~}g(_{nQqR%VX)>WJP=+XI9 zXSHBx6MP^KB_#ntsDf?I2<`y1b?usrHBH(f{dYChsB$A1)!>fF0)dn#&;vyQuo-4C z!~8OYAe6E$UR(yvK6;HW*^QtoWU}ATm4VE<|Ba4p7!Jz`xRpSMislls?v%bv=x`2sDUPV(C8F`_%_xAKMN9Q9E6D$r@R0rHd&rvhX7zt7G|EuP%chF%Tl z@bXYQPX|aQ2w_7vEWk?)cROW0fN%}!_=HP7%eYb~_>JshPZV`*2q+{=|4E~PnD69G zJ*4!14UmqOApPjz5y-W-J4p9DgK%jY3;@rKMNrDX4EhqexHMk6Zl))4yMYMKIiMHG z7fk!G0v67$m$YfgaJ#VO@*~k=pdpr$=W_&9&EEL{jPvh@>ZoyuGP(SIL7@!9d&f}r zfExRbHeuz}zj?(t1}IgpL-Xs_)kJWrJODIcpaImY@XKErN}_W0cs`{sh>5s$$rmXP zVpQxLvpweDJ*$&hf*`{~ZViqlhXv;+LQxOXv2eZvd#e^uW^Gk7YB|9+jGg!gV9egT zmR0P$1BkTQh^v>Ek~A(*K=c=j4a~YXs7TS^FY+6iV&uoCU@|b?%dKC1{J%^N(?VR7 z=v$B30f~;d4tDl`$$|XX8z71iMCXDV&+7O!i%8x~Qo6$&gaXrUi@pB!qP3&+rs8`j zoSf`tV+_A=Hb|A;__Tff@Mo%2DB2)Tp@Wh~=>?j<$dYo_P{s_ba_4_zxP{rj)Ba*~ z7SKM}27udl1X*~&TpNXh4>Yr#JIVlgX4`~p?vJZ=0UFGqQ&a=&Svko|lwcR}&d1%A zF;Qa~X3CE+Gb&QgFEg}Lmq1{G)D3ZvdhB~!_YTnzkW@80qWPwC2;ye1uy=+Hz`7>< zB5{ILJod$7_I~WruON;a%PS@o2y!?9KHje`cUchxxy>(x9Ueg&@|HB%40spSL8ULU~{6juv+e-Af4i7*9V zOL)ib9Tp*-C$)CXeW!04zRm0|1dsc&a^{gL(|17DxnE1P0UQ@GJBLzGw7V?V%r+f4@Cs1%CRXZsksS2b z1W7!RZuHHiNog4`kT<+TuqpBQe<1&}cRv2$%H|IbVU$?1pNIdJdp;$vp@`D|N|7Fc zPovZ$697J{_<1M&52S%3q+DllYB~Ug``u0lQ2TB|UXjm@f6Re~dEN<}W7K8*%aL6c z(2#B6YkNV7;3Av`xcRJOQ4F;DG{&Gi2xI`p3QvIGf%DB0hp~szGKGEs$+-;XtcGm8 zz7;PuW0}$7a;;Y5k9=wE#p~YDw{{>Bx#K%43>K$YtOBL<$?M~{{H`k>Qc#wgt93lBchCw9cA2W?>);%ArxK8CYc$DjBC%V zC}reYk*$RAE+J%v{GLa>Ki}VX_^U2=o!5Dt^E{8oepglAJDDXHp_7gdAVV?=39Y}0 zrOUmLc2^ZqB`Y_K9LP-$f4>tLpqMNEpR!$x4s43sE(dBj47J>tZsW-JhJvuI)6}C6 zw;R%W?Y_>VQ{B%hSlttG_>E*c!(f=1x0A%9MrS}Ft-~<-9jYjE*a;8!Ufb!S7Jp5V z&OS#+*O=%OsvMw{O4f&^NdEwH=D4AtmS#NP3?ZcD%#jmuA>Uqqm-1F(ag`scvmrOa ztAk}^#5h)5Pu?rmal@G*S1?223#b^AFW5S?XWxd6`+;bu1m`~2a<4D06zZ28-#HB` z@GDP`5*H3$qE>ZzQU}WKaVUe{ZQ~f11$dp#1XNfI9!+QouigfH?0KnKxW}9N%Lg;6=rq&|A|L^r26={1PVp9)A|Q@u+MnNtKV+3g~hPF zX)l3@l4XQ?)kK>b7NHVtaS)*P%!~_~V((5D;p>=Gu+|a(K<2Mr8OZJ)SO7^XyO}qq z4{r>CC!(d8u7i22TkkI+W-I@Dk&ycx8YRT8{LH`s&K;5Qy4c*^>^2Vu?ncy0v6g)nG3Sa5W}s?ZNN;+=KmQmC|16t(j01{=_puEEFYm-YHN@!uEz)_ zjzwII*%*p4t&UT8SPlB8>EDDpIG9RZ-vL!(?A}!D6BsAIy(1A?!7WmEb1SUmJX>xR zeC>~vnmIyBm$M{wz`UUjYTb$C2OER&4y6Q5zQ{jk?AUuZEDa#ORclHtu+k!Y56%W# zML@z? zqN-6mc1UtOq#B(_-tnffKrLUxC=~ZVDc}1q3COM&YD(oIjK@P#+ea z*HalU&6D;=xXb|(6M@s-P>f(XhC=D=`7$wC36}-NmyYyk4#@{H(i>s(-rtJ4H-ju= zUyq==6&(cirgSt(;uW}uU{7TsQ)*NhK5O%)>g<|<0?u`)`a7lc45Vwl^_qRP%;m&) zn3$H*Rai>zg$mTRfH`b+kdxiJB^o^(e(nRDMN5TZKUbg%RYv<>v&YgKqX0qDSD>6K za~iVOSJ`aAzEf^u@^>g31{dGJn3$hk_`5su45K5avBuzl`;RG`C!iH z20zsbW#cleknih4hy{NmvSifT_JCYV=q(?AYaM97OM8;0XDWj>)N4ZP^W~Gb0E2n} zo3yx&yC%*9WWkRE@3LX9q#uopEeL3<+}o}b`=;zMdo zi}G@=u&EJclHHruQNO9Z{@CA|xs@<=;+%O`Dg3Hrr?U(#!^I{x%40Jtc-NYKJzt9O zwF4RVDaS)9^pmjH9Y-g7#`fp;@=Y83N@|2Jq?y!jnN^nBc_kK@aeYT6veQ6fz@jKJ@5VJ#$3uKP5ilb=+eZt@L!Jc|b_g9Q-7xRjD5e9Ug`z3g($ji_VHA z>Iv%3Bb24$pFAf~>_KdxTKR9z_OF2I9`r{EKBd%n61-%OR^W-)8+h43(rl0!R1CX& z&`=(CZx==>gW-J|*cy5{l*ctr>ptc`bE#PSm{cdSu-$Z2RXBe7u=dHmTNk)1Oxz8H`=wMAdFmoRmBe1R1;|_-MwdGq` z`CVw2W@7des{+5L?cQuThn!wQcTK@+W3KrE=J4}OYQ`xvO3WNoh`OSaOxL=WxWd|GsJO^3%R++#E^>EtuTgFQVad+{u z?S?uz9AT=mep=keP$s-vUa4Jg0^uM*Vryx%Q6(6^;YB0lb6}Ezid;hLCIb4`u-@SJsfx`Waha+MM@|bWV3jOQcr^Ie#At z;F(su@Ffa797J}G&oT8{K9^k0v1z}4rT-ng2g`^=+ymcG2RYIzCwfleYqwHAS~=6z z(*!R42B;veNnPSLvNqJwGFKjaLd`+Tx6GoQ)9glbl9t?N=B(9kXu}zaib^mmuSqY$ zw%^>@;YeL|Jcvdx^BJJLFC>&#ptCEs1BI)%DS6P$QREt_jN=_x-!x!tG`$pK^(m`{ z;TU32;Xse_=NVruo|~h2ZDc_%f(`Vi4r;5!PCbrn6CkYvw?~0}7f^mYRH3IWDvSu^ zWJI|YhCWYnajZE_h!4~}!UF8ndvH4VJIK{;tCpa5g7A~FVvs9&TynXp{dOE-r4&Q3 z%S$*kP0Kf&BbPB{?xkbsTv1i80|KS>bLnjC^j)<)G^v_<6xkI1iEVPDl>$d;q59RO zG+uLN_G2T#Aywsb2ht08={>$59*T+zsf96@(0OT1Rv}=ZORGlvHY-G4PBY-b^qQr# zds?|XI@Cdp2}CQMDUx$lA|tC9E>gjVj#Dl^_KWuzP$?y2=9c_er3@ zZqJW4H~rZIR1tcp)yLQQ)CZYtS+|xd1VYmWG(Ey&3xrs z)pI#`&cS>N@?b1|X?+f59)^Yl_ki5uX-KroS=p*Q%&c%nx}u&=A|(;u9mXeSa8GNa zRis!7kH;F)8e}oV#41d&q_rTm*7_P8Qke})dPK_fauPHGTekv+p6VS6{j;r$X`jNo zvq6k_n=szd2TZqOx}lk{w7Mn1HvDt8$Q~seO{>ri7WXRa%c6wxoPG`?_OMBB>@v;p zqIHfyKrJ|Y5S;wZ2GuKtGtuXf^rrRQd1$*c0;t)3PhtU2 zyOQThyJ#049bP;4E8WxmWaP1-wQ^#dcJ{t?$Kj34tkyqcoOB8y?u2IQLf469syQ<4 z3vY%GP9!{WhL+=WsE8EE%H%MFgq?`hhk?*okmYv$OKY^Xd!**x3aD{gRC9(sWny1T zc>r|Ux-C|qZ<|u_DNdbmL!BMZZ`1;bO_1^)B8i1tBI*3}rp2ccta0CHjR+ zeg{AQi8+DSCHM#L00EK2H+^UX6*b6i-B7!S!3||b85>-%t5AN5&ZteR;jk;-k^t9F zq93YggsfVlegIb&1il5lPLN>ZdcB;`>B^&0fdW)^m%&HaBRE^2t`8xAW;v~I@32B4 zoZQS|_X;lH>Ai+d=SvzNd2+w1epF&|KS~=3hFiN!s6$FYG9>SW|4W>}qgdcx(Jyn! zcYd!mOfCS9+5$8WJYxNybr=5oFLgo~hkhYbM8Cj;JO9Bsv9?>JpN{3A3 zb~DTC;bpJ`e@O?ej78+$a6qt%Z4>}d6nGR7>jLqjL}R8#xFE;D@4KiN5(I)EJcG;R zY9HcXnX;Ru=C_!~QA_So#C4)*l+o(I4iTb&ITYQ8VQkHe zBC$sx8cfCGFWQ;Of%)VI*Vhw5-rp~-0U+B5UDO(@Z^p#z;ocQ?Y6RyGW&|;!8Bn~^ zZ`fij0Udyia2n|T=e|T^GcXmLGNy#li-QUM{4>v8B4&53a@`1I6hnMHAmOd*%n7;! znRnj=`h|!B-E!Rf=Ap0(FbXX9P9Vn}mC$N(89)^Uubch4)A2k05hCrLM`*R)H+zqQbhHt9 zJHsTZUN=gh6C8VVMerCjCd8w7`KDP|i0U7@FLgjXmatJ?K8l-k0$Y%AZ~!<6@Yp+) ze#g}(^M@bR3V+7&9o~!*$U+r;=9Z;h77ctsD0e9Y)Xxrs&IF}yIOWX#Nk8q{#w4eA z%8v2@1*i`IMU35^m+c1%$OY=!UD|)2+__JlyL6%*%=HAA48+#b()OtWy7^_~ z(=clVbNMi=g;a11jWjo6xWqCWN)lQeAs$-6T@ln?87P2vzM9v1nM#tY+`gAbCy(Zt zGu$r1TcoFXQTNlJO__#^CvbRy{$%@`9bUjKbAJA4@(a{KM5Gu~T`ao0&5s(^Qf-d! zz&uziM+?fx-*q#|Nkww+cr(aSSom8IxuVCd$o~q#mu5uy#!8f%jJV!PN-oc8AWa*A zJ7&BJVc|c3Nt!C$INXUM-0dI?Lcg{V4Cn>WGNABA0^G1K$>pKcAeUTfzI-@e6bOMS zUm$8B)~QC)7~!9!@hH*JJX(Pv*j!H`p(V^3G-7eF)>$vVK)WNgAMnMa6>?n1JUHveMG~`6tX1(LOI+lZ-k^>$3W6;;m@xBS21YPP^>u z2&NrFRW=((A{=O2j{j2ss@@eH^Df4a(0%7`9~U1ma;HCZmO@p(2={u4?jRMbOkI9O z&i^+;Dt(GiZ=w`;Nn+_ZEzTIF-Q_b8Ilf3j7wu%7Ex8yTOKyU{UC!|xy4dpx7JlXU z&51f0-(PCWjHtjmxVS{quQ8-(r}X3U${^C!8nV>G8MWGR`~Hsy)=y(6pb?!RZxkKa z5V1F1sOg1$kOIBqi!#Xa0Rcmv8vM!z7PRM4Jc;&R2kf~M(4Z;&nd~)tq$^^=e?-lBLgQr+ zaE($v&=We20QdCc?sfhl$u=}T`Rc%BMZ1Gj#Km!EMWW&K}?35 zPkqodtdRu%|A=R#Bt6wRNzSi)(0&7bUdQe;%AE_Kl@lN|W9*h@Cg5lmZ-HY7ov&g`4zDOqxn-x+*#zhQB$ic{~(_MUqD5=f%7JJ#U`oCZkO+f(7DkQz7ds3l%5|or|XKQYydfmM36T20BveX z1m?{}qGBo;QNc_PbICaJgtUcWivVHG2FNAPq?zP^E7LiS$e$x6GOFapSbfPHJ$9GT;2?=q5@daOO+g)O7O3hOd0H*5CE>nR3mCy9nh616TWVibu= zgmMh2zD$5jz&W`KalsBXuogc5yk#}D0bE};<*&A72>1y?{13@Itd7B_#VLrGY-HTT znzouKAj)oO5QG}jWQzXAvRsxXl{QePz32zCL^vS{n5&7Esxzq)@JJkMW^q`9y#jbi=bDVdq_$AjZIklF7d91}7H{j{#ut-6 zjHQr|+1l1X)8?;|AoI~S$TzHGHBlb!KakXdWv4A*&9%AWPVi?4%k|}8$9eS`kSmk( zmY3E60s@2KqWMwrf|6fODoXmEv?0IgC8}W;Lm5)GitEbnahYYF=D5VMTwhYJ)pva6r~yMKXYpmk!x<_KE0p?S zMtYW~g>$Jn6RwO~$&gA+U+0uJ1WF#Fdnl+RS<6?sG}N6%DRd~#dP!BjVR=>T zmARRekK$r?HoERwFt+$&J?1MzsB@47%HmmEOnE0NJa^M6U;wnvIHNfoyE%dS4$7d}reoCfQrgRU`lP;z!9^{9C5b^u zMAQ2y26WH^$Qxq+6lcwR;g*wBN|X0@fNqA|et41x1CM$A^=d4(lb-UGUK-NgI4!8G zjDo76U*z(?3MFN21~ z(y(W#QG!%&*oL98^b^v%K%vo@BKkaK3KdS5jY+Rgcjc*3x7<>t&%-jS5==X)1B@Ph zsQW883+}0N>F~PF9FPblH-D;)Mg@W1IyWFs3JETfC-adK7SGvU0|^HcqxR&$F)rM- z>^rtx_q@86)8PH9aL z4uY-xeW_THUqeoTZ={siWUhSS`cA-qFJj-hS z{B-f;`KDFr0QKn?hOn?sCVQYM^{;dzHq6B#7N--Oo!AhrT4d_C!TsC#Sng?SFHAM& z9Y$&f3J-~*VNh%w6$JW}(TYY-lRa)&GN6sGa0gLlBu=AMuELKw^`7B4V(qaxniUG% z^rwaGt6PoS?PV%_$i4Aa*f8rEJf~;oOU}raN!lKoHZ5lb66HLV0M>jHSZ1#dnf@e3|Y=RS&r9*5_aGS zU-qFtnpbYlBN=V>NcO=_wOk?G#h8wPCWhZaO8S+mU*W+2(EmWrt)#Q|T2jWC0;a^; zu+<21>DWsiA3@JTcSC8tk;R7GqusUHlEzLmQL$Uzu?ZF8LHgvbKL2UM@IzuWLSFPuv!!X_tNdm|Ce_`H5&8Njhfwu6*!P=??piwfN zOvD2O`)@REe}~h>qGUjKq>%H2WS0|P+ny%P z!$~NTC_mEXMDnCj0sv@wir1LQrRS1+hI9v;^LL9%W@?Eg z9||q~1*}F-)=dyJMa~9)c7$UCjt{RwNM z0AE-RLxy*-LnhjED=}PZ>@4XV;Tm|j|KcSeAN~i}A2qo=jLMY4U&dbpv-YL}5Hj^* zqv;zhHBL88B0*BBk^%!ed#qjgC3ymN@>jd1BDK7yyj^@F`;P>e!`6@+c+}#G65$)B zLLzeG-70v2d8Uy|Nu4NQgNn4b7}})bD^3?+B8fF@#eiulI$u$Mmfaf>?yJ5q+ zk>~aHKE(?yaNdf;k-5pXcPZtm4-1uk(z>F;H=ZM4ndsR`ZbEJidzwp~Y`^Fw?k?Ga z3=2#x=*k&xtif3$5Je$E*~%=8|Kbz3kmOhxyxipfBg(qVg1Kyrx(CUigg;nrmr&)r znyfxbcm-aX0>x}owYB;743 z?jHmN4;eLwcK`zFU+r*cb5{>aLvOle*?Q4}AmA{cgXh4rZ?Qd2|7e~x8(JL55S4P_ zc0`{pRzZ0GG528l%ddI?)g8PQbuozVj2urRkI=xe^uufC0HI+F)7qmna*32XE_fD! z-U?)lgb_Pq6^(@X3!#+>ts+yZhV8lFA5u)$F3ib z2xdh^bUz;0JG1PQ8^v|jIwkhST;jEe6;*onP32twWA01u8_Nls36RSoSJf=_luw1g zTy8aU!1GV}{NW&MUe|@mh4FdJ2bpO zioHjo!Vkqz<+#|@;PU$ddv|*1erlR4x-{3_8ol8`{Zu$|U`(HpqR=y`WlPP+iX6{31{$QMDSG2rt>h!PhBljv81J|3?h%(R`*v`4Qj!Gmhmq#$?uXC;w)!1LW74 zS?&94ZGjb3dykPZZ+JsuT9q)eN0VfiPr8q8 z&)WoZGuF(;0>H}00m~+)7%~z~-v2%i3QZT#83rOA?sh~b{!%~oP|KF=Bd$fiZY<&R zU(Aef8{4=ItEo94bK`)9gv@9<%e%ZjJaep5?6i|9#Pe11ZdqK&b_OW0p@ld*jLdq- zBn&c4K(D)ZB%`!WU<`F&#V5u+2D`*FhPSR}i^r0C9OT(`xxAsGozdej6mh1ETJOrE zq@tgJVZh39|GrN7_X@6r2w4JF=bCytAQ6F;<<~!+&xa^^^NK#5P+7F-GlIe9JcC0! zMw^0QCmZ6o{VNbD>yQngD|{}bVts(Hli3g4ah4Ev9-O@iy$uK?%_Xc1Dp7>${|12q zFT%3S0h>LHPUAzR;3X^5Y5iyg)CFIzsDm%PFq0c zf@l{bJ^fC>P0Yz^@|d1^avZA!SW>MK*AODHS3AsZ53Ck0gyQ0kr|`l z4PX2@^}Pk`x$732D1hGD+(0Ji2u6z)j)Z!->u@7Z2~r>wp9)!}q7J+_U~YNLd-Z!_ zt(6104(fY={)f{wX9i-C$rlnqnFDow@U&s2z=8K#o9zOtWzel5;8gmKr^LIgdlv7} zxXhV+0qP;5ar`5>`~c2k-iMRK(_j%~4h;sO#^m3A-6hnRfk8A{nC0t1E{>GH&3CWD zoSG?~ubFLTL+D)`X1^jYSPy|Df*+RuzLvfrdTVGOB_ z+PHhJFX7ge4bn!E<9vg6`&`(fIH>pbg^?R@t0!t=#OKB7-%$^esW+Wg{C0V>K}-`GNa6V5==Dggt7U($BDRY1bG z@L^^i#AHrqoZ9bFOuNfCsA30VUCN!M0P(W)jEgMBQwAU`<#$JeH~Dc0Vt9DBmnZTS z6`mIP;%4Vgn9rXkxMT8><`Ox;9?k#7?j!*Vsij1m!U2SBpO-NKR}Trz_pcUwq!=!B zHUQbOer`_>Ao=vy;c9d@+}+t#|1a~)b@zaz?gte5Go2gz(=oI7d)YOrUJ)MQ{n7yH z`6OqAIqT*0M9*BpG0oziiiUQ_2`wYb!4y%kRl51Kw$@Ac_XQ$uruvGUj~d??=7Zt+*FhqWD%LLxFE$eH&V#adx7&+lYo;O^YLFzn zo&7P2^O5JjB_;zTa{W=dsxUyw`@$< zOo-4EcnWv%+|=aD*gvfhWQ+p&>g6uqp;$Ob&-jB^;F!3RUKQ+rmLzK+NoxLF!(K)yxvUBVv*4Rwd{CM%x#)IFlopyn||0&G1<5wZo(%*fL;`qzqHt{Uu$ zq4gA3#=x#1x>mtn)iZmc(7Ab>qg~r_p;qaTE4Ze#Te+ zbH;Yn4jFO+fpwpMFZ0VRyZV}3A$K8vjIAr_7+QlA*PoPucq5&*Qf&p5rsD;=Ly?%D z4x5Y|VhrRxKDqZMFOZe7L_!19HOkL9_pY~@R{h;{Y2RlR3R3foA{5?<;~=NR2q-?^ zz@5$74{K~OkdxDZ>=UMQJTv5G?~zd@@cnq!Gzpae3u->mqluTRgDtCx=~9<)S3FF{7Aa48z8gsm~&i zq47bL-W#6>0wN`VYc&d}rw>_OJqMa_VMM?|cgg2d9Uh7rv5f+P*$!dL5cPcQau+Q0 z#rc7jxZ;d|r%Sfk)cV@$=>* zqQ5-F%x=@TO*>IqPiHcMr;0uF^m$eUTg0BBBX3;=&gR5rX8BJ_(s!|pNq$;RtUUFn zNX*<9@r{!Zg`v#}%9t|kN2L5d(0$-W6!!h1ux8vk^C6C!TnF!n_bKn?S|1n3US8}H zN++kk4eQz6gSD93Io_nN_b#_KdxFPH$9t<}Ixw2V-9zmIt28>sVmG;UV(DC=^ItL(}t z=0;)9t|i)=i%egkg-*YZZSXo+BiaTueg9<{^^os%l3XBP!MUMqy$I-4;tBd@CTo-S z=;z^pDv&D!`Shxv0M@9Z328}PHd{9??`3IO&-(P}FD%X^v2U8<5ayMRzI7~Ps+Y9bL3J10AUMoL8+9!`U%zZWk;}$t5m|H$6 zPR>YisRy-P-@i~SP~M=%uE|c$v)gBcM3X{CN1%c*UR?v_BbgH0&x@=3^Q^Fj;RtJE!wPZbfFV<^DcztN|ocf)r}-K{*jDY9oAW-0ZVjOV9f)1;L6J`caR!P>=JWsD^29tUu1 zi%F_|9vg%E6K+RRz7{wU$`JoOt^COIiKqek%`>yn1M4}O8Eyk{k26;vV_CBa5^rF< zjkH#nm*)diZ6oG7{Qna2bY%Dn{f#$fJG55>X!ft=+7Kuk@mt-3p+a9l0soMl);;Ao zf~=VdSaO;+p>qs4Yi!E%d!*$iR$e&=i8{lsD8kIB3TWF&H#+g|i6|2MH&($j_{sVv{6j}yXL(&X6OeW@QPLkDe>MteI6x7J1%yZ;ta;e_!e8cH1;=Tt0%(#2sAJ|jX z5G`URSvo_iQ5>AC1?g6MfpTvipzkRK zKawBQ9!)GR*z|yHKj>-;*_39_&$PE5(H4mUlr2L4D{(KlFFddmZ~2CWgs)^O7IiPG zDxZJPPBjiGjaxRTA1l9gJWY=;iO-N--sN&$;}Z3~Ov%B7&2Z{~i(iqyRG%&O?Hu^Q zYgd?)e4WqeaLNhn2M|@`$2{DeL%=ktDCrb_bGDl$@X% zIg@9XK?u_c9sP}zg0H#e4kh*q)Yf6u0xnU!6 zDCTKoy&P}zsrS+>2{sAl9!n9A*!$Iua>Vk^LRPa(4HxN zMY?#y#gx45b7dgkF>dwfVeKr4P$q)j=|mpCgfknYOGZwL(i6#VY`OR-I>-*8j{Dp0 z$m#)V-M+W7Nfp5wK^k}mPJ%rnJ~JtZAsFjJQ~pIa^7-d`#e~5n>ye8+-X*1bNcOZL zqMJ3ox+ft!qWZPnb7c}YkW&Q0{>QJQqIpQcnttH90{b2soXtiL82p=*&vUYTc~Xk^ zm%8m2MM_Jktuk@W4WH4YkrS&t4XP0uWO7`LxhQVyiq!;;x@pBBn5> zCnLle>Xb0zl~;;Wt;cMWX$FqJK2mT@+fae8UM~%oJ7L+75fDbHI&Db&6xYHQW+{D7 z^1$-Q`7Eyy*dF&%Nam_$goKS@_t@}L=LPtl4>T+P@WrDH#P&^>o_{_j!Q0xl?Ph6m#wTicF6iSKvv$%Uwk?gbV7A=zd9dY0jOL#?y1vsd_7 zi{_jd_eZeTU88M%PAPd=QiX8gYbebrpGkM#8Q1n{y$<2p>hiSqfeE$x(%r%4qu8-q z>f*k1jO^u1fxPitwPyn9x288_z*aOrDnVYZ1}-<3#xbuh=gKz1^t9 z952P(&+!Jcb_bDwOmvR>bats8 zDngq*Q;wM6@RVl^&Xk_Uq}siIXAd-lGe#pfYx1|3Or1<=tTR5>N%p!DiQ2s;XS*Hc z92GI1u9rvh4-89dU22^am1SElDY>ity1DKFiYq)wNpX_+X3lHdrPjyEQPB2B+Qw`G zbL3nni!V7zc>?*RQ?pMzuDPW09WT?EuU@eH3_*!V?B9OOy=a}Nf zi^}D2U}QMdhw--4Z-pD-M0;8Dme~-)-%M789Baug~C7oB1iIM+FjrjzLP@X%tdh`!ArH>CEL~a8DrFU4I-*dC- zM9XmJYmCNi+wa|CMxJl;h5Cm1XQM+y1I1b7a9<44VYS}+SZ9iW+9m47k_gE-#)HbZ zmc@r5SU>mVFE^PE3SRTRF@A23x7X6xeZGu3zG1D$OY6`1Do=dyS@d@J$8&CgS0<+a zMUuh(jgbo4!6HayH z#^nR?BCPhA^*UcNt=vgDW&^JNa)hAi9HLRq45^n}*Nvm(GZ4?R=KP*c`yGJ5kXXkd zh7_#t;#PubQOV?n*2m^$Zap>k7~Fm`rTi-aRrrm`!G3(=kpJ_aRp>mubs_L3xxLec z0I_|p6!$0^>?RXQU&C$6_`P!9Wws|-jhGauG$xwiKO6IROo4Vo&d4!OF z2Nq;KIsdq+5D0BShOyNq3h8%BEX}?(zdE|pV^44McRjvX^@fevS%+y;YCpNzL9HI~ zAj(pKMn=AGcH7F#^22Av@x^$)LZt5j(C>J!51AK$;RgW^(;pSz`k+W``}L~M7b_^D zdm`2a@v7l?+0ip2DYxXXdNwq5!n((2qs5cOXtuzILpL$1kdIy&cW9fSRjnQ4Z-GnI zwm#j4cIeY$%@gT*QQ2Sd4LNPYO@Jd~XIXElKBX>xmHt?t2lkY( zix3>wb!+F$ou6?8t6toHq%ui;G`68?U{7)bU;IN;wR1$}81twVoLF%EWpgjn34Kd}Ex#UBz5i(#sSUFG!8 zg)vzp2x1zClRXu<&$JtJz_a_1Qw+QafoB>Y^PzYfD52f30=Q+IBv9}G+N~o%>n8-e z;;0yU@1%b_Ig{!DEjkA`m^3t+A>$d;1ilV-ChjxH&Pc4~51_|X4f}c!LM8ScjN!f3 znKDv!2=!_}&1&ABAYAS4aCp{fYkkqYCsU=`{lgJoz(~g-EP(+MBr5QY{6 z9?esoxzgyMAOV8)%gC7kLw5PY~sO#TcREMM$_-C&8I6xo)`ylFct7+ zqCjb=XoaJo8yEzh0Fti@4LhCNxIuJo_69`-Qk;%T9x^lzvMA@@f>DO1*i+`R9Qn=p zWt~iSEwd7BLeg2>-_pEIfnKL^s27RGU_Hr&``h*=<_7&(N(cK;L+%e~Vu`KL-GW_y z;&8V16V=9tuuGQa^X0Hx9Me`#h>OvQ{<`r)JEnjWfZ1ONU#*%yIG)6_dX(=33at?W z=x(|*C19=rc&cWSc{o5F);0FPA?c}GJQB-3|2u1#E*h#Doa`4P+(Er#h$r{~1ttEF z_#m4A>WzVpIx1+uH`uZV*`O!<&+P@K#KYRSy+S7hxO>q~*LVX+@*NUIz_|cm$<|#D z(zLHiK5q_q1pS|q0;R1St}lgb6Oz)7*(9(dkBP;KM#tb+XyOz8y4?_hfT`%WUl#}7 z1Es?~K+YFPKZ%g|acD??XMDZ_xnz6?>j&ZDXQUXckN@Yw^mRyyX+-52XlwI|KWJSE zj5vOwdE@iat}g`gE^xo?F+b`>s0DdreG{cOjf0ENVKCOn(=vNhe7!0X7~PYovpfAt zyx=%2CE-NHoJY7=kH%7~`;1pj;efih0Z>e_fjI+l3pAI3h!SxEKz4Br=*GvjH!1s< z!Gj`U(~&Z69PX9~`nXu<#l*Nxm>5*3r{0j497kT4f2W^yT>JMgzH0`NfGGIpFMS99 z9kj4a_{N2_WVbz9&EFMGiK$FE;O1jy5ov$`c>a<4y5Lc#68jFZNTp#-t5CzMZ+2WO}4EeMARMBGfODQ3JC zbzS96B3W+fCYw)sK!l4msFE-s;4_ty4W0do>0796R#uHJ4}fYrnjJ|wW^ z*cI^OZrWf+J<1JSH#%nDtX=qY%;ue&sQ&z<>($QfPfyy_+!i`mmws;l265%FKGeR_ za_X7yyBz`b#a#ACI!eQvs`v3%|BHGPA4en5l1z0eq-Hm)hc=uX zxDzwLKPzYYBjD)cA5UasFk7-a%d*^9zx6k)g$+yRgLS=%tUpeE`r;)8t03V;-PGw7 z&IWf->&MOfc*q%%!l4!m{7AQ$bh@mPHBqaB|&PUlcDp;t}bs2C+v`_8W zCVqXIKTfpjI$*lq_^s)TpKI{Zf+XcfIZ=9#AO4Ij@_GKNC%zv0Y@pz}M_#Fjn`z9Y zJ2C%6U+g8X(h*G=-28g&t^uh={hBh%J1*S3?IBAJ?Z)@Ne-~y;O`YQYCALTKwMgDc zQ+SOyiOM_oo^-fB8aT~=xM8K>My!3wTY{RfILKvmczS5*W+rB2cf7n#Fa?`N-rbrc z{`~TZ)TsRT9nr+MSDfyAoHfxc6t#{Uj9MLqFJSKN=L#Uhdm)&tM@q;GBQY<2AzHBKtYEJc6IGu?LB@UdjSt4l(n zXCl8ll&$2WTG4~ixmCU!uy}2Ytn{|Fy;7q@ZK5ljxZtbcpS3xAXJDiIfef~j@d~yz z@n)po&kW*`YZjlKZ`>IX(SK|woYv=mXX)dD-|Zq>Pcd3)5%txqb1!rA3)`)9c}VF# z(c+d~T8jvdbEP}-xG{X3?s!5x-}drlv%BwAT2Iss`VE$$(`(BwZ5{YbcZRzBD!1C* zU1u(gRVVKmRFkujo6WuE>#%WQZKa0mpyGr^RwKvQBs8JkPJQ05jJeaqZ=z}Op)7SJ zpY{S>nMjEzv>#)Oe5Uw`M=YP|lN#$}Pp=T~INYE)J6^o99bXkIkth)t6+6JurQ5kL z`Ktp_N8deZ=3K9*EA&Lf2EX(9vf07cjenyo-`+)+cv3BsIQwWuq}MUHSU)sr=G3@^ z@QpQ>YP|-25)uJv4i*xUi_BKT|6IKGwnLZ4TZH@ZU=($9*-Kh&j+pZuSX}=}8)?gc z%N#m7Jky-L8o@G&_u9@HcYbUeenWP8#)I42>d`4WNB!u*^>^3&v;s7(=xr@J=h;uj zc-CGUKknAbZY@2Ynzd{p*rk$n;O>w6tdI4Qa$kI4=T9tEK9#kM7ZF zocx+)I?EK-JmrF=gH`p
ACVL^E7ERA5-*zpC=meN$wJpyBQ0t`h@#SbRt7D_$v zYWOnN+O;!%uAy3Le`03)2lLsGCbfH0wjuZ`X@ze27Af8EPoF-eXntRP(cr6pCFyBw z+7$EHx}uklU{cyLsM7_yv}Kk?XI8w&Hb3e5LO9?V)@y!CF+{96hjIPbR#>oqq6KmG z$?#|lXyDvQ#;ffQ~UU92g zV6RSBig0!?bDt0$eokv{Mt4hiIxfdIPCuhrUf33}TOpbiH zEk$7`ImWN;ka$s5KqdLz056+(j8QnKK=OSS!t=$Q4#~J?Fn`QZ^H=E9s9k%xVC?&u zjP<1K+Nbs@Nfi>ulrL?WdZhYtrdb5qZQ2_)rL=CQw22}h{}Gpv(Bqk0T6P*NPWPUm zOViU)nm^Ti9Q1|O&eju7?m-4+;=*IMc_>7W-D;%ZeY|$nuaLa*ZG~FA^>a(day=UFlqzDODE0zAd=kQ5W(ScL1hkpIcmNjlP`gFj{bZV^Q zrisZ|vVT1NV=t4knsTp&w-#H4BqYT*gI!2qK{RSqAyws4t!PWc=~u`9!w{^uq1?T` z6yIPpp_1jD^!2&y`hD4xG1BTkTCpE&WtJ0{s?}t;zj5VT(VO>^+bo^pU!%265_>q; z{cE{AD3AOJG(yJ1Z8+n2c3IjO z#1Ym^{KB&ttlM@Qw?+3PUSuxN6e#k_`k17VI3v_G^!BZMum(ki{-PJr@KdReOqcJM z>aL$=oL!o!4dgXa3tm?_=t8~5Dja@CC~}z3&h|R26{UpBZoQEecro^S^#yV5#kq~` z)`csd&QZjBQ7tbweBOKJWYGvGEA-HwN}x`b7}U!jqbDJW6QrRcAu&5q8YBWPSz0m- zmphXjbVe!)McSEO%Rg2N7Mn?$QY*5NHk(lHpRBn0h0BjyUZIngNZFV_)PB=Q?#jf~ z&hM&b%4)5N_Nd2fB-=ZwY<6k6Je4ATMuLV{_S&;+GHxkpr(Sc)n#y&^{+^W8xw{d6 zSDz5^gGAQgQ?z?+krQM{x zwdBp^bHep4&5CVdj8IIMHLY`DuM35;*GG|;akH(PoaVyXcIsh7c{3gJ={q$|r>|?) z3%c^n@`_#>;Olw~^|o;#-Gadz?bJ=5uHcsxx{@(My$=$9wz(${2=U7;Z_P+{9K&yK zZ(qo8`To1i#>3JivhJ>ENxbOr((UQ7cZm+}6%Kubr6g{*X^+7tbz@0`@$cH|>b=Ik zcq!|th52lO6k^%Sv3g;Dc^_(m zO^sB`QRrM#z4&gx=A%Wwpv{~ahn9?B-H*m4kB(nmLo&P+LBW!d-XAgun^3;5WHi9| z(pzTbCY`DnYO8tkHdS`BOqPF^^U3n)vtU=rF<9^9bo&K)7^i%rDpEI&(UFZo(WKMm z*(~}^*6X8pw|s4WSNokzS7i9`u9%$IA~h|{{T=lqOf9qxH~E}dtM2l><#kn5KVIn9 z!p`r7TD3ZBYfDmNld*i|jyvK-N&(iNdaKnxJjo;P&);evt0;0`TdMt_wlXzi=S#I_ zUAS<7ghZ$Ce%DZX{XUq(aiz4vnv+m}3l4M)za%^DKLv87)3%=0#+LQjs1V_%;*tNj z7;#&`G+rZ0B^OVC*42!y>W?3{rq7+x)}+9qJ}e4uR#;`*@crPcTjox<&dxn-W$_gi zdRG`#HPp_wStjPRF?HmRhvgq@_kIhXd#AAS#+b>8D_>O4wtdlD_UN$Es+Z}-QD}I{ zEE#bneF>{1&+sw+y`4|p&ehK=yYX&mX4zLc0xj}=U?@G(vt(Ji& z&qBzPpM3mo!`;aJY|6V*1Q$2+-92s7DDf)se5=vSrds>f=l!yJIyc1~>B>xmO9W!v z6R%#mcjX{)Eg`sCt!=7Wz0POw&5254;*IDTar5t>of)dO5m`u6)0<9y-7orUDVgWD zwShpFZ~cS$`FUCvxZG>;uj0Qp)hX(eCFRz%Iws}}_x6^#JAX6!^1VS#6^SCbS>|Pe z2bJ-}J8X3RUe~OD%@^K&IV-s6dTshwJq-TTw_gQ2jtnFuuadb>a&cJ&@M_yZExuw) zm5(pY-y!04tq6QE*Mo7j`_t5zxEas7I$Wd0(W;2J#uZ25B3Rd7?N>GP`m~&?n^jFE z{j+Q{Wk+4X5p2dO63R z#I^P1&ei1hfdzFABOX#w=~#-__{`Qc)36yUo>H6Txf^%y=s!t#SM}0sDJIRReql>( zLv%xuE7J?$rFaovm-&~9_O-`C z)kOogl}k5lOAEY?ukp;(a&K8D35{KTX3^>8xNY1X0Tpndg^z7IaT9nRHW%lZz8xwh6{bB4V2?>h?s?%OcaW`5YvgR3Q5N$kNu0}l- zmY-kKe!fK~m_pw!5Fm0Hi@fU1tGppId~P&TShH z`_3ZAXIX1|Nl4hyI@hk9EnlVh+t#YHfp`RJbh8nFjL-9K7ERTZMQ5I| zm20JNlaokXqyoo>Z`W_v8iIUCH};e$RPm?&RPhF7HtNU*=+_A$X8l>uFVRXmteb-^ zst?IX?#0UC$w)}-IEATfW`H^^0EdEvZx_Zku6lsOaco&HOkr;7ad?AawqBQ4-;aZc ztq&BEMjzo~7P9Vv7r2C8fP|qhW&URNw~iZr?tNoprXPOO zalQFm;R5UdOT2%^W|prFlz1PY)FZhmg1$2P8CKhU3LNFc>2}|l%kk8U>mLoq;C-IP z{FyT4x8@8M55b=pKSY%d{B+x(i->;86EXYY|2&d(*TGP6MH(JDzxxpSr8Cc;cjozw zwsrK9B_q=u5}hIwV^dsb5c)8G2gdew zfqq%m?h~{B`xEbp+Z$h@fETp=^G!}Vo`N}X5q-ae?Bu{}#QFe>{B{Q(yZJvpxLpQxPY?Xp?0MRedq~-|PDNYc9{s^W67) zzhAHSeLs1wZxSgIB(KvAv>m+=@~KUmk+J>vH{jEPnHnW&Wg<)XLRwfMMtZ{~OnGmA zftRRc=c3>IG>hj8wxi%V4s+9;7dQ1@cM`SxsUme+Qr}rA2A!*Z4WlG;Pvpm zM!n{5bX5v@gEQ`6Xj@s3 zpO(J9gW)mj_1a!47d^owdglPWIh4?rPi_!a<*REfGiBOdNm=&nxr=+Af@Y_g_KL$t!?c2An^*9J! z1&=*E)+N}}$0v`pr9v^WaNjA?T&MXf#-Hd(vPdf&1^4tWd~IT6VSw2Uv)g9321Z8I zqyu2^vTL#HqH(|P-nsLL7)kGO*Lb@Bb}xfWRZvKR9J+hA$U&)rp`jhT)2B}_)0lm( zjq@c&FhsNs3{-32ODcJn1@>^O)kmbe@s$$isW;8r$%^Fjfr0c9lp4$kE2Ublp8Lo{P#u-9(fsJ#NN z?zCL+E^`YD>+VkUBgG5;M2}UxMMggi-#w^{$XmG85f+Sq`Sc|uOh=N$MVv_>{9qy0&fyppuj(Gp(W;5Mbt_Ja-v1k1c0+M+P@z9rI6h9x+qG-gX3m=_gv?(8*tQ)Sn=aHta5ueOqN<_?sYyxZ z60UgF{x7|I^iedW9>yjtr`BR`L2X#)*63hNiin87Vd{HZhedtePhY;cYiVdGa0-cI zWv@PbNGK43B%(1J|6)g|HmBOJ4GRk+E2wIa6VB{|YlkV{2n$h0Zc`>+4FE~XwU5AJ zIvF0)Q}hb(_m7W{OVajMZ3;gCC44Qnq0v~}kBH_DV zy?Vuh=FF#azb$!zvMI0`tUdFL8vuh>AG%^+c-DFrr7&d-33q;JMjaR)R^{P*Al9kz zWG`e(`x4E-yUr?i1C>ktmpZDc5&*J8P+{%X4;U5{hyeSHPK8LPVgFn{^@YI=_5v z5-(D;N3pEe|E73xSu{+m0juJ&mg0O`T3W=~HE29fV6J*g819hI?Q*cu5ijGrFi8ynOjG`BznWCDG`n z__awB#-~V~lGWAKim%|wDq7fp9}0i`h%4ZT>&Ot+>^y0M-60?Ida9x+^|U2rb@$u1 zM{wRj5x@QRTiow3)4Vuj)|@+c)4*gQ{Qc@fvNR@ju0eq@?>NyEN?es~;B=np$@m^; z?-N~WIEqCA68kyqi7tO<b*{DlC@f9Zmf zjoo=M*`dEWIy^Bu#hat(ar5k{Q}@N$HF(kJsYE7o7YgtyI1~n{`!xZ1RbXZ)etHuE z(1+c*h5x=YxdV%yA*SP{fORr5tleJi?%4fyVV4)F9(4&0E)j=w!mEtefqeh?;}3IW z*Grg4^Rh?8WS5+f=D&Ea2n*FTJ2XXEsW&G$;_NKk%5I$_)6>(exw$zCq#sW$^c|xF zWb~^^_iG^p;(Xo^9emOtjPy(O%`QCG9TPkC#oEa1^ zJ#ih{ee)MczxHmcAZ8C?A<+|jFJB-@)4*(ME0*>=f!_l0;Cxh;MZN??qIY+H4bcK- z`Rpc||FiubRI~Tg-usMCjUl$kL_Y0D^Xs#qo>qJvqCKz`rI0i^Y}}5CBD}6HHO|w) zUJ|>Ej;=UY{FlGkK%z6tI>6R}w!Z<>lR*t8Znn0zuyZw?k~9O8D8QBnes?AVsur&~ zjwFMqM#Eo~_et|XDVVJs#-7s(6$T5P28_8HFXlzP0@BzRw$j#o4$~0;_xvRN@#Doh zx|fgy8?o}g@c_z8R1wqKkNP!etp=xCT4MoLAJx{>*~G*|)`q!n*XaX1*fJz0E4M`+ ztsQ%1YhZJjIhy@==FeJ_cUOeh)@Fad`YjySY-)-ktkpdLN4c=cy?zAN{hSop<6R(o zXq{YA3Z|-3DM_>_4jSvjqjGb+28HxIQ)y{wP_r{m0w>Aw$9JOIX$W33nORX`APuVJ z2r-Wn-inT<9o#4?F`lJW(_6;w={bpX_~9x_gdA0kS(*qv?|06S;JV>IC;g!r#HMoc zrJBIhRds&87B)ncl)&~gR_ff3$HE6Hwm2`O=m0Q#nYC;462bdEU}0`Bt}Z3(o;lUb zxjV1|cvV}po6aA)18lt`X4=e-Cq*H`j0>&Cj>(kzZHSY$o}H{U%z@Y$FG4TCV;Z+a zI!Zf0H9H{HJw7;=Iv*-TEF&pGV3o=WA1_FaA1J;s64GTK2IR<-k*7>lwlJ4r$)prp zl3FKR`+!Hv8V+g>X`NsN?cYPARa8{q`3jY$kat%@ z&cN2dy2&C%5rk#Ka7LFJFR-K~nFWnP6#{`^`-Gd|7j5=lf7oR7e!_$uuOy#SSy@Ty zH{XwBiHe}K%gZGse)ZA(#?1{cL?WvfBG|cOIG{+GH})NlL#eDd_2|Pw@}?vIkVD}p zbg9n_%{mS|R&NV$G<3FeWY)1bB`OtO4q1?Rs|5N_GS$MqnsJjlf20eD&A~RrzzcBx z^>IYzW{!kUlnQO-E(93>OzL&VTMf^r7?3vCK%GtVfFX?`19*E8AFr`^3unj)ppw$A z*9q!xBYm@-lJc)Dhb!O`^Z zoq*!n0=cIrFjUv8oaW}T02dGG9jWW0C(tAOB1W|~&QI26)*6gICI9*jjyB1NhHYr0 zzHs)94X`4nET_3qy3kgWKq%I%Q9Qp4jPor6srt)K#w!6-2C+uewn`H=^t7gvx&$^w zEQCZ+^z9>$16wz5+f_O|a(45r^4=ZZ85<_pwekM+`5%_^$I)VTZcvC`{UGEv{=;8W zxixczrS8{g>65 zS<}3TSsv^0Ju%!# zfjOnGN{cG(81mBF(v#nZww9~#qV(E(Bzg-=;K)i=PfriNZVp8Itjra02rM*Mq3y7J zQ(VY&ORA$7aZ|(u>i-zk--Q?C_l+W$Z}zo6WnL>c=uJ~bSyI3nv14npABh77qX5-|(e>9W4=Yg;w{ixxlsCI6yFCi) zS?2U)#F`5&*5#Uc$H#5dimsmJftdikeKSj>-`jXd( zMa)197R9jtePOtT!8~C6r4_c;s5K)K9Z_6Sh0Ck`jEA`kOm1s z_c9jU(xzhO(z{)AwajOMRe6FoE>3qgh7Nf+Wg1LYNkSaXum2uURO%2mq6-X6+{X~0 z_bExgw;nLpgM&L+s5yrF_uno;=qh2&HKvdghoHg0R9KKYo7LXD($U-78y69o7a_Eb zu;x29CFntGYhZXs>iksdh{h`V(g`raOc?TYS;~R1m!w^lA=&C6FT0PJ$%#Pn z<(hwK6hew=aRr3z0ER?cDhh2}WykM-CVR-i@dpn2{1q`Dit&`dpeVhszneZTy3GJaH{D`AnJj_JwI@628B@6?Lt15RC!RlPc_$5tQ}CvdKXqaB;veLRMF?X(3-Dux{{P+4~!odG}BB;)@_8D9zJP;&FT*f4`}T>>ET-`U(9IGcd>0e`EEwqQV;w*^<@-yTQ35atD8K**@L z6=_jcw097}XaZ$HtSRm3YF_9t7oX2Kffx}L53UmGMZa2Bz? ze*HQ+9UUFr8UuxhGFAjBGr56dcMINZ_>s1RJHf(dD4Mj`;48EC3whpI(}5iQqMPQCFC2scEDGJqJusQipl+XhH= z^s(MiO{^_r)JsWYo$1Wq6Ymhz`_s7IY(F&rF2W1-mf?Ac)^{SEa>Iq04RN1Pi;UoT zQMqUFo3piur|un#LR}MF)L9mmQWO9?JZ!u}Kd*0i_&(!t%84-Gy}Ld?bTIyeiiz?e zPwz}e$ffk!QE$)Avf^nJA&0dMW!cUO^^+NNn+YI1gv`FK=;)CM+fCu@ly~R@^U99C-9_7C(3W$uHTb|(10~`) zif#@zZm#$oM*ivWtota6{ti);^DDjZGR}7@At7Psa&1YJV*m*R!nW)z0?&aO|AtvE zvnT+_BWq}o9#vJApXfs=EHX4|%o{wF>>Od-oI<*Ay_N5e@`pp+58nsm%W}){=EqYcV8FWhb8ZgnuHG zBY6r?E89RVrpOTrh4F>g8!c@PK;dhcJ>sBEfW*|1)!wvUsWboDC^Y?%8P}3B%&Ya` ztifEYDyb_BwM1x5{fWj`&?ZgPhm5o1E+yp7fr-N0uB@S z0+U(rlqb-37lT1{s;{cYU)+L|=yR4je`kPSh$^!o5tQPX7ouIR$JI!556&ur zK<9u4F&7oVK7R@zn_n;#|9qZ#HK@Z%7nGpaoGa$4R%y6s(IR2_z^NiXG%ignGWOt? zYGmBKa9fIw+aNFDnv3&cgx|z~LJGu#^C>@}9yuU{0H@#lDk>&MuliS4_X${ub&u)+ z7It3sE=bAfoq3I7nc~(TKYk?KxV-K|i#SsRJO+a)0%9W(leW2F#$CthZ^cPAi?0RV_k%}T)psru^J7pyMrE4Y&nOE5$ZMC$0;giDr`mNxh+ znfSRvUCBwohB&^hYR5hKBA^_|3KF`pXdsQ(P;oNI8Wzfd+v<1lakViLBf`kgeD%G_sF6 zlT0}QEW4=d2=F#cR!&Y1EXR_@o;p7|BSz}%LT@Q7gmYJ>fmRop!2nFZJ3HlTo*uI> zH$M&=_Fajkx_kDJ5GTLW4}(AZ6qUD0tK{FOz|I1)=SCGb5W?* z=?g=U!Sn5M5VZ02>lsm~auSLbz&Jv~%63?_AQJDavvXSe3(8#)`a}+r#IwtYTubxB z@5E~Wa^G$u+hFXm=dd}lZ)yOhhi4Y|?fLdV<0RcUUFRW2GCzD6iOHx9S-+7zSYi>* zm!Kv=yPuJfF}fj2X#+U+vzr*OaOq5XPd;C*|5eqoSPbm|+V(INuCKm=p6guYQtrfW zLjbO>?g_(RZz{Jia6Uoam>P@;KSlXm?_W_O5V+lSbm?ldQ{U1pk`7TDt|)=Rj1fCW zT}=PhD#NEZwqxYRi;M4CTC7ldtSJI`ZTcvD<0^*lEHUxGaPBKQ5>Le0AgX?8sBM;* z^SlEw-efhYRkY7M#{@8v9?G(1~E{4SYi?t0jgX&aNP~6e!UNGgo^3jXqCx}pt z7Gr{2gf%Oy-v2Kf@*rCrP4+1U*WRyVahTjy##>x2D}m%FruqEo9B`R{cwPZun?N8&;Y9+hlp#3>6ngl7NdJ) zAZnSam)a3HeLkxxFtIznjiChQu3{tyP{byXT!dN0;HK}!Y;PIkMjSijU)xfxjC z%!?OAF|j;fpg3qzSr23(N%0tQhw@JLx{Hyzfnx=EraZGp9>m zU%x(p(HxL!>uN;{V_UsqR00l+;E&p^PBoteX$_bn!8B|(7r<$Wrk*$_8Chmx+e-~6xcin`O61)zR^L91kN8eQX)dvN&^8Wzry&o zclk={%G>V~&7a*=9jmC|9BW@cQIMGz6GmZt+4_I4K+I}%odN)1} zmcCl?gG3K%Lvb+r9cM%~v`In;{vuKcw2KX*gTXS2EtnYBM?A5p9qc0r8VyAzsKr7cP7t_#&pX`6c8K;%7kiIT%Goojq{A{%pN_)2)Hy7F54EVADAj1ZjvO z&W0Lax#Eve^W*)6guByE0gd7}muXL+WSL9S|{gy9Zj>RKCtoNeP>JkDYa5jwJF;6WhL$JCCT2dq%N}{`bHb;Zp zedx8!x^)@TGcy3g!%QBjS%nhjcZlXN24O7D!#^nvYS5t?1T44sAXe9k0*e-wVd*aH zi9>E!yM*ZG7v$}#%5s`3=xdAK|NT!1gll{^t?lm(AkSl10ikjm!q!=6`#>SBM{yN@ zw7mD9qP6SSV>(wOAu<$z6k+2^!r)L*pt_Ltp|F}zBldP=N+ z8TWgF2z|s9$<0pZXUOM>ePsKd=5o2#aL{{3yV9lX6NRj~!50lCop4IV(WVIdA}}QP{j|{<`jY=^>&qsv3$|b!A-y`v!;QQtyKx(kEv37EDnq3DHTm2_0#eAq*M^#o& zgX$~$y5L1+A|l-!U(|`l5v57d{P>hMrZjz9VL-zas5iv)ZKbmxP*@)pG=Vb t!r#A*Wbo;K($e%Fy^sGpcVn^U45g|APj+rQz(7+uxc{(8j Date: Sun, 20 Oct 2024 08:45:51 -0400 Subject: [PATCH 13/13] Add demo video link --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 802f5d61..10474170 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Serene Hook +Demo video : [Serene Hook Demo](https://www.youtube.com/watch?v=-t8BABsqj3o) + ## What the hook does ### Abstract