From 7d0f6fc87cc19464304458ee9a33c4e4b3f2806a Mon Sep 17 00:00:00 2001 From: Doggie B <3859395+fubuloubu@users.noreply.github.com> Date: Thu, 22 May 2025 09:44:10 -0400 Subject: [PATCH 1/7] feat(Accessory): add ERC-3156 Flash Lender accessory --- contracts/accessories/Flashlend.vy | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 contracts/accessories/Flashlend.vy diff --git a/contracts/accessories/Flashlend.vy b/contracts/accessories/Flashlend.vy new file mode 100644 index 0000000..b47bc0e --- /dev/null +++ b/contracts/accessories/Flashlend.vy @@ -0,0 +1,69 @@ +# pragma version 0.4.2 +# 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 + + +# TODO: Configure which tokens are allowed to be transferred? + +@view +@external +def maxFlashLoan(token: IERC20) -> uint256: + return staticcall token.balanceOf(self) + + +@view +def _fee(token: IERC20, amount: uint256) -> uint256: + # TODO: Make this configurable? + return amount // 100 # 1% fee + + +@view +@external +def flashFee(token: IERC20, amount: uint256) -> uint256: + return self._fee(token, amount) + + +@external +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 ( + # 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") + ) + + # Get our tokens back + assert extcall token.transferFrom(receiver.address, self, amount + fee, default_return_value=True) + + return True From 97de8033899b67d2ce7bf1e22bb7cae63dbd81d4 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Wed, 18 Jun 2025 17:15:11 -0400 Subject: [PATCH 2/7] fix(Accessories): use 0.4.3 for all accessories --- contracts/accessories/Create.vy | 2 +- contracts/accessories/Flashlend.vy | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/accessories/Create.vy b/contracts/accessories/Create.vy index 6fc90cc..8aca00e 100644 --- a/contracts/accessories/Create.vy +++ b/contracts/accessories/Create.vy @@ -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 diff --git a/contracts/accessories/Flashlend.vy b/contracts/accessories/Flashlend.vy index b47bc0e..2b39521 100644 --- a/contracts/accessories/Flashlend.vy +++ b/contracts/accessories/Flashlend.vy @@ -1,4 +1,4 @@ -# pragma version 0.4.2 +# pragma version 0.4.3 # pragma nonreentrancy on from ethereum.ercs import IERC20 From 3a94d85e50330ac3663d4c5132310b5dfc32f980 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:27:15 -0500 Subject: [PATCH 3/7] refactor(Flashlend): add ability to set fee basis; reject 0 fee lends --- .../accessories/Flashlend.storageLayout.json | 7 ++++ contracts/accessories/Flashlend.vy | 37 ++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 contracts/accessories/Flashlend.storageLayout.json diff --git a/contracts/accessories/Flashlend.storageLayout.json b/contracts/accessories/Flashlend.storageLayout.json new file mode 100644 index 0000000..e98baa7 --- /dev/null +++ b/contracts/accessories/Flashlend.storageLayout.json @@ -0,0 +1,7 @@ +{ + "feeBasis": { + "type": "HashMap[address, uint256]", + "n_slots": 0, + "slot": 154682873977518743260419555346329208424049161262037279244905357072823178018 + } +} diff --git a/contracts/accessories/Flashlend.vy b/contracts/accessories/Flashlend.vy index 2b39521..5b59f4c 100644 --- a/contracts/accessories/Flashlend.vy +++ b/contracts/accessories/Flashlend.vy @@ -23,19 +23,29 @@ interface IERC3156: 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 -# TODO: Configure which tokens are allowed to be transferred? @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: - # TODO: Make this configurable? - return amount // 100 # 1% fee + return self.feeBasis[token] * amount // 10_000_000 @view @@ -45,6 +55,17 @@ def flashFee(token: IERC20, amount: uint256) -> uint256: @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, @@ -56,14 +77,20 @@ def flashLoan( # 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) + assert extcall token.transferFrom( + receiver.address, + self, + amount + fee, + default_return_value=True, + ) return True From 5f7ce563e0e6232f9f3107bb9d1eb82ac60d7976 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:30:38 -0500 Subject: [PATCH 4/7] tests(Flashloan,Flashlend): add tests for flash lending functionality --- pyproject.toml | 8 +++ tests/conftest.py | 27 ++++++++ tests/mocks/MockFlashReceiver.vy | 27 ++++++++ tests/mocks/MockToken.vy | 113 +++++++++++++++++++++++++++++++ tests/test_flashloans.py | 61 +++++++++++++++++ 5 files changed, 236 insertions(+) create mode 100644 tests/mocks/MockFlashReceiver.vy create mode 100644 tests/mocks/MockToken.vy create mode 100644 tests/test_flashloans.py diff --git a/pyproject.toml b/pyproject.toml index 04fe59e..6fb4d78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/conftest.py b/tests/conftest.py index 2b090e3..3bbbb9f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from purse import Accessory, Purse @@ -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 @@ -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 diff --git a/tests/mocks/MockFlashReceiver.vy b/tests/mocks/MockFlashReceiver.vy new file mode 100644 index 0000000..7deb704 --- /dev/null +++ b/tests/mocks/MockFlashReceiver.vy @@ -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") diff --git a/tests/mocks/MockToken.vy b/tests/mocks/MockToken.vy new file mode 100644 index 0000000..e1d285b --- /dev/null +++ b/tests/mocks/MockToken.vy @@ -0,0 +1,113 @@ +from ethereum.ercs import IERC20 + +implements: IERC20 + +totalSupply: public(uint256) +balanceOf: public(HashMap[address, uint256]) +allowance: public(HashMap[address, HashMap[address, uint256]]) + + +@deploy +def __init__(): + self.totalSupply = 100 * 10 ** 18 + self.balanceOf[msg.sender] = 100 * 10 ** 18 + + +@external +def transfer(receiver: address, amount: uint256) -> bool: + self.balanceOf[msg.sender] -= amount + self.balanceOf[receiver] += amount + log IERC20.Transfer(sender=msg.sender, receiver=receiver, value=amount) + return True + + +@external +def approve(spender: address, amount: uint256) -> bool: + self.allowance[msg.sender][spender] = amount + log IERC20.Approval(owner=msg.sender, spender=spender, value=amount) + return True + + +@external +def transferFrom(owner: address, receiver: address, amount: uint256) -> bool: + self.allowance[owner][msg.sender] -= amount + self.balanceOf[owner] -= amount + self.balanceOf[receiver] += amount + log IERC20.Transfer(sender=owner, receiver=receiver, value=amount) + return True + + +@external +def mint(receiver: address, amount: uint256): + self.totalSupply += amount + self.balanceOf[receiver] += amount + log IERC20.Transfer(sender=empty(address), receiver=receiver, value=amount) + + +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 + + +@view +@external +def maxFlashLoan(token: IERC20) -> uint256: + if token.address == self: + return max_value(uint256) - self.totalSupply + + return 0 + + +@view +@external +def flashFee(token: IERC20, amount: uint256) -> uint256: + return 0 + + +@external +def flashLoan( + receiver: IERC3156FlashBorrower, + token: IERC20, + amount: uint256, + data: Bytes[65535], +) -> bool: + assert token.address == self + + # Send our tokens to receiver + self.totalSupply += amount + self.balanceOf[receiver.address] += amount + log IERC20.Transfer(sender=empty(address), receiver=receiver.address, value=amount) + + assert ( + # NOTE: `msg.sender` is original caller of delegatecall + extcall receiver.onFlashLoan(msg.sender, IERC20(self), amount, 0, data) + # NOTE: Magic value per ERC-3156 + == keccak256("ERC3156FlashBorrower.onFlashLoan") + ), "Flashloan receiver not valid" + + # Get our tokens back (mimic a real flash lender) + self.allowance[receiver.address][self] -= amount + self.totalSupply -= amount + self.balanceOf[receiver.address] -= amount + log IERC20.Transfer(sender=receiver.address, receiver=empty(address), value=amount) + + return True diff --git a/tests/test_flashloans.py b/tests/test_flashloans.py new file mode 100644 index 0000000..2ba4d7c --- /dev/null +++ b/tests/test_flashloans.py @@ -0,0 +1,61 @@ +import ape +from ape.utils.misc import ZERO_ADDRESS +import pytest + +from eth_abi import abi + +from purse import Purse + + +@pytest.fixture() +def purse(singleton, owner, flashloan, flashlend): + return Purse.initialize(owner, flashloan, flashlend, singleton=singleton) + + +def test_flashloan(purse, token, other): + assert token.allowance(purse, other) == 0 + with ape.reverts("Flashloan:!authorized"): + purse.onFlashLoan(purse, token, 1_000, 0, b"", sender=other) + assert token.allowance(purse, other) == 0 + + tx = token.flashLoan( + purse, + token, + 1_000, + # NOTE: View call is a no-op, should work + abi.encode(["address", "uint256"], [purse.address, 0]) + + purse.maxFlashLoan.encode_input(token), + sender=purse, + ) + assert tx.events == [ + token.Transfer(sender=ZERO_ADDRESS, receiver=purse, value=1_000), + token.Approval(owner=purse, spender=token, value=1_000), + token.Transfer(sender=purse, receiver=ZERO_ADDRESS, value=1_000), + ] + + +@pytest.fixture(scope="module") +def flash_receiver(mocks, owner): + return owner.deploy(mocks.MockFlashReceiver) + + +def test_flashlend(purse, token, flash_receiver): + assert purse.maxFlashLoan(token) == 0 + assert purse.flashFee(token, 1_000_000) == 0 + + with ape.reverts("Flashlend:!token-allowed"): + purse.flashLoan(flash_receiver, token, token.balanceOf(purse) // 100, b"") + + purse.setFlashFee(token, 10_000) # 10k mbps = 10 bps = 0.1% + + assert purse.maxFlashLoan(token) == token.balanceOf(purse) + assert purse.flashFee(token, 10_000_000) == 10_000 + + token.mint(flash_receiver, int(1e18), sender=purse) + purse.flashLoan( + flash_receiver, + token, + prev_bal := token.balanceOf(purse), + b"", + ) + assert token.balanceOf(purse) == prev_bal + int(prev_bal * (10_000 / 10_000_000)) From 10bd72eb2eec1732acd56b1033aa2617e50f81f2 Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:26:19 -0500 Subject: [PATCH 5/7] refactor(Flashloan): encode inner call properly --- contracts/accessories/Flashloan.vy | 43 +++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/contracts/accessories/Flashloan.vy b/contracts/accessories/Flashloan.vy index 105d250..984bfce 100644 --- a/contracts/accessories/Flashloan.vy +++ b/contracts/accessories/Flashloan.vy @@ -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: @@ -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") From c18b356589c940af23db776a0d978fa730b3aaca Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:27:40 -0500 Subject: [PATCH 6/7] refactor(SDK): allow using Purse and Accessories as address types --- sdk/py/purse/accessory.py | 9 +++++++-- sdk/py/purse/main.py | 10 +++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/sdk/py/purse/accessory.py b/sdk/py/purse/accessory.py index db85fc6..37336e3 100644 --- a/sdk/py/purse/accessory.py +++ b/sdk/py/purse/accessory.py @@ -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 @@ -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 @@ -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: diff --git a/sdk/py/purse/main.py b/sdk/py/purse/main.py index db6c6ae..ff7e927 100644 --- a/sdk/py/purse/main.py +++ b/sdk/py/purse/main.py @@ -6,6 +6,7 @@ from ape.contracts import ( ContractInstance, ) +from ape.api.address import BaseAddress from ape.contracts.base import ( ContractCallHandler, ContractEvent, @@ -19,11 +20,10 @@ 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", @@ -31,7 +31,7 @@ def __init__( ): 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 @@ -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: From a3a8efbda6c03c088cbf0c5695d03bb1f79869df Mon Sep 17 00:00:00 2001 From: fubuloubu <3859395+fubuloubu@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:32:23 -0500 Subject: [PATCH 7/7] fixup! tests(Flashloan,Flashlend): add tests for flash lending functionality --- tests/test_flashloans.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_flashloans.py b/tests/test_flashloans.py index 2ba4d7c..8b7c06f 100644 --- a/tests/test_flashloans.py +++ b/tests/test_flashloans.py @@ -14,7 +14,7 @@ def purse(singleton, owner, flashloan, flashlend): def test_flashloan(purse, token, other): assert token.allowance(purse, other) == 0 - with ape.reverts("Flashloan:!authorized"): + with ape.reverts(expected_message="Flashloan:!authorized"): purse.onFlashLoan(purse, token, 1_000, 0, b"", sender=other) assert token.allowance(purse, other) == 0 @@ -43,7 +43,7 @@ def test_flashlend(purse, token, flash_receiver): assert purse.maxFlashLoan(token) == 0 assert purse.flashFee(token, 1_000_000) == 0 - with ape.reverts("Flashlend:!token-allowed"): + with ape.reverts(expected_message="Flashlend:!token-allowed"): purse.flashLoan(flash_receiver, token, token.balanceOf(purse) // 100, b"") purse.setFlashFee(token, 10_000) # 10k mbps = 10 bps = 0.1%