Skip to content
Open
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
2 changes: 1 addition & 1 deletion contracts/accessories/Create.vy
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# pragma version 0.4.2
# pragma version 0.4.3

# NOTE: Maximum possible `raw_create` initcode size
MAX_CODE_SIZE: constant(uint16) = 41592
Expand Down
7 changes: 7 additions & 0 deletions contracts/accessories/Flashlend.storageLayout.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"feeBasis": {
"type": "HashMap[address, uint256]",
"n_slots": 0,
"slot": 154682873977518743260419555346329208424049161262037279244905357072823178018
}
}
96 changes: 96 additions & 0 deletions contracts/accessories/Flashlend.vy
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# pragma version 0.4.3
# pragma nonreentrancy on
from ethereum.ercs import IERC20

interface IERC3156FlashBorrower:
def onFlashLoan(
initiator: address,
token: IERC20,
amount: uint256,
fee: uint256,
data: Bytes[65535],
) -> bytes32: nonpayable

interface IERC3156:
def maxFlashLoan(token: IERC20) -> uint256: view
def flashFee(token: IERC20, amount: uint256) -> uint256: view
def flashLoan(
receiver: IERC3156FlashBorrower,
token: IERC20,
amount: uint256,
data: Bytes[65535],
) -> bool: nonpayable

implements: IERC3156

# @custom:storage-location erc7201:purse.accessories.Flashlend
# keccak256(abi.encode(uint256(keccak256("purse.accessories.Flashlend")) - 1)) & ~bytes32(uint256(0xff))
feeBasis: HashMap[IERC20, uint256] # 0x578c22acf65ce07623403df1f4aaaea33129ac33b22142a968e7c121335322


# NOTE: Can watch for generic events of this type to find Purses that have "opted-in" to flashloans
event FlashFeeUpdated:
token: indexed(IERC20)
milli_bps: uint256


@view
@external
def maxFlashLoan(token: IERC20) -> uint256:
if self.feeBasis[token] == 0:
return 0

return staticcall token.balanceOf(self)


@view
def _fee(token: IERC20, amount: uint256) -> uint256:
return self.feeBasis[token] * amount // 10_000_000


@view
@external
def flashFee(token: IERC20, amount: uint256) -> uint256:
return self._fee(token, amount)


@external
def setFlashFee(token: IERC20, feeBasis: uint256):
assert feeBasis <= 10_000_000, "Flashlend:incorrect-fee-basis"
# NOTE: Can only work in a EIP-7702 context from Purse
assert tx.origin == self, "Flashlend:!authorized"

self.feeBasis[token] = feeBasis
log FlashFeeUpdated(token=token, milli_bps=feeBasis)


@external
# NOTE: Non-reentrant
def flashLoan(
receiver: IERC3156FlashBorrower,
token: IERC20,
amount: uint256,
data: Bytes[65535],
) -> bool:
# Send our tokens to receiver
assert extcall token.transfer(receiver.address, amount, default_return_value=True)

# Tell receiver about the flashloan
fee: uint256 = self._fee(token, amount)
assert fee > 0, "Flashlend:!token-allowed"
assert (
# NOTE: `msg.sender` is original caller of delegatecall
extcall receiver.onFlashLoan(msg.sender, token, amount, fee, data)
# NOTE: Magic value per ERC-3156
== keccak256("ERC3156FlashBorrower.onFlashLoan")
), "Flashlend:!receiver-returndata-invalid"

# Get our tokens back
assert extcall token.transferFrom(
receiver.address,
self,
amount + fee,
default_return_value=True,
)

return True
43 changes: 33 additions & 10 deletions contracts/accessories/Flashloan.vy
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# pragma version 0.4.3
# pragma nonreentrancy on
"""
@title Purse Accessory - ERC3156 Flashloan Callback
@author Purse contributors
@dev
This contract implements the `ERC3156FlashBorrower` interface and logic,
so that the Purse can handle the flash loan context and continue operation
by performing the encoded call provided with the callback data.

It should not be possible for anyone besides the Purse itself to call this,
as that would allow aribtrary approvals to malicious addresses, as well as
enable arbitrary delegate calls more generally, if it was improperly handled.
"""
from ethereum.ercs import IERC20

interface IERC3156FlashBorrower:
Expand All @@ -21,17 +33,28 @@ def onFlashLoan(
token: IERC20,
amount: uint256,
fee: uint256,
data: Bytes[65535],
data: Bytes[1 + 65535 + 32 * 2],
) -> bytes32:
# NOTE: Only trusted context allowed
assert initiator == self, "Flashloan:!authorized"

# NOTE: Ensure that appropriate amount of allowance is given to caller
if staticcall token.allowance(tx.origin, msg.sender) < amount + fee:
extcall token.approve(msg.sender, amount + fee)

# NOTE: Forward whatever we specified to follow-up with back to ourselves
raw_call(tx.origin, data)
"""
@notice Handle the ERC3156 Flashloan Callback
@param initiator The initiator of the flash loan (ignored, should be `self`)
@param token The token used for the flash loan
@param amount The amount of `token` that is loaned
@param fee The amount of `token` that must be repaid for borrowing `amount`
@param data The encoded internal call to make, encoded as `to|value|data`
@return The magic value `keccak256("ERC3156FlashBorrower.onFlashLoan")`
"""
# NOTE: Only purse is allowed to do this
assert tx.origin == self, "Flashloan:!authorized"

