diff --git a/README.md b/README.md index 54b660a..376fe06 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,18 @@ txn.add(vault.deposit, amount) txn(sender=safe) ``` +Propose a simple transaction by using `submit=False` as a transaction kwargs so it can await signatures from other signers: + +```python +from ape import accounts + +safe = accounts.load("my-safe") +other = accounts.load("other") +safe.transfer(other, "1 wei", submit=False) +``` + +**NOTE**: It may error saying the transaction was not fully signed but that is ok. + You can use the CLI extension to view and sign for pending transactions: ```bash diff --git a/ape_safe/_cli/click_ext.py b/ape_safe/_cli/click_ext.py index 9f99273..3c0badd 100644 --- a/ape_safe/_cli/click_ext.py +++ b/ape_safe/_cli/click_ext.py @@ -1,7 +1,7 @@ import click from ape import accounts from ape.cli import ApeCliContextObject, ape_cli_context -from click import MissingParameter +from click import MissingParameter, BadOptionUsage from ape_safe.accounts import SafeContainer @@ -28,8 +28,11 @@ def _safe_callback(ctx, param, value): options = ", ".join(safes.aliases) raise MissingParameter(message=f"Must specify safe to use (one of '{options}').") - else: + elif value in safes.aliases: return accounts.load(value) + else: + raise BadOptionUsage("--safe", f"No safe with alias '{value}'") + safe_option = click.option("--safe", callback=_safe_callback) diff --git a/ape_safe/_cli/pending.py b/ape_safe/_cli/pending.py index c61b083..a3a4219 100644 --- a/ape_safe/_cli/pending.py +++ b/ape_safe/_cli/pending.py @@ -26,11 +26,11 @@ def _list(cli_ctx: SafeCliContext, network, safe) -> None: """ _ = network # Needed for NetworkBoundCommand - for safe_tx in safe.client.get_transactions(confirmed=False): + for tx in safe.client.get_transactions(confirmed=False): rich.print( - f"Transaction {safe_tx.nonce}: " - f"({len(safe_tx.confirmations)}/{safe.confirmations_required}) " - f"safe_tx_hash={safe_tx.safe_tx_hash}" + f"Transaction {tx.nonce}: " + f"({len(tx.confirmations)}/{safe.confirmations_required}) " + f"safe_tx_hash={tx.safe_tx_hash}" ) @@ -86,7 +86,7 @@ def approve(cli_ctx: SafeCliContext, network, safe, nonce, execute): if not txn: cli_ctx.abort(f"Pending transaction '{nonce}' not found.") - safe_tx = safe.create_safe_tx(**txn.dict()) + safe_tx = safe.create_safe_tx(**txn.dict(by_alias=True)) num_confirmations = len(txn.confirmations) signatures_added = {} diff --git a/ape_safe/_cli/safe_mgmt.py b/ape_safe/_cli/safe_mgmt.py index 203855d..df9d18d 100644 --- a/ape_safe/_cli/safe_mgmt.py +++ b/ape_safe/_cli/safe_mgmt.py @@ -91,9 +91,12 @@ def remove(cli_ctx: SafeCliContext, safe): Stop tracking a locally-tracked Safe """ - if click.confirm(f"Remove safe {safe.address} ({safe.alias})"): - cli_ctx.safes.delete_account(safe.alias) - cli_ctx.logger.success(f"Safe '{safe.address}' ({safe.alias}) removed.") + alias = safe.alias + address = safe.address + + if click.confirm(f"Remove safe {address} ({alias})"): + cli_ctx.safes.delete_account(alias) + cli_ctx.logger.success(f"Safe '{address}' ({alias}) removed.") @click.command(cls=NetworkBoundCommand) diff --git a/ape_safe/accounts.py b/ape_safe/accounts.py index 58cafa4..db8d041 100644 --- a/ape_safe/accounts.py +++ b/ape_safe/accounts.py @@ -7,7 +7,7 @@ from ape.api.address import BaseAddress from ape.api.networks import LOCAL_NETWORK_NAME, ForkedNetworkAPI from ape.contracts import ContractInstance -from ape.exceptions import ProviderNotConnectedError +from ape.exceptions import ProviderNotConnectedError, SignatureError from ape.logging import logger from ape.managers.accounts import AccountManager, TestAccountManager from ape.types import AddressType, HexBytes, MessageSignature, SignableMessage @@ -169,6 +169,12 @@ def _safe_tx_exec_args(safe_tx: SafeTx) -> List: return list(safe_tx._body_["message"].values()) +def hash_transaction(safe_tx: SafeTx) -> str: + safe_tx.to = safe_tx.to or ZERO_ADDRESS + safe_tx.data = safe_tx.data or b"" + return hash_eip712_message(safe_tx).hex() + + class SafeAccount(AccountAPI): account_file_path: Path # NOTE: Cache any relevant data here @@ -303,12 +309,13 @@ def create_safe_tx(self, txn: Optional[TransactionAPI] = None, **safe_tx_kwargs) "gasToken": ZERO_ADDRESS, "refundReceiver": ZERO_ADDRESS, } + safe_tx = {**safe_tx, **{k: v for k, v in safe_tx_kwargs.items() if k in safe_tx}} return self.safe_tx_def(**safe_tx) def pending_transactions(self) -> Iterator[Tuple[SafeTx, List[SafeTxConfirmation]]]: for executed_tx in self.client.get_transactions(confirmed=False): - yield self.create_safe_tx(**executed_tx.dict()), executed_tx.confirmations + yield self.create_safe_tx(**executed_tx.dict(by_alias=True)), executed_tx.confirmations @property def local_signers(self) -> List[AccountAPI]: @@ -457,12 +464,14 @@ def call( # type: ignore[override] if impersonate: return self._impersonated_call(txn, **call_kwargs) - return super().call(txn, **call_kwargs) + try: + return super().call(txn, **call_kwargs) + except SignatureError: + # TODO: Create an intermediate receipt object + return None # type: ignore def get_api_confirmations(self, safe_tx: SafeTx) -> Dict[AddressType, MessageSignature]: - # TODO: This signature is wrong. - safe_tx.to = safe_tx.to or ZERO_ADDRESS - safe_tx_hash = hash_eip712_message(safe_tx).hex() + safe_tx_hash = hash_transaction(safe_tx) try: client_confirmations = self.client.get_confirmations(safe_tx_hash) except SafeClientException: @@ -487,6 +496,7 @@ def _contract_approvals(self, safe_tx: SafeTx) -> Mapping[AddressType, MessageSi def _all_approvals(self, safe_tx: SafeTx) -> Dict[AddressType, MessageSignature]: approvals = self.get_api_confirmations(safe_tx) + # NOTE: Do this last because it should take precedence approvals.update(self._contract_approvals(safe_tx)) return approvals diff --git a/ape_safe/client/__init__.py b/ape_safe/client/__init__.py index 1be6bb5..653826c 100644 --- a/ape_safe/client/__init__.py +++ b/ape_safe/client/__init__.py @@ -114,7 +114,7 @@ def post_transaction(self, safe_tx: SafeTx, sigs: Dict[AddressType, MessageSigna ) ) post_dict: Dict = {} - for key, value in tx_data.dict().items(): + for key, value in tx_data.dict(by_alias=True).items(): if isinstance(value, HexBytes): post_dict[key] = value.hex() elif isinstance(value, OperationType):