Skip to content

Commit

Permalink
Lagoon deposit flow (#264)
Browse files Browse the repository at this point in the history
- Add `lagoon.analysis` module to track deposits and redemptions to Lagoon vault
  • Loading branch information
miohtama authored Jan 4, 2025
1 parent b876bad commit 5d242c4
Show file tree
Hide file tree
Showing 7 changed files with 384 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/source/api/lagoon/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ Lagoon protocol vaults integration.

eth_defi.lagoon.vault
eth_defi.lagoon.deployment
eth_defi.lagoon.analysis
114 changes: 114 additions & 0 deletions eth_defi/lagoon/analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Analyse Lagoon protocol deposits and redemptions.
- To track our treasury balance
- Find Lagoon events here https://github.com/hopperlabsxyz/lagoon-v0/blob/b790b1c1fbb51a101b0c78a4bb20e8700abed054/src/vault/primitives/Events.sol
"""
import datetime
from dataclasses import dataclass
from decimal import Decimal

from hexbytes import HexBytes
from web3._utils.events import EventLogErrorFlags

from eth_defi.lagoon.vault import LagoonVault
from eth_defi.timestamp import get_block_timestamp
from eth_defi.token import TokenDetails


@dataclass(slots=True, frozen=True)
class LagoonSettlementEvent:
"""Capture Lagoon vault flow when it is settled.
- Use to adjust vault treasury balances for internal accounting
- We do not capture individual users
"""

#: Chain we checked
chain_id: int

#: settleDeposit() transaction by the asset managre
tx_hash: HexBytes

#: When the settlement was done
block_number: int

#: When the settlement was done
timestamp: datetime.datetime

#: Vault address
vault: LagoonVault

#: How much new underlying was added to the vault
deposited: Decimal

#: How much was redeemed successfully
redeemed: Decimal

#: Shares added for new investor
shares_minted: Decimal

#: Shares burned for redemptions
shares_burned: Decimal

@property
def underlying(self) -> TokenDetails:
"""Get USDC."""
return self.vault.underlying_token

@property
def share_token(self) -> TokenDetails:
"""Get USDC."""
return self.vault.share_token

def get_serialiable_diagnostics_data(self) -> dict:
"""JSON serialisable diagnostics data for logging"""
return {
"chain_id": self.chain_id,
"block_number": self.block_number,
"timestamp": self.timestamp,
"tx_hash": self.tx_hash.hex(),
"vault": self.vault.vault_address,
"underlying": self.underlying.address,
"share_token": self.share_token.address,
"deposited": self.deposited,
"redeemed": self.redeemed,
"shares_minted": self.shares_minted,
"shares_burned": self.shares_minted,
}

def analyse_vault_flow_in_settlement(
vault: LagoonVault,
tx_hash: HexBytes,
) -> LagoonSettlementEvent:
"""Extract deposit and redeem events from a settlement transaction"""
web3 = vault.web3
receipt = web3.eth.get_transaction_receipt(tx_hash)
assert receipt is not None, f"Cannot find tx: {tx_hash}"
assert isinstance(tx_hash, HexBytes), f"Got {tx_hash}"

deposits = vault.vault_contract.events.SettleDeposit().process_receipt(receipt, errors=EventLogErrorFlags.Discard)
redeems = vault.vault_contract.events.SettleRedeem().process_receipt(receipt, errors=EventLogErrorFlags.Discard)

assert len(deposits) == 1, f"Does not look like settleDeposit() tx: {tx_hash.hex()}"

new_deposited_raw = sum(log["args"]["assetsDeposited"] for log in deposits)
new_minted_raw = sum(log["args"]["sharesMinted"] for log in deposits)

new_redeem_raw = sum(log["args"]["assetsWithdrawed"] for log in redeems)
new_burned_raw = sum(log["args"]["sharesBurned"] for log in redeems)

block_number = receipt["blockNumber"]
timestamp = get_block_timestamp(web3, block_number)

return LagoonSettlementEvent(
chain_id=vault.chain_id,
tx_hash=tx_hash,
block_number=block_number,
timestamp=timestamp,
vault=vault,
deposited=vault.underlying_token.convert_to_decimals(new_deposited_raw),
redeemed=vault.underlying_token.convert_to_decimals(new_redeem_raw),
shares_minted=vault.share_token.convert_to_decimals(new_minted_raw),
shares_burned=vault.share_token.convert_to_decimals(new_burned_raw),
)
16 changes: 12 additions & 4 deletions eth_defi/lagoon/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from eth.typing import BlockRange
from eth_typing import HexAddress, BlockIdentifier, ChecksumAddress
from fontTools.unicodedata import block
from hexbytes import HexBytes
from web3 import Web3
from web3.contract import Contract
from web3.contract.contract import ContractFunction
Expand Down Expand Up @@ -125,9 +126,9 @@ def fetch_safe(self, address) -> Safe:
def chain_id(self) -> int:
return self.spec.chain_id

@property
@cached_property
def vault_address(self) -> HexAddress:
return self.spec.vault_address
return Web3.to_checksum_address(self.spec.vault_address)

@property
def name(self) -> str:
Expand Down Expand Up @@ -462,14 +463,19 @@ def post_valuation_and_settle(
valuation: Decimal,
asset_manager: HexAddress,
gas=1_000_000,
):
) -> HexBytes:
"""Do both new valuation and settle.
- Quickhand method for asset_manager code
- Only after this we can read back
- Broadcasts two transactions
- Broadcasts two transactions and waits for the confirmation
- If there is not enough USDC to redeem, the second transaction will fail with revert
:return:
The transaction hash of the settlement transaction
"""

assert isinstance(valuation, Decimal)
Expand All @@ -482,6 +488,8 @@ def post_valuation_and_settle(
tx_hash = bound_func.transact({"from": asset_manager, "gas": gas})
assert_transaction_success_with_explanation(self.web3, tx_hash)

return tx_hash

def request_deposit(self, depositor: HexAddress, raw_amount: int) -> ContractFunction:
"""Build a deposit transction.
Expand Down
47 changes: 47 additions & 0 deletions eth_defi/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import warnings

import cachetools
from web3.contract.contract import ContractFunction

with warnings.catch_warnings():
# DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html
Expand Down Expand Up @@ -151,6 +152,52 @@ def fetch_balance_of(self, address: HexAddress | str, block_identifier="latest")
raw_amount = self.contract.functions.balanceOf(address).call(block_identifier=block_identifier)
return self.convert_to_decimals(raw_amount)

def transfer(
self,
to: HexAddress | str,
amount: Decimal,
) -> ContractFunction:
"""Prepare a ERC20.transfer() transaction with human-readable amount.
Example:
.. code-block:: python
another_new_depositor = web3.eth.accounts[6]
tx_hash = base_usdc.transfer(another_new_depositor, Decimal(500)).transact({"from": usdc_holder, "gas": 100_000})
assert_transaction_success_with_explanation(web3, tx_hash)
:return:
Bound contract function you need to turn to a tx
"""
assert isinstance(amount, Decimal), f"Give amounts in decimal, got {type(amount)}"
to = Web3.to_checksum_address(to)
raw_amount = self.convert_to_raw(amount)
return self.contract.functions.transfer(to, raw_amount)

def approve(
self,
to: HexAddress | str,
amount: Decimal,
) -> ContractFunction:
"""Prepare a ERC20.approve() transaction with human-readable amount.
Example:
.. code-block:: python
usdc_amount = Decimal(9.00)
tx_hash = usdc.approve(vault.address, usdc_amount).transact({"from": depositor})
assert_transaction_success_with_explanation(web3, tx_hash)
:return:
Bound contract function you need to turn to a tx
"""
assert isinstance(amount, Decimal), f"Give amounts in decimal, got {type(amount)}"
to = Web3.to_checksum_address(to)
raw_amount = self.convert_to_raw(amount)
return self.contract.functions.approve(to, raw_amount)

def fetch_raw_balance_of(self, address: HexAddress | str, block_identifier="latest") -> Decimal:
"""Get an address token balance.
Expand Down
104 changes: 101 additions & 3 deletions tests/lagoon/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@
- Roles: https://app.safe.global/apps/open?safe=base:0x20415f3Ec0FEA974548184bdD6e67575D128953F&appUrl=https%3A%2F%2Fzodiac.gnosisguild.org%2F
"""
import os
from decimal import Decimal

import pytest
from eth_account.signers.local import LocalAccount
from eth_typing import HexAddress
from web3 import Web3

from eth_defi.hotwallet import HotWallet
from eth_defi.lagoon.deployment import LagoonDeploymentParameters, deploy_automated_lagoon_vault, LagoonAutomatedDeployment
from eth_defi.lagoon.vault import LagoonVault
from eth_defi.provider.anvil import AnvilLaunch, fork_network_anvil
from eth_defi.provider.multi_provider import create_multi_provider_web3
from eth_defi.token import TokenDetails, fetch_erc20_details
from eth_defi.token import TokenDetails, fetch_erc20_details, USDC_NATIVE_TOKEN
from eth_defi.trace import assert_transaction_success_with_explanation
from eth_defi.uniswap_v2.constants import UNISWAP_V2_DEPLOYMENTS
from eth_defi.uniswap_v2.deployment import fetch_deployment
from eth_defi.vault.base import VaultSpec

JSON_RPC_BASE = os.environ.get("JSON_RPC_BASE")
Expand Down Expand Up @@ -166,17 +171,59 @@ def base_test_vault_spec() -> VaultSpec:

@pytest.fixture()
def lagoon_vault(web3, base_test_vault_spec: VaultSpec) -> LagoonVault:
"""Get the predeployed lagoon vault.
- This is an early vault without TradingStrategyModuleV0 - do not use in new tests
"""
return LagoonVault(web3, base_test_vault_spec)


@pytest.fixture()
def automated_lagoon_vault(
web3,
deployer_local_account,
asset_manager,
multisig_owners,
uniswap_v2,
) -> LagoonAutomatedDeployment:
"""Deploy a new Lagoon vault with TradingStrategyModuleV0.
- Whitelist any Uniswap v2 token for trading using TradingStrategyModuleV0 and asset_manager
"""

chain_id = web3.eth.chain_id
deployer = deployer_local_account

parameters = LagoonDeploymentParameters(
underlying=USDC_NATIVE_TOKEN[chain_id],
name="Example",
symbol="EXA",
)

deploy_info = deploy_automated_lagoon_vault(
web3=web3,
deployer=deployer,
asset_manager=asset_manager,
parameters=parameters,
safe_owners=multisig_owners,
safe_threshold=2,
uniswap_v2=uniswap_v2,
uniswap_v3=None,
any_asset=True,
)

return deploy_info



@pytest.fixture()
def asset_manager() -> HexAddress:
"""The asset manager role."""
return "0x0b2582E9Bf6AcE4E7f42883d4E91240551cf0947"


@pytest.fixture()
def topped_up_asset_manager(web3, asset_manager):
def topped_up_asset_manager(web3, asset_manager) -> HexAddress:
# Topped up with some ETH
tx_hash = web3.eth.send_transaction({
"to": asset_manager,
Expand All @@ -188,7 +235,7 @@ def topped_up_asset_manager(web3, asset_manager):


@pytest.fixture()
def topped_up_valuation_manager(web3, valuation_manager):
def topped_up_valuation_manager(web3, valuation_manager) -> HexAddress:
# Topped up with some ETH
tx_hash = web3.eth.send_transaction({
"to": valuation_manager,
Expand All @@ -199,6 +246,57 @@ def topped_up_valuation_manager(web3, valuation_manager):
return valuation_manager


@pytest.fixture()
def new_depositor(web3, base_usdc, usdc_holder) -> HexAddress:
"""User with some USDC ready to deposit.
- Start with 500 USDC
"""
new_depositor = web3.eth.accounts[5]
tx_hash = base_usdc.transfer(new_depositor, Decimal(500)).transact({"from": usdc_holder, "gas": 100_000})
assert_transaction_success_with_explanation(web3, tx_hash)
return new_depositor


@pytest.fixture()
def another_new_depositor(web3, base_usdc, usdc_holder) -> HexAddress:
"""User with some USDC ready to deposit.
- Start with 500 USDC
- We need two test users
"""
another_new_depositor = web3.eth.accounts[6]
tx_hash = base_usdc.transfer(another_new_depositor, Decimal(500)).transact({"from": usdc_holder, "gas": 100_000})
assert_transaction_success_with_explanation(web3, tx_hash)
return another_new_depositor


@pytest.fixture()
def uniswap_v2(web3):
"""Uniswap V2 on Base"""
return fetch_deployment(
web3,
factory_address=UNISWAP_V2_DEPLOYMENTS["base"]["factory"],
router_address=UNISWAP_V2_DEPLOYMENTS["base"]["router"],
init_code_hash=UNISWAP_V2_DEPLOYMENTS["base"]["init_code_hash"],
)



@pytest.fixture()
def deployer_local_account(web3) -> LocalAccount:
"""Account that we use for Lagoon deployment"""
hot_wallet = HotWallet.create_for_testing(web3, eth_amount=1)
return hot_wallet.account


@pytest.fixture()
def multisig_owners(web3) -> list[HexAddress]:
"""Accouunts that are set as the owners of deployed Safe w/valt"""
return [web3.eth.accounts[2], web3.eth.accounts[3], web3.eth.accounts[4]]



# @pytest.fixture()
# def spoofed_safe(web3, safe_address):
# # Topped up with some ETH
Expand Down
Loading

0 comments on commit 5d242c4

Please sign in to comment.