# NOTE: Ensure that appropriate amount of allowance is made available to caller
assert extcall token.approve(msg.sender, amount + fee, default_return_value=True)

# Perform encoded call as Purse
to: address = empty(address)
amt: uint256 = 0
to, amt = abi_decode(slice(data, 0, 32*2), (address, uint256))
raw_call(to, slice(data, 32*2, len(data)), value=amt)

# NOTE: Magic value per ERC-3156
return keccak256("ERC3156FlashBorrower.onFlashLoan")
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ include = [ "purse" ]

[[tool.ape.plugins]]
name = "vyper"
[tool.ape.compile]
exclude = [
"accessories/*.storageLayout.json",
]

[[tool.ape.dependencies]]
pypi = "snekmate"
version = "0.1.2"

[[tool.ape.plugins]]
name = "foundry"
Expand Down
9 changes: 7 additions & 2 deletions sdk/py/purse/accessory.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import string
from typing import TYPE_CHECKING, Any
from ape.api.address import BaseAddress
from ape.contracts import ContractInstance
from ape.types import AddressType
from ape.utils import ManagerAccessMixin
Expand Down Expand Up @@ -28,9 +29,9 @@ def __hash__(self) -> int:
return int(self.accessory.lower().replace("0x", "") + self.method.hex(), 16)


class Accessory(ManagerAccessMixin):
class Accessory(BaseAddress, ManagerAccessMixin):
def __init__(self, address: AddressType | ContractInstance, *purses: "Purse"):
self.address = self.conversion_manager.convert(address, AddressType)
self._address = self.conversion_manager.convert(address, AddressType)

if isinstance(address, ContractInstance):
self.contract = address
Expand All @@ -40,6 +41,10 @@ def __init__(self, address: AddressType | ContractInstance, *purses: "Purse"):
purse.address: purse for purse in purses
}

@property
def address(self) -> AddressType:
return self._address

# TODO: `Accessory.load_package_type(package: uri or PackageManifest, contract_name: str)`

def __repr__(self) -> str:
Expand Down
10 changes: 7 additions & 3 deletions sdk/py/purse/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ape.contracts import (
ContractInstance,
)
from ape.api.address import BaseAddress
from ape.contracts.base import (
ContractCallHandler,
ContractEvent,
Expand All @@ -19,19 +20,18 @@

if TYPE_CHECKING:
from ape.api import AccountAPI
from ape.api.address import BaseAddress
from ape.api.transactions import ReceiptAPI


class Purse(ManagerAccessMixin):
class Purse(BaseAddress, ManagerAccessMixin):
def __init__(
self,
account: "AccountAPI | BaseAddress | AddressType",
*accessories: "Accessory",
):
from ape.api import AccountAPI

self.address = self.conversion_manager.convert(account, AddressType)
self._address = self.conversion_manager.convert(account, AddressType)

if isinstance(account, AccountAPI):
self.wallet = account
Expand Down Expand Up @@ -61,6 +61,10 @@ def initialize(

return cls(account, *accessories)

@property
def address(self) -> AddressType:
return self._address

@cached_property
def wallet(self) -> "AccountAPI | None":
if self.address in self.accounts_manager:
Expand Down
27 changes: 27 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

import pytest

from purse import Accessory, Purse
Expand All @@ -18,6 +20,21 @@ def singleton(project, owner):
return owner.deploy(project.Purse)


@pytest.fixture(scope="session")
def mocks():
from ape import Project

return Project(
Path(__file__).parent,
config_override=dict(contracts_folder="mocks"),
)


@pytest.fixture(scope="session")
def token(mocks, owner):
return owner.deploy(mocks.MockToken)


@pytest.fixture()
def purse(singleton, owner):
# NOTE: Empty purse for testing purposes
Expand All @@ -39,6 +56,16 @@ def sponsor(project, owner):
return Accessory(owner.deploy(project.Sponsor))


@pytest.fixture(scope="module")
def flashloan(project, owner):
return Accessory(owner.deploy(project.Flashloan))


@pytest.fixture(scope="module")
def flashlend(project, owner):
return Accessory(owner.deploy(project.Flashlend))


@pytest.fixture(scope="session")
def dummy(compilers, owner):
SRC = """# pragma version 0.4.1
Expand Down
27 changes: 27 additions & 0 deletions tests/mocks/MockFlashReceiver.vy
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from ethereum.ercs import IERC20


interface IERC3156FlashBorrower:
def onFlashLoan(
initiator: address,
token: IERC20,
amount: uint256,
fee: uint256,
data: Bytes[65535],
) -> bytes32: nonpayable


implements: IERC3156FlashBorrower


@external
def onFlashLoan(
initiator: address,
token: IERC20,
amount: uint256,
fee: uint256,
data: Bytes[65535],
) -> bytes32:
# NOTE: Make sure this contract has enough balance prior to calling
extcall token.approve(msg.sender, amount + fee)
return keccak256("ERC3156FlashBorrower.onFlashLoan")
Loading
Loading