Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Velvet vault redemptions #251

Merged
merged 6 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions eth_defi/lagoon/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ def __init__(
def has_block_range_event_support(self):
return True

def has_deposit_distribution_to_all_positions(self):
return False

def get_flow_manager(self) -> "LagoonFlowManager":
return LagoonFlowManager(self)

Expand Down
2 changes: 1 addition & 1 deletion eth_defi/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ def assert_transaction_success_with_explanation(
trace_data = trace_evm_transaction(web3, tx_hash, TraceMethod.parity)
trace_output = print_symbolic_trace(get_or_create_contract_registry(web3), trace_data)
raise RaisedException(
f"Revert reason: {revert_reason}\nSolidity stack trace:\n{trace_output}\nTransaction details:\n{tx_details}",
f"Revert reason: {revert_reason}\nSolidity stack trace:\n{trace_output}\nTransaction details:\n{tx_details}\nTransaction receipt:{receipt}",
revert_reason=revert_reason,
solidity_stack_trace=trace_output,
)
Expand Down
11 changes: 11 additions & 0 deletions eth_defi/vault/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,17 @@ def has_block_range_event_support(self) -> bool:
- If not we use chain balance polling-based approach
"""

@abstractmethod
def has_deposit_distribution_to_all_positions(self) -> bool:
"""Deposits go automatically to all open positions.

- Deposits do not land into the vault as cash

- Instead, smart contracts automatically increase all open positions

- The behaviour of Velvet Capital
"""

@abstractmethod
def fetch_portfolio(
self,
Expand Down
4 changes: 3 additions & 1 deletion eth_defi/velvet/deposit.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def deposit_to_velvet(
deposit_token_address: HexAddress | str,
amount: int,
chain_id: int,
slippage: float,
api_url=VELVET_DEFAULT_API_URL,
) -> dict:
"""Construct Velvet deposit payload.
Expand Down Expand Up @@ -53,7 +54,8 @@ def deposit_to_velvet(
"depositToken": deposit_token_address,
"user": from_address,
"depositType": "batch",
"tokenType": "erc20"
"tokenType": "erc20",
"slippage": str(int(slippage * 10_000)), # 100 = 1%
}

url = f"{api_url}/portfolio/deposit"
Expand Down
69 changes: 69 additions & 0 deletions eth_defi/velvet/redeem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Velvet deposit handling.

- Need to call proprietary centralised API to make a deposit
"""
from pprint import pformat
import logging

import requests
from eth_typing import HexAddress
from requests import HTTPError
from web3 import Web3

from eth_defi.velvet.config import VELVET_DEFAULT_API_URL


logger = logging.getLogger(__name__)


class VelvetRedemptionError(Exception):
"""Error reply from velvet txn API"""


def redeem_from_velvet_velvet(
portfolio: HexAddress | str,
from_address: HexAddress | str,
withdraw_token_address: HexAddress | str,
amount: int,
chain_id: int,
slippage: float,
api_url=VELVET_DEFAULT_API_URL,
) -> dict:
"""Construct Velvet redemption payload.

- See https://github.com/Velvet-Capital/3rd-party-integration/issues/2#issuecomment-2497119390
"""
assert from_address.startswith("0x")
assert portfolio.startswith("0x")
assert withdraw_token_address.startswith("0x")
assert type(amount) == int

payload = {
"withdrawAmount": str(amount),
"withdrawToken": withdraw_token_address,
"user": from_address,
"withdrawType": "batch",
"tokenType": "erc20",
"portfolio": portfolio,
"slippage": str(int(slippage * 10_000)), # 100 = 1%
}

url = f"{api_url}/portfolio/withdraw"

logger.info("Velvet withdraw to %s with params:\n%s", url, pformat(payload))

resp = requests.post(url, json=payload)

try:
resp.raise_for_status()
except HTTPError as e:
raise VelvetRedemptionError(f"Velvet API error on {api_url}, code {resp.status_code}: {resp.text}. Params: {pformat(payload)}") from e

tx_data = resp.json()

if "error" in tx_data:
raise VelvetRedemptionError(str(tx_data))

tx_data["from"] = Web3.to_checksum_address(from_address)
tx_data["chainId"] = chain_id
return tx_data
41 changes: 39 additions & 2 deletions eth_defi/velvet/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
from eth_typing import BlockIdentifier, HexAddress
from web3 import Web3

from eth_defi.abi import get_deployed_contract
from eth_defi.balances import fetch_erc20_balances_fallback
from eth_defi.token import fetch_erc20_details
from eth_defi.vault.base import VaultBase, VaultInfo, VaultSpec, TradingUniverse, VaultPortfolio
from eth_defi.velvet.deposit import deposit_to_velvet
from eth_defi.velvet.enso import swap_with_velvet_and_enso
from eth_defi.velvet.redeem import redeem_from_velvet_velvet

#: Signing API URL
DEFAULT_VELVET_API_URL = "https://eventsapi.velvetdao.xyz/api/v3"
Expand Down Expand Up @@ -84,6 +87,9 @@ def __init__(
def has_block_range_event_support(self):
return False

def has_deposit_distribution_to_all_positions(self):
return True

def get_flow_manager(self):
raise NotImplementedError("Velvet does not support individual deposit/redemption events yet")

Expand Down Expand Up @@ -186,17 +192,46 @@ def prepare_deposit_with_enso(
from_: HexAddress | str,
deposit_token_address: HexAddress | str,
amount: int,
):
slippage: float,
) -> dict:
"""Prepare a deposit transaction with Enso intents.

- Velvet trades any incoming assets and distributes them on open positions

:return:
Ethereum transaction payload
"""
tx_data = deposit_to_velvet(
portfolio=self.portfolio_address,
from_address=from_,
deposit_token_address=deposit_token_address,
amount=amount,
chain_id=self.web3.eth.chain_id,
slippage=slippage,
)
return tx_data

def prepare_redemption(
self,
from_: HexAddress | str,
amount: int,
withdraw_token_address: HexAddress | str,
slippage: float,
) -> dict:
"""Perform a redemption.

:return:
Ethereum transaction payload
"""

chain_id = self.web3.eth.chain_id
tx_data = redeem_from_velvet_velvet(
from_address=Web3.to_checksum_address(from_),
portfolio=Web3.to_checksum_address(self.portfolio_address),
amount=amount,
chain_id=chain_id,
withdraw_token_address=Web3.to_checksum_address(withdraw_token_address),
slippage=slippage,
)
return tx_data

Expand All @@ -215,7 +250,9 @@ def fetch_denomination_token(self):
raise NotImplementedError()

def fetch_share_token(self):
raise NotImplementedError()
# Velvet's share token is the same contract as
portfolio_address = self.info["portfolio"]
return fetch_erc20_details(self.web3, portfolio_address)

def fetch_nav(self):
raise NotImplementedError()
Expand Down
105 changes: 99 additions & 6 deletions tests/velvet/test_velvet_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import os
import time
from decimal import Decimal

import pytest
Expand Down Expand Up @@ -42,6 +43,17 @@ def usdc_holder() -> HexAddress:
return "0x3304E22DDaa22bCdC5fCa2269b418046aE7b566A"


@pytest.fixture()
def existing_shareholder() -> HexAddress:
"""A user that has shares for the vault that can be redeemed.

- This user has a pre-approved approve() to withdraw all shares

https://basescan.org/token/0x205e80371f6d1b33dff7603ca8d3e92bebd7dc25#balances
"""
return "0x0C9dB006F1c7bfaA0716D70F012EC470587a8D4F"


@pytest.fixture()
def slippage() -> float:
"""Slippage value to be used in tests.
Expand All @@ -55,15 +67,15 @@ def slippage() -> float:


@pytest.fixture()
def anvil_base_fork(request, vault_owner, usdc_holder, deposit_user) -> AnvilLaunch:
def anvil_base_fork(request, vault_owner, usdc_holder, deposit_user, existing_shareholder) -> AnvilLaunch:
"""Create a testable fork of live BNB chain.

:return: JSON-RPC URL for Web3
"""
assert JSON_RPC_BASE is not None, "JSON_RPC_BASE not set"
launch = fork_network_anvil(
JSON_RPC_BASE,
unlocked_addresses=[vault_owner, usdc_holder, deposit_user],
unlocked_addresses=[vault_owner, usdc_holder, deposit_user, existing_shareholder],
# fork_block_number=23261311, # Cannot use forked state because Enso has its own state
)
try:
Expand Down Expand Up @@ -113,6 +125,13 @@ def usdc(web3) -> TokenDetails:
)


@pytest.fixture()
def base_doginme_token(web3) -> TokenDetails:
"""DogInMe"""
return fetch_erc20_details(web3, "0x6921B130D297cc43754afba22e5EAc0FBf8Db75b")



@pytest.fixture()
def hot_wallet_user(web3, usdc, usdc_holder) -> HotWallet:
"""A test account with USDC balance."""
Expand Down Expand Up @@ -264,7 +283,6 @@ def test_vault_swap_very_little(
assert_transaction_success_with_explanation(web3, tx_hash)


#@pytest.mark.skipif(CI, reason="Enso is such unstable crap that there is no hope we could run any tests with in CI")
def test_vault_swap_sell_to_usdc(
vault: VelvetVault,
vault_owner: HexAddress,
Expand Down Expand Up @@ -305,13 +323,13 @@ def test_vault_swap_sell_to_usdc(
assert portfolio.spot_erc20["0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"] > existing_usdc_balance


#@pytest.mark.skipif(CI, reason="Enso is such unstable crap that there is no hope we could run any tests with in CI")
def test_velvet_api_deposit(
vault: VelvetVault,
vault_owner: HexAddress,
deposit_user: HexAddress,
usdc: TokenDetails,
slippage: float,
base_doginme_token: TokenDetails,
):
"""Use Velvet API to perform deposit"""

Expand All @@ -320,7 +338,7 @@ def test_velvet_api_deposit(
# Velvet vault tracked assets
universe = TradingUniverse(
spot_token_addresses={
"0x6921B130D297cc43754afba22e5EAc0FBf8Db75b", # DogInMe
base_doginme_token.address, # DogInMe
usdc.address, # USDC on Base
}
)
Expand Down Expand Up @@ -355,19 +373,94 @@ def test_velvet_api_deposit(
from_=deposit_user,
deposit_token_address=usdc.address,
amount=5 * 10**6,
slippage=slippage,
)
assert tx_data["to"] == deposit_manager
started_at = time.time()
tx_hash = web3.eth.send_transaction(tx_data)
try:
assert_transaction_success_with_explanation(web3, tx_hash)
except Exception as e:
# Double check allowance - Anvil bug
duration = time.time() - started_at
allowance = usdc.contract.functions.allowance(
Web3.to_checksum_address(deposit_user),
Web3.to_checksum_address(deposit_manager),
).call()
raise RuntimeError(f"transferFrom() failed, allowance after broadcast {allowance / 10**6} USDC: {e}") from e
raise RuntimeError(f"transferFrom() failed, allowance after broadcast {allowance / 10**6} USDC: {e}, crash took {duration} seconds") from e

# USDC balance has increased after the deposit
portfolio = vault.fetch_portfolio(universe, web3.eth.block_number)
assert portfolio.spot_erc20[usdc.address] > existing_usdc_balance


def test_velvet_api_redeem(
vault: VelvetVault,
vault_owner: HexAddress,
existing_shareholder: HexAddress,
usdc: TokenDetails,
base_doginme_token: TokenDetails,
slippage: float,
):
"""Use Velvet API to perform redemption.

- Do autosell redemption
"""

web3 = vault.web3

# Check we have our shares
share_token = vault.share_token
assert share_token.name == "Example 2"
assert share_token.symbol == "EXA2"
assert share_token.total_supply == 1000 * 10**18
shares = share_token.fetch_balance_of(existing_shareholder)
assert shares > 0

withdrawal_manager = "0x99e9C4d3171aFAA3075D0d1aE2Bb42B5E53aEdAB"

# Check there is ready-made manual approve() waiting onchain
allowance = share_token.contract.functions.allowance(
Web3.to_checksum_address(existing_shareholder),
Web3.to_checksum_address(withdrawal_manager),
).call()
assert allowance == pytest.approx(1000 * 10**18)

tx_hash = share_token.contract.functions.approve(
Web3.to_checksum_address(vault.portfolio_address),
share_token.convert_to_raw(shares)
).transact({
"from": Web3.to_checksum_address(existing_shareholder),
})
assert_transaction_success_with_explanation(web3, tx_hash)

# Velvet vault tracked assets
universe = TradingUniverse(
spot_token_addresses={
base_doginme_token.address, # DogInMe
usdc.address, # USDC on Base
}
)

# Check the existing portfolio USDC balance before starting the
# the deposit process
latest_block = get_almost_latest_block_number(web3)
portfolio = vault.fetch_portfolio(universe, latest_block)
existing_usdc_balance = portfolio.spot_erc20[usdc.address]
assert existing_usdc_balance > Decimal(1.0)

# Prepare the redemption tx payload
tx_data = vault.prepare_redemption(
from_=existing_shareholder,
amount=share_token.convert_to_raw(shares),
withdraw_token_address=usdc.address,
slippage=slippage,
)
assert tx_data["to"] == withdrawal_manager
tx_hash = web3.eth.send_transaction(tx_data)
assert_transaction_success_with_explanation(web3, tx_hash)

# Vault balances are zero after redeeming everything
portfolio = vault.fetch_portfolio(universe, web3.eth.block_number)
assert portfolio.spot_erc20[usdc.address] == pytest.approx(0)
assert portfolio.spot_erc20[base_doginme_token.address] == pytest.approx(0)