diff --git a/CHANGELOG.md b/CHANGELOG.md index fe60f03..fff592d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the Aptos Python SDK will be captured in this file. This changelog is written by hand for now. +## Unreleased +- Add Multikey support for Python, with an example +- Deprecate non-BCS transaction submission + ## 0.8.6 - add client for graphql indexer service with light demo in coin transfer - add mypy to ignore missing types for graphql and ecdsa diff --git a/aptos_sdk/account_address.py b/aptos_sdk/account_address.py index b0f3287..0215d7e 100644 --- a/aptos_sdk/account_address.py +++ b/aptos_sdk/account_address.py @@ -208,6 +208,8 @@ def from_key(key: asymmetric_crypto.PublicKey) -> AccountAddress: hasher.update(AuthKeyScheme.MultiEd25519) elif isinstance(key, asymmetric_crypto_wrapper.PublicKey): hasher.update(AuthKeyScheme.SingleKey) + elif isinstance(key, asymmetric_crypto_wrapper.MultiPublicKey): + hasher.update(AuthKeyScheme.MultiKey) else: raise Exception("Unsupported asymmetric_crypto.PublicKey key type.") diff --git a/aptos_sdk/asymmetric_crypto_wrapper.py b/aptos_sdk/asymmetric_crypto_wrapper.py index 2637d00..bdc7776 100644 --- a/aptos_sdk/asymmetric_crypto_wrapper.py +++ b/aptos_sdk/asymmetric_crypto_wrapper.py @@ -3,6 +3,8 @@ from __future__ import annotations +from typing import List, Tuple, cast + from . import asymmetric_crypto, ed25519, secp256k1_ecdsa from .bcs import Deserializer, Serializer @@ -29,7 +31,10 @@ def to_crypto_bytes(self) -> bytes: return ser.output() def verify(self, data: bytes, signature: asymmetric_crypto.Signature) -> bool: - return self.public_key.verify(data, signature) + # Convert signature to the original signature + sig = cast(Signature, signature) + + return self.public_key.verify(data, sig.signature) @staticmethod def deserialize(deserializer: Deserializer) -> PublicKey: @@ -85,3 +90,133 @@ def deserialize(deserializer: Deserializer) -> Signature: def serialize(self, serializer: Serializer): serializer.uleb128(self.variant) serializer.struct(self.signature) + + +class MultiPublicKey(asymmetric_crypto.PublicKey): + keys: List[PublicKey] + threshold: int + + MIN_KEYS = 2 + MAX_KEYS = 32 + MIN_THRESHOLD = 1 + + def __init__(self, keys: List[asymmetric_crypto.PublicKey], threshold: int): + assert ( + self.MIN_KEYS <= len(keys) <= self.MAX_KEYS + ), f"Must have between {self.MIN_KEYS} and {self.MAX_KEYS} keys." + assert ( + self.MIN_THRESHOLD <= threshold <= len(keys) + ), f"Threshold must be between {self.MIN_THRESHOLD} and {len(keys)}." + + # Ensure keys are wrapped + self.keys = [] + for key in keys: + if isinstance(key, PublicKey): + self.keys.append(key) + else: + self.keys.append(PublicKey(key)) + + self.threshold = threshold + + def __str__(self) -> str: + return f"{self.threshold}-of-{len(self.keys)} Multi key" + + def verify(self, data: bytes, signature: asymmetric_crypto.Signature) -> bool: + try: + total_sig = cast(MultiSignature, signature) + assert self.threshold <= len( + total_sig.signatures + ), f"Insufficient signatures, {self.threshold} > {len(total_sig.signatures)}" + + for idx, signature in total_sig.signatures: + assert ( + len(self.keys) > idx + ), f"Signature index exceeds available keys {len(self.keys)} < {idx}" + assert self.keys[idx].verify( + data, signature + ), "Unable to verify signature" + + except Exception: + return False + return True + + @staticmethod + def from_crypto_bytes(indata: bytes) -> MultiPublicKey: + deserializer = Deserializer(indata) + return deserializer.struct(MultiPublicKey) + + def to_crypto_bytes(self) -> bytes: + serializer = Serializer() + serializer.struct(self) + return serializer.output() + + @staticmethod + def deserialize(deserializer: Deserializer) -> MultiPublicKey: + keys = deserializer.sequence(PublicKey.deserialize) + threshold = deserializer.u8() + return MultiPublicKey(keys, threshold) + + def serialize(self, serializer: Serializer): + serializer.sequence(self.keys, Serializer.struct) + serializer.u8(self.threshold) + + +class MultiSignature(asymmetric_crypto.Signature): + signatures: List[Tuple[int, Signature]] + BITMAP_NUM_OF_BYTES: int = 4 + + def __init__(self, signatures: List[Tuple[int, asymmetric_crypto.Signature]]): + # Sort first to ensure no issues in order + # signatures.sort(key=lambda x: x[0]) + self.signatures = [] + for index, signature in signatures: + assert ( + index < self.BITMAP_NUM_OF_BYTES * 8 + ), "bitmap value exceeds maximum value" + if isinstance(signature, Signature): + self.signatures.append((index, signature)) + else: + self.signatures.append((index, Signature(signature))) + + def __eq__(self, other: object): + if not isinstance(other, MultiSignature): + return NotImplemented + return self.signatures == other.signatures + + def __str__(self) -> str: + return f"{self.signatures}" + + @staticmethod + def deserialize(deserializer: Deserializer) -> MultiSignature: + signatures = deserializer.sequence(Signature.deserialize) + deserializer.uleb128() + bitmap = deserializer.u32() + num_bits = MultiSignature.BITMAP_NUM_OF_BYTES * 8 + sig_index = 0 + indexed_signatures = [] + + for i in range(0, num_bits): + has_signature = (bitmap & index_to_bitmap_value(i)) != 0 + if has_signature: + indexed_signatures.append((i, signatures[sig_index])) + sig_index += 1 + + return MultiSignature(signatures) + + def serialize(self, serializer: Serializer): + actual_sigs = [] + bitmap = 0 + + for i, signature in self.signatures: + bitmap |= index_to_bitmap_value(i) + actual_sigs.append(signature) + + serializer.sequence(actual_sigs, Serializer.struct) + serializer.uleb128(self.BITMAP_NUM_OF_BYTES) + serializer.u32(bitmap) + + +def index_to_bitmap_value(i: int) -> int: + bit = i % 8 + byte = i.__floordiv__(8) + return (128 >> bit) << (byte * 8) diff --git a/aptos_sdk/async_client.py b/aptos_sdk/async_client.py index ea7cf75..95fd13b 100644 --- a/aptos_sdk/async_client.py +++ b/aptos_sdk/async_client.py @@ -9,6 +9,7 @@ import httpx import python_graphql_client +from typing_extensions import deprecated from .account import Account from .account_address import AccountAddress @@ -518,8 +519,10 @@ async def submit_and_wait_for_bcs_transaction( await self.wait_for_transaction(txn_hash) return await self.transaction_by_hash(txn_hash) + @deprecated("please use bcs_submit_transaction for better performance and security") async def submit_transaction(self, sender: Account, payload: Dict[str, Any]) -> str: """ + Deprecated, please use bcs_submit_transaction for better performance and security 1) Generates a transaction request 2) submits that to produce a raw transaction 3) signs the raw transaction @@ -716,17 +719,22 @@ async def create_multi_agent_bcs_transaction( async def create_bcs_transaction( self, - sender: Account, + sender: Account | AccountAddress, payload: TransactionPayload, sequence_number: Optional[int] = None, ) -> RawTransaction: + if isinstance(sender, Account): + sender_address = sender.address() + else: + sender_address = sender + sequence_number = ( sequence_number if sequence_number is not None - else await self.account_sequence_number(sender.address()) + else await self.account_sequence_number(sender_address) ) return RawTransaction( - sender.address(), + sender_address, sequence_number, payload, self.client_config.max_gas_amount, @@ -751,10 +759,13 @@ async def create_bcs_signed_transaction( # Transaction wrappers # + @deprecated("please use bcs_transfer for better performance and security") async def transfer( self, sender: Account, recipient: AccountAddress, amount: int ) -> str: - """Transfer a given coin amount from a given Account to the recipient's account address. + """ + Deprecated: please use bcs_transfer for greater performance and security + Transfer a given coin amount from a given Account to the recipient's account address. Returns the sequence number of the transaction used to transfer.""" payload = { diff --git a/aptos_sdk/authenticator.py b/aptos_sdk/authenticator.py index 3d60886..44c44c0 100644 --- a/aptos_sdk/authenticator.py +++ b/aptos_sdk/authenticator.py @@ -104,6 +104,8 @@ def __init__(self, authenticator: typing.Any): self.variant = AccountAuthenticator.MULTI_ED25519 elif isinstance(authenticator, SingleKeyAuthenticator): self.variant = AccountAuthenticator.SINGLE_KEY + elif isinstance(authenticator, MultiKeyAuthenticator): + self.variant = AccountAuthenticator.MULTI_KEY else: raise Exception("Invalid type") self.authenticator = authenticator @@ -360,3 +362,29 @@ def deserialize(deserializer: Deserializer) -> SingleKeyAuthenticator: def serialize(self, serializer: Serializer): serializer.struct(self.public_key) serializer.struct(self.signature) + + +class MultiKeyAuthenticator: + public_key: asymmetric_crypto_wrapper.MultiPublicKey + signature: asymmetric_crypto_wrapper.MultiSignature + + def __init__( + self, + public_key: asymmetric_crypto_wrapper.MultiPublicKey, + signature: asymmetric_crypto_wrapper.MultiSignature, + ): + self.public_key = public_key + self.signature = signature + + def verify(self, data: bytes) -> bool: + return self.public_key.verify(data, self.signature) + + @staticmethod + def deserialize(deserializer: Deserializer) -> MultiKeyAuthenticator: + public_key = deserializer.struct(asymmetric_crypto_wrapper.MultiPublicKey) + signature = deserializer.struct(asymmetric_crypto_wrapper.MultiSignature) + return MultiKeyAuthenticator(public_key, signature) + + def serialize(self, serializer: Serializer): + serializer.struct(self.public_key) + serializer.struct(self.signature) diff --git a/examples/common.py b/examples/common.py index 6ac9d84..0ee8339 100644 --- a/examples/common.py +++ b/examples/common.py @@ -11,11 +11,11 @@ # :!:>section_1 FAUCET_URL = os.getenv( "APTOS_FAUCET_URL", - "https://faucet.devnet.aptoslabs.com", + "http://localhost:8081", ) INDEXER_URL = os.getenv( "APTOS_INDEXER_URL", "https://api.devnet.aptoslabs.com/v1/graphql", ) -NODE_URL = os.getenv("APTOS_NODE_URL", "https://api.devnet.aptoslabs.com/v1") +NODE_URL = os.getenv("APTOS_NODE_URL", "http://localhost:8080/v1") # <:!:section_1 diff --git a/examples/multikey.py b/examples/multikey.py new file mode 100644 index 0000000..aeac707 --- /dev/null +++ b/examples/multikey.py @@ -0,0 +1,119 @@ +# Copyright © Aptos Foundation +# SPDX-License-Identifier: Apache-2.0 + +import asyncio + +from aptos_sdk import asymmetric_crypto_wrapper, ed25519, secp256k1_ecdsa +from aptos_sdk.account import Account +from aptos_sdk.account_address import AccountAddress +from aptos_sdk.asymmetric_crypto_wrapper import MultiSignature, Signature +from aptos_sdk.async_client import FaucetClient, IndexerClient, RestClient +from aptos_sdk.authenticator import AccountAuthenticator, MultiKeyAuthenticator +from aptos_sdk.bcs import Serializer +from aptos_sdk.transactions import ( + EntryFunction, + SignedTransaction, + TransactionArgument, + TransactionPayload, +) + +from .common import FAUCET_URL, INDEXER_URL, NODE_URL + + +async def main(): + # :!:>section_1 + rest_client = RestClient(NODE_URL) + faucet_client = FaucetClient(FAUCET_URL, rest_client) # <:!:section_1 + if INDEXER_URL and INDEXER_URL != "none": + IndexerClient(INDEXER_URL) + else: + pass + + # :!:>section_2 + key1 = secp256k1_ecdsa.PrivateKey.random() + key2 = ed25519.PrivateKey.random() + key3 = secp256k1_ecdsa.PrivateKey.random() + pubkey1 = key1.public_key() + pubkey2 = key2.public_key() + pubkey3 = key3.public_key() + + alice_pubkey = asymmetric_crypto_wrapper.MultiPublicKey( + [pubkey1, pubkey2, pubkey3], 2 + ) + alice_address = AccountAddress.from_key(alice_pubkey) + + bob = Account.generate() + + print("\n=== Addresses ===") + print(f"Multikey Alice: {alice_address}") + print(f"Bob: {bob.address()}") + + # :!:>section_3 + alice_fund = faucet_client.fund_account(alice_address, 100_000_000) + bob_fund = faucet_client.fund_account(bob.address(), 0) # <:!:section_3 + await asyncio.gather(*[alice_fund, bob_fund]) + + print("\n=== Initial Balances ===") + # :!:>section_4 + alice_balance = rest_client.account_balance(alice_address) + bob_balance = rest_client.account_balance(bob.address()) + [alice_balance, bob_balance] = await asyncio.gather(*[alice_balance, bob_balance]) + print(f"Alice: {alice_balance}") + print(f"Bob: {bob_balance}") # <:!:section_4 + + # Have Alice give Bob 1_000 coins + # :!:>section_5 + + # TODO: Rework SDK to support this without the extra work + + # Build Transaction to sign + transaction_arguments = [ + TransactionArgument(bob.address(), Serializer.struct), + TransactionArgument(1_000, Serializer.u64), + ] + + payload = EntryFunction.natural( + "0x1::aptos_account", + "transfer", + [], + transaction_arguments, + ) + + raw_transaction = await rest_client.create_bcs_transaction( + alice_address, TransactionPayload(payload) + ) + + # Sign by multiple keys + raw_txn_bytes = raw_transaction.keyed() + sig1 = key1.sign(raw_txn_bytes) + sig2 = key2.sign(raw_txn_bytes) + + # Combine them + total_sig = MultiSignature([(0, Signature(sig1)), (1, Signature(sig2))]) + alice_auth = AccountAuthenticator(MultiKeyAuthenticator(alice_pubkey, total_sig)) + + # Verify signatures + assert key1.public_key().verify(raw_txn_bytes, sig1) + assert key2.public_key().verify(raw_txn_bytes, sig2) + assert alice_pubkey.verify(raw_txn_bytes, total_sig) + assert alice_auth.verify(raw_txn_bytes) + + # Submit to network + signed_txn = SignedTransaction(raw_transaction, alice_auth) + txn_hash = await rest_client.submit_bcs_transaction(signed_txn) + + # :!:>section_6 + await rest_client.wait_for_transaction(txn_hash) # <:!:section_6 + + print("\n=== Final Balances ===") + alice_balance = rest_client.account_balance(alice_address) + bob_balance = rest_client.account_balance(bob.address()) + [alice_balance, bob_balance] = await asyncio.gather(*[alice_balance, bob_balance]) + print(f"Alice: {alice_balance}") + print(f"Bob: {bob_balance}") # <:!:section_4 + + await rest_client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/transfer_coin.py b/examples/transfer_coin.py index 21bc12e..4624982 100644 --- a/examples/transfer_coin.py +++ b/examples/transfer_coin.py @@ -41,7 +41,9 @@ async def main(): # Have Alice give Bob 1_000 coins # :!:>section_5 - txn_hash = await rest_client.transfer(alice, bob.address(), 1_000) # <:!:section_5 + txn_hash = await rest_client.bcs_transfer( + alice, bob.address(), 1_000 + ) # <:!:section_5 # :!:>section_6 await rest_client.wait_for_transaction(txn_hash) # <:!:section_6