Skip to content

Commit 65e8a37

Browse files
authored
feat!(Contracts): assert success if Execute.success_required (#30)
1 parent 75daae0 commit 65e8a37

File tree

7 files changed

+103
-43
lines changed

7 files changed

+103
-43
lines changed

contracts/Caravan.vy

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ MODIFY_TYPEHASH: constant(bytes32) = keccak256(
2626
)
2727

2828
CALL_TYPEHASH: constant(bytes32) = keccak256(
29-
"Call(address target,uint256 value,bytes data)"
29+
"Call(address target,uint256 value,bool success_required,bytes data)"
3030
)
3131
EXECUTE_TYPEHASH: constant(bytes32) = keccak256(
32-
"Execute(bytes32 parent,Call[] calls)Call(address target,uint256 value,bytes data)"
32+
"Execute(bytes32 parent,Call[] calls)Call(address target,uint256 value,bool success_required,bytes data)"
3333
)
3434

3535
# @dev The current implementation address for `CaravanProxy`
@@ -294,6 +294,7 @@ def execute(
294294
CALL_TYPEHASH,
295295
call.target,
296296
call.value,
297+
call.success_required,
297298
# NOTE: Per EIP712, Dynamic ABI types are encoded as the hash of their contents
298299
keccak256(call.data),
299300
)
@@ -321,12 +322,19 @@ def execute(
321322
extcall guard.preExecuteCheck(call)
322323

323324
# NOTE: No delegatecalls allowed (cannot modify configuration via `update` this way)
324-
success: bool = raw_call(
325-
call.target,
326-
call.data,
327-
value=call.value,
328-
revert_on_failure=False,
329-
)
325+
success: bool = True
326+
if call.success_required:
327+
# NOTE: Allow failure to bubble up normally
328+
raw_call(call.target, call.data, value=call.value)
329+
330+
else:
331+
success = raw_call(
332+
call.target,
333+
call.data,
334+
value=call.value,
335+
revert_on_failure=False,
336+
)
337+
330338

331339
if guard.address != empty(address):
332340
extcall guard.postExecuteCheck()

contracts/ICaravan.vyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
struct Call:
33
target: address
44
value: uint256
5+
success_required: bool
56
# TODO: Increase size to 65540 once Vyper improves memory allocation (gas costs too high)
67
data: Bytes[16388]
78

pyproject.toml

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.11"
1111
dependencies = [
1212
"eth-ape>=0.8.43",
13+
"eip712>=0.3.3", # NOTE: Special pin needed
1314
"createx>=0.1.1",
1415
]
1516

@@ -20,6 +21,7 @@ test = [
2021
"ape-vyper>=0.8.9",
2122
]
2223
dev = [
24+
"ape-etherscan>=0.8.5",
2325
{include-group = "test"},
2426
]
2527

@@ -45,17 +47,3 @@ name = "foundry"
4547
name = "solidity"
4648
[[tool.ape.plugins]]
4749
name = "vyper"
48-
49-
# Factory
50-
#[[tool.ape.deployments.ethereum.deterministic]]
51-
[[tool.ape.deployments.ethereum.local]]
52-
address = "0x04579FFC45fE10A7901B88EaEc8F4850b847D37c"
53-
contract_type = "CaravanFactory"
54-
55-
# Singleton
56-
# NOTE: Update latest Singleton when contract code changes
57-
# v1 (Latest)
58-
#[[tool.ape.deployments.ethereum.deterministic]]
59-
[[tool.ape.deployments.ethereum.local]]
60-
address = "0x685B0DEA9daF4DC73Ab9dba4bc4156d90A7F8a5d"
61-
contract_type = "Caravan"

sdk/py/caravan/factory.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,24 @@ def __init__(self, address: AddressType | None = None):
1818
self.address = address
1919

2020
elif len((factory_type := PackageType.FACTORY()).deployments) == 0:
21-
raise RuntimeError("No CaravanFactory deployment on this chain")
21+
# NOTE: This is the deterministic deployment address via CreateX
22+
self.address = "0x04579FFC45fE10A7901B88EaEc8F4850b847D37c"
23+
24+
if not len(self.provider.get_code(self.address)) > 0:
25+
raise RuntimeError("No CaravanFactory deployment on this chain")
2226

2327
else:
2428
# NOTE: Override cached value of `contract`
2529
self.contract = factory_type.deployments[0]
2630
self.address = self.contract.address
2731

2832
# NOTE: also lets us override for testing
29-
self._cached_releases: dict[Version, "ContractInstance"] = dict()
30-
31-
# TODO: classmethod `.inject`?
33+
self._cached_releases: dict[Version, "ContractInstance"] = {
34+
# NOTE: This is the deterministic deployment address for v1 via CreateX
35+
Version("1"): PackageType.SINGLETON("1").at(
36+
"0xf7AC37e8A31Da4D2Fbe7687118c142dd46e63517"
37+
),
38+
}
3239

3340
@cached_property
3441
def contract(self) -> "ContractInstance":
@@ -39,13 +46,13 @@ def contract(self) -> "ContractInstance":
3946
)
4047

4148
def get_release(self, version: Version) -> "ContractInstance | None":
42-
if release := self._cached_releases.get(version):
49+
if (release := self._cached_releases.get(version)) and len(release.code) > 0:
4350
return release
4451

4552
elif not (singleton_type := PackageType.SINGLETON(version)).deployments:
4653
return None
4754

48-
elif not (release := singleton_type.deployments[-1]).code:
55+
elif not len((release := singleton_type.deployments[-1]).code) > 0:
4956
return None
5057

5158
self._cached_releases[version] = release
@@ -65,7 +72,8 @@ def new(
6572
if isinstance(version, str):
6673
version = Version(version.lstrip("v"))
6774

68-
release = self.get_release(version)
75+
if not (release := self.get_release(version)):
76+
raise RuntimeError("No Caravan Singleton deployment on this chain")
6977

7078
args = [release, signers, threshold]
7179
if tag is not None:

sdk/py/caravan/messages/execute.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
if TYPE_CHECKING:
1212
from ape.api import ReceiptAPI
13+
from ape.api.address import BaseAddress
1314
from ape.types import AddressType
1415
from packaging.version import Version
1516

@@ -20,10 +21,11 @@
2021
class Call(BaseModel):
2122
target: abi.address
2223
value: abi.uint256
24+
success_required: bool
2325
data: HexBytes
2426

2527
def render(self) -> str:
26-
return f"{self.target}({self.data}, value={self.value})"
28+
return f"{self.target}({self.data}, value={self.value}, required={self.success_required})"
2729

2830

2931
class Execute(EIP712Message, ManagerAccessMixin):
@@ -72,7 +74,11 @@ def new(
7274
return self
7375

7476
def add_raw(
75-
self, target: "AddressType", value: int = 0, data: HexBytes = b""
77+
self,
78+
target: "BaseAddress | AddressType | str",
79+
value: str | int = 0,
80+
success_required: bool = True,
81+
data: HexBytes = b"",
7682
) -> Self:
7783
if len(self.calls) >= self.MAX_CALLS:
7884
raise RuntimeError(
@@ -85,20 +91,48 @@ def add_raw(
8591
f" {self.MAX_CALLDATA_SIZE} bytes."
8692
)
8793

88-
self.calls.append(Call(target=target, value=value, data=data))
94+
# NOTE: Avoid imports otherwise just for typing
95+
from ape.types import AddressType
96+
97+
self.calls.append(
98+
Call(
99+
target=self.conversion_manager.convert(target, AddressType),
100+
value=self.conversion_manager.convert(value, int),
101+
success_required=success_required,
102+
data=data,
103+
)
104+
)
89105
return self
90106

91-
def add(self, call, *args, value: int = 0) -> Self:
107+
def add(self, call, *args, value: int = 0, success_required: bool = True) -> Self:
92108
return self.add_raw(
93-
target=call.contract.address,
109+
target=call.contract,
94110
value=value,
111+
success_required=success_required,
95112
data=call.encode_input(*args),
96113
)
97114

98-
def add_from_receipt(self, receipt: "ReceiptAPI") -> Self:
115+
def add_transfer(
116+
self,
117+
target: "BaseAddress | AddressType | str",
118+
value: int | str,
119+
data: bytes = b"",
120+
success_required: bool = True,
121+
):
122+
return self.add_raw(
123+
target=target,
124+
value=value,
125+
success_required=success_required,
126+
data=data, # In case you want to add a message or something
127+
)
128+
129+
def add_from_receipt(
130+
self, receipt: "ReceiptAPI", success_required: bool = True
131+
) -> Self:
99132
return self.add_raw(
100133
target=receipt.receiver,
101134
value=receipt.value,
135+
success_required=success_required,
102136
data=receipt.data,
103137
)
104138

tests/test_operation.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,32 +37,51 @@ def test_initialize(singleton, van, THRESHOLD, owners):
3737
def test_execute(accounts, van, owners, calls):
3838
msg = van.new_batch()
3939

40+
accounts[-1].transfer(van.contract, "10 ether")
41+
starting_balance = {van.contract.address: van.contract.balance}
4042
for idx in range(total_calls := int(calls.split("_")[0])):
41-
msg.add_raw(
42-
target=accounts[idx].address,
43-
value=idx,
43+
msg.add_transfer(
44+
target=accounts[idx],
45+
value=f"{idx + 1} ether",
4446
data=f"{idx}".encode("utf-8"),
4547
)
4648

49+
# NOTE: track for later assertion
50+
starting_balance[a.address] = (a := accounts[idx]).balance
51+
4752
assert van.head == msg.parent
4853
assert msg not in van.queue
4954

5055
van.stage(msg)
5156
assert msg in van.queue
5257

5358
receipt = van.commit(msg, sender=owners[0])
59+
if total_calls > 0:
60+
# NOTE: We need this to ensure accurate tracking for later in `sum()`
61+
starting_balance[owners[0].address] -= receipt.gas_used * receipt.gas_price
5462
assert van.head == msg.hash
5563

5664
if total_calls > 0:
5765
assert receipt.events == [
5866
van.contract.Executed(
5967
executor=owners[0],
6068
target=account,
61-
value=idx,
69+
value=(idx + 1) * int(1e18),
6270
data=f"{idx}".encode("utf-8"),
6371
)
6472
for idx, account in enumerate(accounts[:total_calls])
6573
]
74+
assert all(
75+
a.balance == starting_balance[a.address] + (idx + 1) * int(1e18)
76+
for idx, a in enumerate(accounts[:total_calls])
77+
)
78+
assert van.contract.balance == (
79+
starting_balance[van.contract.address]
80+
- sum(
81+
a.balance - starting_balance[a.address] for a in accounts[:total_calls]
82+
)
83+
)
6684

6785
else:
6886
assert receipt.events == []
87+
assert van.contract.balance == starting_balance[van.contract.address]

uv.lock

Lines changed: 6 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)