import base64
from typing import List, Optional
import 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.environment import Environment
from agora.client.internal import InternalClient, SubmitTransactionResult
from agora.error import AccountExistsError, InvoiceError, InsufficientBalanceError, BadNonceError, \
TransactionRejectedError, Error, BlockchainVersionError, AccountNotFoundError, NoSubsidizerError, \
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
from agora.model.memo import AgoraMemo
from agora.model.payment import Payment
from agora.model.result import EarnBatchResult, EarnError
from agora.model.transaction import TransactionData
from agora.model.transaction_type import TransactionType
from agora.retry import retry, LimitStrategy, BackoffWithJitterStrategy, BinaryExponentialBackoff, \
NonRetriableErrorsStrategy, RetriableErrorsStrategy
from agora.solana import Commitment, memo, system, token
_MIN_VERSION = 4
_MAX_VERSION = 4
_ENDPOINTS = {
Environment.PRODUCTION: 'api.agorainfra.net:443',
Environment.TEST: 'api.agorainfra.dev:443',
}
_NON_RETRIABLE_ERRORS = [
AccountExistsError,
AccountNotFoundError,
InsufficientBalanceError,
TransactionRejectedError,
InvoiceError,
BadNonceError,
BlockchainVersionError,
AlreadySubmittedError,
]
_GRPC_TIMEOUT_SECONDS = 10
_MAX_BATCH_SIZE = 15
[docs]class RetryConfig:
"""A :class:`RetryConfig <RetryConfig>` for configuring retries for Agora requests.
:param max_retries: (optional) The max number of times the client will retry a request, excluding the initial
attempt. Defaults to 5 if value is not provided or value is below 0.
:param max_nonce_refreshes: (optional) The max number of times the client will attempt to refresh a nonce, excluding
the initial attempt. Defaults to 3 if value is not provided or value is below 0.
:param min_delay: (optional) The minimum amount of time to delay between request retries, in seconds. Defaults to
0.5 seconds if value is not provided or value is below 0.
:param min_delay: (optional) The maximum amount of time to delay between request retries, in seconds. Defaults to
5 seconds if value is not provided or value is below 0.
"""
def __init__(
self, max_retries: Optional[int] = None, min_delay: Optional[float] = None, max_delay: Optional[float] = None,
max_nonce_refreshes: Optional[int] = None,
):
self.max_retries = max_retries if max_retries is not None and max_retries >= 0 else 5
self.min_delay = min_delay if min_delay is not None and min_delay >= 0 else 0.5
self.max_delay = max_delay if max_delay is not None and max_delay >= 0 else 10
self.max_nonce_refreshes = (max_nonce_refreshes if max_nonce_refreshes is not None and max_nonce_refreshes >= 0
else 3)
[docs]class BaseClient:
"""An interface for accessing Agora features.
"""
[docs] def create_account(self, private_key: PrivateKey, commitment: Optional[Commitment] = None,
subsidizer: Optional[PrivateKey] = None):
"""Creates a new Kin account.
:param private_key: The :class:`PrivateKey <agora.keys.PrivateKey>` of the account to create
: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.
:raise: :exc:`UnsupportedVersionError <agora.error.UnsupportedVersionError>`
:raise: :exc:`AccountExistsError <agora.error.AccountExistsError>`
"""
raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement create_account')
[docs] def get_transaction(self, tx_id: bytes, commitment: Optional[Commitment] = None) -> TransactionData:
"""Retrieves a transaction.
: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.
:return: a :class:`TransactionData <agora.model.transaction.TransactionData>` object.
"""
raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement get_transaction')
[docs] def get_balance(
self, public_key: PublicKey, commitment: Optional[Commitment] = None,
account_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
) -> int:
"""Retrieves the balance of an account.
:param public_key: The :class:`PublicKey <agora.keys.PublicKey>` of the account to retrieve the balance
for.
:param commitment: (optional) The commitment to use.
:param account_resolution: (optional) The :class:`AccountResolution <agora.client.account.AccountResolution>` to
use if the original account was not found. Only applies for Kin 4. Defaults to AccountResolution.PREFERRED.
:raise: :exc:`UnsupportedVersionError <agora.error.UnsupportedVersionError>`
:raise: :exc:`AccountNotFoundError <agora.error.AccountNotFoundError>`
:return: The balance of the account, in quarks.
"""
raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement get_balance')
[docs] def resolve_token_accounts(self, public_key: PublicKey) -> List[PublicKey]:
"""Resolves the token accounts owned by the specified account on Kin 4.
:param public_key: The public key of the owner account.
:return: a List of token accounts owned by the account with the provided public key.
"""
[docs] 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')
[docs] 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 <agora.model.payment.Payment>` to submit.
:param commitment: (optional) The commitment to use.
:param sender_resolution: (optional) The :class:`AccountResolution <agora.client.account.AccountResolution>` to
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 <agora.client.account.AccountResolution>` to
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 <agora.error.UnsupportedVersionError>`
:raise: :exc:`InvalidSignatureError <agora.error.InvalidSignatureError>`
:raise: :exc:`InsufficientBalanceError <agora.error.InsufficientBalanceError>`
:raise: :exc:`BadNonceError <agora.error.BadNonceError>`
:raise: :exc:`TransactionError <agora.error.TransactionError>`
:raise: :exc:`InvoiceError <agora.error.InvoiceError>`
:return: The id of the transaction.
"""
raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement submit_payment')
[docs] def submit_earn_batch(
self, batch: EarnBatch, commitment: Optional[Commitment] = None,
sender_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
dest_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
) -> EarnBatchResult:
"""Submit multiple earn payments.
:param batch: The :class:`EarnBatch <agora.model.earn.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.
:param sender_resolution: (optional) The :class:`AccountResolution <agora.client.account.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.
:param dest_resolution: (optional) The :class:`AccountResolution <agora.client.account.AccountResolution>` to
use for the earn destination accounts if the transaction fails due to an account error. Only applies for
Kin 4 transactions. Defaults to AccountResolution.PREFERRED.
:raise: :exc:`UnsupportedVersionError <agora.error.UnsupportedVersionError>`
:return: a :class:`BatchEarnResult <agora.model.result.BatchEarnResult>`
"""
raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement submit_earn_batch')
[docs] def request_airdrop(
self, public_key: PublicKey, quarks: int, commitment: Optional[Commitment] = None,
) -> bytes:
"""Requests an airdrop of Kin to a Kin account. Only available on Kin 4 on the test environment.
:param public_key: the public key of the Kin token account to airdrop to. To get all the token accounts owned by
an owner, use Client.resolve_token_accounts.
:param quarks: The amount, in quarks, to request.
:param commitment: (optional) The commitment to use.
:raise: :exc:`UnsupportedMethodError <agora.error.UnsupportedMethodError>`
:return: The transaction ID of the airdrop transaction submitted by Agora.
"""
raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement request_airdrop')
[docs] def close(self) -> None:
"""Closes the connection-related resources (e.g. the gRPC channel) used by the client. Subsequent requests to
this client will cause an exception to be thrown.
"""
raise NotImplementedError('BaseClient is an abstract class. Subclasses must implement close')
[docs]class Client(BaseClient):
"""A :class:`Client <Client>` object for accessing Agora API features.
:param env: The :class:`Environment <agora.environment.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 grpc_channel: (optional) A GRPC :class:`Channel <grpc.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
endpoint should be set.
:param retry_config: (optional): A :class:`RetryConfig <RetryConfig>` object to configure Agora retries. If not
provided, a default configuration will be used.
:param default_commitment: (optional) The commitment requirement to use by default for Kin 4 Agora requests.
Defaults to using Commitment.SINGLE.
"""
def __init__(
self, env: Environment, app_index: int = 0,
grpc_channel: Optional[grpc.Channel] = None, endpoint: Optional[str] = None,
retry_config: Optional[RetryConfig] = None,
default_commitment: Optional[Commitment] = Commitment.SINGLE,
):
if grpc_channel and endpoint:
raise ValueError('`grpc_channel` and `endpoint` cannot both be set')
self._env = env
self._app_index = app_index
if not grpc_channel:
endpoint = endpoint if endpoint else _ENDPOINTS[env]
ssl_credentials = grpc.ssl_channel_credentials()
self._grpc_channel = grpc.secure_channel(endpoint, ssl_credentials)
else:
self._grpc_channel = grpc_channel
retry_config = retry_config if retry_config else RetryConfig()
self._internal_retry_strategies = [
NonRetriableErrorsStrategy(_NON_RETRIABLE_ERRORS),
LimitStrategy(retry_config.max_retries + 1),
BackoffWithJitterStrategy(BinaryExponentialBackoff(retry_config.min_delay),
retry_config.max_delay, 0.1),
]
self._nonce_retry_strategies = [
RetriableErrorsStrategy([BadNonceError]),
LimitStrategy(retry_config.max_nonce_refreshes + 1)
]
self._internal_client = InternalClient(self._grpc_channel, self._internal_retry_strategies, self._app_index)
self._default_commitment = default_commitment
[docs] def create_account(self, private_key: PrivateKey, commitment: Optional[Commitment] = None,
subsidizer: Optional[PrivateKey] = None):
commitment = commitment if commitment else self._default_commitment
return retry(self._nonce_retry_strategies, self._create_solana_account, private_key, commitment,
subsidizer)
[docs] def get_balance(
self, public_key: PublicKey, commitment: Optional[Commitment] = None,
account_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
) -> int:
commitment = commitment if commitment else self._default_commitment
try:
return self._internal_client.get_solana_account_info(public_key, commitment=commitment).balance
except AccountNotFoundError as e:
if account_resolution == AccountResolution.PREFERRED:
account_infos = self._internal_client.resolve_token_accounts(public_key, True)
if account_infos:
return account_infos[0].balance
raise e
[docs] def resolve_token_accounts(self, public_key: PublicKey) -> List[PublicKey]:
account_infos = self._internal_client.resolve_token_accounts(public_key, False)
return [a.account_id for a in account_infos]
[docs] 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
[docs] 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)
[docs] 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, dest_resolution, sender_create,
)
if result.errors:
if len(result.errors.op_errors) > 0:
if len(result.errors.op_errors) != 1:
raise Error(f'invalid number of operation errors, expected 0 or 1, got '
f'{len(result.errors.op_errors)}')
raise result.errors.op_errors[0]
if result.errors.tx_error:
raise result.errors.tx_error
if result.invoice_errors:
if len(result.invoice_errors) != 1:
raise Error(f'invalid number of invoice errors, expected 0 or 1, got {len(result.invoice_errors)}')
raise invoice_error_from_proto(result.invoice_errors[0])
return result.tx_id
[docs] def submit_earn_batch(
self, batch: EarnBatch, commitment: Optional[Commitment] = None,
sender_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
dest_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
) -> EarnBatchResult:
if len(batch.earns) == 0:
raise ValueError('earn batch must contain at least 1 earn')
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:
if self._app_index <= 0:
raise ValueError('cannot submit a payment with an invoice without an app index')
if len(invoices) != len(batch.earns):
raise ValueError('Either all or none of the earns must contain invoices')
if batch.memo:
raise ValueError('Cannot use both text memo and invoices')
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, config, commitment=commitment,
sender_resolution=sender_resolution,
dest_resolution=dest_resolution)
result = EarnBatchResult(submit_result.tx_id)
if submit_result.errors:
result.tx_error = submit_result.errors.tx_error
if submit_result.errors.payment_errors:
result.earn_errors = []
for idx, e in enumerate(submit_result.errors.payment_errors):
if e:
result.earn_errors.append(EarnError(idx, e))
elif submit_result.invoice_errors:
result.tx_error = TransactionRejectedError()
result.earn_errors = []
for invoice_error in submit_result.invoice_errors:
result.earn_errors.append(EarnError(invoice_error.op_index, invoice_error_from_proto(invoice_error)))
return result
[docs] def request_airdrop(
self, public_key: PublicKey, quarks: int, commitment: Optional[Commitment] = None,
) -> bytes:
if self._env != Environment.TEST:
raise UnsupportedMethodError()
commitment = commitment if commitment else self._default_commitment
return self._internal_client.request_airdrop(public_key, quarks, commitment)
[docs] def close(self) -> None:
self._grpc_channel.close()
def _create_solana_account(
self, private_key: PrivateKey, commitment: Commitment, subsidizer: Optional[PrivateKey] = None
):
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(config.subsidizer_account.value))
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,
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])
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: AccountResolution,
dest_resolution: AccountResolution, sender_create: bool
) -> SubmitTransactionResult:
config = self._internal_client.get_service_config()
if not config.subsidizer_account.value and not payment.subsidizer:
raise NoSubsidizerError()
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_source = None
create_instructions = []
create_signer = None
resubmit = False
if sender_resolution == AccountResolution.PREFERRED:
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:
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,
config,
commitment,
transfer_source=transfer_source,
create_instructions=create_instructions,
create_signer=create_signer,
)
return result
def _resolve_and_submit_solana_earn_batch(
self, batch: EarnBatch, service_config: tx_pb.GetServiceConfigResponse, commitment: Commitment,
sender_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
dest_resolution: Optional[AccountResolution] = AccountResolution.PREFERRED,
) -> SubmitTransactionResult:
result = self._submit_solana_earn_batch_tx(batch, service_config, commitment)
if result.errors and isinstance(result.errors.tx_error, AccountNotFoundError):
transfer_source = None
resubmit = False
if sender_resolution == AccountResolution.PREFERRED:
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:
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_source)
return result
def _submit_solana_payment_tx(
self, payment: Payment, service_config: tx_pb.GetServiceConfigResponse, commitment: Commitment,
transfer_source: Optional[PublicKey] = None, create_instructions: List[solana.Instruction] = None,
create_signer: Optional[PrivateKey] = None,
) -> SubmitTransactionResult:
subsidizer_id = (payment.subsidizer.public_key if payment.subsidizer
else PublicKey(service_config.subsidizer_account.value))
instructions = []
invoice_list = None
if payment.memo:
instructions = [memo.memo_instruction(payment.memo)]
elif self._app_index > 0:
if payment.invoice:
invoice_list = InvoiceList(invoices=[payment.invoice])
fk = invoice_list.get_sha_224_hash() if payment.invoice else b''
m = AgoraMemo.new(1, payment.tx_type, self._app_index, fk)
instructions = [memo.memo_instruction(base64.b64encode(m.val).decode('utf-8'))]
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:
signers = [payment.subsidizer, payment.sender]
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)
def _submit_solana_earn_batch_tx(
self, batch: EarnBatch, service_config: tx_pb.GetServiceConfigResponse, commitment: Commitment,
transfer_sender: Optional[PublicKey] = None,
) -> SubmitTransactionResult:
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,
) for earn in batch.earns]
invoices = [earn.invoice for earn in batch.earns if earn.invoice]
invoice_list = InvoiceList(invoices) if invoices else None
if batch.memo:
instructions = [memo.memo_instruction(batch.memo)] + instructions
elif self._app_index > 0:
fk = invoice_list.get_sha_224_hash() if invoice_list else b''
agora_memo = AgoraMemo.new(1, TransactionType.EARN, self._app_index, fk)
instructions = [memo.memo_instruction(base64.b64encode(agora_memo.val).decode('utf-8'))] + instructions
tx = solana.Transaction.new(subsidizer_id, instructions)
if batch.subsidizer:
signers = [batch.subsidizer, batch.sender]
else:
signers = [batch.sender]
return self._sign_and_submit_solana_tx(signers, tx, commitment, invoice_list=invoice_list,
dedupe_id=batch.dedupe_id)
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,
) -> 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
return retry(self._nonce_retry_strategies, _get_blockhash_and_submit)