From 9a17a984d91e262aedef4fc4d7dc68456e4da5c2 Mon Sep 17 00:00:00 2001 From: Kik Engineering Date: Sun, 25 Apr 2021 21:35:32 -0400 Subject: [PATCH] all: add sender create, merge_token_accounts GitOrigin-RevId: b97b69a754f43b9cd846d242128a03c3b986356d --- CHANGELOG.md | 5 + README.md | 84 +-- agora/client/account/resolver.py | 70 --- agora/client/client.py | 385 ++++++++---- agora/client/internal.py | 94 ++- agora/client/utils.py | 7 - agora/error.py | 103 ++-- agora/keys.py | 4 +- agora/model/__init__.py | 7 +- agora/model/account.py | 27 +- agora/model/creation.py | 24 + agora/model/earn.py | 5 +- agora/model/payment.py | 62 +- agora/model/transaction.py | 4 +- agora/model/utils.py | 245 ++++++++ agora/solana/address.py | 12 +- agora/solana/token/program.py | 2 +- agora/solana/transaction.py | 4 +- agora/webhook/create_account.py | 55 ++ agora/webhook/handler.py | 68 ++- agora/webhook/sign_transaction.py | 29 +- examples/client/submit_payment.py | 14 +- examples/webhook/app.py | 3 - requirements.txt | 4 +- tests/client/account/__init__.py | 0 tests/client/account/test_resolver.py | 91 --- tests/client/test_client.py | 772 ++++++++++++++++++------- tests/client/test_internal.py | 276 +++++++-- tests/model/test_payment.py | 150 ----- tests/model/test_transaction.py | 6 +- tests/model/test_utils.py | 380 ++++++++++++ tests/solana/test_address.py | 94 +++ tests/solana/test_transaction.py | 10 +- tests/solana/token/test_program.py | 2 +- tests/test_error.py | 13 +- tests/webhook/test_create_account.py | 106 ++++ tests/webhook/test_events.py | 7 +- tests/webhook/test_handler.py | 104 +++- tests/webhook/test_sign_transaction.py | 112 ++-- 39 files changed, 2394 insertions(+), 1046 deletions(-) delete mode 100644 agora/client/account/resolver.py delete mode 100644 agora/client/utils.py create mode 100644 agora/model/creation.py create mode 100644 agora/model/utils.py create mode 100644 agora/webhook/create_account.py delete mode 100644 tests/client/account/__init__.py delete mode 100644 tests/client/account/test_resolver.py delete mode 100644 tests/model/test_payment.py create mode 100644 tests/model/test_utils.py create mode 100644 tests/webhook/test_create_account.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5364e32..0975d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - Removed `envelope`, `kin_version` and `get_tx_hash()` from `SignTransactionRequest` - Removed `envelope` from `SignTransactionResponse` - Removed `kin_version` and `stellar_event` from `TransactionEvent` +- Add sender create support for `Client.submit_payment` +- Add `merge_token_accounts` to `Client` +- Add create account webhook support +- Add creation parsing to `SigntransactionRequest` +- `SignTransactionResponse.sign` now signs Solana transactions ## [0.6.1](https://github.com/kinecosystem/kin-python/releases/tag/0.6.1) - Bump agora-api and grpcio version diff --git a/README.md b/README.md index 355bb19..38d45a1 100644 --- a/README.md +++ b/README.md @@ -37,45 +37,36 @@ from agora.client import Client, Environment client = Client(Environment.TEST, app_index=1) ``` -Additional options include: -- `whitelist_key`: The private key of an account that will be used to co-sign all transactions. Should only be set for Kin 3. -- `grpc_channel`: A specific `grpc.Channel` to use. Cannot be set if `endpoint` is set. -- `endpoint`: A specific endpoint to use in the client. Cannot be set if `grpc_channel` is set. -- `retry_config`: A custom `agora.client.RetryConfig` to configure how the client retries requests. -- `kin_version`: The version of Kin to use. Defaults to 3. -- `default_commitment`: (Kin 4 only) The commitment requirement to use by default for Kin 4 Agora requests. See the [website documentation](https://docs.kin.org/solana#commitment) for more information. - ### Usage + +The following outlines some example usages of the client. See the [documentation](https://docs.kin.org/python/api) for more details. + #### Create an Account The `create_account` method creates an account with the provided private key. -To create a new account, first generate a new private key: +To create a new account, first generate a new private key, then submit it using `create_account`: ```python +from agora.client import Client, Environment from agora.keys import PrivateKey -private_key = PrivateKey.random() -``` +client = Client(Environment.TEST, app_index=1) -Next, submit it using `create_account`: -```python +private_key = PrivateKey.random() client.create_account(private_key) ``` -In addition to the mandatory `private_key` parameter, `create_account` has the following optional parameters: -- `commitment`: (Kin 4 only) Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. -- `subsidizer`: (Kin 4 only) The private key of an account to use as the funder of the transaction instead of the subsidizer configured on Agora. - #### Get a Transaction The `get_transaction` method gets transaction data by transaction id. ```python +from agora.client import Client, Environment + +client = Client(Environment.TEST, app_index=1) + # tx_id is either a 32-byte Stellar transaction hash or a 64-byte Solana transaction signature tx_id = b'' transaction_data = client.get_transaction(tx_id) ``` -In addition to the mandatory `tx_id` parameter, `get_transaction` has the following optional parameters: -- `commitment`: (Kin 4 only) Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. - #### Get an Account Balance The `get_balance` method gets the balance of the provided account, in [quarks](https://docs.kin.org/terms-and-concepts#quark). ```python @@ -87,14 +78,12 @@ public_key = PrivateKey.random().public_key balance = client.get_balance(public_key) ``` -In addition to the mandatory `public_key` parameter, `get_balance` has the following optional parameters: -- `commitment`: (Kin 4 only) Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. - #### Submit a Payment The `submit_payment` method submits the provided payment to Agora. ```python from agora.client import Client, Environment -from agora.model import Payment, TransactionType, PrivateKey, PublicKey +from agora.keys import PrivateKey, PublicKey +from agora.model import Payment, TransactionType from agora.utils import kin_to_quarks client = Client(Environment.TEST, app_index=1) @@ -105,29 +94,13 @@ payment = Payment(sender, dest, TransactionType.EARN, kin_to_quarks("1")) tx_hash = client.submit_payment(payment) ``` -A `Payment` has the following required properties: -- `sender`: The private key of the account from which the payment will be sent. -- `destination`: The public key of the account to which the payment will be sent. -- `tx_type`: The transaction type of the payment. -- `quarks`: The amount of the payment, in [quarks](https://docs.kin.org/terms-and-concepts#quark). - -Additionally, it has some optional properties: -- `channel`: (Kin 2 and Kin 3 only) The private key of a [channel](https://docs.kin.org/how-it-works#channels) account to use as the source of the transaction. If unset, `sender` will be used as the transaction source. -- `invoice`: An [Invoice](https://docs.kin.org/how-it-works#invoices) to associate with this payment. Cannot be set if `memo` is set. -- `memo` A text memo to include in the transaction. Cannot be set if `invoice` is set. -- `subsidizer`: (Kin 4 only) The private key of an account to use as the funder of the transaction instead of the subsidizer configured on Agora. - -`submit_payment` also has the following optional properties: -- `commitment`: (Kin 4 only) Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. -- `sender_resolution`: (Kin 4 only) Indicates which type of account resolution to use for the payment sender. -- `dest_resolution`: (Kin 4 only) Indicates which type of account resolution to use for the payment destination. - #### Submit an Earn Batch The `submit_earn_batch` method submits a batch of earns to Agora from a single account. It batches the earns into fewer transactions where possible and submits as many transactions as necessary to submit all the earns. ```python from agora.client import Client, Environment -from agora.model import Earn, PrivateKey, PublicKey +from agora.keys import PublicKey, PrivateKey +from agora.model import Earn, EarnBatch from agora.utils import kin_to_quarks client = Client(Environment.TEST, app_index=1) @@ -141,24 +114,11 @@ earns = [ Earn(dest2, 100000), # also equivalent to 1 Kin ... ] +batch = EarnBatch(sender, earns) -batch_earn_result = client.submit_earn_batch(sender, earns) +batch_earn_result = client.submit_earn_batch(batch) ``` -A single `Earn` has the following properties: -- `destination`: The public key of the account to which the earn will be sent. -- `quarks`: The amount of the earn, in [quarks](https://docs.kin.org/terms-and-concepts#quark). -- `invoice`: (optional) An [Invoice](https://docs.kin.org/how-it-works#invoices) to associate with this earn. - -The `submit_earn_batch` method has the following parameters: -- `sender`: The private key of the account from which the earns will be sent. -- `earns`: The list of earns to send. -- `channel`: (optional, Kin 2 and Kin 3 only) The private key of a [channel](https://docs.kin.org/how-it-works#channels) account to use as the transaction source. If not set, `sender` will be used as the source. -- `memo`: (optional) A text memo to include in the transaction. Cannot be used if the earns have invoices associated with them. -- `commitment`: (Kin 4 only) Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. -- `sender_resolution`: (Kin 4 only) Indicates which type of account resolution to use for the payment sender. -- `dest_resolution`: (Kin 4 only) Indicates which type of account resolution to use for the payment destination. - ### Examples A few examples for creating an account and different ways of submitting payments and batched earns can be found in `examples/client`. @@ -221,11 +181,6 @@ def events_endpoint_func(request): # respond using provided status_code and request_body ``` -`WebhookHandler.handle_events` takes in the following mandatory parameters: -- `f`: A function that accepts a list of Events. Any return value will be ignored. -- `signature`: The base64-encoded signature included as the `X-Agora-HMAC-SHA256` header in the HTTP request (see the [Agora Webhook Reference](https://docs.kin.org/agora/webhook) for more details). -- `req_body`: The string request body. - #### Sign Transaction Webhook The sign transaction webhook is used to sign Kin 3 transactions with a whitelisted Kin 3 account to remove fees. On Kin 4, the webhook can be used to simply approve or reject transactions submitted by mobile clients. @@ -261,11 +216,6 @@ def sign_tx_endpoint_func(request): # respond using provided status_code and request_body ``` -`WebhookHandler.handle_sign_transaction` takes in the following mandatory parameters: -- `f`: A function that takes in a SignTransactionRequest and a SignTransactionResponse. Any return value will be ignored. -- `signature`: The base64-encoded signature included as the `X-Agora-HMAC-SHA256` header in the HTTP request (see the [Agora Webhook Reference](https://docs.kin.org/agora/webhook) for more details). -- `req_body`: The string request body. - ### Example Code A simple example Flask server implementing both the Events and Sign Transaction webhooks can be found in `examples/webhook/app.py`. To run it, first install all required dependencies (it is recommended that you use a virtual environment): ``` diff --git a/agora/client/account/resolver.py b/agora/client/account/resolver.py deleted file mode 100644 index 4f60e25..0000000 --- a/agora/client/account/resolver.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import List - -from agoraapi.account.v4 import account_service_pb2_grpc as account_pb_grpc, account_service_pb2 as account_pb -from agoraapi.common.v4 import model_pb2 as model_pb - -from agora.cache.cache import LRUCache -from agora.error import NoTokenAccountsError -from agora.keys import PublicKey, ED25519_PUB_KEY_SIZE -from agora.retry import Strategy, retry - - -class TokenAccountResolver: - """Resolves owner account IDs to their token accounts. Handles caching. - - :param account_stub: the Agora Account Service stub to use for requests. - """ - - def __init__(self, account_stub: account_pb_grpc.AccountStub, retry_strategies: List[Strategy] = None): - self._account_stub = account_stub - self._retry_strategies = retry_strategies if retry_strategies else [] - self._cache = LRUCache(300, 500) - - def resolve_token_accounts(self, public_key: PublicKey) -> List[PublicKey]: - """Resolve the provided public key to its token accounts. - - :param public_key: the :class:`PublicKey ` of the owner. - :return: a list of :class:`PublicKey ` objects. - """ - cached = self._get_from_cache(public_key) - if cached: - return cached - - def _call_resolve(): - response = self._account_stub.ResolveTokenAccounts(account_pb.ResolveTokenAccountsRequest( - account_id=model_pb.SolanaAccountId(value=public_key.raw) - )) - if not response.token_accounts: - raise NoTokenAccountsError() - - return response - - try: - resp = retry(self._retry_strategies, _call_resolve) - token_accounts = [PublicKey(account_id.value) for account_id in resp.token_accounts] - except NoTokenAccountsError: - token_accounts = [] - - if token_accounts: - self._set_in_cache(public_key, token_accounts) - - return token_accounts - - def _set_in_cache(self, public_key: PublicKey, token_accounts: List[PublicKey]): - cache_key = self._get_cache_key(public_key) - entry = self._create_cache_entry(token_accounts) - self._cache.set(cache_key, entry) - - def _get_from_cache(self, public_key: PublicKey) -> List[PublicKey]: - cache_key = self._get_cache_key(public_key) - entry = self._cache.get(cache_key) - return self._parse_cache_entry(entry) if entry else [] - - def _get_cache_key(self, public_key: PublicKey): - return public_key.raw - - def _create_cache_entry(self, accounts: List[PublicKey]) -> bytes: - return b''.join([account.raw for account in accounts]) - - def _parse_cache_entry(self, entry: bytes) -> List[PublicKey]: - return [PublicKey(entry[i:i + ED25519_PUB_KEY_SIZE]) for i in range(0, len(entry), ED25519_PUB_KEY_SIZE)] diff --git a/agora/client/client.py b/agora/client/client.py index 7434153..81f91a3 100644 --- a/agora/client/client.py +++ b/agora/client/client.py @@ -2,19 +2,15 @@ from typing import List, Optional import grpc -from agoraapi.account.v4 import account_service_pb2_grpc as account_pb_grpc from agoraapi.transaction.v4 import transaction_service_pb2 as tx_pb from agora import solana from agora.client.account.resolution import AccountResolution -from agora.client.account.resolver import TokenAccountResolver from agora.client.environment import Environment from agora.client.internal import InternalClient, SubmitTransactionResult -from agora.client.utils import _generate_token_account -from agora.error import AccountExistsError, InvoiceError, TransactionMalformedError, SenderDoesNotExistError, \ - InsufficientBalanceError, DestinationDoesNotExistError, InsufficientFeeError, BadNonceError, \ +from agora.error import AccountExistsError, InvoiceError, InsufficientBalanceError, BadNonceError, \ TransactionRejectedError, Error, BlockchainVersionError, AccountNotFoundError, NoSubsidizerError, \ - AlreadySubmittedError, invoice_error_from_proto, UnsupportedMethodError + AlreadySubmittedError, invoice_error_from_proto, UnsupportedMethodError, PayerRequiredError from agora.keys import PrivateKey, PublicKey from agora.model.earn import EarnBatch from agora.model.invoice import InvoiceList @@ -25,7 +21,7 @@ from agora.model.transaction_type import TransactionType from agora.retry import retry, LimitStrategy, BackoffWithJitterStrategy, BinaryExponentialBackoff, \ NonRetriableErrorsStrategy, RetriableErrorsStrategy -from agora.solana import Commitment, system, token, memo +from agora.solana import Commitment, memo, system, token _MIN_VERSION = 4 _MAX_VERSION = 4 @@ -38,11 +34,7 @@ _NON_RETRIABLE_ERRORS = [ AccountExistsError, AccountNotFoundError, - TransactionMalformedError, - SenderDoesNotExistError, - DestinationDoesNotExistError, InsufficientBalanceError, - InsufficientFeeError, TransactionRejectedError, InvoiceError, BadNonceError, @@ -52,6 +44,8 @@ _GRPC_TIMEOUT_SECONDS = 10 +_MAX_BATCH_SIZE = 15 + class RetryConfig: """A :class:`RetryConfig ` for configuring retries for Agora requests. @@ -86,10 +80,10 @@ def create_account(self, private_key: PrivateKey, commitment: Optional[Commitmen """Creates a new Kin account. :param private_key: The :class:`PrivateKey ` of the account to create - :param commitment: (optional) The commitment to use. Only applicable for Kin 4 transactions. + :param commitment: (optional) The commitment to use. :param subsidizer: (optional) The subsidizer to use for the create account transaction. The subsidizer will be used both as the payer of the transaction and will also be given the CloseAccount authority on the created - account. Only applicable for Kin 4 transactions. + account. :raise: :exc:`UnsupportedVersionError ` :raise: :exc:`AccountExistsError ` @@ -101,7 +95,7 @@ def get_transaction(self, tx_id: bytes, commitment: Optional[Commitment] = None) :param tx_id: The id of the transaction to retrieve. This can be either the 32-byte hash of a Stellar-based transaction (on Kin 2 or 3) or the 64-byte signature of a Solana-based transaction (on Kin 4). - :param commitment: (optional) The commitment to use. Only applicable for Kin 4 transactions. + :param commitment: (optional) The commitment to use. :return: a :class:`TransactionData ` object. """ raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement get_transaction') @@ -114,7 +108,7 @@ def get_balance( :param public_key: The :class:`PublicKey ` of the account to retrieve the balance for. - :param commitment: (optional) The commitment to use. Only applicable for Kin 4 transactions. + :param commitment: (optional) The commitment to use. :param account_resolution: (optional) The :class:`AccountResolution ` to use if the original account was not found. Only applies for Kin 4. Defaults to AccountResolution.PREFERRED. @@ -131,34 +125,51 @@ def resolve_token_accounts(self, public_key: PublicKey) -> List[PublicKey]: :return: a List of token accounts owned by the account with the provided public key. """ + def merge_token_accounts( + self, private_key: PrivateKey, create_associated_account: bool, commitment: Optional[Commitment] = None, + subsidizer: Optional[PrivateKey] = None + ) -> Optional[bytes]: + """Merges all of an account's token accounts into one. + + :param private_key: The owner account for which to merge token accounts. + :param create_associated_account: Indicates whether or not to create the associated token account and use it as + the destination for all the merged token accounts. + :param subsidizer: (optional) The subsidizer to use for the merge account transaction. The subsidizer will be + used both as the payer of the transaction and will also be given the CloseAccount authority on the created + account. + :param commitment: (optional) The commitment to use. + :return: The id of the transaction, if one was submitted. If `None` gets returned, there was no transaction + submitted. + """ + raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement merge_token_accounts') + def submit_payment( self, payment: Payment, commitment: Optional[Commitment] = None, sender_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED, dest_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED, + sender_create: Optional[bool] = False ) -> bytes: """Submits a payment to the Kin blockchain. :param payment: The :class:`Payment ` to submit. - :param commitment: (optional) The commitment to use. Only applicable for Kin 4 transactions. + :param commitment: (optional) The commitment to use. :param sender_resolution: (optional) The :class:`AccountResolution ` to - use for the payment sender account if the transaction fails due to an account error. Only applies for Kin 4 - transactions. Defaults to AccountResolution.PREFERRED. + use for the payment sender account if the transaction fails due to an account error. Defaults to + AccountResolution.PREFERRED. :param dest_resolution: (optional) The :class:`AccountResolution ` to - use for the payment destination account if the transaction fails due to an account error. Only applies for - Kin 4 transactions. Defaults to AccountResolution.PREFERRED. + use for the payment destination account if the transaction fails due to an account error. Defaults to + AccountResolution.PREFERRED. + :param sender_create: (optional) Specifies whether or not destination token accounts should be created if they + do not exist. :raise: :exc:`UnsupportedVersionError ` - :raise: :exc:`TransactionMalformedError ` :raise: :exc:`InvalidSignatureError ` :raise: :exc:`InsufficientBalanceError ` - :raise: :exc:`InsufficientFeeError ` - :raise: :exc:`SenderDoesNotExistError ` - :raise: :exc:`DestinationDoesNotExistError ` :raise: :exc:`BadNonceError ` :raise: :exc:`TransactionError ` :raise: :exc:`InvoiceError ` - :return: The hash of the transaction. + :return: The id of the transaction. """ raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement submit_payment') @@ -172,7 +183,7 @@ def submit_earn_batch( :param batch: The :class:`EarnBatch ` to submit. The number of earns in the batch is limited to 15, which is roughly the max number of transfers that can fit inside a Solana transaction. - :param commitment: (optional) The commitment to use. Only applicable for Kin 4 transactions. + :param commitment: (optional) The commitment to use. :param sender_resolution: (optional) The :class:`AccountResolution ` to use for the sender account if the transaction fails due to an account error. Only applies for Kin 4 transactions. Defaults to AccountResolution.PREFERRED. @@ -215,7 +226,6 @@ class Client(BaseClient): :param env: The :class:`Environment ` to use. :param app_index: (optional) The Agora index of the app, used for all transactions and requests. Required to make use of invoices. - :param kin_version: (optional) The version of Kin to use. Defaults to using Kin 4. :param grpc_channel: (optional) A GRPC :class:`Channel ` object to use for Agora requests. Only one of grpc_channel or endpoint should be set. :param endpoint: (optional) An endpoint to use instead of the default Agora endpoints. Only one of grpc_channel or @@ -261,25 +271,11 @@ def __init__( self._default_commitment = default_commitment - self._token_account_resolver = TokenAccountResolver( - account_pb_grpc.AccountStub(self._grpc_channel), - retry_strategies=[ - LimitStrategy(retry_config.max_retries + 1), - BackoffWithJitterStrategy(BinaryExponentialBackoff(retry_config.min_delay), - retry_config.max_delay, 0.1), - ] - ) - def create_account(self, private_key: PrivateKey, commitment: Optional[Commitment] = None, subsidizer: Optional[PrivateKey] = None): commitment = commitment if commitment else self._default_commitment - min_balance_resp = self._internal_client.get_minimum_balance_for_rent_exception() return retry(self._nonce_retry_strategies, self._create_solana_account, private_key, commitment, - min_balance_resp.lamports, subsidizer) - - def get_transaction(self, tx_id: bytes, commitment: Optional[Commitment] = None) -> TransactionData: - commitment = commitment if commitment else self._default_commitment - return self._internal_client.get_transaction(tx_id, commitment) + subsidizer) def get_balance( self, public_key: PublicKey, commitment: Optional[Commitment] = None, @@ -290,27 +286,113 @@ def get_balance( return self._internal_client.get_solana_account_info(public_key, commitment=commitment).balance except AccountNotFoundError as e: if account_resolution == AccountResolution.PREFERRED: - token_accounts = self._token_account_resolver.resolve_token_accounts(public_key) - if token_accounts: - return self._internal_client.get_solana_account_info(token_accounts[0], - commitment=commitment).balance + account_infos = self._internal_client.resolve_token_accounts(public_key, True) + if account_infos: + return account_infos[0].balance + raise e def resolve_token_accounts(self, public_key: PublicKey) -> List[PublicKey]: - return self._token_account_resolver.resolve_token_accounts(public_key) + account_infos = self._internal_client.resolve_token_accounts(public_key, False) + return [a.account_id for a in account_infos] + + def merge_token_accounts( + self, private_key: PrivateKey, create_associated_account: bool, commitment: Optional[Commitment] = None, + subsidizer: Optional[PrivateKey] = None, + ) -> Optional[bytes]: + commitment = commitment if commitment else self._default_commitment + + existing_accounts = self._internal_client.resolve_token_accounts(private_key.public_key, True) + if len(existing_accounts) == 0 or (len(existing_accounts) == 1 and not create_associated_account): + return None + + dest = existing_accounts[0].account_id + instructions = [] + signers = [private_key] + + config = self._internal_client.get_service_config() + if not config.subsidizer_account.value and not subsidizer: + raise NoSubsidizerError() + + if subsidizer: + subsidizer_id = subsidizer.public_key + signers.append(subsidizer) + else: + subsidizer_id = PublicKey(config.subsidizer_account.value) + + if create_associated_account: + create_instruction, assoc = token.create_associated_token_account( + subsidizer_id, + private_key.public_key, + PublicKey(config.token.value), + ) + if existing_accounts[0].account_id.raw != assoc.raw: + instructions.append(create_instruction) + instructions.append(token.set_authority( + assoc, + private_key.public_key, + token.AuthorityType.CLOSE_ACCOUNT, + new_authority=subsidizer_id)) + dest = assoc + elif len(existing_accounts) == 1: + return None + + for existing_account in existing_accounts: + if existing_account.account_id == dest: + continue + + instructions.append(token.transfer( + existing_account.account_id, + dest, + private_key.public_key, + existing_account.balance, + )) + + # If no close authority is set, it likely means we don't know it, and can't make any assumptions + if not existing_account.close_authority: + continue + + # If the subsidizer is the close authority, we can include the close instruction as they will be ok with + # signing for it + # + # Alternatively, if we're the close authority, we are signing it. + should_close = False + for a in [private_key.public_key, subsidizer_id]: + if existing_account.close_authority == a: + should_close = True + break + + if should_close: + instructions.append(token.close_account( + existing_account.account_id, + existing_account.close_authority, + existing_account.close_authority, + )) + + transaction = solana.Transaction.new(subsidizer_id, instructions) + + result = self._sign_and_submit_solana_tx(signers, transaction, commitment) + if result.errors and result.errors.tx_error: + raise result.errors.tx_error + + return result.tx_id + + def get_transaction(self, tx_id: bytes, commitment: Optional[Commitment] = None) -> TransactionData: + commitment = commitment if commitment else self._default_commitment + return self._internal_client.get_transaction(tx_id, commitment) def submit_payment( self, payment: Payment, commitment: Optional[Commitment] = None, sender_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED, dest_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED, + sender_create: Optional[bool] = False, ) -> bytes: if payment.invoice and self._app_index <= 0: raise ValueError('cannot submit a payment with an invoice without an app index') commitment = commitment if commitment else self._default_commitment result = self._resolve_and_submit_solana_payment( - payment, commitment, sender_resolution=sender_resolution, - dest_resolution=dest_resolution + payment, commitment, sender_resolution, dest_resolution, sender_create, ) if result.errors: @@ -338,8 +420,8 @@ def submit_earn_batch( ) -> EarnBatchResult: if len(batch.earns) == 0: raise ValueError('earn batch must contain at least 1 earn') - if len(batch.earns) > 15: - raise ValueError('earn batch must not contain more than 15 earns') + if len(batch.earns) > _MAX_BATCH_SIZE: + raise ValueError(f'earn batch must not contain more than {_MAX_BATCH_SIZE} earns') invoices = [earn.invoice for earn in batch.earns if earn.invoice] if invoices: @@ -350,12 +432,12 @@ def submit_earn_batch( if batch.memo: raise ValueError('Cannot use both text memo and invoices') - service_config = self._internal_client.get_service_config() - if not service_config.subsidizer_account.value and not batch.subsidizer: + config = self._internal_client.get_service_config() + if not config.subsidizer_account.value and not batch.subsidizer: raise NoSubsidizerError() commitment = commitment if commitment else self._default_commitment - submit_result = self._resolve_and_submit_solana_earn_batch(batch, service_config, commitment=commitment, + submit_result = self._resolve_and_submit_solana_earn_batch(batch, config, commitment=commitment, sender_resolution=sender_resolution, dest_resolution=dest_resolution) @@ -389,80 +471,114 @@ def close(self) -> None: self._grpc_channel.close() def _create_solana_account( - self, private_key: PrivateKey, commitment: Commitment, min_balance: int, subsidizer: Optional[PrivateKey] = None + self, private_key: PrivateKey, commitment: Commitment, subsidizer: Optional[PrivateKey] = None ): - token_account_key = _generate_token_account(private_key) - - service_config_resp = self._internal_client.get_service_config() - if not service_config_resp.subsidizer_account.value and not subsidizer: + config = self._internal_client.get_service_config() + if not config.subsidizer_account.value and not subsidizer: raise NoSubsidizerError() subsidizer_id = (subsidizer.public_key if subsidizer else - PublicKey(service_config_resp.subsidizer_account.value)) + PublicKey(config.subsidizer_account.value)) - recent_blockhash_resp = self._internal_client.get_recent_blockhash() - token_program = PublicKey(service_config_resp.token_program.value) - transaction = solana.Transaction.new( + instructions = [] + if self._app_index > 0: + m = AgoraMemo.new(1, TransactionType.NONE, self._app_index, b'') + instructions.append(memo.memo_instruction(base64.b64encode(m.val).decode('utf-8'))) + + create_instruction, addr = token.create_associated_token_account( subsidizer_id, - [ - system.create_account( - subsidizer_id, - token_account_key.public_key, - token_program, - min_balance, - token.ACCOUNT_SIZE, - ), - token.initialize_account( - token_account_key.public_key, - PublicKey(service_config_resp.token.value), - private_key.public_key, - token_program, - ), - token.set_authority( - token_account_key.public_key, - private_key.public_key, - token.AuthorityType.CloseAccount, - token_program, - new_authority=subsidizer_id, - ) - ] - ) + private_key.public_key, + PublicKey(config.token.value)) + instructions.append(create_instruction) + instructions.append(token.set_authority( + addr, + private_key.public_key, + token.AuthorityType.CLOSE_ACCOUNT, + new_authority=subsidizer_id, + )) + transaction = solana.Transaction.new(subsidizer_id, instructions) + + recent_blockhash_resp = self._internal_client.get_recent_blockhash() transaction.set_blockhash(recent_blockhash_resp.blockhash.value) - transaction.sign([private_key, token_account_key]) + transaction.sign([private_key]) if subsidizer: transaction.sign([subsidizer]) self._internal_client.create_solana_account(transaction, commitment) def _resolve_and_submit_solana_payment( - self, payment: Payment, commitment: Commitment, - sender_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED, - dest_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED, + self, payment: Payment, commitment: Commitment, sender_resolution: AccountResolution, + dest_resolution: AccountResolution, sender_create: bool ) -> SubmitTransactionResult: - service_config = self._internal_client.get_service_config() - if not service_config.subsidizer_account.value and not payment.subsidizer: + config = self._internal_client.get_service_config() + if not config.subsidizer_account.value and not payment.subsidizer: raise NoSubsidizerError() - result = self._submit_solana_payment_tx(payment, service_config, commitment) + subsidizer_id = (payment.subsidizer.public_key if payment.subsidizer else + PublicKey(config.subsidizer_account.value)) + + result = self._submit_solana_payment_tx(payment, config, commitment) if result.errors and isinstance(result.errors.tx_error, AccountNotFoundError): - transfer_sender = None + transfer_source = None + create_instructions = [] + create_signer = None resubmit = False if sender_resolution == AccountResolution.PREFERRED: - sender_token_accounts = self._token_account_resolver.resolve_token_accounts(payment.sender.public_key) - if sender_token_accounts: - transfer_sender = sender_token_accounts[0] + token_account_infos = self._internal_client.resolve_token_accounts(payment.sender.public_key, False) + if token_account_infos: + transfer_source = token_account_infos[0].account_id resubmit = True if dest_resolution == AccountResolution.PREFERRED: - dest_token_accounts = self._token_account_resolver.resolve_token_accounts(payment.destination) - if dest_token_accounts: - payment.destination = dest_token_accounts[0] + token_account_infos = self._internal_client.resolve_token_accounts(payment.destination, False) + if token_account_infos: + payment.destination = token_account_infos[0].account_id + resubmit = True + elif sender_create: + lamports = self._internal_client.get_minimum_balance_for_rent_exception() + temp_key = PrivateKey.random() + + original_dest = payment.destination + payment.destination = temp_key.public_key + create_instructions = [ + system.create_account( + subsidizer_id, + temp_key.public_key, + token.PROGRAM_KEY, + lamports, + token.ACCOUNT_SIZE, + ), + token.initialize_account( + temp_key.public_key, + PublicKey(config.token.value), + temp_key.public_key, + ), + token.set_authority( + temp_key.public_key, + temp_key.public_key, + token.AuthorityType.CLOSE_ACCOUNT, + new_authority=subsidizer_id, + ), + token.set_authority( + temp_key.public_key, + temp_key.public_key, + token.AuthorityType.ACCOUNT_HOLDER, + new_authority=original_dest, + ), + ] + create_signer = temp_key resubmit = True if resubmit: - result = self._submit_solana_payment_tx(payment, service_config, commitment, - transfer_sender=transfer_sender) + result = self._submit_solana_payment_tx( + payment, + config, + commitment, + transfer_source=transfer_source, + create_instructions=create_instructions, + create_signer=create_signer, + ) return result @@ -474,33 +590,33 @@ def _resolve_and_submit_solana_earn_batch( result = self._submit_solana_earn_batch_tx(batch, service_config, commitment) if result.errors and isinstance(result.errors.tx_error, AccountNotFoundError): - transfer_sender = None + transfer_source = None resubmit = False if sender_resolution == AccountResolution.PREFERRED: - sender_token_accounts = self._token_account_resolver.resolve_token_accounts(batch.sender.public_key) - if sender_token_accounts: - transfer_sender = sender_token_accounts[0] + token_account_infos = self._internal_client.resolve_token_accounts(batch.sender.public_key, False) + if token_account_infos: + transfer_source = token_account_infos[0].account_id resubmit = True if dest_resolution == AccountResolution.PREFERRED: for earn in batch.earns: - dest_token_accounts = self._token_account_resolver.resolve_token_accounts(earn.destination) - if dest_token_accounts: - earn.destination = dest_token_accounts[0] + token_account_infos = self._internal_client.resolve_token_accounts(earn.destination, False) + if token_account_infos: + earn.destination = token_account_infos[0].account_id resubmit = True if resubmit: result = self._submit_solana_earn_batch_tx(batch, service_config, commitment, - transfer_sender=transfer_sender) + transfer_sender=transfer_source) return result def _submit_solana_payment_tx( self, payment: Payment, service_config: tx_pb.GetServiceConfigResponse, commitment: Commitment, - transfer_sender: Optional[PublicKey] = None + transfer_source: Optional[PublicKey] = None, create_instructions: List[solana.Instruction] = None, + create_signer: Optional[PrivateKey] = None, ) -> SubmitTransactionResult: - token_program = PublicKey(service_config.token_program.value) subsidizer_id = (payment.subsidizer.public_key if payment.subsidizer else PublicKey(service_config.subsidizer_account.value)) @@ -515,9 +631,16 @@ def _submit_solana_payment_tx( m = AgoraMemo.new(1, payment.tx_type, self._app_index, fk) instructions = [memo.memo_instruction(base64.b64encode(m.val).decode('utf-8'))] - sender = transfer_sender if transfer_sender else payment.sender.public_key - instructions.append(token.transfer(sender, payment.destination, payment.sender.public_key, - payment.quarks, token_program)) + if create_instructions: + instructions += create_instructions + + sender = transfer_source if transfer_source else payment.sender.public_key + instructions.append(token.transfer( + sender, + payment.destination, + payment.sender.public_key, + payment.quarks, + )) tx = solana.Transaction.new(subsidizer_id, instructions) if payment.subsidizer: @@ -525,6 +648,9 @@ def _submit_solana_payment_tx( else: signers = [payment.sender] + if create_signer: + signers.append(create_signer) + return self._sign_and_submit_solana_tx(signers, tx, commitment, invoice_list=invoice_list, dedupe_id=payment.dedupe_id) @@ -532,14 +658,17 @@ def _submit_solana_earn_batch_tx( self, batch: EarnBatch, service_config: tx_pb.GetServiceConfigResponse, commitment: Commitment, transfer_sender: Optional[PublicKey] = None, ) -> SubmitTransactionResult: - token_program = PublicKey(service_config.token_program.value) subsidizer_id = (batch.subsidizer.public_key if batch.subsidizer else PublicKey(service_config.subsidizer_account.value)) transfer_sender = transfer_sender if transfer_sender else batch.sender.public_key instructions = [ - token.transfer(transfer_sender, earn.destination, batch.sender.public_key, earn.quarks, token_program) - for earn in batch.earns] + token.transfer( + transfer_sender, + earn.destination, + batch.sender.public_key, + earn.quarks, + ) for earn in batch.earns] invoices = [earn.invoice for earn in batch.earns if earn.invoice] invoice_list = InvoiceList(invoices) if invoices else None @@ -562,15 +691,31 @@ def _submit_solana_earn_batch_tx( def _sign_and_submit_solana_tx( self, signers: List[PrivateKey], tx: solana.Transaction, commitment: Commitment, invoice_list: Optional[InvoiceList] = None, dedupe_id: Optional[bytes] = None, - ): - def _get_blockhash_and_submit(): + ) -> SubmitTransactionResult: + def _get_blockhash_and_submit() -> SubmitTransactionResult: recent_blockhash = self._internal_client.get_recent_blockhash().blockhash.value tx.set_blockhash(recent_blockhash) tx.sign(signers) + # If the transaction isn't signed by the subsidizer, request a signature. + remote_signed = False + if tx.signatures[0] == bytes(solana.SIGNATURE_LENGTH): + sign_result = self._internal_client.sign_transaction(tx, invoice_list) + if sign_result.invoice_errors: + return SubmitTransactionResult(sign_result.tx_id, sign_result.invoice_errors) + + if not sign_result.tx_id: + raise PayerRequiredError() + + remote_signed = True + tx.signatures[0] = sign_result.tx_id + result = self._internal_client.submit_solana_transaction(tx, invoice_list=invoice_list, commitment=commitment, dedupe_id=dedupe_id) if result.errors and isinstance(result.errors.tx_error, BadNonceError): + if remote_signed: + tx.signatures[0] = bytes(solana.SIGNATURE_LENGTH) + raise result.errors.tx_error return result diff --git a/agora/client/internal.py b/agora/client/internal.py index 811abe0..b8513f6 100644 --- a/agora/client/internal.py +++ b/agora/client/internal.py @@ -11,7 +11,7 @@ from agora.cache.cache import LRUCache from agora.error import AccountExistsError, AccountNotFoundError, TransactionRejectedError, \ TransactionErrors, Error, InsufficientBalanceError, PayerRequiredError, AlreadySubmittedError, \ - BadNonceError + BadNonceError, TransactionError from agora.keys import PublicKey from agora.model import AccountInfo, TransactionData, TransactionState, InvoiceList from agora.retry import Strategy, retry @@ -24,6 +24,12 @@ _SERVICE_CONFIG_CACHE_KEY = b'GetServiceConfig' +class SignTransactionResult: + def __init__(self, tx_id: Optional[bytes] = None, invoice_errors: Optional[List[model_pb_v3.InvoiceError]] = None): + self.tx_id = tx_id + self.invoice_errors = invoice_errors if invoice_errors else [] + + class SubmitTransactionResult: def __init__(self, tx_id: Optional[bytes] = None, invoice_errors: Optional[List[model_pb_v3.InvoiceError]] = None, @@ -114,10 +120,38 @@ def _submit_request(): if resp.result == account_pb.GetAccountInfoResponse.Result.NOT_FOUND: raise AccountNotFoundError - return AccountInfo.from_proto_v4(resp.account_info) + return AccountInfo.from_proto(resp.account_info) return retry(self._retry_strategies, _submit_request) + def resolve_token_accounts(self, public_key: PublicKey, include_account_info: bool) -> List[AccountInfo]: + """Resolves token accounts using Agora. + + :param public_key: the public key of the account to resolve token accounts for. + :param include_account_info: indicates whether to include token account info in the response + :return: A list of :class:`AccountInfo ` objects each representing a token + account. Information other than AccountInfo.account_id will only be populated if `include_account_info` is + True. + """ + + def _resolve(): + return self._account_stub_v4.ResolveTokenAccounts(account_pb.ResolveTokenAccountsRequest( + account_id=model_pb.SolanaAccountId(value=public_key.raw), + include_account_info=include_account_info, + ), metadata=self._metadata, timeout=_GRPC_TIMEOUT_SECONDS) + + resp = retry(self._retry_strategies, _resolve) + + # This is currently in place for backward compat with the server - `token_accounts` is deprecated + if resp.token_accounts and len(resp.token_account_infos) != len(resp.token_accounts): + # If we aren't requesting account info, we can interpolate the results ourselves. + if not include_account_info: + return [AccountInfo(PublicKey(a.value)) for a in resp.token_accounts] + else: + raise Error('server does not support resolving with account info') + + return [AccountInfo.from_proto(a) for a in resp.token_account_infos] + def get_transaction( self, tx_id: bytes, commitment: Optional[Commitment] = Commitment.SINGLE ) -> TransactionData: @@ -143,6 +177,43 @@ def _submit_request(): return TransactionData(tx_id, TransactionState.from_proto_v4(resp.state)) + def sign_transaction( + self, tx: solana.Transaction, invoice_list: Optional[InvoiceList] = None + ) -> SignTransactionResult: + """ Submits a transaction + + :param tx: + :param invoice_list: + :return: A :class:`SignTransactionResult ` object. + """ + tx_bytes = tx.marshal() + + result = SignTransactionResult() + + def _submit_request(): + req = tx_pb.SignTransactionRequest( + transaction=model_pb.Transaction( + value=tx_bytes, + ), + invoice_list=invoice_list.to_proto() if invoice_list else None, + ) + resp = self._transaction_stub_v4.SignTransaction(req, metadata=self._metadata, + timeout=_GRPC_TIMEOUT_SECONDS) + + if resp.signature and len(resp.signature.value) == solana.SIGNATURE_LENGTH: + result.tx_id = resp.signature.value + + if resp.result == tx_pb.SignTransactionResponse.Result.REJECTED: + raise TransactionRejectedError() + elif resp.result == tx_pb.SignTransactionResponse.Result.INVOICE_ERROR: + result.invoice_errors = resp.invoice_errors + elif resp.result != tx_pb.SignTransactionResponse.Result.OK: + raise TransactionError(f'unexpected result from agora: {resp.result}', tx_id=resp.signature.value) + + return result + + return retry(self._retry_strategies, _submit_request) + def submit_solana_transaction( self, tx: solana.Transaction, invoice_list: Optional[InvoiceList] = None, commitment: Optional[Commitment] = Commitment.SINGLE, dedupe_id: Optional[bytes] = None @@ -186,13 +257,13 @@ def _submit_request(): # in quick succession and we should raise the error to the caller. Otherwise, it's likely that the # transaction completed successfully on a previous attempt that failed due to a transient error. if attempt == 1: - raise AlreadySubmittedError() + raise AlreadySubmittedError(tx_id=resp.signature.value) elif resp.result == tx_pb.SubmitTransactionResponse.Result.FAILED: - result.errors = TransactionErrors.from_solana_tx(tx, resp.transaction_error) + result.errors = TransactionErrors.from_solana_tx(tx, resp.transaction_error, resp.signature.value) elif resp.result == tx_pb.SubmitTransactionResponse.Result.INVOICE_ERROR: result.invoice_errors = resp.invoice_errors elif resp.result != tx_pb.SubmitTransactionResponse.Result.OK: - raise Error(f'unexpected result from agora: {resp.result}') + raise TransactionError(f'unexpected result from agora: {resp.result}', tx_id=resp.signature.value) return result @@ -220,12 +291,12 @@ def _submit_request(): return retry(self._retry_strategies, _submit_request) - def get_minimum_balance_for_rent_exception(self) -> tx_pb.GetMinimumBalanceForRentExemptionResponse: + def get_minimum_balance_for_rent_exception(self) -> int: def _submit_request(): return self._transaction_stub_v4.GetMinimumBalanceForRentExemption( tx_pb.GetMinimumBalanceForRentExemptionRequest(size=token.ACCOUNT_SIZE), metadata=self._metadata, timeout=_GRPC_TIMEOUT_SECONDS - ) + ).lamports return retry(self._retry_strategies, _submit_request) @@ -252,12 +323,3 @@ def _request_airdrop(): raise Error(f'unexpected response from airdrop service: {resp.result}') return retry(self._retry_strategies, _request_airdrop) - - def resolve_token_accounts(self, public_key: PublicKey) -> List[PublicKey]: - def _resolve(): - return self._account_stub_v4.ResolveTokenAccounts(account_pb.ResolveTokenAccountsRequest( - account_id=model_pb.SolanaAccountId(value=public_key.raw) - )) - - resp = retry(self._retry_strategies, _resolve) - return [PublicKey(token_account.value) for token_account in resp.token_accounts] diff --git a/agora/client/utils.py b/agora/client/utils.py deleted file mode 100644 index 6b9a4dd..0000000 --- a/agora/client/utils.py +++ /dev/null @@ -1,7 +0,0 @@ -import hashlib - -from agora.keys import PrivateKey - - -def _generate_token_account(key: PrivateKey) -> PrivateKey: - return PrivateKey(hashlib.sha256(key.raw).digest()) diff --git a/agora/error.py b/agora/error.py index c9a18bd..e46960e 100644 --- a/agora/error.py +++ b/agora/error.py @@ -13,11 +13,20 @@ class Error(Exception): """Base error for Agora SDK errors. """ - def __repr__(self): - return f'{self.__class__.__name__}({", ".join([f"{k}={v}" for k, v in self.__dict__.items()])})' + def __init__(self, message: Optional[str] = ''): + self.message = message + super().__init__(self.message) - def __str__(self): - return repr(self) + +class TransactionError(Error): + """Base error for transaction submission errors. + + :param tx_id: The id of the transaction, if available. + """ + + def __init__(self, message: Optional[str] = '', tx_id: Optional[bytes] = None): + super().__init__(message) + self.tx_id = tx_id class UnsupportedVersionError(Error): @@ -35,50 +44,40 @@ class AccountExistsError(Error): """ -class AccountNotFoundError(Error): - """Raised when an account could not be found. - """ - - -class SenderDoesNotExistError(Error): - """Raised when the source account of a transaction does not exist. - """ - - -class DestinationDoesNotExistError(Error): - """Raised when the destination account of a transaction does not exis. - """ - - -class TransactionMalformedError(Error): - """Raised when the provided transaction was malformed in some way. +class TransactionNotFoundError(Error): + """Raised when no transaction data for a specified transaction could be found. """ -class TransactionNotFoundError(Error): - """Raised when no transaction data for a specified transaction could be found. +class AccountNotFoundError(TransactionError): + """Raised when an account could not be found. """ -class InvalidSignatureError(Error): +class InvalidSignatureError(TransactionError): """Raised when the submitted transaction is either missing signatures or contains unused ones. """ -class InsufficientBalanceError(Error): +class InsufficientBalanceError(TransactionError): """Raised when an account has an insufficient balance for a submitted transaction. """ -class InsufficientFeeError(Error): - """Raised when the provided fee for a transaction was insufficient. - """ +class BadNonceError(TransactionError): + """Raised when a transaction contains an invalid nonce.""" -class BadNonceError(Error): - """Raised when a transaction contains an invalid nonce.""" +class AlreadySubmittedError(TransactionError): + """Indicates that the transaction was already submitted. + + If the client is retrying a submission due to a transient failure, then this can occur if the submission in a + previous attempt was successful. Otherwise, it may indicate that the transaction is indistinguishable from a + previous transaction (i.e. same block hash, sender, dest, and amount), and the client should use a different recent + blockhash and try again. + """ class WebhookRequestError(Error): @@ -178,16 +177,6 @@ class NoSubsidizerError(Error): Agora service and none was provided by the method caller.""" -class AlreadySubmittedError(Error): - """Indicates that the transaction was already submitted. - - If the client is retrying a submission due to a transient failure, then this can occur if the submission in a - previous attempt was successful. Otherwise, it may indicate that the transaction is indistinguishable from a - previous transaction (i.e. same block hash, sender, dest, and amount), and the client should use a different recent - blockhash and try again. - """ - - class NoTokenAccountsError(Error): """Indicates that no token accounts were resolved for the requested account ID. """ @@ -211,22 +200,10 @@ def __init__(self, tx_error: Optional[Error] = None, op_errors: Optional[List[Op self.payment_errors = payment_errors if payment_errors else [] @staticmethod - def from_proto_error(tx_error: model_pb_v4.TransactionError) -> Optional['TransactionErrors']: - if tx_error.reason == model_pb_v4.TransactionError.NONE: - return None - if tx_error.reason == model_pb_v4.TransactionError.UNAUTHORIZED: - return TransactionErrors(tx_error=InvalidSignatureError('missing signature')) - if tx_error.reason == model_pb_v4.TransactionError.BAD_NONCE: - return TransactionErrors(tx_error=BadNonceError()) - if tx_error.reason == model_pb_v4.TransactionError.INSUFFICIENT_FUNDS: - return TransactionErrors(tx_error=InsufficientBalanceError()) - if tx_error.reason == model_pb_v4.TransactionError.INVALID_ACCOUNT: - return TransactionErrors(tx_error=AccountNotFoundError()) - return TransactionErrors(tx_error=Error(f'unknown error: {tx_error}')) - - @staticmethod - def from_solana_tx(tx: solana.Transaction, tx_error: model_pb_v4.TransactionError) -> Optional['TransactionErrors']: - err = error_from_proto(tx_error) + def from_solana_tx( + tx: solana.Transaction, tx_error: model_pb_v4.TransactionError, tx_id: bytes + ) -> Optional['TransactionErrors']: + err = error_from_proto(tx_error, tx_id) if not err: return None @@ -255,9 +232,9 @@ def from_solana_tx(tx: solana.Transaction, tx_error: model_pb_v4.TransactionErro return errors @staticmethod - def from_stellar_tx(env: te.TransactionEnvelope, tx_error: model_pb_v4.TransactionError) -> Optional[ + def from_stellar_tx(env: te.TransactionEnvelope, tx_error: model_pb_v4.TransactionError, tx_id: bytes) -> Optional[ 'TransactionErrors']: - err = error_from_proto(tx_error) + err = error_from_proto(tx_error, tx_id) if not err: return None @@ -284,17 +261,17 @@ def from_stellar_tx(env: te.TransactionEnvelope, tx_error: model_pb_v4.Transacti return errors -def error_from_proto(tx_error: model_pb_v4.TransactionError) -> Optional[Error]: +def error_from_proto(tx_error: model_pb_v4.TransactionError, tx_id: bytes) -> Optional[Error]: if tx_error.reason == model_pb_v4.TransactionError.NONE: return None if tx_error.reason == model_pb_v4.TransactionError.UNAUTHORIZED: - return InvalidSignatureError() + return InvalidSignatureError(tx_id=tx_id) if tx_error.reason == model_pb_v4.TransactionError.BAD_NONCE: - return BadNonceError() + return BadNonceError(tx_id=tx_id) if tx_error.reason == model_pb_v4.TransactionError.INSUFFICIENT_FUNDS: - return InsufficientBalanceError() + return InsufficientBalanceError(tx_id=tx_id) if tx_error.reason == model_pb_v4.TransactionError.INVALID_ACCOUNT: - return AccountNotFoundError() + return AccountNotFoundError(tx_id=tx_id) return Error(f'unknown tx error reason: {tx_error.reason}') diff --git a/agora/keys.py b/agora/keys.py index e38841d..7ba9d51 100644 --- a/agora/keys.py +++ b/agora/keys.py @@ -15,7 +15,9 @@ class PublicKey: """ def __init__(self, public_key: bytes): - self._verify_key = signing.VerifyKey(public_key) + # Passing in a bytearray for public_key passes the type annotation checker, but fails an isinstance check + # inside verify key, so we cast to bytes just in case. + self._verify_key = signing.VerifyKey(bytes(public_key)) def __eq__(self, other): if not isinstance(other, PublicKey): diff --git a/agora/model/__init__.py b/agora/model/__init__.py index 86b21f3..57134c8 100644 --- a/agora/model/__init__.py +++ b/agora/model/__init__.py @@ -1,15 +1,19 @@ from .account import AccountInfo -from .earn import Earn +from .creation import Creation +from .earn import Earn, EarnBatch from .invoice import LineItem, Invoice, InvoiceList from .memo import AgoraMemo from .payment import Payment, ReadOnlyPayment from .result import EarnError, EarnBatchResult from .transaction import TransactionData, TransactionState from .transaction_type import TransactionType +from .utils import parse_transaction __all__ = [ 'AccountInfo', + 'Creation', 'Earn', + 'EarnBatch', 'LineItem', 'Invoice', 'InvoiceList', @@ -21,4 +25,5 @@ 'TransactionData', 'TransactionState', 'TransactionType', + 'parse_transaction', ] diff --git a/agora/model/account.py b/agora/model/account.py index c937c1f..001f5ef 100644 --- a/agora/model/account.py +++ b/agora/model/account.py @@ -1,24 +1,33 @@ from typing import Optional -from agoraapi.account.v3 import account_service_pb2 as account_pb from agoraapi.account.v4 import account_service_pb2 as account_pb_v4 +from agora.keys import PublicKey + class AccountInfo: """The information of a Kin account. :param account_id: The ID of the account. - :param balance: The balance of the account, in quarks. + :param balance: The balance of the account, in quarks. Included only if account info was requested. + :param owner: The owner of the account, included only if it is a token account. + :param close_authority: The close authority of the account, included only if it is a token account. """ - def __init__(self, account_id: bytes, balance: int): + def __init__( + self, account_id: PublicKey, balance: Optional[int] = None, owner: Optional[PublicKey] = None, + close_authority: Optional[PublicKey] = None + ): self.account_id = account_id self.balance = balance + self.owner = owner + self.close_authority = close_authority @classmethod - def from_proto(cls, proto: account_pb.AccountInfo) -> 'AccountInfo': - return cls(proto.account_id.value, proto.balance) - - @classmethod - def from_proto_v4(cls, proto: account_pb_v4.AccountInfo) -> 'AccountInfo': - return cls(proto.account_id.value, proto.balance) + def from_proto(cls, proto: account_pb_v4.AccountInfo) -> 'AccountInfo': + return cls( + PublicKey(proto.account_id.value), + proto.balance, + PublicKey(proto.owner.value) if proto.owner and proto.owner.value else None, + PublicKey(proto.close_authority.value) if proto.close_authority and proto.close_authority.value else None, + ) diff --git a/agora/model/creation.py b/agora/model/creation.py new file mode 100644 index 0000000..e92890e --- /dev/null +++ b/agora/model/creation.py @@ -0,0 +1,24 @@ +from agora.keys import PublicKey + + +class Creation: + """ The :class:`Creation ` object, which represents a token account creation. + + :param owner: The :class:`PublicKey ` of the account that owns this token account. + :param address: The :class:`PublicKey ` representing the address of the token account. + """ + + def __init__(self, owner: PublicKey, address: PublicKey): + self.owner = owner + self.address = address + + def __eq__(self, other): + if not isinstance(other, Creation): + return False + + return (self.owner == other.owner and + self.address == other.address) + + def __repr__(self): + return f'{self.__class__.__name__}(' \ + f'owner={self.owner!r}, address={self.address!r})' diff --git a/agora/model/earn.py b/agora/model/earn.py index 24df15d..57052b7 100644 --- a/agora/model/earn.py +++ b/agora/model/earn.py @@ -39,7 +39,10 @@ class EarnBatch: :param memo: (optional) The memo to include in the transaction. If set, none of the invoices included in earns will be applied. :param subsidizer: (optional) The subsidizer to use for the create account transaction. The subsidizer will be - used both as the payer of the transaction. Only applicable for Kin 4 transactions. + used as the payer of the transaction. + :param dedupe_id: (optional) The dedupe ID to use for the transaction submission. If included, Agora will verify + that no transaction was previously submitted the same dedupe ID before submitting the transaction to the + blockchain. """ diff --git a/agora/model/payment.py b/agora/model/payment.py index ddd4bff..6e8742a 100644 --- a/agora/model/payment.py +++ b/agora/model/payment.py @@ -1,11 +1,7 @@ -from typing import Optional, List +from typing import Optional -from agoraapi.common.v3 import model_pb2 - -from agora import solana from agora.keys import PrivateKey, PublicKey from agora.model.invoice import Invoice -from agora.model.memo import AgoraMemo from agora.model.transaction_type import TransactionType @@ -20,7 +16,10 @@ class Payment: one of invoice or memo should be set. :param memo: (optional) The text memo to include with the transaction. Only one of invoice or memo should be set. :param subsidizer: (optional) The subsidizer to use for the create account transaction. The subsidizer will be - used both as the payer of the transaction. Only applicable for Kin 4 transactions. + used as the payer of the transaction. + :param dedupe_id: (optional) The dedupe ID to use for the transaction submission. If included, Agora will verify + that no transaction was previously submitted the same dedupe ID before submitting the transaction to the + blockchain. """ def __init__( @@ -56,8 +55,8 @@ def __eq__(self, other): def __repr__(self): return f'{self.__class__.__name__}(' \ f'sender={self.sender!r}, destination={self.destination!r}, tx_type={self.tx_type!r}, ' \ - f'quarks={self.quarks}, invoice={self.invoice!r}, memo={self.memo!r}), ' \ - f'subsidizer={self.subsidizer!r}, dedupe_id={self.dedupe_id}' + f'quarks={self.quarks}, invoice={self.invoice!r}, memo={self.memo!r}, ' \ + f'subsidizer={self.subsidizer!r}, dedupe_id={self.dedupe_id})' class ReadOnlyPayment: @@ -99,50 +98,3 @@ def __repr__(self): return f'{self.__class__.__name__}(' \ f'sender={self.sender!r}, destination={self.destination!r}, tx_type={self.tx_type!r}, ' \ f'quarks={self.quarks}, invoice={self.invoice!r}, memo={self.memo!r})' - - @classmethod - def payments_from_transaction( - cls, tx: solana.Transaction, invoice_list: Optional[model_pb2.InvoiceList] = None - ) -> List['ReadOnlyPayment']: - """Returns a list of read only payments from a Solana transaction. - - :param tx: The transaction. - :param invoice_list: (optional) A protobuf invoice list associated with the transaction. - :return: A List of :class:`ReadOnlyPayment ` objects. - """ - text_memo = None - agora_memo = None - start_index = 0 - program_idx = tx.message.instructions[0].program_index - if tx.message.accounts[program_idx] == solana.MEMO_PROGRAM_KEY: - decompiled_memo = solana.decompile_memo(tx.message, 0) - start_index = 1 - memo_data = decompiled_memo.data.decode('utf-8') - try: - agora_memo = AgoraMemo.from_b64_string(memo_data) - except ValueError: - text_memo = memo_data - - transfer_count = len(tx.message.instructions) - start_index - if invoice_list and invoice_list.invoices and len(invoice_list.invoices) != transfer_count: - raise ValueError(f'number of invoices ({len(invoice_list.invoices)}) does not match number of non-memo ' - f'transaction instructions ({transfer_count})') - - payments = [] - for idx, op in enumerate(tx.message.instructions[start_index:]): - try: - decompiled_transfer = solana.decompile_transfer(tx.message, idx + start_index) - except ValueError as e: - continue - - inv = invoice_list.invoices[idx] if invoice_list and invoice_list.invoices else None - payments.append(ReadOnlyPayment( - sender=decompiled_transfer.source, - destination=decompiled_transfer.dest, - tx_type=agora_memo.tx_type() if agora_memo else TransactionType.UNKNOWN, - quarks=decompiled_transfer.amount, - invoice=Invoice.from_proto(inv) if inv else None, - memo=text_memo if text_memo else None, - )) - - return payments diff --git a/agora/model/transaction.py b/agora/model/transaction.py index 62d99cf..56dca28 100644 --- a/agora/model/transaction.py +++ b/agora/model/transaction.py @@ -96,7 +96,7 @@ def from_proto( tx_type = agora_memo.tx_type() except ValueError: memo = memo_data - tx_errors = TransactionErrors.from_solana_tx(solana_tx, item.transaction_error) + tx_errors = TransactionErrors.from_solana_tx(solana_tx, item.transaction_error, item.transaction_id.value) elif item.stellar_transaction.envelope_xdr: env = te.TransactionEnvelope.from_xdr(base64.b64encode(item.stellar_transaction.envelope_xdr)) tx = env.tx @@ -109,7 +109,7 @@ def from_proto( elif isinstance(tx.memo, stellar_memo.TextMemo): memo = tx.memo.text.decode() - tx_errors = TransactionErrors.from_stellar_tx(env, item.transaction_error) + tx_errors = TransactionErrors.from_stellar_tx(env, item.transaction_error, item.transaction_id.value) for idx, p in enumerate(item.payments): inv = il.invoices[idx] if il and il.invoices else None diff --git a/agora/model/utils.py b/agora/model/utils.py new file mode 100644 index 0000000..fa1221c --- /dev/null +++ b/agora/model/utils.py @@ -0,0 +1,245 @@ +from typing import Optional, List, Tuple + +from agoraapi.common.v3 import model_pb2 + +from agora import solana +from agora.solana import memo, token, system +from .creation import Creation +from .invoice import InvoiceList, Invoice +from .memo import AgoraMemo +from .payment import ReadOnlyPayment +from .transaction_type import TransactionType + + +def parse_transaction( + tx: solana.Transaction, invoice_list: Optional[model_pb2.InvoiceList] = None +) -> Tuple[List[Creation], List[ReadOnlyPayment]]: + """Parses payments and creations from a Solana transaction. + + :param tx: The transaction. + :param invoice_list: (optional) A protobuf invoice list associated with the transaction. + :return: A Tuple containing a List of :class:`ReadOnlyPayment ` objects and a + List of :class:`Creation ` objects. + """ + payments = [] + creations = [] + + invoice_hash = None + if invoice_list: + invoice_hash = InvoiceList.from_proto(invoice_list).get_sha_224_hash() + + text_memo = None + agora_memo = None + + il_ref_count = 0 + invoice_transfers = 0 + + has_earn = False + has_spend = False + has_p2p = False + + app_index = 0 + app_id = None + + i = 0 + while i < len(tx.message.instructions): + if _is_memo(tx, i): + decompiled_memo = solana.decompile_memo(tx.message, i) + memo_data = decompiled_memo.data.decode('utf-8') + + # Attempt to pull out an app ID or app index from the memo data. + # + # If either are set, then we ensure that it's either the first value for the transaction, or that it's the + # same as a previously parsed one. + # + # Note: if both an app id and app index get parsed, we do not verify that they match to the same app. We + # leave that up to the user of this SDK. + try: + agora_memo = AgoraMemo.from_b64_string(memo_data) + except ValueError: + text_memo = memo_data + + if text_memo: + try: + parsed_id = app_id_from_text_memo(text_memo) + except ValueError: + i += 1 + continue + + if app_id and parsed_id != app_id: + raise ValueError('multiple app IDs') + + app_id = parsed_id + i += 1 + continue + + # From this point on we can assume we have an agora memo + fk = agora_memo.foreign_key() + if invoice_hash and fk[:28] == invoice_hash and fk[28] == 0: + il_ref_count += 1 + + if 0 < app_index != agora_memo.app_index(): + raise ValueError('multiple app indexes') + + app_index = agora_memo.app_index() + if agora_memo.tx_type() == TransactionType.EARN: + has_earn = True + elif agora_memo.tx_type() == TransactionType.SPEND: + has_spend = True + elif agora_memo.tx_type() == TransactionType.P2P: + has_p2p = True + + elif _is_system(tx, i): + create = system.decompile_create_account(tx.message, i) + if create.owner != token.PROGRAM_KEY: + raise ValueError('System::CreateAccount must assign owner to the SplToken program') + if create.size != token.ACCOUNT_SIZE: + raise ValueError('invalid size in System::CreateAccount') + + i += 1 + if i == len(tx.message.instructions): + raise ValueError('missing SplToken::InitializeAccount instruction') + + initialize = token.decompile_initialize_account(tx.message, i) + if create.address != initialize.account: + raise ValueError('SplToken::InitializeAccount address does not match System::CreateAccount address') + + i += 1 + if i == len(tx.message.instructions): + raise ValueError('missing SplToken::SetAuthority(Close) instruction') + + close_authority = token.decompile_set_authority(tx.message, i) + if close_authority.authority_type != token.AuthorityType.CLOSE_ACCOUNT: + raise ValueError('SplToken::SetAuthority must be of type Close following an initialize') + if close_authority.account != create.address: + raise ValueError('SplToken::SetAuthority(Close) authority must be for the created account') + + if close_authority.new_authority != create.funder: + raise ValueError('SplToken::SetAuthority has incorrect new authority') + + # Changing of the account holder is optional + i += 1 + if i == len(tx.message.instructions): + creations.append(Creation(initialize.owner, initialize.account)) + break + + try: + account_holder = token.decompile_set_authority(tx.message, i) + except ValueError: + creations.append(Creation(initialize.owner, initialize.account)) + continue + + if account_holder.authority_type != token.AuthorityType.ACCOUNT_HOLDER: + raise ValueError('SplToken::SetAuthority must be of type AccountHolder following a close authority') + if account_holder.account != create.address: + raise ValueError('SplToken::SetAuthority(AccountHolder) must be for the created account') + + creations.append(Creation(account_holder.new_authority, initialize.account)) + elif _is_spl_assoc(tx, i): + create = token.decompile_create_associated_account(tx.message, i) + + i += 1 + if i == len(tx.message.instructions): + raise ValueError('missing SplToken::SetAuthority(Close) instruction') + + close_authority = token.decompile_set_authority(tx.message, i) + if close_authority.authority_type != token.AuthorityType.CLOSE_ACCOUNT: + raise ValueError('SplToken::SetAuthority must be of type Close following an assoc creation') + + if close_authority.account != create.address: + raise ValueError('SplToken::SetAuthority(Close) authority must be for the created account') + + if close_authority.new_authority != create.subsidizer: + raise ValueError('SplToken::SetAuthority has incorrect new authority') + + creations.append(Creation(create.owner, create.address)) + elif _is_spl(tx, i): + cmd = token.get_command(tx.message, i) + if cmd == token.Command.TRANSFER: + transfer = token.decompile_transfer(tx.message, i) + + # TODO: maybe don't need this check here? + # Ensure that the transfer doesn't reference the subsidizer + if transfer.owner == tx.message.accounts[0]: + raise ValueError('cannot transfer from a subsidizer-owned account') + + inv = None + if agora_memo: + fk = agora_memo.foreign_key() + if invoice_hash and fk[:28] == invoice_hash and fk[28] == 0: + # If the number of parsed transfers matching this invoice is >= the number of invoices, + # raise an error + if invoice_transfers >= len(invoice_list.invoices): + raise ValueError( + f'invoice list doesn\'t have sufficient invoices for this transaction (parsed: {invoice_transfers}, invoices: {len(invoice_list.invoices)})') + inv = invoice_list.invoices[invoice_transfers] + invoice_transfers += 1 + + payments.append(ReadOnlyPayment( + transfer.source, + transfer.dest, + tx_type=agora_memo.tx_type() if agora_memo else TransactionType.UNKNOWN, + quarks=transfer.amount, + invoice=Invoice.from_proto(inv) if inv else None, + memo=text_memo if text_memo else None + )) + elif cmd != token.Command.CLOSE_ACCOUNT: + # closures are valid, but otherwise the instruction is not supported + raise ValueError(f'unsupported instruction at {i}') + else: + raise ValueError(f'unsupported instruction at {i}') + + i += 1 + + if has_earn and (has_spend or has_p2p): + raise ValueError('cannot mix earns with P2P/spends') + + if invoice_list and il_ref_count != 1: + raise ValueError(f'invoice list does not match to exactly one memo in the transaction (matched {il_ref_count})') + + if invoice_list and len(invoice_list.invoices) != invoice_transfers: + raise ValueError(f'invoice count ({len(invoice_list.invoices)}) does not match number of transfers referencing ' + f'the invoice list ({invoice_transfers})') + + return creations, payments + + +def _is_memo(tx: solana.Transaction, index: int) -> bool: + return tx.message.accounts[tx.message.instructions[index].program_index] == memo.PROGRAM_KEY + + +def _is_spl(tx: solana.Transaction, index: int) -> bool: + return tx.message.accounts[tx.message.instructions[index].program_index] == token.PROGRAM_KEY + + +def _is_spl_assoc(tx: solana.Transaction, index: int) -> bool: + return tx.message.accounts[tx.message.instructions[index].program_index] == \ + token.ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_KEY + + +def _is_system(tx: solana.transaction, index: int) -> bool: + return tx.message.accounts[tx.message.instructions[index].program_index] == system.PROGRAM_KEY + + +def app_id_from_text_memo(text_memo: str) -> str: + parts = text_memo.split('-') + if len(parts) < 2: + raise ValueError('no app id in memo') + + if parts[0] != "1": + raise ValueError('no app id in memo') + + if not is_valid_app_id(parts[1]): + raise ValueError('no valid app id in memo') + + return parts[1] + + +def is_valid_app_id(app_id: str) -> bool: + if len(app_id) < 3 or len(app_id) > 4: + return False + + if not app_id.isalnum(): + return False + + return True diff --git a/agora/solana/address.py b/agora/solana/address.py index 6d1129b..48949ba 100644 --- a/agora/solana/address.py +++ b/agora/solana/address.py @@ -1,7 +1,7 @@ import hashlib from typing import List, Optional -from nacl.bindings.crypto_core import crypto_core_ed25519_is_valid_point +from pure25519.basic import decodepoint, NotOnCurve from agora.keys import PublicKey @@ -45,11 +45,13 @@ def create_program_address(program: PublicKey, seeds: List[bytes]) -> PublicKey: h = sha256.digest() pub = h[:32] - # Following the Solana SDK, we want to _reject the generated public key if it's a a valid point on the ed25519 curve - if crypto_core_ed25519_is_valid_point(pub): - raise InvalidPublicKeyError() + # Following the Solana SDK, we want to _reject_ the generated public key if it's a a valid point on the ed25519 curve + try: + decodepoint(pub) + except NotOnCurve: + return PublicKey(pub) - return PublicKey(pub) + raise InvalidPublicKeyError() def find_program_address(program: PublicKey, seeds: List[bytes]) -> Optional[PublicKey]: diff --git a/agora/solana/token/program.py b/agora/solana/token/program.py index 84f5750..7ff8c4a 100644 --- a/agora/solana/token/program.py +++ b/agora/solana/token/program.py @@ -70,7 +70,7 @@ def initialize_account(account: PublicKey, mint: PublicKey, owner: PublicKey) -> PROGRAM_KEY, bytes([Command.INITIALIZE_ACCOUNT]), [ - AccountMeta.new(account, True), + AccountMeta.new(account, False), AccountMeta.new_read_only(mint, False), AccountMeta.new_read_only(owner, False), AccountMeta.new_read_only(system.RENT_SYS_VAR, False), diff --git a/agora/solana/transaction.py b/agora/solana/transaction.py index a443efd..ad42a22 100644 --- a/agora/solana/transaction.py +++ b/agora/solana/transaction.py @@ -148,8 +148,8 @@ def __str__(self): instructions = ''.join([ f' {i}:\n' f' ProgramIndex: {instruction.program_index}\n' - f' Accounts: {instruction.accounts}' - f' Data: {instruction.data}' for i, instruction in enumerate(self.message.instructions) + f' Accounts: {instruction.accounts}\n' + f' Data: {instruction.data}\n' for i, instruction in enumerate(self.message.instructions) ]) return f'Signatures:\n{signatures}' \ diff --git a/agora/webhook/create_account.py b/agora/webhook/create_account.py new file mode 100644 index 0000000..cf34212 --- /dev/null +++ b/agora/webhook/create_account.py @@ -0,0 +1,55 @@ +import base64 + +from agora import solana +from agora.keys import PrivateKey +from agora.model import Creation +from agora.model.utils import parse_transaction + + +class CreateAccountRequest: + """A create account request received from Agora. + + :param creation: The :class:`Creation ` an app client is requesting the server to + verify. + :param transaction: The :class:`Transaction ` object. + """ + + def __init__(self, creation: Creation, transaction: solana.Transaction): + self.creation = creation + self.transaction = transaction + + @classmethod + def from_json(cls, data: dict): + kin_version = data.get('kin_version', 4) + if kin_version != 4: + raise ValueError(f'unsupported kin version {kin_version}') + + tx_string = data.get('solana_transaction', "") + if not tx_string: + raise ValueError('`solana_transaction` is required') + + tx = solana.Transaction.unmarshal(base64.b64decode(tx_string)) + creations, payments = parse_transaction(tx) + if len(payments) != 0: + raise ValueError('unexpected payments present') + if len(creations) != 1: + raise ValueError(f'expected exactly 1 creation, got {len(creations)}') + + return cls(creations[0], tx) + + +class CreateAccountResponse: + def __init__(self, transaction: solana.Transaction): + self.rejected = False + self.transaction = transaction + + def sign(self, private_key: PrivateKey): + if len(self.transaction.signatures) > len(self.transaction.message.accounts): + raise ValueError('invalid transaction: more signers than accounts') + + # Check to see if our public key corresponds to a signer before signing + if private_key.public_key == self.transaction.message.accounts[0]: + self.transaction.sign([private_key]) + + def reject(self): + self.rejected = True diff --git a/agora/webhook/handler.py b/agora/webhook/handler.py index b37c7a8..59872f3 100644 --- a/agora/webhook/handler.py +++ b/agora/webhook/handler.py @@ -5,10 +5,12 @@ from json import JSONDecodeError from typing import Tuple, Callable, List, Optional +from agora import solana from agora.client import Environment from agora.error import WebhookRequestError -from agora.webhook.events import Event -from agora.webhook.sign_transaction import SignTransactionRequest, SignTransactionResponse +from .create_account import CreateAccountRequest, CreateAccountResponse +from .events import Event +from .sign_transaction import SignTransactionRequest, SignTransactionResponse AGORA_HMAC_HEADER = 'X-Agora-HMAC-SHA256' APP_USER_ID_HEADER = "X-App-User-ID" @@ -67,13 +69,53 @@ def handle_events(self, f: Callable[[List[Event]], None], signature: str, req_bo return 200, '' + def handle_create_account( + self, f: Callable[[CreateAccountRequest, CreateAccountResponse], None], signature: str, req_body: str + ) -> Tuple[int, str]: + """A hook for handling a create account request from Agora. + + :param f: A function to call with the recieved request. Implementations can raise + :exc:`WebhookRequestError ` to return a specific HTTP status code and body. + :param signature: The Agora HMAC signature included in the request headers. + :param req_body: The request body. + :return: A Tuple of the status code (int) and the request body (str) + """ + if self.secret and not self.is_valid_signature(req_body, signature): + return 401, '' + + try: + json_req_body = json.loads(req_body) + except JSONDecodeError: + return 400, 'invalid json request body' + + try: + req = CreateAccountRequest.from_json(json_req_body) + except ValueError as e: + return 400, str(e) + + resp = CreateAccountResponse(req.transaction) + try: + f(req, resp) + except WebhookRequestError as e: + return e.status_code, e.response_body + except Exception as e: + return 500, str(e) + + if resp.rejected: + return 403, '{}' + + sig = resp.transaction.get_signature() + if sig != bytes(solana.transaction.SIGNATURE_LENGTH): + return 200, json.dumps({'signature': base64.b64encode(sig).decode('utf-8')}) + + return 200, json.dumps({}) + def handle_sign_transaction( self, f: Callable[[SignTransactionRequest, SignTransactionResponse], None], signature: str, req_body: str ) -> Tuple[int, str]: """A hook for handling a sign transaction request from Agora. - :param f: A function to call with the received event. Implementations can raise - :exc:`InvoiceError ` to return a 403 response with invoice error details or + :param f: A function to call with the received request. Implementations can raise :exc:`WebhookRequestError ` to return a specific HTTP status code and body. :param signature: The Agora HMAC signature included in the request headers. :param req_body: The request body. @@ -86,14 +128,14 @@ def handle_sign_transaction( try: json_req_body = json.loads(req_body) except JSONDecodeError: - return 400, 'invalid request body' + return 400, 'invalid json request body' try: req = SignTransactionRequest.from_json(json_req_body) - except ValueError: - return 400, 'invalid sign transaction request' + except ValueError as e: + return 400, str(e) - resp = SignTransactionResponse() + resp = SignTransactionResponse(req.transaction) try: f(req, resp) except WebhookRequestError as e: @@ -101,8 +143,14 @@ def handle_sign_transaction( except Exception as e: return 500, str(e) - data = resp.to_json() if resp.rejected: + data = {} + if resp.invoice_errors: + data['invoice_errors'] = [e.to_json() for e in resp.invoice_errors] return 403, json.dumps(data) - return 200, json.dumps(data) + sig = resp.transaction.get_signature() + if sig != bytes(solana.transaction.SIGNATURE_LENGTH): + return 200, json.dumps({'signature': base64.b64encode(sig).decode('utf-8')}) + + return 200, json.dumps({}) diff --git a/agora/webhook/sign_transaction.py b/agora/webhook/sign_transaction.py index 560ae78..4275652 100644 --- a/agora/webhook/sign_transaction.py +++ b/agora/webhook/sign_transaction.py @@ -6,7 +6,7 @@ from agora import solana from agora.error import InvoiceErrorReason, OperationInvoiceError from agora.keys import PrivateKey -from agora.model import InvoiceList, ReadOnlyPayment +from agora.model import InvoiceList, ReadOnlyPayment, parse_transaction, Creation class SignTransactionRequest: @@ -18,8 +18,9 @@ class SignTransactionRequest: """ def __init__( - self, payments: List[ReadOnlyPayment], transaction: [solana.Transaction], + self, creations: List[Creation], payments: List[ReadOnlyPayment], transaction: solana.Transaction, ): + self.creations = creations self.payments = payments self.transaction = transaction @@ -38,7 +39,8 @@ def from_json(cls, data: dict): raise ValueError('`solana_transaction` is required on Kin 4 transactions') tx = solana.Transaction.unmarshal(base64.b64decode(tx_string)) - return cls(ReadOnlyPayment.payments_from_transaction(tx, il), tx) + creations, payments = parse_transaction(tx, il) + return cls(creations, payments, tx) def get_tx_id(self) -> Optional[bytes]: """Returns the transaction id of the transaction in the sign transaction request, if available. The id is @@ -53,17 +55,22 @@ class SignTransactionResponse: """A response to a sign transaction request received from Agora. """ - def __init__(self): + def __init__(self, transaction: solana.Transaction): self.invoice_errors = [] self.rejected = False + self.transaction = transaction def sign(self, private_key: PrivateKey): """Signs the transaction envelope with the provided account private key. No-op on Kin 4 transactions. :param private_key: The account :class:`PrivateKey ` """ - # TODO: add solana transaction signing for subsidization - pass + if len(self.transaction.signatures) > len(self.transaction.message.accounts): + raise ValueError('invalid transaction: more signers than accounts') + + # check to see if our public key corresponds to a signer + if private_key.public_key == self.transaction.message.accounts[0]: + self.transaction.sign([private_key]) def reject(self): """Marks that the sign transaction request is rejected. @@ -79,13 +86,3 @@ def mark_invoice_error(self, idx: int, reason: InvoiceErrorReason): """ self.rejected = True self.invoice_errors.append(OperationInvoiceError(idx, reason)) - - def to_json(self): - if self.rejected: - resp = {} - if self.invoice_errors: - resp['invoice_errors'] = [e.to_json() for e in self.invoice_errors] - return resp - - resp = {} - return resp diff --git a/examples/client/submit_payment.py b/examples/client/submit_payment.py index 81ad497..73af070 100644 --- a/examples/client/submit_payment.py +++ b/examples/client/submit_payment.py @@ -1,5 +1,7 @@ import argparse +import base58 + from agora.client import Client, Environment from agora.error import Error, TransactionErrors from agora.keys import PrivateKey, PublicKey @@ -19,8 +21,8 @@ # Send a payment of 1 Kin payment = Payment(source, dest, TransactionType.EARN, kin_to_quarks('1')) try: - tx_hash = client.submit_payment(p) - print(f'transaction successfully submitted with hash: {tx_hash.hex()}') + tx_id = client.submit_payment(p) + print(f'transaction successfully submitted with hash: {base58.b58encode(tx_id)}') except Error as e: print(f'transaction failed: {repr(e)}') if isinstance(e, TransactionErrors): @@ -32,8 +34,8 @@ payment = Payment(source, dest, TransactionType.EARN, kin_to_quarks('1'), memo='1-test') try: - tx_hash = client.submit_payment(p) - print(f'transaction successfully submitted with hash: {tx_hash.hex()}') + tx_id = client.submit_payment(p) + print(f'transaction successfully submitted with hash: {base58.b58encode(tx_id)}') except Error as e: print(f'transaction failed: {repr(e)}') if isinstance(e, TransactionErrors): @@ -47,8 +49,8 @@ payment = Payment(source, dest, TransactionType.EARN, kin_to_quarks('1'), invoice=invoice) try: - tx_hash = client.submit_payment(p) - print(f'transaction successfully submitted with hash: {tx_hash.hex()}') + tx_id = client.submit_payment(p) + print(f'transaction successfully submitted with hash: {base58.b58encode(tx_id)}') except Error as e: print(f'transaction failed: {repr(e)}') if isinstance(e, TransactionErrors): diff --git a/examples/webhook/app.py b/examples/webhook/app.py index 05b51c7..3ec67f4 100644 --- a/examples/webhook/app.py +++ b/examples/webhook/app.py @@ -96,9 +96,6 @@ def _sign_transaction(req: SignTransactionRequest, resp: SignTransactionResponse # # Backends may keep track of the transaction themselves using SignTransactionRequest.get_tx_hash() and rely on # either the Events webhook or polling to get the transaction status. - # - # Note: Calling `sign` on a Kin 4 transaction is currently a no-op, but sign functionality for Solana transactions - # will be added at a later date. resp.sign(webhook_private_key) return diff --git a/requirements.txt b/requirements.txt index b78fe68..2b53432 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ grpcio==1.34.1 -agora-api==0.25.1 +agora-api==0.26.0 kin-base==1.4.1 ed25519==1.4; sys_platform != "win32" and sys_platform != "cygwin" -pure25519==0.0.1; sys_platform == "win32" or sys_platform == "cygwin" +pure25519==0.0.1 protobuf==3.12.2 pynacl==1.4.0 base58==2.0.1 diff --git a/tests/client/account/__init__.py b/tests/client/account/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/client/account/test_resolver.py b/tests/client/account/test_resolver.py deleted file mode 100644 index dbf4206..0000000 --- a/tests/client/account/test_resolver.py +++ /dev/null @@ -1,91 +0,0 @@ -from concurrent import futures - -import grpc -import grpc_testing -import pytest -from agoraapi.account.v4 import account_service_pb2_grpc as account_pb_grpc, account_service_pb2 as account_pb -from agoraapi.common.v4 import model_pb2 as model_pb - -from agora.client.account.resolver import TokenAccountResolver -from agora.keys import PrivateKey -from agora.retry import LimitStrategy -from tests.utils import generate_keys - - -@pytest.fixture(scope='class') -def grpc_channel(): - return grpc_testing.channel([ - account_pb.DESCRIPTOR.services_by_name['Account'], - ], grpc_testing.strict_real_time) - - -@pytest.fixture(scope='class', autouse=True) -def executor(): - executor = futures.ThreadPoolExecutor(1) - yield executor - executor.shutdown(wait=False) - - -class TestTokenAccountResolver: - def test_all(self, grpc_channel, executor): - resolver = TokenAccountResolver( - account_stub=account_pb_grpc.AccountStub(grpc_channel) - ) - - owner, token1, token2 = [key.public_key for key in generate_keys(3)] - future = executor.submit(resolver.resolve_token_accounts, owner) - - md, request, rpc = grpc_channel.take_unary_unary( - account_pb.DESCRIPTOR.services_by_name['Account'].methods_by_name['ResolveTokenAccounts'] - ) - rpc.terminate(account_pb.ResolveTokenAccountsResponse( - token_accounts=[model_pb.SolanaAccountId(value=key.raw) for key in [token1, token2]] - ), (), grpc.StatusCode.OK, '') - - assert future.result() == [token1, token2] - - # ensure it's cached - assert resolver.resolve_token_accounts(owner) == [token1, token2] - - def test_no_accounts(self, grpc_channel, executor): - resolver = TokenAccountResolver( - account_stub=account_pb_grpc.AccountStub(grpc_channel) - ) - - account = generate_keys(1)[0].public_key - future = executor.submit(resolver.resolve_token_accounts, account) - - md, request, rpc = grpc_channel.take_unary_unary( - account_pb.DESCRIPTOR.services_by_name['Account'].methods_by_name['ResolveTokenAccounts'] - ) - rpc.terminate(account_pb.ResolveTokenAccountsResponse(), (), grpc.StatusCode.OK, '') - - assert future.result() == [] - - # ensure not cached - future = executor.submit(resolver.resolve_token_accounts, account) - md, request, rpc = grpc_channel.take_unary_unary( - account_pb.DESCRIPTOR.services_by_name['Account'].methods_by_name['ResolveTokenAccounts'] - ) - rpc.terminate(account_pb.ResolveTokenAccountsResponse(), (), grpc.StatusCode.OK, '') - - assert future.result() == [] - - def test_no_account_retry(self, grpc_channel, executor): - resolver = TokenAccountResolver( - account_stub=account_pb_grpc.AccountStub(grpc_channel), - retry_strategies=[ - LimitStrategy(3) - ] - ) - - owner = PrivateKey.random() - future = executor.submit(resolver.resolve_token_accounts, owner) - - for _ in range(3): - md, request, rpc = grpc_channel.take_unary_unary( - account_pb.DESCRIPTOR.services_by_name['Account'].methods_by_name['ResolveTokenAccounts'] - ) - rpc.terminate(account_pb.ResolveTokenAccountsResponse(), (), grpc.StatusCode.OK, '') - - assert future.result() == [] diff --git a/tests/client/test_client.py b/tests/client/test_client.py index bf3804b..2f4fb14 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,7 +1,7 @@ import base64 import uuid from concurrent import futures -from typing import Tuple, Optional +from typing import Tuple, Optional, List import grpc import grpc_testing @@ -15,22 +15,18 @@ from agora.client.account.resolution import AccountResolution from agora.client.client import Client, RetryConfig, BaseClient from agora.client.environment import Environment -from agora.client.utils import _generate_token_account from agora.error import AccountNotFoundError, InsufficientBalanceError, BadNonceError, TransactionRejectedError, \ UnsupportedMethodError, NoSubsidizerError from agora.keys import PrivateKey, PublicKey +from agora.model import AccountInfo from agora.model.earn import Earn, EarnBatch from agora.model.invoice import InvoiceList, Invoice, LineItem from agora.model.memo import AgoraMemo from agora.model.payment import Payment from agora.model.transaction import TransactionState from agora.model.transaction_type import TransactionType -from agora.solana import token, transfer, memo_instruction, Commitment -from agora.solana.memo import decompile_memo -from agora.solana.system import decompile_create_account -from agora.solana.token import decompile_initialize_account, decompile_transfer, decompile_set_authority -from agora.solana.token.program import DecompiledTransfer -from agora.solana.transaction import HASH_LENGTH, Transaction, SIGNATURE_LENGTH +from agora.solana import token, memo, Commitment, system +from agora.solana.transaction import Transaction, HASH_LENGTH, SIGNATURE_LENGTH from agora.utils import user_agent from agora.version import VERSION from tests.utils import generate_keys @@ -43,13 +39,15 @@ blockhash=model_pb_v4.Blockhash(value=_recent_blockhash) ) -_subsidizer = PrivateKey.random().public_key +_subsidizer_key = PrivateKey.random() +_subsidizer = _subsidizer_key.public_key _token = PrivateKey.random().public_key -_token_program = PrivateKey.random().public_key _min_balance = 2039280 _min_balance_resp = tx_pb_v4.GetMinimumBalanceForRentExemptionResponse(lamports=_min_balance) +_app_index = 1 + @pytest.fixture(scope='class') def grpc_channel(): @@ -72,7 +70,7 @@ def app_index_client(grpc_channel) -> Client: """Returns an AgoraClient that has an app index and no retrying configured. """ retry_config = RetryConfig(max_retries=0, min_delay=0, max_delay=0, max_nonce_refreshes=0) - return Client(Environment.TEST, app_index=1, grpc_channel=grpc_channel, retry_config=retry_config) + return Client(Environment.TEST, app_index=_app_index, grpc_channel=grpc_channel, retry_config=retry_config) @pytest.fixture(scope='class') @@ -87,14 +85,15 @@ def no_app_client(grpc_channel) -> Client: def retry_client(grpc_channel): """Returns an AgoraClient that has retrying configured for non-nonce-related errors. """ - return Client(Environment.TEST, app_index=1, grpc_channel=grpc_channel, retry_config=_config_with_retry) + return Client(Environment.TEST, app_index=_app_index, grpc_channel=grpc_channel, retry_config=_config_with_retry) @pytest.fixture(scope='class') def nonce_retry_client(grpc_channel): """Returns an AgoraClient that has retrying configured only for nonce-related errors. """ - return Client(Environment.TEST, app_index=1, grpc_channel=grpc_channel, retry_config=_config_with_nonce_retry) + return Client(Environment.TEST, app_index=_app_index, grpc_channel=grpc_channel, + retry_config=_config_with_nonce_retry) class TestBaseClient: @@ -129,27 +128,37 @@ def test_invalid_inits(self, grpc_channel): with pytest.raises(ValueError): Client(Environment.TEST, grpc_channel=grpc_channel, endpoint='fakeendpoint') - def test_kin_4_create_account(self, grpc_channel, executor, app_index_client): + def test_create_account(self, grpc_channel, executor, app_index_client, no_app_client): private_key = PrivateKey.random() + # With app index future = executor.submit(app_index_client.create_account, private_key) - self._set_v4_get_min_balance_resp(grpc_channel) - self._set_v4_get_service_config_resp(grpc_channel, app_index_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + + req = self._set_create_account_resp(grpc_channel, account_pb_v4.CreateAccountResponse()) + self._assert_create_tx(req.transaction.value, private_key, app_index=_app_index) + + assert not future.result() + + # Without app index + future = executor.submit(no_app_client.create_account, private_key) - req = self._set_v4_create_account_resp(grpc_channel, account_pb_v4.CreateAccountResponse()) + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + + req = self._set_create_account_resp(grpc_channel, account_pb_v4.CreateAccountResponse()) self._assert_create_tx(req.transaction.value, private_key) assert not future.result() - def test_kin_4_create_account_no_service_subsidizer(self, grpc_channel, executor, app_index_client): + def test_create_account_no_service_subsidizer(self, grpc_channel, executor, app_index_client): private_key = PrivateKey.random() future = executor.submit(app_index_client.create_account, private_key) - self._set_v4_get_min_balance_resp(grpc_channel) - self._set_v4_get_service_config_resp_no_subsidizer(grpc_channel, app_index_client) + self._set_get_service_config_resp_no_subsidizer(grpc_channel, app_index_client) with pytest.raises(NoSubsidizerError): future.result() @@ -157,38 +166,37 @@ def test_kin_4_create_account_no_service_subsidizer(self, grpc_channel, executor subsidizer = PrivateKey.random() future = executor.submit(app_index_client.create_account, private_key, subsidizer=subsidizer) - self._set_v4_get_min_balance_resp(grpc_channel) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) - req = self._set_v4_create_account_resp(grpc_channel, account_pb_v4.CreateAccountResponse()) - self._assert_create_tx(req.transaction.value, private_key, subsidizer) + req = self._set_create_account_resp(grpc_channel, account_pb_v4.CreateAccountResponse()) + self._assert_create_tx(req.transaction.value, private_key, app_index=_app_index, subsidizer=subsidizer) assert not future.result() - def test_kin_4_create_account_with_nonce_retry(self, grpc_channel, executor): - client = Client(Environment.TEST, app_index=1, grpc_channel=grpc_channel, retry_config=_config_with_nonce_retry) + def test_create_account_with_nonce_retry(self, grpc_channel, executor): + client = Client(Environment.TEST, app_index=_app_index, grpc_channel=grpc_channel, + retry_config=_config_with_nonce_retry) private_key = PrivateKey.random() future = executor.submit(client.create_account, private_key) - self._set_v4_get_min_balance_resp(grpc_channel) - self._set_v4_get_service_config_resp(grpc_channel, client) + self._set_get_service_config_resp(grpc_channel, client) create_reqs = [] for i in range(_config_with_nonce_retry.max_nonce_refreshes + 1): - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) resp = account_pb_v4.CreateAccountResponse(result=account_pb_v4.CreateAccountResponse.Result.BAD_NONCE) - create_reqs.append(self._set_v4_create_account_resp(grpc_channel, resp)) + create_reqs.append(self._set_create_account_resp(grpc_channel, resp)) assert len(create_reqs) == _config_with_nonce_retry.max_nonce_refreshes + 1 for req in create_reqs: - self._assert_create_tx(req.transaction.value, private_key) + self._assert_create_tx(req.transaction.value, private_key, app_index=_app_index) with pytest.raises(BadNonceError): future.result() - def test_kin_4_get_balance(self, grpc_channel, executor, app_index_client): + def test_get_balance(self, grpc_channel, executor, app_index_client): private_key = PrivateKey.random() future = executor.submit(app_index_client.get_balance, private_key.public_key) @@ -199,62 +207,160 @@ def test_kin_4_get_balance(self, grpc_channel, executor, app_index_client): balance=100000, ) ) - req = self._set_v4_get_account_info_resp(grpc_channel, resp) + req = self._set_get_account_info_resp(grpc_channel, resp) assert future.result() == 100000 assert req.account_id.value == private_key.public_key.raw - def test_kin_4_get_balance_not_found(self, grpc_channel, executor, app_index_client): - private_key = PrivateKey.random() - resolved_key = PrivateKey.random() + def test_get_balance_not_found(self, grpc_channel, executor, app_index_client): + pub = PrivateKey.random().public_key # Test with EXACT resolution - future = executor.submit(app_index_client.get_balance, private_key.public_key, + future = executor.submit(app_index_client.get_balance, pub, account_resolution=AccountResolution.EXACT) resp = account_pb_v4.GetAccountInfoResponse( result=account_pb_v4.GetAccountInfoResponse.Result.NOT_FOUND, ) - req = self._set_v4_get_account_info_resp(grpc_channel, resp) + req = self._set_get_account_info_resp(grpc_channel, resp) with pytest.raises(AccountNotFoundError): future.result() - assert req.account_id.value == private_key.public_key.raw + assert req.account_id.value == pub.raw # Test with PREFERRED resolution - future = executor.submit(app_index_client.get_balance, private_key.public_key, + future = executor.submit(app_index_client.get_balance, pub, account_resolution=AccountResolution.PREFERRED) - req1 = self._set_v4_get_account_info_resp(grpc_channel, resp) - - self._set_v4_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( - token_accounts=[model_pb_v4.SolanaAccountId(value=resolved_key.public_key.raw)] - )) + req = self._set_get_account_info_resp(grpc_channel, resp) + assert req.account_id.value == pub.raw - req2 = self._set_v4_get_account_info_resp(grpc_channel, account_pb_v4.GetAccountInfoResponse( - result=account_pb_v4.GetAccountInfoResponse.Result.OK, - account_info=account_pb_v4.AccountInfo( - account_id=model_pb_v4.SolanaAccountId(value=resolved_key.public_key.raw), + resolve_req = self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + token_account_infos=[account_pb_v4.AccountInfo( + account_id=model_pb_v4.SolanaAccountId(value=PrivateKey.random().public_key.raw), balance=200000 - ) + )] )) + assert resolve_req.account_id.value == pub.raw assert future.result() == 200000 - assert req1.account_id.value == private_key.public_key.raw - assert req2.account_id.value == resolved_key.public_key.raw + def test_resolve_token_accounts(self, grpc_channel, executor, app_index_client): + pub = PrivateKey.random().public_key + resolved_keys = [priv.random().public_key for priv in generate_keys(3)] + + future = executor.submit(app_index_client.resolve_token_accounts, pub) + + resolve_req = self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + token_account_infos=[account_pb_v4.AccountInfo( + account_id=model_pb_v4.SolanaAccountId(value=k.raw), + ) for k in resolved_keys], + token_accounts=[model_pb_v4.SolanaAccountId(value=k.raw) for k in resolved_keys] + )) + assert resolve_req.account_id.value == pub.raw + + result = future.result() + assert len(result) == len(resolved_keys) + for idx, resolved_key in enumerate(resolved_keys): + assert result[idx] == resolved_key + + def test_merge_token_accounts(self, grpc_channel, executor, app_index_client): + priv = PrivateKey.random() + resolved_keys = [priv.random().public_key for priv in generate_keys(3)] + + # No accounts + future = executor.submit(app_index_client.merge_token_accounts, priv, False) + resolve_req = self._set_resolve_token_accounts_resp(grpc_channel, self._generate_resolve_resp([])) + assert resolve_req.account_id.value == priv.public_key.raw + + assert not future.result() + + # Exactly one account, no create + future = executor.submit(app_index_client.merge_token_accounts, priv, False) + resolve_req = self._set_resolve_token_accounts_resp(grpc_channel, + self._generate_resolve_resp(resolved_keys[:1])) + assert resolve_req.account_id.value == priv.public_key.raw + + assert not future.result() + + # Multiple accounts, no create + future = executor.submit(app_index_client.merge_token_accounts, priv, False) + + resolve_resp = self._generate_resolve_resp(resolved_keys, owner=priv.public_key, close_auth=_subsidizer) + accounts = [AccountInfo.from_proto(a) for a in resolve_resp.token_account_infos] + resolve_req = self._set_resolve_token_accounts_resp(grpc_channel, resolve_resp) + assert resolve_req.account_id.value == priv.public_key.raw + + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + + sign_req = self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + self._assert_merge_tx(sign_req.transaction.value, priv, accounts, create_assoc=False, should_close=True) + + submit_req = self._set_submit_tx_resp(grpc_channel, tx_pb_v4.SubmitTransactionResponse( + signature=model_pb_v4.TransactionSignature(value=b'somesig') + )) + # Should be signed by the subsidizer at this point + self._assert_merge_tx(submit_req.transaction.value, priv, accounts, create_assoc=False, should_close=True, + subsidizer=_subsidizer_key) + + assert future.result() == b'somesig' + + # Multiple accounts, create + future = executor.submit(app_index_client.merge_token_accounts, priv, True) + + resolve_resp = self._generate_resolve_resp(resolved_keys, owner=priv.public_key, close_auth=_subsidizer) + accounts = [AccountInfo.from_proto(a) for a in resolve_resp.token_account_infos] + resolve_req = self._set_resolve_token_accounts_resp(grpc_channel, resolve_resp) + assert resolve_req.account_id.value == priv.public_key.raw + + self._set_get_recent_blockhash_resp(grpc_channel) + + sign_req = self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + self._assert_merge_tx(sign_req.transaction.value, priv, accounts, create_assoc=True, should_close=True) - def test_kin_4_get_transaction(self, grpc_channel, executor, app_index_client): + submit_req = self._set_submit_tx_resp(grpc_channel, tx_pb_v4.SubmitTransactionResponse( + signature=model_pb_v4.TransactionSignature(value=b'somesig') + )) + # Should be signed by the subsidizer at this point + self._assert_merge_tx(submit_req.transaction.value, priv, accounts, create_assoc=True, should_close=True, + subsidizer=_subsidizer_key) + + assert future.result() == b'somesig' + + # Multiple accounts, no closing + future = executor.submit(app_index_client.merge_token_accounts, priv, True) + + resolve_resp = self._generate_resolve_resp(resolved_keys, owner=priv.public_key) + accounts = [AccountInfo.from_proto(a) for a in resolve_resp.token_account_infos] + resolve_req = self._set_resolve_token_accounts_resp(grpc_channel, resolve_resp) + assert resolve_req.account_id.value == priv.public_key.raw + + self._set_get_recent_blockhash_resp(grpc_channel) + + sign_req = self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + self._assert_merge_tx(sign_req.transaction.value, priv, accounts, create_assoc=True, should_close=False) + + submit_req = self._set_submit_tx_resp(grpc_channel, tx_pb_v4.SubmitTransactionResponse( + signature=model_pb_v4.TransactionSignature(value=b'somesig') + )) + # Should be signed by the subsidizer at this point + self._assert_merge_tx(submit_req.transaction.value, priv, accounts, create_assoc=True, should_close=False, + subsidizer=_subsidizer_key) + + assert future.result() == b'somesig' + + def test_get_transaction(self, grpc_channel, executor, app_index_client): source, dest = [key.public_key for key in generate_keys(2)] transaction_id = b'someid' future = executor.submit(app_index_client.get_transaction, transaction_id) agora_memo = AgoraMemo.new(1, TransactionType.SPEND, 0, b'') tx = Transaction.new(PrivateKey.random().public_key, [ - memo_instruction(base64.b64encode(agora_memo.val).decode('utf-8')), - transfer(source, dest, PrivateKey.random().public_key, 100, _token_program), + memo.memo_instruction(base64.b64encode(agora_memo.val).decode('utf-8')), + token.transfer(source, dest, PrivateKey.random().public_key, 100), ]) resp = tx_pb_v4.GetTransactionResponse( @@ -306,7 +412,7 @@ def test_kin_4_get_transaction(self, grpc_channel, executor, app_index_client): assert p.invoice.to_proto().SerializeToString() == resp.item.invoice_list.invoices[0].SerializeToString() assert not p.memo - def test_kin_4_get_transaction_unknown(self, grpc_channel, executor, app_index_client): + def test_get_transaction_unknown(self, grpc_channel, executor, app_index_client): transaction_id = b'someid' future = executor.submit(app_index_client.get_transaction, transaction_id) @@ -324,35 +430,36 @@ def test_kin_4_get_transaction_unknown(self, grpc_channel, executor, app_index_c self._assert_user_agent(md) assert request.transaction_id.value == transaction_id - def test_kin_4_submit_payment_simple(self, grpc_channel, executor, no_app_client): + def test_submit_payment_simple(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key payment = Payment(sender, dest, TransactionType.NONE, 100000) future = executor.submit(no_app_client.submit_payment, payment) - self._set_v4_get_service_config_resp(grpc_channel, no_app_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=b'somesig') ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) - decompiled = decompile_transfer(tx.message, 0, _token_program) + decompiled = token.decompile_transfer(tx.message, 0) self._assert_transfer(decompiled, sender.public_key, dest, sender.public_key, 100000) assert not req.invoice_list.invoices assert future.result() == b'somesig' - def test_kin_4_submit_payment_with_no_service_subsidizer(self, grpc_channel, executor, no_app_client): + def test_submit_payment_with_no_service_subsidizer(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key subsidizer = PrivateKey.random() @@ -360,7 +467,7 @@ def test_kin_4_submit_payment_with_no_service_subsidizer(self, grpc_channel, exe future = executor.submit(no_app_client.submit_payment, payment) - self._set_v4_get_service_config_resp_no_subsidizer(grpc_channel, no_app_client) + self._set_get_service_config_resp_no_subsidizer(grpc_channel, no_app_client) with pytest.raises(NoSubsidizerError): future.result() @@ -368,27 +475,27 @@ def test_kin_4_submit_payment_with_no_service_subsidizer(self, grpc_channel, exe payment.subsidizer = subsidizer future = executor.submit(no_app_client.submit_payment, payment) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=b'somesig') ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert payment.subsidizer.public_key.verify(tx.message.marshal(), tx.signatures[0]) - assert payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + payment.subsidizer.public_key.verify(tx.message.marshal(), tx.signatures[0]) + payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) - decompiled = decompile_transfer(tx.message, 0, _token_program) + decompiled = token.decompile_transfer(tx.message, 0) self._assert_transfer(decompiled, sender.public_key, dest, sender.public_key, 100000) assert not req.invoice_list.invoices assert future.result() == b'somesig' - def test_kin_4_submit_payment_with_invoice(self, grpc_channel, executor, app_index_client): + def test_submit_payment_with_invoice(self, grpc_channel, executor, app_index_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key invoice = Invoice([LineItem('title1', int(5e5))]) @@ -396,64 +503,66 @@ def test_kin_4_submit_payment_with_invoice(self, grpc_channel, executor, app_ind future = executor.submit(app_index_client.submit_payment, payment) - self._set_v4_get_service_config_resp(grpc_channel, app_index_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=b'somesig') ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) expected_memo = AgoraMemo.new(1, TransactionType.NONE, 1, InvoiceList([payment.invoice]).get_sha_224_hash()).val - m = decompile_memo(tx.message, 0) + m = memo.decompile_memo(tx.message, 0) assert base64.b64decode(m.data) == expected_memo - decompiled = decompile_transfer(tx.message, 1, _token_program) + decompiled = token.decompile_transfer(tx.message, 1) self._assert_transfer(decompiled, sender.public_key, dest, sender.public_key, 100000) assert req.invoice_list.invoices[0].SerializeToString() == invoice.to_proto().SerializeToString() assert future.result() == b'somesig' - def test_kin_4_submit_payment_with_memo(self, grpc_channel, executor, app_index_client): + def test_submit_payment_with_memo(self, grpc_channel, executor, app_index_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key payment = Payment(sender, dest, TransactionType.EARN, 100000, memo='somememo') future = executor.submit(app_index_client.submit_payment, payment) - self._set_v4_get_service_config_resp(grpc_channel, app_index_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=b'somesig') ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) - m = decompile_memo(tx.message, 0) + m = memo.decompile_memo(tx.message, 0) assert m.data.decode('utf-8') == payment.memo - decompiled = decompile_transfer(tx.message, 1, _token_program) + decompiled = token.decompile_transfer(tx.message, 1) self._assert_transfer(decompiled, sender.public_key, dest, sender.public_key, 100000) assert not req.invoice_list.invoices assert future.result() == b'somesig' - def test_kin_4_submit_payment_with_acc_resolution(self, grpc_channel, executor, no_app_client): + def test_submit_payment_with_acc_resolution(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() resolved_sender = PrivateKey.random().public_key dest = PrivateKey.random().public_key @@ -462,9 +571,10 @@ def test_kin_4_submit_payment_with_acc_resolution(self, grpc_channel, executor, future = executor.submit(no_app_client.submit_payment, payment) - self._set_v4_get_service_config_resp(grpc_channel, no_app_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, transaction_error=model_pb_v4.TransactionError( @@ -472,37 +582,39 @@ def test_kin_4_submit_payment_with_acc_resolution(self, grpc_channel, executor, ), signature=model_pb_v4.TransactionSignature(value=b'failedsig') ) - self._set_v4_submit_tx_resp(grpc_channel, resp) + self._set_submit_tx_resp(grpc_channel, resp) # Both sender and destination should get resolved - self._set_v4_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( token_accounts=[model_pb_v4.SolanaAccountId(value=resolved_sender.raw)] )) - self._set_v4_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( token_accounts=[model_pb_v4.SolanaAccountId(value=resolved_dest.raw)] )) # Resubmit transaction - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) + + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=b'somesig'), ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) - decompiled = decompile_transfer(tx.message, 0, _token_program) + decompiled = token.decompile_transfer(tx.message, 0) self._assert_transfer(decompiled, resolved_sender, resolved_dest, sender.public_key, 100000) assert not req.invoice_list.invoices assert future.result() == b'somesig' - def test_kin_4_submit_payment_with_acc_resolution_exact(self, grpc_channel, executor, no_app_client): + def test_submit_payment_with_acc_resolution_exact(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key payment = Payment(sender, dest, TransactionType.NONE, 100000) @@ -510,9 +622,10 @@ def test_kin_4_submit_payment_with_acc_resolution_exact(self, grpc_channel, exec future = executor.submit(no_app_client.submit_payment, payment, sender_resolution=AccountResolution.EXACT, dest_resolution=AccountResolution.EXACT) - self._set_v4_get_service_config_resp(grpc_channel, no_app_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, transaction_error=model_pb_v4.TransactionError( @@ -520,14 +633,14 @@ def test_kin_4_submit_payment_with_acc_resolution_exact(self, grpc_channel, exec ), signature=model_pb_v4.TransactionSignature(value=b'failedsig') ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) - transfer = decompile_transfer(tx.message, 0, _token_program) + transfer = token.decompile_transfer(tx.message, 0) assert transfer.source == sender.public_key assert transfer.dest == dest assert transfer.owner == sender.public_key @@ -538,33 +651,129 @@ def test_kin_4_submit_payment_with_acc_resolution_exact(self, grpc_channel, exec with pytest.raises(AccountNotFoundError): future.result() - def test_kin_4_submit_payment_error(self, grpc_channel, executor, app_index_client): + def test_submit_payment_with_sender_create(self, grpc_channel, executor, no_app_client): + sender = PrivateKey.random() + resolved_sender = PrivateKey.random().public_key + dest = PrivateKey.random().public_key + payment = Payment(sender, dest, TransactionType.NONE, 100000) + + future = executor.submit(no_app_client.submit_payment, payment, sender_create=True) + + self._set_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_recent_blockhash_resp(grpc_channel) + + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + resp = tx_pb_v4.SubmitTransactionResponse( + result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, + transaction_error=model_pb_v4.TransactionError( + reason=model_pb_v4.TransactionError.Reason.INVALID_ACCOUNT, + ), + signature=model_pb_v4.TransactionSignature(value=b'failedsig') + ) + self._set_submit_tx_resp(grpc_channel, resp) + + # Sender gets resolved but destination does not + self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + token_accounts=[model_pb_v4.SolanaAccountId(value=resolved_sender.raw)] + )) + self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + token_accounts=[] + )) + + # Resubmit transaction + self._set_get_min_balance_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + resp = tx_pb_v4.SubmitTransactionResponse( + result=tx_pb_v4.SubmitTransactionResponse.Result.OK, + signature=model_pb_v4.TransactionSignature(value=b'somesig'), + ) + req = self._set_submit_tx_resp(grpc_channel, resp) + + tx = Transaction.unmarshal(req.transaction.value) + assert len(tx.signatures) == 3 # one extra for account creation + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + assert len(tx.signatures[1]) == SIGNATURE_LENGTH + assert tx.signatures[1] != bytes(SIGNATURE_LENGTH) + payment.sender.public_key.verify(tx.message.marshal(), tx.signatures[2]) + + # create + create = system.decompile_create_account(tx.message, 0) + assert create.funder == _subsidizer + assert create.address.raw # randomly generated, just make sure it exists + assert create.owner == token.PROGRAM_KEY + assert create.lamports == _min_balance + assert create.size == token.ACCOUNT_SIZE + + # init + init = token.decompile_initialize_account(tx.message, 1) + assert init.account == create.address + assert init.mint == _token + assert init.owner == create.address + + # set auth + close_auth = token.decompile_set_authority(tx.message, 2) + assert close_auth.account == create.address + assert close_auth.current_authority == create.address + assert close_auth.authority_type == token.AuthorityType.CLOSE_ACCOUNT + assert close_auth.new_authority == _subsidizer + + # set auth + holder_auth = token.decompile_set_authority(tx.message, 3) + assert holder_auth.account == create.address + assert holder_auth.current_authority == create.address + assert holder_auth.authority_type == token.AuthorityType.ACCOUNT_HOLDER + assert holder_auth.new_authority == dest + + decompiled = token.decompile_transfer(tx.message, 4) + self._assert_transfer(decompiled, resolved_sender, create.address, sender.public_key, 100000) + + assert not req.invoice_list.invoices + + assert future.result() == b'somesig' + + def test_submit_payment_error(self, grpc_channel, executor, app_index_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key payment = Payment(sender, dest, TransactionType.EARN, 100000) + # Sign error + future = executor.submit(app_index_client.submit_payment, payment) + + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + + resp = tx_pb_v4.SignTransactionResponse( + result=tx_pb_v4.SignTransactionResponse.Result.REJECTED, + ) + self._set_sign_tx_resp(grpc_channel, resp) + + with pytest.raises(TransactionRejectedError): + future.result() + + # Submit error future = executor.submit(app_index_client.submit_payment, payment) - self._set_v4_get_service_config_resp(grpc_channel, app_index_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.REJECTED, signature=model_pb_v4.TransactionSignature(value=b'somesig') ) - self._set_v4_submit_tx_resp(grpc_channel, resp) + self._set_submit_tx_resp(grpc_channel, resp) with pytest.raises(TransactionRejectedError): future.result() - def test_kin_4_submit_payment_with_nonce_retry(self, grpc_channel, executor, nonce_retry_client): + def test_submit_payment_with_nonce_retry(self, grpc_channel, executor, nonce_retry_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key payment = Payment(sender, dest, TransactionType.EARN, 100000) future = executor.submit(nonce_retry_client.submit_payment, payment) - self._set_v4_get_service_config_resp(grpc_channel, nonce_retry_client) + self._set_get_service_config_resp(grpc_channel, nonce_retry_client) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, @@ -577,13 +786,14 @@ def test_kin_4_submit_payment_with_nonce_retry(self, grpc_channel, executor, non for i in range(_config_with_nonce_retry.max_nonce_refreshes + 1): # this blocks until the system under test invokes the RPC, so if the test completes then the RPC was called # the expected number of times. - self._set_v4_get_recent_blockhash_resp(grpc_channel) - self._set_v4_submit_tx_resp(grpc_channel, resp) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + self._set_submit_tx_resp(grpc_channel, resp) with pytest.raises(BadNonceError): future.result() - def test_kin_4_submit_payment_invalid(self, grpc_channel, executor, no_app_client): + def test_submit_payment_invalid(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() dest = PrivateKey.random().public_key @@ -593,31 +803,32 @@ def test_kin_4_submit_payment_invalid(self, grpc_channel, executor, no_app_clien with pytest.raises(ValueError): future.result() - def test_kin_4_submit_earn_batch_simple(self, grpc_channel, executor, no_app_client): + def test_submit_earn_batch_simple(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() earns = [Earn(PrivateKey.random().public_key, i) for i in range(15)] b = EarnBatch(sender, earns, dedupe_id=uuid.uuid4().bytes) future = executor.submit(no_app_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp(grpc_channel, no_app_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) tx_id = b'somesig' resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=tx_id), ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) assert req.dedupe_id == b.dedupe_id tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) for idx, earn in enumerate(earns): - transfer = decompile_transfer(tx.message, idx, _token_program) + transfer = token.decompile_transfer(tx.message, idx) assert transfer.source == sender.public_key assert transfer.dest == earn.destination assert transfer.owner == sender.public_key @@ -630,7 +841,7 @@ def test_kin_4_submit_earn_batch_simple(self, grpc_channel, executor, no_app_cli assert not result.tx_error assert not result.earn_errors - def test_kin_4_submit_earn_batch_with_subsidizer(self, grpc_channel, executor, no_app_client): + def test_submit_earn_batch_with_subsidizer(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() subsidizer = PrivateKey.random() earns = [Earn(PrivateKey.random().public_key, i) for i in range(15)] @@ -638,23 +849,23 @@ def test_kin_4_submit_earn_batch_with_subsidizer(self, grpc_channel, executor, n future = executor.submit(no_app_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp_no_subsidizer(grpc_channel, no_app_client) + self._set_get_service_config_resp_no_subsidizer(grpc_channel, no_app_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) tx_id = b'somesig' resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=tx_id), ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert subsidizer.public_key.verify(tx.message.marshal(), tx.signatures[0]) - assert sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + subsidizer.public_key.verify(tx.message.marshal(), tx.signatures[0]) + sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) for instruction_idx, earn in enumerate(earns): - transfer = decompile_transfer(tx.message, instruction_idx, _token_program) + transfer = token.decompile_transfer(tx.message, instruction_idx) assert transfer.source == sender.public_key assert transfer.dest == earn.destination assert transfer.owner == sender.public_key @@ -667,33 +878,35 @@ def test_kin_4_submit_earn_batch_with_subsidizer(self, grpc_channel, executor, n assert not result.tx_error assert not result.earn_errors - def test_kin_4_submit_earn_batch_memo(self, grpc_channel, executor, no_app_client): + def test_submit_earn_batch_memo(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() earns = [Earn(PrivateKey.random().public_key, i) for i in range(15)] b = EarnBatch(sender, earns, memo='somememo') future = executor.submit(no_app_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_service_config_resp(grpc_channel, no_app_client) + + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) - self._set_v4_get_recent_blockhash_resp(grpc_channel) tx_id = b'somesig' resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=tx_id), ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) - m = decompile_memo(tx.message, 0) + m = memo.decompile_memo(tx.message, 0) assert m.data.decode('utf-8') == 'somememo' for instruction_idx, earn in enumerate(earns): - transfer = decompile_transfer(tx.message, instruction_idx + 1, _token_program) + transfer = token.decompile_transfer(tx.message, instruction_idx + 1) assert transfer.source == sender.public_key assert transfer.dest == earn.destination assert transfer.owner == sender.public_key @@ -706,7 +919,7 @@ def test_kin_4_submit_earn_batch_memo(self, grpc_channel, executor, no_app_clien assert not result.tx_error assert not result.earn_errors - def test_kin_4_submit_earn_batch_with_invoices(self, grpc_channel, executor, app_index_client): + def test_submit_earn_batch_with_invoices(self, grpc_channel, executor, app_index_client): sender = PrivateKey.random() invoice = Invoice([LineItem('title1', 100000, 'description1', b'somesku')]) earns = [Earn(PrivateKey.random().public_key, i, @@ -715,28 +928,30 @@ def test_kin_4_submit_earn_batch_with_invoices(self, grpc_channel, executor, app future = executor.submit(app_index_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_service_config_resp(grpc_channel, app_index_client) + + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) - self._set_v4_get_recent_blockhash_resp(grpc_channel) tx_id = b'somesig' resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=tx_id), ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) - m = decompile_memo(tx.message, 0) + m = memo.decompile_memo(tx.message, 0) il = InvoiceList([invoice] * len(earns)) expected_memo = AgoraMemo.new(1, TransactionType.EARN, 1, il.get_sha_224_hash()).val assert m.data == base64.b64encode(expected_memo) for instruction_idx, earn in enumerate(earns): - transfer = decompile_transfer(tx.message, instruction_idx + 1, _token_program) + transfer = token.decompile_transfer(tx.message, instruction_idx + 1) assert transfer.source == sender.public_key assert transfer.dest == earn.destination assert transfer.owner == sender.public_key @@ -749,7 +964,7 @@ def test_kin_4_submit_earn_batch_with_invoices(self, grpc_channel, executor, app assert not result.tx_error assert not result.earn_errors - def test_kin_4_submit_earn_batch_with_acc_resolution(self, grpc_channel, executor, no_app_client): + def test_submit_earn_batch_with_acc_resolution(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() resolved_sender = PrivateKey.random().public_key earns = [Earn(PrivateKey.random().public_key, i) for i in range(10)] @@ -758,9 +973,11 @@ def test_kin_4_submit_earn_batch_with_acc_resolution(self, grpc_channel, executo future = executor.submit(no_app_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_service_config_resp(grpc_channel, no_app_client) + + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) - self._set_v4_get_recent_blockhash_resp(grpc_channel) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, transaction_error=model_pb_v4.TransactionError( @@ -768,32 +985,34 @@ def test_kin_4_submit_earn_batch_with_acc_resolution(self, grpc_channel, executo ), signature=model_pb_v4.TransactionSignature(value=b'failedsig') ) - self._set_v4_submit_tx_resp(grpc_channel, resp) + self._set_submit_tx_resp(grpc_channel, resp) # Both sender and destination should get resolved - self._set_v4_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( token_accounts=[model_pb_v4.SolanaAccountId(value=resolved_sender.raw)] )) for resolved_dest in resolved_destinations: - self._set_v4_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( + self._set_resolve_token_accounts_resp(grpc_channel, account_pb_v4.ResolveTokenAccountsResponse( token_accounts=[model_pb_v4.SolanaAccountId(value=resolved_dest.raw)] )) # Resubmit transaction - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.OK, signature=model_pb_v4.TransactionSignature(value=b'somesig'), ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) for idx, earn in enumerate(earns): - transfer = decompile_transfer(tx.message, idx, _token_program) + transfer = token.decompile_transfer(tx.message, idx) assert transfer.source == resolved_sender assert transfer.dest == resolved_destinations[idx] assert transfer.owner == sender.public_key @@ -806,7 +1025,7 @@ def test_kin_4_submit_earn_batch_with_acc_resolution(self, grpc_channel, executo assert not result.tx_error assert not result.earn_errors - def test_kin_4_submit_earn_batch_failed_acc_resolution_exact(self, grpc_channel, executor, no_app_client): + def test_submit_earn_batch_failed_acc_resolution_exact(self, grpc_channel, executor, no_app_client): sender = PrivateKey.random() earns = [Earn(PrivateKey.random().public_key, i) for i in range(10)] b = EarnBatch(sender, earns) @@ -816,9 +1035,11 @@ def test_kin_4_submit_earn_batch_failed_acc_resolution_exact(self, grpc_channel, dest_resolution=AccountResolution.EXACT, ) - self._set_v4_get_service_config_resp(grpc_channel, no_app_client) + self._set_get_service_config_resp(grpc_channel, no_app_client) + + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) - self._set_v4_get_recent_blockhash_resp(grpc_channel) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, transaction_error=model_pb_v4.TransactionError( @@ -828,15 +1049,15 @@ def test_kin_4_submit_earn_batch_failed_acc_resolution_exact(self, grpc_channel, signature=model_pb_v4.TransactionSignature(value=b'failedsig') ) - req = self._set_v4_submit_tx_resp(grpc_channel, resp) + req = self._set_submit_tx_resp(grpc_channel, resp) tx = Transaction.unmarshal(req.transaction.value) assert len(tx.signatures) == 2 - assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) + _subsidizer.verify(tx.message.marshal(), tx.signatures[0]) + sender.public_key.verify(tx.message.marshal(), tx.signatures[1]) for idx, earn in enumerate(earns): - transfer = decompile_transfer(tx.message, idx, _token_program) + transfer = token.decompile_transfer(tx.message, idx) assert transfer.source == sender.public_key assert transfer.dest == earn.destination assert transfer.owner == sender.public_key @@ -851,7 +1072,7 @@ def test_kin_4_submit_earn_batch_failed_acc_resolution_exact(self, grpc_channel, assert result.earn_errors[0].earn_index == 1 assert isinstance(result.earn_errors[0].error, AccountNotFoundError) - def test_kin_4_submit_earn_batch_rejected(self, grpc_channel, executor, app_index_client): + def test_submit_earn_batch_rejected(self, grpc_channel, executor, app_index_client): sender = PrivateKey.random() earns = [ Earn(PrivateKey.random().public_key, 100000), @@ -859,21 +1080,35 @@ def test_kin_4_submit_earn_batch_rejected(self, grpc_channel, executor, app_inde ] b = EarnBatch(sender, earns) + # Sign rejected future = executor.submit(app_index_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp(grpc_channel, app_index_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + + resp = tx_pb_v4.SignTransactionResponse( + result=tx_pb_v4.SignTransactionResponse.Result.REJECTED, + ) + self._set_sign_tx_resp(grpc_channel, resp) + + with pytest.raises(TransactionRejectedError): + future.result() + + # Submit rejected + future = executor.submit(app_index_client.submit_earn_batch, b) + + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.REJECTED, - signature=model_pb_v4.TransactionSignature(value=b'somesig') ) - self._set_v4_submit_tx_resp(grpc_channel, resp) + self._set_submit_tx_resp(grpc_channel, resp) with pytest.raises(TransactionRejectedError): future.result() - def test_kin_4_submit_earn_batch_tx_failed(self, grpc_channel, executor, app_index_client): + def test_submit_earn_batch_tx_failed(self, grpc_channel, executor, app_index_client): sender = PrivateKey.random() earns = [ Earn(PrivateKey.random().public_key, 100000), @@ -883,9 +1118,10 @@ def test_kin_4_submit_earn_batch_tx_failed(self, grpc_channel, executor, app_ind future = executor.submit(app_index_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp(grpc_channel, app_index_client) - self._set_v4_get_recent_blockhash_resp(grpc_channel) + self._set_get_service_config_resp(grpc_channel, app_index_client) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, signature=model_pb_v4.TransactionSignature(value=b'somesig'), @@ -894,7 +1130,7 @@ def test_kin_4_submit_earn_batch_tx_failed(self, grpc_channel, executor, app_ind instruction_index=2, ), ) - self._set_v4_submit_tx_resp(grpc_channel, resp) + self._set_submit_tx_resp(grpc_channel, resp) result = future.result() assert result.tx_id == b'somesig' @@ -904,14 +1140,14 @@ def test_kin_4_submit_earn_batch_tx_failed(self, grpc_channel, executor, app_ind assert result.earn_errors[0].earn_index == 1 assert isinstance(result.earn_errors[0].error, InsufficientBalanceError) - def test_kin_4_submit_earn_batch_with_nonce_retry(self, grpc_channel, executor, nonce_retry_client): + def test_submit_earn_batch_with_nonce_retry(self, grpc_channel, executor, nonce_retry_client): sender = PrivateKey.random() earns = [Earn(PrivateKey.random().public_key, 100000)] b = EarnBatch(sender, earns) future = executor.submit(nonce_retry_client.submit_earn_batch, b) - self._set_v4_get_service_config_resp(grpc_channel, nonce_retry_client) + self._set_get_service_config_resp(grpc_channel, nonce_retry_client) resp = tx_pb_v4.SubmitTransactionResponse( result=tx_pb_v4.SubmitTransactionResponse.Result.FAILED, @@ -924,13 +1160,14 @@ def test_kin_4_submit_earn_batch_with_nonce_retry(self, grpc_channel, executor, for i in range(_config_with_nonce_retry.max_nonce_refreshes + 1): # this blocks until the system under test invokes the RPC, so if the test completes then the RPC was called # the expected number of times. - self._set_v4_get_recent_blockhash_resp(grpc_channel) - self._set_v4_submit_tx_resp(grpc_channel, resp) + self._set_get_recent_blockhash_resp(grpc_channel) + self._set_sign_tx_resp_with_signer(grpc_channel, _subsidizer_key) + self._set_submit_tx_resp(grpc_channel, resp) with pytest.raises(BadNonceError): future.result() - def test_kin_4_earn_batch_invalid(self, grpc_channel, executor, no_app_client, app_index_client): + def test_earn_batch_invalid(self, grpc_channel, executor, no_app_client, app_index_client): sender = PrivateKey.random() # invoices with no app index @@ -995,7 +1232,7 @@ def test_request_airdrop_unsupported_env(self, grpc_channel, executor): future.result() @staticmethod - def _set_v4_create_account_resp( + def _set_create_account_resp( channel: grpc_testing.Channel, resp: account_pb_v4.CreateAccountResponse, status: grpc.StatusCode = grpc.StatusCode.OK, ) -> account_pb_v4.CreateAccountRequest: @@ -1008,7 +1245,7 @@ def _set_v4_create_account_resp( return request @staticmethod - def _set_v4_get_account_info_resp( + def _set_get_account_info_resp( channel: grpc_testing.Channel, resp: account_pb_v4.GetAccountInfoResponse, status: grpc.StatusCode = grpc.StatusCode.OK ) -> account_pb_v4.GetAccountInfoRequest: @@ -1020,7 +1257,7 @@ def _set_v4_get_account_info_resp( return request @staticmethod - def _set_v4_resolve_token_accounts_resp( + def _set_resolve_token_accounts_resp( channel: grpc_testing.Channel, resp: account_pb_v4.ResolveTokenAccountsResponse, status: grpc.StatusCode = grpc.StatusCode.OK ) -> account_pb_v4.GetAccountInfoRequest: @@ -1031,10 +1268,41 @@ def _set_v4_resolve_token_accounts_resp( return request @staticmethod - def _set_v4_submit_tx_resp( + def _set_sign_tx_resp_with_signer( + channel: grpc_testing.Channel, signer: PrivateKey, + status: grpc.StatusCode = grpc.StatusCode.OK + ) -> tx_pb_v4.SignTransactionRequest: + md, request, rpc = channel.take_unary_unary( + tx_pb_v4.DESCRIPTOR.services_by_name['Transaction'].methods_by_name['SignTransaction'] + ) + tx = Transaction.unmarshal(request.transaction.value) + assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) + + tx.sign([signer]) + resp = tx_pb_v4.SignTransactionResponse( + signature=model_pb_v4.TransactionSignature(value=tx.get_signature()) + ) + rpc.terminate(resp, (), status, '') + TestAgoraClient._assert_kin_4_md(md) + return request + + @staticmethod + def _set_sign_tx_resp( + channel: grpc_testing.Channel, resp: tx_pb_v4.SignTransactionResponse, + status: grpc.StatusCode = grpc.StatusCode.OK + ) -> tx_pb_v4.SignTransactionRequest: + md, request, rpc = channel.take_unary_unary( + tx_pb_v4.DESCRIPTOR.services_by_name['Transaction'].methods_by_name['SignTransaction'] + ) + rpc.terminate(resp, (), status, '') + TestAgoraClient._assert_kin_4_md(md) + return request + + @staticmethod + def _set_submit_tx_resp( channel: grpc_testing.Channel, resp: tx_pb_v4.SubmitTransactionResponse, status: grpc.StatusCode = grpc.StatusCode.OK - ): + ) -> tx_pb_v4.SubmitTransactionRequest: md, request, rpc = channel.take_unary_unary( tx_pb_v4.DESCRIPTOR.services_by_name['Transaction'].methods_by_name['SubmitTransaction'] ) @@ -1043,7 +1311,7 @@ def _set_v4_submit_tx_resp( return request @staticmethod - def _set_v4_get_recent_blockhash_resp( + def _set_get_recent_blockhash_resp( channel: grpc_testing.Channel, ) -> tx_pb_v4.GetRecentBlockhashRequest: md, request, rpc = channel.take_unary_unary( @@ -1055,7 +1323,7 @@ def _set_v4_get_recent_blockhash_resp( return request @staticmethod - def _set_v4_get_service_config_resp( + def _set_get_service_config_resp( channel: grpc_testing.Channel, client: Client, ) -> tx_pb_v4.GetServiceConfigRequest: client._internal_client._response_cache.clear_all() @@ -1066,14 +1334,14 @@ def _set_v4_get_service_config_resp( rpc.terminate(tx_pb_v4.GetServiceConfigResponse( subsidizer_account=model_pb_v4.SolanaAccountId(value=_subsidizer.raw), token=model_pb_v4.SolanaAccountId(value=_token.raw), - token_program=model_pb_v4.SolanaAccountId(value=_token_program.raw), + token_program=model_pb_v4.SolanaAccountId(value=token.PROGRAM_KEY.raw), ), (), grpc.StatusCode.OK, '') TestAgoraClient._assert_kin_4_md(md) return request @staticmethod - def _set_v4_get_service_config_resp_no_subsidizer( + def _set_get_service_config_resp_no_subsidizer( channel: grpc_testing.Channel, client: Client, ) -> tx_pb_v4.GetServiceConfigRequest: client._internal_client._response_cache.clear_all() @@ -1082,14 +1350,14 @@ def _set_v4_get_service_config_resp_no_subsidizer( ) rpc.terminate(tx_pb_v4.GetServiceConfigResponse( token=model_pb_v4.SolanaAccountId(value=_token.raw), - token_program=model_pb_v4.SolanaAccountId(value=_token_program.raw), + token_program=model_pb_v4.SolanaAccountId(value=token.PROGRAM_KEY.raw), ), (), grpc.StatusCode.OK, '') TestAgoraClient._assert_kin_4_md(md) return request @staticmethod - def _set_v4_get_min_balance_resp( + def _set_get_min_balance_resp( channel: grpc_testing.Channel, ) -> tx_pb_v4.GetMinimumBalanceForRentExemptionRequest: md, request, rpc = channel.take_unary_unary( @@ -1101,7 +1369,7 @@ def _set_v4_get_min_balance_resp( return request @staticmethod - def _set_v4_get_min_blockchain_version( + def _set_get_min_blockchain_version( channel: grpc_testing.Channel, kin_version=4, ) -> tx_pb_v4.GetMinimumKinVersionRequest: @@ -1124,38 +1392,106 @@ def _set_request_airdrop_resp( return request @staticmethod - def _assert_create_tx(tx_bytes: bytes, private_key: PrivateKey, subsidizer: Optional[PrivateKey] = None): - token_account_key = _generate_token_account(private_key) + def _assert_create_tx( + tx_bytes: bytes, private_key: PrivateKey, app_index: Optional[int] = 0, subsidizer: Optional[PrivateKey] = None + ): + token_account_key = token.get_associated_account(private_key.public_key, _token) tx = Transaction.unmarshal(tx_bytes) - assert len(tx.signatures) == 3 + assert len(tx.signatures) == 2 if subsidizer: - assert subsidizer.public_key.verify(tx.message.marshal(), tx.signatures[0]) + subsidizer.public_key.verify(tx.message.marshal(), tx.signatures[0]) else: assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) - assert token_account_key.public_key.verify(tx.message.marshal(), tx.signatures[1]) - assert private_key.public_key.verify(tx.message.marshal(), tx.signatures[2]) - - sys_create = decompile_create_account(tx.message, 0) - assert sys_create.funder == subsidizer.public_key if subsidizer else _subsidizer - assert sys_create.address == token_account_key.public_key - assert sys_create.owner == _token_program - assert sys_create.lamports == _min_balance - assert sys_create.size == token.ACCOUNT_SIZE - - token_init = decompile_initialize_account(tx.message, 1, _token_program) - assert token_init.account == token_account_key.public_key - assert token_init.mint == _token - assert token_init.owner == private_key.public_key - - token_set_auth = decompile_set_authority(tx.message, 2, _token_program) - assert token_set_auth.account == token_account_key.public_key + private_key.public_key.verify(tx.message.marshal(), tx.signatures[1]) + + assert len(tx.message.instructions) == (3 if app_index > 0 else 2) + i = 0 + if app_index > 0: + i += 1 + expected_memo = AgoraMemo.new(1, TransactionType.NONE, 1, b'').val + m = memo.decompile_memo(tx.message, 0) + assert base64.b64decode(m.data) == expected_memo + + create_assoc = token.decompile_create_associated_account(tx.message, i) + assert create_assoc.subsidizer == subsidizer.public_key if subsidizer else _subsidizer + assert create_assoc.address == token_account_key + assert create_assoc.owner == private_key.public_key + assert create_assoc.mint == _token + + token_set_auth = token.decompile_set_authority(tx.message, i + 1) + assert token_set_auth.account == token_account_key assert token_set_auth.current_authority == private_key.public_key - assert token_set_auth.authority_type == token.AuthorityType.CloseAccount + assert token_set_auth.authority_type == token.AuthorityType.CLOSE_ACCOUNT assert token_set_auth.new_authority == subsidizer.public_key if subsidizer else _subsidizer @staticmethod - def _assert_transfer(decompiled: DecompiledTransfer, source: PublicKey, dest: PublicKey, owner: PublicKey, + def _assert_merge_tx( + tx_bytes: bytes, private_key: PrivateKey, accounts: List[AccountInfo], create_assoc: Optional[bool] = False, + should_close: Optional[bool] = True, subsidizer: Optional[PrivateKey] = None, + ): + token_account_key = token.get_associated_account(private_key.public_key, _token) + tx = Transaction.unmarshal(tx_bytes) + assert len(tx.signatures) == 2 + + if subsidizer: + subsidizer.public_key.verify(tx.message.marshal(), tx.signatures[0]) + else: + assert tx.signatures[0] == bytes(SIGNATURE_LENGTH) + private_key.public_key.verify(tx.message.marshal(), tx.signatures[1]) + + i = 0 + if create_assoc: + # create, set auth, [transfer, optional[close]] for each account + if should_close: + assert len(tx.message.instructions) == 2 * (len(accounts) + 1) + else: + assert len(tx.message.instructions) == 2 + len(accounts) + + create_assoc = token.decompile_create_associated_account(tx.message, i) + assert create_assoc.subsidizer == subsidizer.public_key if subsidizer else _subsidizer + assert create_assoc.address == token_account_key + assert create_assoc.owner == private_key.public_key + assert create_assoc.mint == _token + + i += 1 + set_auth = token.decompile_set_authority(tx.message, i) + assert set_auth.account == token_account_key + assert set_auth.current_authority == private_key.public_key + assert set_auth.authority_type == token.AuthorityType.CLOSE_ACCOUNT + assert set_auth.new_authority == subsidizer.public_key if subsidizer else _subsidizer + + i += 1 + dest = token_account_key + remaining_accounts = accounts + else: + # [transfer, optional[close]] for all but 1 account + if should_close: + assert len(tx.message.instructions) == 2 * (len(accounts) - 1) + else: + assert len(tx.message.instructions) == len(accounts) - 1 + + dest = accounts[0].account_id + remaining_accounts = accounts[1:] + + for a in remaining_accounts: + transfer = token.decompile_transfer(tx.message, i) + assert transfer.source == a.account_id + assert transfer.dest == dest + assert transfer.owner == private_key.public_key + assert transfer.amount == a.balance + + i += 1 + + if should_close: + close = token.decompile_close_account(tx.message, i) + assert close.account == a.account_id + assert close.destination == subsidizer.public_key if subsidizer else _subsidizer + assert close.owner == subsidizer.public_key if subsidizer else _subsidizer + i += 1 + + @staticmethod + def _assert_transfer(decompiled: token.DecompiledTransfer, source: PublicKey, dest: PublicKey, owner: PublicKey, amount: int): assert decompiled.source == source assert decompiled.dest == dest @@ -1173,3 +1509,17 @@ def _assert_kin_4_md(md: Tuple[Tuple, ...]): assert len(md) == 3 assert md[0] == user_agent(VERSION) assert md[1] == ('kin-version', '4') + + @staticmethod + def _generate_resolve_resp( + keys: List[PublicKey], owner: Optional[PublicKey] = None, close_auth: Optional[PublicKey] = None + ) -> account_pb_v4.ResolveTokenAccountsResponse: + return account_pb_v4.ResolveTokenAccountsResponse( + token_account_infos=[account_pb_v4.AccountInfo( + account_id=model_pb_v4.SolanaAccountId(value=k.raw), + balance=10 + idx, + owner=model_pb_v4.SolanaAccountId(value=owner.raw) if owner else None, + close_authority=model_pb_v4.SolanaAccountId(value=close_auth.raw) if close_auth else None, + ) for idx, k in enumerate(keys)], + token_accounts=[model_pb_v4.SolanaAccountId(value=k.raw) for k in keys] + ) diff --git a/tests/client/test_internal.py b/tests/client/test_internal.py index db827c1..8755235 100644 --- a/tests/client/test_internal.py +++ b/tests/client/test_internal.py @@ -14,17 +14,14 @@ from agora import solana from agora.client.client import _NON_RETRIABLE_ERRORS from agora.client.internal import InternalClient -from agora.client.utils import _generate_token_account from agora.error import AccountExistsError, AccountNotFoundError, Error, TransactionRejectedError, BadNonceError, \ InsufficientBalanceError, PayerRequiredError, AlreadySubmittedError from agora.keys import PrivateKey -from agora.model import TransactionType, AgoraMemo +from agora.model import TransactionType, AgoraMemo, InvoiceList, Invoice, LineItem from agora.model.transaction import TransactionState from agora.retry import NonRetriableErrorsStrategy, LimitStrategy -from agora.solana import Transaction, Commitment, system -from agora.solana import token -from agora.solana.memo import memo_instruction -from agora.solana.token import transfer +from agora.solana import Transaction, Commitment +from agora.solana import token, memo from agora.solana.transaction import HASH_LENGTH from agora.utils import user_agent from agora.version import VERSION @@ -40,11 +37,9 @@ _subsidizer = PrivateKey.random().public_key _token = PrivateKey.random().public_key -_token_program = PrivateKey.random().public_key _service_config_resp = tx_pb_v4.GetServiceConfigResponse( subsidizer_account=model_pb_v4.SolanaAccountId(value=_subsidizer.raw), token=model_pb_v4.SolanaAccountId(value=_token.raw), - token_program=model_pb_v4.SolanaAccountId(value=_token_program.raw), ) @@ -96,7 +91,7 @@ def test_get_blockchain_version(self, grpc_channel, executor, no_retry_client): assert future.result() == 4 def test_create_solana_account(self, grpc_channel, executor, no_retry_client): - tx = self._generate_create_tx() + tx = self._gen_create_tx() # Test default commitment future = executor.submit(no_retry_client.create_solana_account, tx) @@ -125,7 +120,7 @@ def test_create_solana_account(self, grpc_channel, executor, no_retry_client): ] ) def test_create_solana_account_errors(self, grpc_channel, executor, no_retry_client, result, error_type): - tx = self._generate_create_tx() + tx = self._gen_create_tx() future = executor.submit(no_retry_client.create_solana_account, tx) @@ -148,7 +143,7 @@ def test_get_account_info(self, grpc_channel, executor, no_retry_client): assert req.account_id.value == private_key.public_key.raw account_info = future.result() - assert account_info.account_id == private_key.public_key.raw + assert account_info.account_id == private_key.public_key assert account_info.balance == 10 def test_get_account_info_not_found(self, grpc_channel, executor, no_retry_client): @@ -162,6 +157,110 @@ def test_get_account_info_not_found(self, grpc_channel, executor, no_retry_clien with pytest.raises(AccountNotFoundError): future.result() + def test_resolve_token_accounts(self, grpc_channel, executor, no_retry_client): + owner = PrivateKey.random().public_key + close_authority = PrivateKey.random().public_key + token_accounts = [priv.public_key for priv in generate_keys(2)] + + # account info not requested, only IDs available + future = executor.submit(no_retry_client.resolve_token_accounts, owner, False) + resp = account_pb_v4.ResolveTokenAccountsResponse( + token_accounts=[ + model_pb_v4.SolanaAccountId( + value=token_account.raw + ) for token_account in token_accounts + ] + ) + req = self._set_resolve_token_accounts_resp(grpc_channel, resp) + assert req.account_id.value == owner.raw + assert not req.include_account_info + + token_account_infos = future.result() + assert len(token_account_infos) == 2 + for idx, token_account in enumerate(token_accounts): + account_info = token_account_infos[idx] + assert account_info.account_id == token_account + assert not account_info.balance + assert not account_info.owner + assert not account_info.close_authority + + # account info not requested, account infos available + future = executor.submit(no_retry_client.resolve_token_accounts, owner, False) + resp = account_pb_v4.ResolveTokenAccountsResponse( + token_account_infos=[ + account_pb_v4.AccountInfo( + account_id=model_pb_v4.SolanaAccountId( + value=token_account.raw + ), + ) for token_account in token_accounts + ], + token_accounts=[ + model_pb_v4.SolanaAccountId( + value=token_account.raw + ) for token_account in token_accounts + ] + ) + req = self._set_resolve_token_accounts_resp(grpc_channel, resp) + assert req.account_id.value == owner.raw + assert not req.include_account_info + + token_account_infos = future.result() + assert len(token_account_infos) == 2 + for idx, token_account in enumerate(token_accounts): + account_info = token_account_infos[idx] + assert account_info.account_id == token_account + assert not account_info.balance + assert not account_info.owner + assert not account_info.close_authority + + # account info requested + future = executor.submit(no_retry_client.resolve_token_accounts, owner, True) + resp = account_pb_v4.ResolveTokenAccountsResponse( + token_account_infos=[ + account_pb_v4.AccountInfo( + account_id=model_pb_v4.SolanaAccountId( + value=token_account.raw + ), + balance=10 + idx, + owner=model_pb_v4.SolanaAccountId( + value=owner.raw, + ), + close_authority=model_pb_v4.SolanaAccountId( + value=close_authority.raw + ) + ) for idx, token_account in enumerate(token_accounts) + ], + ) + req = self._set_resolve_token_accounts_resp(grpc_channel, resp) + assert req.account_id.value == owner.raw + assert req.include_account_info + + token_account_infos = future.result() + assert len(token_account_infos) == 2 + for idx, token_account in enumerate(token_accounts): + account_info = token_account_infos[idx] + assert account_info.account_id == token_account + assert account_info.balance == 10 + idx + assert account_info.owner == owner + assert account_info.close_authority == close_authority + + # account info requested but not available + future = executor.submit(no_retry_client.resolve_token_accounts, owner, True) + resp = account_pb_v4.ResolveTokenAccountsResponse( + token_accounts=[ + model_pb_v4.SolanaAccountId( + value=token_account.raw + ) for token_account in token_accounts + ], + ) + req = self._set_resolve_token_accounts_resp(grpc_channel, resp) + assert req.account_id.value == owner.raw + assert req.include_account_info + + with pytest.raises(Error) as e: + future.result() + assert 'account info' in str(e) + def test_get_transaction(self, grpc_channel, executor, no_retry_client): source, dest = [key.public_key for key in generate_keys(2)] transaction_id = b'someid' @@ -169,8 +268,8 @@ def test_get_transaction(self, grpc_channel, executor, no_retry_client): agora_memo = AgoraMemo.new(1, TransactionType.SPEND, 0, b'') tx = Transaction.new(PrivateKey.random().public_key, [ - memo_instruction(base64.b64encode(agora_memo.val).decode('utf-8')), - transfer(source, dest, PrivateKey.random().public_key, 100, _token_program), + memo.memo_instruction(base64.b64encode(agora_memo.val).decode('utf-8')), + token.transfer(source, dest, PrivateKey.random().public_key, 100), ]) resp = tx_pb_v4.GetTransactionResponse( @@ -233,9 +332,63 @@ def test_get_transaction_not_successful(self, grpc_channel, executor, no_retry_c assert len(tx_data.payments) == 0 assert not tx_data.error + def test_sign_transaction(self, grpc_channel, executor, no_retry_client): + tx = self._gen_tx() + il = self._gen_invoice_list() + + # OK response + future = executor.submit(no_retry_client.sign_transaction, tx, il) + tx_sig = bytes(solana.SIGNATURE_LENGTH) + resp = tx_pb_v4.SignTransactionResponse( + result=tx_pb_v4.SignTransactionResponse.Result.OK, + signature=model_pb_v4.TransactionSignature(value=tx_sig) + ) + req = self._set_sign_transaction_resp(grpc_channel, resp) + assert req.transaction.value == tx.marshal() + assert req.invoice_list == il.to_proto() + + result = future.result() + assert result.tx_id == tx_sig + assert not result.invoice_errors + + # Rejected + future = executor.submit(no_retry_client.sign_transaction, tx) + resp = tx_pb_v4.SignTransactionResponse( + result=tx_pb_v4.SignTransactionResponse.Result.REJECTED, + ) + req = self._set_sign_transaction_resp(grpc_channel, resp) + assert req.transaction.value == tx.marshal() + assert not req.invoice_list.invoices + + with pytest.raises(TransactionRejectedError): + future.result() + + # Invoice Errors + future = executor.submit(no_retry_client.sign_transaction, tx, il) + invoice_errors = [ + model_pb_v3.InvoiceError( + op_index=0, + invoice=il.invoices[0].to_proto(), + reason=model_pb_v3.InvoiceError.Reason.SKU_NOT_FOUND, + ), + ] + resp = tx_pb_v4.SignTransactionResponse( + result=tx_pb_v4.SignTransactionResponse.Result.INVOICE_ERROR, + invoice_errors=invoice_errors, + ) + req = self._set_sign_transaction_resp(grpc_channel, resp) + assert req.transaction.value == tx.marshal() + assert req.invoice_list == il.to_proto() + + result = future.result() + assert not result.tx_id + assert len(result.invoice_errors) == 1 + assert result.invoice_errors[0] == invoice_errors[0] + def test_submit_transaction(self, grpc_channel, executor, no_retry_client): - tx = self._generate_tx() - future = executor.submit(no_retry_client.submit_solana_transaction, tx) + tx = self._gen_tx() + il = self._gen_invoice_list() + future = executor.submit(no_retry_client.submit_solana_transaction, tx, il) tx_sig = b'somesig' resp = tx_pb_v4.SubmitTransactionResponse( @@ -244,6 +397,7 @@ def test_submit_transaction(self, grpc_channel, executor, no_retry_client): ) req = self._set_submit_transaction_resp(grpc_channel, resp) assert req.transaction.value == tx.marshal() + assert req.invoice_list == il.to_proto() result = future.result() assert result.tx_id == tx_sig @@ -251,7 +405,7 @@ def test_submit_transaction(self, grpc_channel, executor, no_retry_client): assert not result.invoice_errors def test_submit_transaction_already_submitted(self, grpc_channel, executor, retry_client): - tx = self._generate_tx() + tx = self._gen_tx() tx_sig = b'somesig' # Receive ALREADY_SUBMITTED on first attempt - should result in an error @@ -262,6 +416,7 @@ def test_submit_transaction_already_submitted(self, grpc_channel, executor, retr ) req = self._set_submit_transaction_resp(grpc_channel, resp) assert req.transaction.value == tx.marshal() + assert not req.invoice_list.invoices with pytest.raises(AlreadySubmittedError): future.result() @@ -282,6 +437,7 @@ def test_submit_transaction_already_submitted(self, grpc_channel, executor, retr ) req = self._set_submit_transaction_resp(grpc_channel, resp) assert req.transaction.value == tx.marshal() + assert not req.invoice_list.invoices result = future.result() assert result.tx_id == tx_sig @@ -289,7 +445,7 @@ def test_submit_transaction_already_submitted(self, grpc_channel, executor, retr assert not result.invoice_errors def test_submit_transaction_invoice_error(self, grpc_channel, executor, no_retry_client): - tx = self._generate_tx() + tx = self._gen_tx() future = executor.submit(no_retry_client.submit_solana_transaction, tx) tx_sig = b'somesig' @@ -318,7 +474,7 @@ def test_submit_transaction_invoice_error(self, grpc_channel, executor, no_retry assert result.invoice_errors == resp.invoice_errors def test_submit_transaction_rejected(self, grpc_channel, executor, no_retry_client): - tx = self._generate_tx() + tx = self._gen_tx() future = executor.submit(no_retry_client.submit_solana_transaction, tx) tx_sig = b'somesig' @@ -333,7 +489,7 @@ def test_submit_transaction_rejected(self, grpc_channel, executor, no_retry_clie future.result() def test_submit_transaction_payer_required(self, grpc_channel, executor, no_retry_client): - tx = self._generate_tx() + tx = self._gen_tx() future = executor.submit(no_retry_client.submit_solana_transaction, tx) tx_sig = b'somesig' @@ -348,7 +504,7 @@ def test_submit_transaction_payer_required(self, grpc_channel, executor, no_retr future.result() def test_submit_transaction_failed(self, grpc_channel, executor, no_retry_client): - tx = self._generate_tx() + tx = self._gen_tx() future = executor.submit(no_retry_client.submit_solana_transaction, tx) tx_sig = b'somesig' @@ -372,7 +528,7 @@ def test_submit_transaction_failed(self, grpc_channel, executor, no_retry_client assert not result.invoice_errors def test_submit_transaction_unexpected_result(self, grpc_channel, executor, no_retry_client): - tx = self._generate_tx() + tx = self._gen_tx() future = executor.submit(no_retry_client.submit_solana_transaction, tx) tx_sig = b'somesig' @@ -386,6 +542,13 @@ def test_submit_transaction_unexpected_result(self, grpc_channel, executor, no_r with pytest.raises(Error): future.result() + def test_minimum_balance(self, grpc_channel, executor, no_retry_client): + future = executor.submit(no_retry_client.get_minimum_balance_for_rent_exception) + self._set_get_min_balance_response(grpc_channel) + + result = future.result() + assert result == _min_balance + def test_request_airdrop(self, grpc_channel, executor, no_retry_client): public_key = PrivateKey.random().public_key future = executor.submit(no_retry_client.request_airdrop, public_key, 100, Commitment.MAX) @@ -460,6 +623,19 @@ def _set_get_account_info_resp( TestInternalClientV4._assert_metadata(md) return request + @staticmethod + def _set_resolve_token_accounts_resp( + channel: grpc_testing.Channel, resp: account_pb_v4.ResolveTokenAccountsResponse, + status: grpc.StatusCode = grpc.StatusCode.OK, + ) -> account_pb_v4.ResolveTokenAccountsRequest: + md, request, rpc = channel.take_unary_unary( + account_pb_v4.DESCRIPTOR.services_by_name['Account'].methods_by_name['ResolveTokenAccounts'] + ) + rpc.terminate(resp, (), status, '') + + TestInternalClientV4._assert_metadata(md) + return request + @staticmethod def _set_get_transaction_resp( channel: grpc_testing.Channel, resp: tx_pb_v4.GetTransactionResponse, @@ -473,11 +649,24 @@ def _set_get_transaction_resp( TestInternalClientV4._assert_metadata(md) return request + @staticmethod + def _set_sign_transaction_resp( + channel: grpc_testing.Channel, resp: tx_pb_v4.SignTransactionResponse, + status: grpc.StatusCode = grpc.StatusCode.OK, + ) -> tx_pb_v4.SignTransactionRequest: + md, request, rpc = channel.take_unary_unary( + tx_pb_v4.DESCRIPTOR.services_by_name['Transaction'].methods_by_name['SignTransaction'] + ) + rpc.terminate(resp, (), status, '') + + TestInternalClientV4._assert_metadata(md) + return request + @staticmethod def _set_submit_transaction_resp( channel: grpc_testing.Channel, resp: tx_pb_v4.SubmitTransactionResponse, status: grpc.StatusCode = grpc.StatusCode.OK, - ) -> tx_pb_v4.GetTransactionRequest: + ) -> tx_pb_v4.SubmitTransactionRequest: md, request, rpc = channel.take_unary_unary( tx_pb_v4.DESCRIPTOR.services_by_name['Transaction'].methods_by_name['SubmitTransaction'] ) @@ -542,41 +731,44 @@ def _assert_metadata(md: Tuple[Tuple, ...]): assert md[1] == ('kin-version', '4') @staticmethod - def _generate_tx(): + def _gen_tx(): sender, dest, owner = generate_keys(3) return solana.Transaction.new( _subsidizer, [ - token.transfer(sender, dest, owner, 0, _token_program) + token.transfer(sender, dest, owner, 0) ] ) @staticmethod - def _generate_create_tx(): + def _gen_create_tx(): private_key = PrivateKey.random() - token_account_key = _generate_token_account(private_key) + create_instruction, addr = token.create_associated_token_account( + _subsidizer, + private_key.public_key, + _token) + return solana.Transaction.new( _subsidizer, [ - system.create_account( - _subsidizer, - token_account_key.public_key, - _token_program, - 10, - token.ACCOUNT_SIZE, - ), - token.initialize_account( - token_account_key.public_key, - _token, - private_key.public_key, - _token_program, - ), + create_instruction, token.set_authority( - token_account_key.public_key, + addr, private_key.public_key, - token.AuthorityType.CloseAccount, - _token_program, + token.AuthorityType.CLOSE_ACCOUNT, new_authority=_subsidizer, ) ] ) + + @staticmethod + def _gen_invoice_list(): + return InvoiceList( + [ + Invoice( + [ + LineItem('Item1', 10), + ] + ) + ] + ) diff --git a/tests/model/test_payment.py b/tests/model/test_payment.py deleted file mode 100644 index a098746..0000000 --- a/tests/model/test_payment.py +++ /dev/null @@ -1,150 +0,0 @@ -import base64 - -import pytest -from agoraapi.common.v3 import model_pb2 - -from agora import solana -from agora.model.invoice import InvoiceList, Invoice -from agora.model.memo import AgoraMemo -from agora.model.payment import ReadOnlyPayment -from agora.model.transaction_type import TransactionType -from tests.utils import generate_keys - - -class TestReadOnlyPayment: - def test_payments_from_transaction(self): - keys = [key.public_key for key in generate_keys(5)] - token_program = keys[4] - tx = solana.Transaction.new( - keys[0], - [ - solana.memo_instruction('somememo'), - solana.transfer( - keys[1], - keys[2], - keys[3], - 20, - token_program, - ), - solana.transfer( - keys[2], - keys[3], - keys[1], - 40, - token_program, - ), - ] - ) - - payments = ReadOnlyPayment.payments_from_transaction(tx) - - assert len(payments) == 2 - - assert payments[0].sender == keys[1] - assert payments[0].destination == keys[2] - assert payments[0].tx_type == TransactionType.UNKNOWN - assert payments[0].quarks == 20 - assert not payments[0].invoice - assert payments[0].memo == 'somememo' - - assert payments[1].sender == keys[2] - assert payments[1].destination == keys[3] - assert payments[1].tx_type == TransactionType.UNKNOWN - assert payments[1].quarks == 40 - assert not payments[1].invoice - assert payments[1].memo == 'somememo' - - def test_payments_from_transaction_with_invoice_list(self): - il = model_pb2.InvoiceList(invoices=[ - model_pb2.Invoice( - items=[ - model_pb2.Invoice.LineItem(title='t1', amount=10), - ] - ), - model_pb2.Invoice( - items=[ - model_pb2.Invoice.LineItem(title='t1', amount=15), - ] - ), - ]) - fk = InvoiceList.from_proto(il).get_sha_224_hash() - memo = AgoraMemo.new(1, TransactionType.P2P, 0, fk) - - keys = [key.public_key for key in generate_keys(5)] - token_program = keys[4] - tx = solana.Transaction.new( - keys[0], - [ - solana.memo_instruction(base64.b64encode(memo.val).decode('utf-8')), - solana.transfer( - keys[1], - keys[2], - keys[3], - 20, - token_program, - ), - solana.transfer( - keys[2], - keys[3], - keys[1], - 40, - token_program, - ), - ] - ) - - payments = ReadOnlyPayment.payments_from_transaction(tx, il) - - assert len(payments) == 2 - - assert payments[0].sender == keys[1] - assert payments[0].destination == keys[2] - assert payments[0].tx_type == TransactionType.P2P - assert payments[0].quarks == 20 - assert payments[0].invoice == Invoice.from_proto(il.invoices[0]) - assert not payments[0].memo - - assert payments[1].sender == keys[2] - assert payments[1].destination == keys[3] - assert payments[1].tx_type == TransactionType.P2P - assert payments[1].quarks == 40 - assert payments[1].invoice == Invoice.from_proto(il.invoices[1]) - assert not payments[1].memo - - def test_payments_from_transaction_invalid(self): - il = model_pb2.InvoiceList(invoices=[ - model_pb2.Invoice( - items=[ - model_pb2.Invoice.LineItem(title='t1', amount=10), - ] - ), - ]) - fk = InvoiceList.from_proto(il).get_sha_224_hash() - memo = AgoraMemo.new(1, TransactionType.P2P, 0, fk) - - keys = [key.public_key for key in generate_keys(5)] - token_program = keys[4] - tx = solana.Transaction.new( - keys[0], - [ - solana.memo_instruction(base64.b64encode(memo.val).decode('utf-8')), - solana.transfer( - keys[1], - keys[2], - keys[3], - 20, - token_program, - ), - solana.transfer( - keys[2], - keys[3], - keys[1], - 40, - token_program, - ), - ] - ) - - # mismatching number of invoices and instructions - with pytest.raises(ValueError): - ReadOnlyPayment.payments_from_transaction(tx, il) diff --git a/tests/model/test_transaction.py b/tests/model/test_transaction.py index dc3d860..8e0a4e2 100644 --- a/tests/model/test_transaction.py +++ b/tests/model/test_transaction.py @@ -131,7 +131,7 @@ def test_from_proto_solana_text_memo(self): source, dest, token_program = [key.public_key for key in generate_keys(3)] tx = Transaction.new(PrivateKey.random().public_key, [ memo_instruction('somememo'), - transfer(source, dest, PrivateKey.random().public_key, 20, token_program), + transfer(source, dest, PrivateKey.random().public_key, 20), ]) history_item = tx_pb.HistoryItem( @@ -181,8 +181,8 @@ def test_from_proto_solana_agora_memo(self): tx = Transaction.new(PrivateKey.random().public_key, [ memo_instruction(base64.b64encode(agora_memo.val).decode('utf-8')), - transfer(acc1, acc2, PrivateKey.random().public_key, 10, token_program), - transfer(acc2, acc1, PrivateKey.random().public_key, 15, token_program), + transfer(acc1, acc2, PrivateKey.random().public_key, 10), + transfer(acc2, acc1, PrivateKey.random().public_key, 15), ]) history_item = tx_pb.HistoryItem( diff --git a/tests/model/test_utils.py b/tests/model/test_utils.py new file mode 100644 index 0000000..5a4fd3f --- /dev/null +++ b/tests/model/test_utils.py @@ -0,0 +1,380 @@ +import base64 +import uuid +from typing import Tuple, List + +import pytest +from agoraapi.common.v3 import model_pb2 + +from agora import solana +from agora.keys import PublicKey, PrivateKey +from agora.model import parse_transaction, TransactionType, AgoraMemo, InvoiceList, Invoice +from agora.solana import token, memo, system +from agora.solana.token import AuthorityType +from tests.utils import generate_keys + + +class TestParseTransaction: + def test_transfers_no_invoices(self): + keys = [priv.public_key for priv in generate_keys(5)] + + tx = solana.Transaction.new( + keys[0], + [ + token.transfer(keys[1], keys[2], keys[3], 10), + token.transfer(keys[2], keys[3], keys[4], 20), + ], + ) + creations, payments = parse_transaction(tx) + assert len(creations) == 0 + + for i in range(2): + assert payments[i].sender == keys[1 + i] + assert payments[i].destination == keys[2 + i] + assert payments[i].tx_type == TransactionType.UNKNOWN + assert payments[i].quarks == (1 + i) * 10 + assert not payments[i].invoice + assert not payments[i].memo + + def test_transfers_with_invoices(self): + keys = [priv.public_key for priv in generate_keys(5)] + + # Single memo + memo_instruction, il = self._get_invoice_memo_instruction(TransactionType.SPEND, 10, 2) + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction, + token.transfer(keys[1], keys[2], keys[3], 10), + token.transfer(keys[2], keys[3], keys[4], 20) + ], + ) + creations, payments = parse_transaction(tx, il) + assert len(creations) == 0 + + for i in range(2): + assert payments[i].sender == keys[1 + i] + assert payments[i].destination == keys[2 + i] + assert payments[i].tx_type == TransactionType.SPEND + assert payments[i].quarks == (1 + i) * 10 + assert payments[i].invoice == Invoice.from_proto(il.invoices[i]) + assert not payments[i].memo + + # Multiple memos + memo_instruction_1, il1 = self._get_invoice_memo_instruction(TransactionType.SPEND, 10, 1) + memo_instruction_2, il2 = self._get_invoice_memo_instruction(TransactionType.P2P, 10, 1) + + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction_1, + token.transfer(keys[1], keys[2], keys[3], 10), + memo_instruction_2, + token.transfer(keys[2], keys[3], keys[4], 20), + ], + ) + creations, payments = parse_transaction(tx, il1) + assert len(creations) == 0 + + expected_invoices = [il1.invoices[0], None] + expected_types = [TransactionType.SPEND, TransactionType.P2P] + for i in range(2): + assert payments[i].sender == keys[1 + i] + assert payments[i].destination == keys[2 + i] + assert payments[i].tx_type == expected_types[i] + assert payments[i].quarks == (1 + i) * 10 + if expected_invoices[i]: + assert payments[i].invoice == Invoice.from_proto(expected_invoices[i]) + else: + assert not payments[i].invoice + assert not payments[i].memo + + def test_with_text_memo(self): + keys = [priv.public_key for priv in generate_keys(5)] + + # transfers with single memo + tx = solana.Transaction.new( + keys[0], + [ + memo.memo_instruction('1-test'), + token.transfer(keys[1], keys[2], keys[3], 10), + token.transfer(keys[2], keys[3], keys[4], 20), + ] + ) + creations, payments = parse_transaction(tx) + assert len(creations) == 0 + + for i in range(2): + assert payments[i].sender == keys[1 + i] + assert payments[i].destination == keys[2 + i] + assert payments[i].tx_type == TransactionType.UNKNOWN + assert payments[i].quarks == (1 + i) * 10 + assert not payments[i].invoice + assert payments[i].memo == '1-test' + + # transfers with multiple memos + expected_memos = ['1-test-alpha', '1-test-beta'] + tx = solana.Transaction.new( + keys[0], + [ + memo.memo_instruction(expected_memos[0]), + token.transfer(keys[1], keys[2], keys[3], 10), + memo.memo_instruction(expected_memos[1]), + token.transfer(keys[2], keys[3], keys[4], 20), + ] + ) + creations, payments = parse_transaction(tx) + assert len(creations) == 0 + + for i in range(2): + assert payments[i].sender == keys[1 + i] + assert payments[i].destination == keys[2 + i] + assert payments[i].tx_type == TransactionType.UNKNOWN + assert payments[i].quarks == (1 + i) * 10 + assert not payments[i].invoice + assert payments[i].memo == expected_memos[i] + + # sender create + create_instructions, addr = self._generate_create(keys[0], keys[1], keys[2]) + + inputs = [] + for i in range(2): + instructions = create_instructions.copy() + instructions.append(memo.memo_instruction('1-test')) + instructions.append(token.transfer(keys[3], keys[4], keys[1], 10)) + + for idx, i in enumerate(inputs): + creations, payments = parse_transaction(i) + assert len(creations) == 1 + assert len(payments) == 1 + + assert creations[0].owner == keys[1] + assert creations[0].address == addr + + assert payments[0].sender == keys[3] + assert payments[0].destination == keys[4] + assert payments[0].tx_type == TransactionType.UNKNOWN + assert payments[0].quarks == 10 + assert not payments[0].invoice + assert payments[0].memo == '1-test' + + def test_create_without_account_holder_auth(self): + keys = [priv.public_key for priv in generate_keys(3)] + + create_instructions, addr = self._generate_create(keys[0], keys[1], keys[2]) + create_assoc_instruction, assoc = token.create_associated_token_account(keys[0], keys[1], keys[2]) + txs = [ + solana.Transaction.new( + keys[0], + create_instructions[:3], + ), + solana.Transaction.new( + keys[0], + [ + create_assoc_instruction, + token.set_authority(assoc, assoc, token.AuthorityType.CLOSE_ACCOUNT, new_authority=keys[0]), + ] + ) + ] + + for idx, tx in enumerate(txs): + creations, payments = parse_transaction(tx) + assert len(creations) == 1 + assert len(payments) == 0 + + if idx == 0: + # Randomly generated in _generate_create + assert creations[0].owner + assert creations[0].address == addr + else: + assert creations[0].owner == keys[1] + assert creations[0].address == assoc + + def test_create_without_close_authority(self): + keys = [priv.public_key for priv in generate_keys(3)] + + create_instructions, addr = self._generate_create(keys[0], keys[1], keys[2]) + create_assoc_instruction, assoc = token.create_associated_token_account(keys[0], keys[1], keys[2]) + txs = [ + solana.Transaction.new( + keys[0], + create_instructions[:2], + ), + solana.Transaction.new( + keys[0], + [ + create_assoc_instruction, + ], + ) + ] + + for tx in txs: + with pytest.raises(ValueError) as e: + parse_transaction(tx) + assert 'SetAuthority(Close)' in str(e) + + def test_invalid_memo_combinations(self): + keys = [priv.public_key for priv in generate_keys(5)] + + # invalid transaction type combinations + memo_instruction1, _ = self._get_invoice_memo_instruction(TransactionType.EARN, 10, 1) + for tx_type in [TransactionType.SPEND, TransactionType.P2P]: + memo_instruction2, _ = self._get_invoice_memo_instruction(tx_type, 10, 1) + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction1, + token.transfer(keys[1], keys[2], keys[3], 10), + memo_instruction2, + token.transfer(keys[2], keys[3], keys[4], 20), + ] + ) + + with pytest.raises(ValueError) as e: + parse_transaction(tx) + assert 'cannot mix' in str(e) + + # mixed app IDs + tx = solana.Transaction.new( + keys[0], + [ + memo.memo_instruction('1-kik'), + memo.memo_instruction('1-kin'), + ] + ) + + with pytest.raises(ValueError) as e: + parse_transaction(tx) + assert 'app IDs' in str(e) + + # mixed app indices + memo_instruction1, _ = self._get_invoice_memo_instruction(TransactionType.EARN, 10, 1) + memo_instruction2, _ = self._get_invoice_memo_instruction(TransactionType.EARN, 11, 1) + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction1, + memo_instruction2, + ] + ) + + with pytest.raises(ValueError) as e: + parse_transaction(tx) + assert 'app indexes' in str(e) + + # no memos match the invoice list + il = self._generate_invoice_list(2) + memo_instruction, il2 = self._get_invoice_memo_instruction(TransactionType.EARN, 10, 1) + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction, + token.transfer(keys[1], keys[2], keys[3], 10), + memo_instruction, + token.transfer(keys[2], keys[3], keys[4], 20), + ] + ) + + with pytest.raises(ValueError) as e: + parse_transaction(tx, il) + assert 'exactly one' in str(e) + + # too many memos match the invoice list + memo_instruction, il = self._get_invoice_memo_instruction(TransactionType.EARN, 10, 2) + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction, + token.transfer(keys[1], keys[2], keys[3], 10), + memo_instruction, + token.transfer(keys[2], keys[3], keys[4], 20), + ] + ) + + with pytest.raises(ValueError) as e: + parse_transaction(tx, il) + assert 'exactly one' in str(e) + + # too many transfers for the invoice list + memo_instruction, il = self._get_invoice_memo_instruction(TransactionType.EARN, 10, 1) + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction, + token.transfer(keys[1], keys[2], keys[3], 10), + memo_instruction, + token.transfer(keys[2], keys[3], keys[4], 20), + ] + ) + + with pytest.raises(ValueError) as e: + parse_transaction(tx, il) + assert 'sufficient invoices' in str(e) + + # too few transfers for the invoice list + memo_instruction, il = self._get_invoice_memo_instruction(TransactionType.EARN, 10, 2) + tx = solana.Transaction.new( + keys[0], + [ + memo_instruction, + token.transfer(keys[1], keys[2], keys[3], 10), + ] + ) + + with pytest.raises(ValueError) as e: + parse_transaction(tx, il) + assert 'does not match number of transfers referencing the invoice list' in str(e) + + def test_with_invalid_instructions(self): + keys = [priv.public_key for priv in generate_keys(5)] + invalid_instructions = [ + token.set_authority(keys[1], keys[2], AuthorityType.ACCOUNT_HOLDER, new_authority=keys[3]), + token.initialize_account(keys[1], keys[2], keys[3]), + system.create_account(keys[1], keys[2], keys[3], 10, 10), + ] + + for i in invalid_instructions: + tx = solana.Transaction.new( + keys[0], + [ + token.transfer(keys[1], keys[2], keys[3], 10), + i, + ] + ) + + with pytest.raises(ValueError): + parse_transaction(tx) + + @staticmethod + def _get_invoice_memo_instruction( + tx_type: TransactionType, app_index: int, transfer_count: int + ) -> Tuple[solana.Instruction, model_pb2.InvoiceList]: + il = TestParseTransaction._generate_invoice_list(transfer_count) + m = AgoraMemo.new(1, tx_type, app_index, InvoiceList.from_proto(il).get_sha_224_hash()) + return memo.memo_instruction(base64.b64encode(m.val).decode('utf-8')), il + + @staticmethod + def _generate_create( + subsidizer: PublicKey, wallet: PublicKey, mint: PublicKey + ) -> Tuple[List[solana.Instruction], PublicKey]: + addr = token.get_associated_account(wallet, mint) + pub = PrivateKey.random().public_key + + instructions = [ + system.create_account(subsidizer, addr, token.PROGRAM_KEY, 10, token.ACCOUNT_SIZE), + token.initialize_account(addr, mint, pub), + token.set_authority(addr, pub, token.AuthorityType.CLOSE_ACCOUNT, subsidizer), + token.set_authority(addr, pub, token.AuthorityType.ACCOUNT_HOLDER, wallet) + ] + return instructions, addr + + @staticmethod + def _generate_invoice_list(transfer_count: int): + return model_pb2.InvoiceList( + invoices=[ + model_pb2.Invoice( + items=[ + model_pb2.Invoice.LineItem(title=str(uuid.uuid4())) + ] + ) for _ in range(transfer_count) + ] + ) diff --git a/tests/solana/test_address.py b/tests/solana/test_address.py index 6e367de..8f386ed 100644 --- a/tests/solana/test_address.py +++ b/tests/solana/test_address.py @@ -56,3 +56,97 @@ class TestFindProgramAddress: def test_find_program_address(self): for i in range(1000): assert find_program_address(PrivateKey.random().public_key, ['Lil\''.encode(), 'Bits'.encode()]) + + @pytest.mark.parametrize( + 'program_id, expected', + [ + ( + "4uQeVj5tqViQh7yWWGStvkEG1Zmhx6uasJtWCJziofM", + "Bn9pAWUXWc5Kd849xTkQcHqiCbHUEizLFn4r5Cf8XYnd", + ), + ( + "8opHzTAnfzRpPEx21XtnrVTX28YQuCpAjcn1PczScKh", + "oDvUHiiGdMo31xYzjefAzUekWH8EbCKrxgs2FkyTs1S", + ), + ( + "CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3", + "B2vBn2bmF9GuaGkebrm8oUqDC34pE6m4bagjNcVE6msv", + ), + ( + "GcdayuLaLyrdmUu324nahyv33G5poQdLUEZ1nEytDeP", + "2mN5Nfq9v1EwTV9FPTHPESZ3XiZce9wi5PQoULFuxvev", + ), + ( + "LX3EUdRUBUa3TbsYXLEUdj9J3prXkWXvLYSWyYyc2Jj", + "9CqF6oTZtW5zSeoLnZRoQmj3s2tXGPqifM1W8Z8LVE1z", + ), + ( + "QRSsyMWN1yHT9ir42bgNZUNZ4PdEhcSWCrL2AryKpy5", + "FwBDYafabYZLDC8FwaDCsLxWkKnaQxKuQv3afDAGiXJ8", + ), + ( + "UKrXU5bFrTzrqqpZXs8GVDbp4xPweiM65ADXNAy3ddR", + "2Y1miPDc3BkHVdNFeFTtRkiw8nbptrBqboJkbqxk5SFt", + ), + ( + "YEGAxog9gxiGXxo538aAQxq55XAebpFfwU72ZUxmSHm", + "5jeaj2d8T2hjU63h2chjtSnuUmjti6qZK7oi6jwTspoo", + ), + ( + "c8fpTXm3XTRgE5maYQ24Li4L65wMYvAFomzXknxVEx7", + "6brHYNpseuh39WW3Md5WxTyw12kqumR4tTyZqzkyPWZP", + ), + ( + "g35TxFqwMx95vCk63fTxGTHb6ei4W24qg5t2x6xD3cT", + "ESVKwnyn9DEkNcR5ZnHFbMK66nCArc9dChFCULstzLy5", + ), + ( + "jwV7SyvqCSrVcKibYvurCCWr7DUmT7yRYPmY9QwvrGo", + "69BytoSYkhMovVk8gfGUwhf9P8HSnrcYhaoWY2dgmrPE", + ), + ( + "oqtkwi1j2wZuJSh74CMk7wk77nFUQDt1Qhf3Liweew9", + "EfwG5mLknsUXPLHkUp1doxgN1W4Azr3gkZ1Zu6w6AxdF", + ), + ( + "skJQSS6csSHJzZfcZToe3gyN8M2BMKnbH1YYY2wNTbV", + "Cw2qpvCaoPGxEJypW7rW5obTKSTLpCDRN7TgrrVugkfC", + ), + ( + "wei3wABWhvzigge84jFXySCd8untJRhB9KS3jLw6GFq", + "8jztcAvddJNqK1ZjwcRkfWYAkfJW7dBbwoxZt7HSNg1G", + ), + ( + "21Z7hRtGQYRi8NocdZzhRuBRt9UZbFXbm1dKYvevp4vB", + "9PPbRbNP3rqwzk16r7NDBzk1YDfo9EpWDWSqCYLn5eaF", + ), + ( + "25TXLvcMJNvRY4vb95G9Kpvf9A3LJCdWLswD47xvXsaX", + "2rXxCqDNwia2f245koA11w7NoyNhNH4PwhSVLwpeBVRf", + ), + ( + "29MvzRLSCDR8wm3ZeaXbDkftQAc719jQvkF6ZKGvFgEs", + "8habU8xKFCDeJNg9No6prtCY1Lq2px5bqWEyudy1SScW", + ), + ( + "2DGLdv4X63urMTAYA5o37gR7fBAsi6qKWcYz4WauyUuD", + "7CPuXK4rdxhNqPUtTjvJ2peNEgVbBCzPV89SVK8boWai", + ), + ( + "2HAkHQnbytQZm9HWfb4V1cALvBjeR3wE6UrsZhtuhHZZ", + "5U8dYpWb2W1s3ptdNhJJAkyf2JaRUxFAzVEnZmSP2t8X", + ), + ( + "2M59vuWgsiuHAqQVB6KvuXuaBCJR8138gMAm4uCuR6Du", + "E5dLtHAM353EPnHyuZ32sKREn26VW4Y8bzb2KQJTBHQh", + ), + ] + ) + def test_find_program_address_ref(self, program_id, expected): + """Test with addresses generated by rust impl + """ + pid = PublicKey.from_base58(program_id) + exp = PublicKey.from_base58(expected) + + actual = find_program_address(pid, ['Lil\''.encode(), 'Bits'.encode()]) + assert actual == exp diff --git a/tests/solana/test_transaction.py b/tests/solana/test_transaction.py index fba58ff..ed4e808 100644 --- a/tests/solana/test_transaction.py +++ b/tests/solana/test_transaction.py @@ -129,7 +129,7 @@ def test_transaction_duplicate_keys(self): message = tx.message.marshal() for idx, key in enumerate([payer, keys[0], keys[3], keys[1]]): - assert key.public_key.verify(message, tx.signatures[idx]) + key.public_key.verify(message, tx.signatures[idx]) expected_keys = [payer, keys[0], keys[3], keys[1], keys[2], program] for idx, account in enumerate(expected_keys): @@ -168,9 +168,9 @@ def test_transaction_single_instruction(self): message = tx.message.marshal() - assert payer.public_key.verify(message, tx.signatures[0]) - assert keys[3].public_key.verify(message, tx.signatures[1]) - assert keys[0].public_key.verify(message, tx.signatures[2]) + payer.public_key.verify(message, tx.signatures[0]) + keys[3].public_key.verify(message, tx.signatures[1]) + keys[0].public_key.verify(message, tx.signatures[2]) expected_keys = [payer, keys[3], keys[0], keys[2], keys[1], program] for idx, key in enumerate(expected_keys): @@ -235,7 +235,7 @@ def test_transaction_multi_instruction(self): message = tx.message.marshal() for idx, key in enumerate([payer, keys[0], keys[1], keys[3], keys[4]]): - assert key.public_key.verify(message, tx.signatures[idx]) + key.public_key.verify(message, tx.signatures[idx]) expected_keys = [payer, keys[0], keys[1], keys[3], keys[4], keys[2], keys[5], program, program2] for idx, account in enumerate(expected_keys): diff --git a/tests/solana/token/test_program.py b/tests/solana/token/test_program.py index 1221e8e..28d7e9e 100644 --- a/tests/solana/token/test_program.py +++ b/tests/solana/token/test_program.py @@ -10,7 +10,7 @@ def test_initialize_account(self): instruction = initialize_account(public_keys[0], public_keys[1], public_keys[2]) assert instruction.data == bytes([Command.INITIALIZE_ACCOUNT]) - assert instruction.accounts[0].is_signer + assert not instruction.accounts[0].is_signer assert instruction.accounts[0].is_writable for i in range(1, 4): assert not instruction.accounts[i].is_signer diff --git a/tests/test_error.py b/tests/test_error.py index da5fdb2..b5580e2 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -49,8 +49,10 @@ class TestTransactionError: ] ) def test_error_from_proto(self, reason, exception_type): - e = error_from_proto(model_pbv4.TransactionError(reason=reason)) + tx_id = b'tx_sig' + e = error_from_proto(model_pbv4.TransactionError(reason=reason), tx_id) assert isinstance(e, exception_type) + assert e.tx_id == tx_id @pytest.mark.parametrize( "reason, exception_type", @@ -78,15 +80,16 @@ def test_errors_from_solana_tx(self, instruction_index, exp_op_index, exp_paymen keys[0], [ memo.memo_instruction('data'), - token.transfer(keys[1], keys[2], keys[1], 100, keys[3]), - token.set_authority(keys[1], keys[1], token.AuthorityType.CloseAccount, keys[3], keys[2]) + token.transfer(keys[1], keys[2], keys[1], 100), + token.set_authority(keys[1], keys[1], token.AuthorityType.CLOSE_ACCOUNT, keys[3]) ] ) + tx_id = b'tx_sig' errors = TransactionErrors.from_solana_tx(tx, model_pbv4.TransactionError( reason=model_pbv4.TransactionError.Reason.INSUFFICIENT_FUNDS, instruction_index=instruction_index, - )) + ), tx_id) assert isinstance(errors.tx_error, InsufficientBalanceError) assert len(errors.op_errors) == 3 for i in range(0, len(errors.op_errors)): @@ -129,7 +132,7 @@ def test_errors_from_stellar_tx(self, instruction_index, exp_op_index, exp_payme errors = TransactionErrors.from_stellar_tx(env, model_pbv4.TransactionError( reason=model_pbv4.TransactionError.Reason.INSUFFICIENT_FUNDS, instruction_index=instruction_index, - )) + ), b'tx_hash') assert isinstance(errors.tx_error, InsufficientBalanceError) assert len(errors.op_errors) == 4 for i in range(0, len(errors.op_errors)): diff --git a/tests/webhook/test_create_account.py b/tests/webhook/test_create_account.py new file mode 100644 index 0000000..bae849b --- /dev/null +++ b/tests/webhook/test_create_account.py @@ -0,0 +1,106 @@ +import base64 +from typing import Tuple + +import pytest + +from agora import solana +from agora.keys import PrivateKey, PublicKey +from agora.solana import token +from agora.webhook.create_account import CreateAccountRequest, CreateAccountResponse +from tests.utils import generate_keys + +_SIGNING_KEY = PrivateKey.random() + + +class TestCreateAccountRequest: + def test_from_json_kin_4(self): + tx, owner, assoc = _generate_create_tx() + + data = { + 'solana_transaction': base64.b64encode(tx.marshal()), + } + + req = CreateAccountRequest.from_json(data) + assert req.creation.address == assoc + assert req.creation.owner == owner + + def test_from_json_invalid(self): + with pytest.raises(ValueError) as e: + CreateAccountRequest.from_json({}) + assert 'solana_transaction' in str(e) + + keys = [key.public_key for key in generate_keys(4)] + tx = solana.Transaction.new( + keys[0], + [ + token.transfer( + keys[1], + keys[2], + keys[3], + 20, + ), + ] + ) + + with pytest.raises(ValueError) as e: + CreateAccountRequest.from_json({ + 'solana_transaction': base64.b64encode(tx.marshal()) + }) + assert 'unexpected payments' in str(e) + + tx = solana.Transaction.new( + keys[0], + [] + ) + with pytest.raises(ValueError) as e: + CreateAccountRequest.from_json({ + 'solana_transaction': base64.b64encode(tx.marshal()) + }) + assert 'expected exactly 1 creation' in str(e) + + create_assoc_instruction1, assoc1 = token.create_associated_token_account(keys[0], keys[1], keys[2]) + create_assoc_instruction2, assoc2 = token.create_associated_token_account(keys[0], keys[1], keys[2]) + tx = solana.Transaction.new( + keys[0], + [ + create_assoc_instruction1, + token.set_authority(assoc1, assoc1, token.AuthorityType.CLOSE_ACCOUNT, new_authority=keys[0]), + create_assoc_instruction2, + token.set_authority(assoc2, assoc2, token.AuthorityType.CLOSE_ACCOUNT, new_authority=keys[0]), + ] + ) + with pytest.raises(ValueError) as e: + CreateAccountRequest.from_json({ + 'solana_transaction': base64.b64encode(tx.marshal()) + }) + assert 'expected exactly 1 creation' in str(e) + + +class TestCreateAccountResponse: + def test_sign(self): + tx, owner, assoc = _generate_create_tx() + resp = CreateAccountResponse(tx) + resp.sign(_SIGNING_KEY) + + _SIGNING_KEY.public_key.verify(resp.transaction.message.marshal(), resp.transaction.signatures[0]) + + def test_reject(self): + tx, _, _ = _generate_create_tx() + resp = CreateAccountResponse(tx) + assert not resp.rejected + + resp.reject() + assert resp.rejected + + +# Returns transaction, owner, and assoc +def _generate_create_tx() -> Tuple[solana.Transaction, PublicKey, PublicKey]: + keys = [key.public_key for key in generate_keys(2)] + create_assoc_instruction, assoc = token.create_associated_token_account(_SIGNING_KEY.public_key, keys[0], keys[1]) + return solana.Transaction.new( + _SIGNING_KEY.public_key, + [ + create_assoc_instruction, + token.set_authority(assoc, assoc, token.AuthorityType.CLOSE_ACCOUNT, new_authority=_SIGNING_KEY.public_key), + ] + ), keys[0], assoc diff --git a/tests/webhook/test_events.py b/tests/webhook/test_events.py index fef5244..ae367ec 100644 --- a/tests/webhook/test_events.py +++ b/tests/webhook/test_events.py @@ -15,7 +15,6 @@ class TestSolanaData: def test_from_json(self): memo = AgoraMemo.new(1, TransactionType.P2P, 0, b'somefk') keys = [key.public_key for key in generate_keys(4)] - token_program = keys[3] tx = solana.Transaction.new( keys[0], [ @@ -25,7 +24,6 @@ def test_from_json(self): keys[2], keys[3], 20, - token_program, ), ] ) @@ -46,7 +44,6 @@ class TestTransactionEvent: def test_from_json_full_kin_4(self): memo = AgoraMemo.new(1, TransactionType.P2P, 0, b'somefk') keys = [key.public_key for key in generate_keys(4)] - token_program = keys[3] tx = solana.Transaction.new( keys[0], [ @@ -56,7 +53,6 @@ def test_from_json_full_kin_4(self): keys[2], keys[3], 20, - token_program, ), ] ) @@ -112,7 +108,7 @@ def test_from_json_empty(self): assert not event.transaction_event def test_from_json_with_tx_event(self): - keys = [key.public_key for key in generate_keys(4)] + keys = [key.public_key for key in generate_keys(3)] tx = solana.Transaction.new( keys[0], [ @@ -121,7 +117,6 @@ def test_from_json_with_tx_event(self): keys[1], keys[2], 20, - keys[3], ), ] ) diff --git a/tests/webhook/test_handler.py b/tests/webhook/test_handler.py index 9c62578..6851b12 100644 --- a/tests/webhook/test_handler.py +++ b/tests/webhook/test_handler.py @@ -2,12 +2,15 @@ import hashlib import hmac import json +import token from typing import List from agora import solana from agora.client import Environment from agora.error import WebhookRequestError, InvoiceErrorReason from agora.keys import PrivateKey +from agora.solana import token +from agora.webhook.create_account import CreateAccountRequest, CreateAccountResponse from agora.webhook.events import Event from agora.webhook.handler import WebhookHandler from agora.webhook.sign_transaction import SignTransactionRequest, SignTransactionResponse @@ -44,7 +47,6 @@ def test_handle_event(self): keys[1], keys[2], 20, - keys[3], ), ] ) @@ -89,21 +91,83 @@ def test_handle_event(self): status_code, resp_body = handler.handle_events(self._event_return_none, "fakesig", req_body) assert status_code == 200 - def test_handle_sign_transaction(self): + def test_handle_create_account(self): secret = 'secret' handler = WebhookHandler(Environment.TEST, secret=secret) - keys = [key.public_key for key in generate_keys(4)] - token_program = keys[3] + keys = [key.public_key for key in generate_keys(3)] + + create_assoc_instruction, assoc = token.create_associated_token_account(keys[0], keys[1], keys[2]) tx = solana.Transaction.new( - keys[0], + _TEST_PRIVATE_KEY.public_key, + [ + create_assoc_instruction, + token.set_authority(assoc, assoc, token.AuthorityType.CLOSE_ACCOUNT, new_authority=keys[0]), + ] + ) + + data = { + 'kin_version': 4, + 'solana_transaction': base64.b64encode(tx.marshal()).decode('utf-8'), + } + + req_body = json.dumps(data) + sig = base64.b64encode(hmac.new(secret.encode(), req_body.encode(), hashlib.sha256).digest()) + text_sig = base64.b64encode(hmac.new(secret.encode(), b'someotherdata', hashlib.sha256).digest()) + + # invalid signature + status_code, resp_body = handler.handle_create_account(self._create_success, text_sig, req_body) + assert status_code == 401 + assert resp_body == '' + + # invalid req body + status_code, resp_body = handler.handle_create_account(self._create_success, text_sig, + 'someotherdata') + assert status_code == 400 + assert resp_body == 'invalid json request body' + + # webhook request error + status_code, resp_body = handler.handle_create_account(self._create_raise_webhook_request_error, sig, + req_body) + assert status_code == 400 + assert resp_body == 'some error' + + # other error + status_code, resp_body = handler.handle_create_account(self._create_raise_other_error, sig, req_body) + assert status_code == 500 + assert resp_body == 'bad stuff' + + # rejected + status_code, resp_body = handler.handle_create_account(self._create_reject, sig, req_body) + assert status_code == 403 + assert json.loads(resp_body) == {} + + # successful + status_code, resp_body = handler.handle_create_account(self._create_success, sig, req_body) + assert status_code == 200 + body = json.loads(resp_body) + _TEST_PRIVATE_KEY.public_key.verify(tx.message.marshal(), base64.b64decode(body['signature'])) + + # fake signature with no webhook secret should result in a successful response + handler = WebhookHandler(Environment.TEST) + status_code, resp_body = handler.handle_create_account(self._create_success, "fakesig", req_body) + assert status_code == 200 + body = json.loads(resp_body) + _TEST_PRIVATE_KEY.public_key.verify(tx.message.marshal(), base64.b64decode(body['signature'])) + + def test_handle_sign_tx(self): + secret = 'secret' + handler = WebhookHandler(Environment.TEST, secret=secret) + + keys = [key.public_key for key in generate_keys(3)] + tx = solana.Transaction.new( + _TEST_PRIVATE_KEY.public_key, [ solana.transfer( + keys[0], keys[1], keys[2], - keys[3], 20, - token_program, ), ] ) @@ -126,7 +190,7 @@ def test_handle_sign_transaction(self): status_code, resp_body = handler.handle_sign_transaction(self._sign_tx_success, text_sig, 'someotherdata') assert status_code == 400 - assert resp_body == 'invalid request body' + assert resp_body == 'invalid json request body' # webhook request error status_code, resp_body = handler.handle_sign_transaction(self._sign_tx_raise_webhook_request_error, sig, @@ -151,13 +215,17 @@ def test_handle_sign_transaction(self): # successful status_code, resp_body = handler.handle_sign_transaction(self._sign_tx_success, sig, req_body) assert status_code == 200 - assert json.loads(resp_body) == {} + + body = json.loads(resp_body) + _TEST_PRIVATE_KEY.public_key.verify(tx.message.marshal(), base64.b64decode(body['signature'])) # fake signature with no webhook secret should result in a successful response handler = WebhookHandler(Environment.TEST) status_code, resp_body = handler.handle_sign_transaction(self._sign_tx_success, "fakesig", req_body) assert status_code == 200 - assert json.loads(resp_body) == {} + + body = json.loads(resp_body) + _TEST_PRIVATE_KEY.public_key.verify(tx.message.marshal(), base64.b64decode(body['signature'])) @staticmethod def _event_return_none(events: List[Event]): @@ -171,6 +239,22 @@ def _event_raise_webhook_request_error(events: List[Event]): def _event_raise_other_error(events: List[Event]): raise Exception('bad stuff') + @staticmethod + def _create_success(req: CreateAccountRequest, resp: CreateAccountResponse): + resp.sign(_TEST_PRIVATE_KEY) + + @staticmethod + def _create_reject(req: CreateAccountRequest, resp: CreateAccountResponse): + resp.reject() + + @staticmethod + def _create_raise_webhook_request_error(req: CreateAccountRequest, resp: CreateAccountResponse): + raise WebhookRequestError(400, response_body='some error') + + @staticmethod + def _create_raise_other_error(req: CreateAccountRequest, resp: CreateAccountResponse): + raise Exception('bad stuff') + @staticmethod def _sign_tx_success(req: SignTransactionRequest, resp: SignTransactionResponse): resp.sign(_TEST_PRIVATE_KEY) diff --git a/tests/webhook/test_sign_transaction.py b/tests/webhook/test_sign_transaction.py index a3c9b2c..6ac3310 100644 --- a/tests/webhook/test_sign_transaction.py +++ b/tests/webhook/test_sign_transaction.py @@ -1,46 +1,23 @@ import base64 +from typing import Optional, Tuple import pytest from agoraapi.common.v3 import model_pb2 from agora import solana from agora.error import InvoiceErrorReason +from agora.keys import PrivateKey from agora.model import AgoraMemo, TransactionType from agora.model.invoice import Invoice, InvoiceList from agora.webhook.sign_transaction import SignTransactionRequest, SignTransactionResponse from tests.utils import generate_keys +_SIGNING_KEY = PrivateKey.random() + class TestSignTransactionRequest: def test_from_json_kin_4(self): - il = model_pb2.InvoiceList( - invoices=[ - model_pb2.Invoice( - items=[ - model_pb2.Invoice.LineItem(title='title1', description='desc1', amount=50, sku=b'somesku') - ] - ) - ] - ) - - fk = InvoiceList.from_proto(il).get_sha_224_hash() - memo = AgoraMemo.new(1, TransactionType.P2P, 0, fk) - - keys = [key.public_key for key in generate_keys(4)] - token_program = keys[3] - tx = solana.Transaction.new( - keys[0], - [ - solana.memo_instruction(base64.b64encode(memo.val).decode('utf-8')), - solana.transfer( - keys[1], - keys[2], - keys[3], - 20, - token_program, - ), - ] - ) + tx, il = _generate_tx(True) data = { 'solana_transaction': base64.b64encode(tx.marshal()), @@ -53,23 +30,8 @@ def test_from_json_kin_4(self): assert req.transaction == tx def test_get_tx_id(self): - keys = generate_keys(4) - public_keys = [key.public_key for key in keys] - token_program = public_keys[3] - - tx = solana.Transaction.new( - public_keys[0], - [ - solana.transfer( - public_keys[1], - public_keys[2], - public_keys[3], - 20, - token_program, - ), - ] - ) - tx.sign([keys[0]]) + tx, _ = _generate_tx(False) + tx.sign([_SIGNING_KEY]) data = { 'kin_version': 4, @@ -86,17 +48,24 @@ def test_from_json_invalid(self): class TestSignTransactionResponse: - # TODO: add test_sign when solana transaction signing is supported + def test_sign(self): + tx, _ = _generate_tx(False) + resp = SignTransactionResponse(tx) + resp.sign(_SIGNING_KEY) + + _SIGNING_KEY.public_key.verify(resp.transaction.message.marshal(), resp.transaction.signatures[0]) def test_reject(self): - resp = SignTransactionResponse() + tx, _ = _generate_tx(False) + resp = SignTransactionResponse(tx) assert not resp.rejected resp.reject() assert resp.rejected def test_mark_invoice_error(self): - resp = SignTransactionResponse() + tx, _ = _generate_tx(False) + resp = SignTransactionResponse(tx) resp.mark_invoice_error(5, InvoiceErrorReason.SKU_NOT_FOUND) assert resp.rejected @@ -104,22 +73,35 @@ def test_mark_invoice_error(self): assert resp.invoice_errors[0].op_index == 5 assert resp.invoice_errors[0].reason == InvoiceErrorReason.SKU_NOT_FOUND - def test_to_json(self): - # not rejected - resp = SignTransactionResponse() - assert resp.to_json() == {} - # rejected - resp.reject() - assert resp.to_json() == {} - - # rejected with invoice errors - resp.mark_invoice_error(0, InvoiceErrorReason.ALREADY_PAID) - assert resp.to_json() == { - "invoice_errors": [ - { - "operation_index": 0, - "reason": InvoiceErrorReason.ALREADY_PAID.to_lowercase() - } +def _generate_tx(with_il: Optional[bool] = False) -> Tuple[solana.Transaction, Optional[model_pb2.InvoiceList]]: + il = None + instructions = [] + + if with_il: + il = model_pb2.InvoiceList( + invoices=[ + model_pb2.Invoice( + items=[ + model_pb2.Invoice.LineItem(title='title1', description='desc1', amount=50, sku=b'somesku') + ] + ) ] - } + ) + + fk = InvoiceList.from_proto(il).get_sha_224_hash() + memo = AgoraMemo.new(1, TransactionType.P2P, 0, fk) + instructions.append(solana.memo_instruction(base64.b64encode(memo.val).decode('utf-8'))) + + keys = [key.public_key for key in generate_keys(3)] + instructions.append(solana.transfer( + keys[0], + keys[1], + keys[2], + 20, + ), ) + + return solana.Transaction.new( + _SIGNING_KEY.public_key, + instructions + ), il