Source code for polaris.management.commands.testnet

from jwt import InvalidIssuerError
import urllib3
from decimal import Decimal
from typing import Optional, Dict
from logging import getLogger

from django.core.management.base import BaseCommand, CommandError
from django.db.models import Q
from stellar_sdk import Keypair, TransactionBuilder, Server
from stellar_sdk import Asset as StellarSdkAsset
from stellar_sdk.account import Account
from stellar_sdk.exceptions import (
    NotFoundError,
    BaseHorizonError,
    Ed25519SecretSeedInvalidError,
)

from polaris import settings
from polaris.models import Transaction, Asset

logger = getLogger(__name__)


[docs]class Command(BaseCommand): """ The testnet command comes with the following commands: ``issue`` allows users to create assets on the Stellar testnet network. When the test network resets, you’ll have to reissue your assets. ``reset`` calls the functionality invoked by ``issue`` for each asset in the anchor’s database. Since the database does not store the issuing account’s secret key, the user must input each key as requested by the Polaris command. It also performs a couple other functions necessary to ensure your Polaris instance runs successfully after a testnet reset: Moves all pending_trust transactions to error This is done because all accounts have been cleared from the network. While its possible an account that required a trustline could be recreated and a trustline could be established, its unlikely. Polaris assumes a testnet reset makes in-progress transactions unrecoverable. Updates the paging_token of latest transaction streamed for each anchored asset :mod:`~polaris.management.commands.watch_transactions` streams transactions to and from each anchored asset’s distribution account. Specifically, it streams transactions starting with the most recently completed transaction’s :attr:`~polaris.models.Transaction.paging_token` on startup. When the testnet resets, the :attr:`~polaris.models.Transaction.paging_token` used for transactions prior to the reset are no longer valid. To fix this, Polaris updates the :attr:`~polaris.models.Transaction.paging_token` of the most recently completed transaction for each anchored asset to "now". **Positional arguments:** reset -h, --help show this help message and exit issue -h, --help show this help message and exit --asset ASSET, -a ASSET the code of the asset issued --issuer-seed ISSUER_SEED, -i ISSUER_SEED the issuer's secret key --distribution-seed DISTRIBUTION_SEED, -d DISTRIBUTION_SEED the distribution account's secret key --client-seed CLIENT_SEED, -c CLIENT_SEED the client account's secret key --issue-amount ISSUE_AMOUNT the amount sent to distribution account. Also the limit for the trustline. --client-amount CLIENT_AMOUNT the amount sent to client account. Also the limit for the trustline. ``add-asset`` allows users to add assets to the database: add-asset -h, --help show this help message and exit --asset ASSET, -a ASSET the code of the asset issued --issuer-public ISSUER_PUBLIC, -i ISSUER_PUBLIC the issuer's public key --distribution-seed DISTRIBUTION_SEED, -d DISTRIBUTION_SEED the distribution account's secret key --sep6-enabled (Optional) flag to enable sep6 for this asset, defaults to false --sep24-enabled (Optional) flag to enable sep24 for this asset, defaults to false --sep31-enabled (Optional) flag to enable sep31 for this asset, defaults to false --sep38-enabled (Optional) flag to enable sep38 for this asset, defaults to false --deposit-enabled (Optional) flag to enable deposits for this asset, defaults to false --withdrawal-enabled (Optional) flag to enable withdrawals for this asset, defaults to false --symbol (Optional) symbol for the asset, default to "$" ``delete-asset`` allows users to delete assets from the database: delete-asset -h, --help show this help message and exit --asset ASSET, -a ASSET the code of the asset to be deleted --issuer-public ISSUER_PUBLIC, -i ISSUER_PUBLIC the issuer's public key """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.reset_parser = None self.issue_parser = None self.add_asset_parser = None self.delete_asset_parser = None self.server = Server("https://horizon-testnet.stellar.org") self.http = urllib3.PoolManager() def add_arguments(self, parser): # pragma: no cover subparsers = parser.add_subparsers(dest="subcommands", required=True) self.reset_parser = subparsers.add_parser( "reset", help="a sub-command for testnet resets" ) self.issue_parser = subparsers.add_parser( "issue", help="a sub-command for issuing assets on testnet" ) self.issue_parser.add_argument( "--asset", "-a", help="the code of the asset issued" ) self.issue_parser.add_argument( "--issuer-seed", "-i", help="the issuer's secret key" ) self.issue_parser.add_argument( "--distribution-seed", "-d", help="the distribution account's secret key" ) self.issue_parser.add_argument( "--client-seed", "-c", help="the client account's secret key" ) self.issue_parser.add_argument( "--issue-amount", type=Decimal, help="the amount sent to distribution account. Also the limit for the trustline.", ) self.issue_parser.add_argument( "--client-amount", type=Decimal, help="the amount sent to client account. Also the limit for the trustline.", ) self.add_asset_parser = subparsers.add_parser( "add-asset", help="a sub-command for adding assets to the database" ) self.add_asset_parser.add_argument( "--asset", help="asset code to add to the database", required=True ) self.add_asset_parser.add_argument( "--issuer-public", help="the asset issuer's public key", required=True ) self.add_asset_parser.add_argument( "--distribution-seed", help="the anchors distribution seed for this asset", required=True, ) self.add_asset_parser.add_argument( "--sep6-enabled", help="enable sep6 for this asset", action="store_true", ) self.add_asset_parser.add_argument( "--sep24-enabled", help="enable sep24 for this asset", action="store_true", ) self.add_asset_parser.add_argument( "--sep31-enabled", help="enable sep31 for this asset", action="store_true", ) self.add_asset_parser.add_argument( "--sep38-enabled", help="enable sep38 for this asset", action="store_true", ) self.add_asset_parser.add_argument( "--deposit-enabled", help="enable deposits for this asset", action="store_true", ) self.add_asset_parser.add_argument( "--withdrawal-enabled", help="enable withdrawals for this asset", action="store_true", ) self.add_asset_parser.add_argument( "--symbol", help="symbol for the asset", ) self.delete_asset_parser = subparsers.add_parser( "delete-asset", help="a sub-command for deleting assets to the database" ) self.delete_asset_parser.add_argument( "--asset", help="asset code to delete to the database", required=True ) self.delete_asset_parser.add_argument( "--issuer-public", help="the asset issuer's public key", required=True ) def handle(self, *_args, **options): # pragma: no cover if options.get("subcommands") == "reset": self.reset(**options) elif options.get("subcommands") == "issue": self.issue(**options) elif options.get("subcommands") == "add-asset": self.add_asset_to_db(**options) elif options.get("subcommands") == "delete-asset": self.delete_asset_from_db(**options) def reset(self, **options): """ Perform any necessary functions to ensure the anchor is in a valid state after a testnet reset. Currently this involves the following: - re-issuing every Asset object in the DB - moves all `pending_trust` Transactions to `error` - setting the most-recently streamed Transaction object's `paging_token` attribute to None. This signals to watch_transactions to stream using the `"now"` keyword. """ print("\nResetting each asset's most recent paging token") for asset in Asset.objects.filter(distribution_seed__isnull=False): transaction = ( Transaction.objects.filter( Q(kind=Transaction.KIND.withdrawal) | Q(kind=Transaction.KIND.send), receiving_anchor_account=asset.distribution_account, status=Transaction.STATUS.completed, ) .order_by("-completed_at") .first() ) if transaction: transaction.paging_token = None transaction.save() print("\nPlacing all pending_trust transactions into error status") Transaction.objects.filter(status=Transaction.STATUS.pending_trust).update( status=Transaction.STATUS.error ) for asset in Asset.objects.filter(issuer__isnull=False): print(f"\nIssuing {asset.code}") issuer_seed = input(f"Seed for {asset.code} issuer (enter to skip): ") if not issuer_seed: continue try: Keypair.from_secret(issuer_seed) except Ed25519SecretSeedInvalidError: raise CommandError("Bad seed string for issuer account") distribution_seed = asset.distribution_seed if not distribution_seed: distribution_seed = input( f"Seed for {asset.code} distribution account: " ) try: Keypair.from_secret(distribution_seed) except Ed25519SecretSeedInvalidError: raise CommandError("Bad seed string for distribution account") self.issue( **{ "asset": asset.code, "issuer_seed": issuer_seed, "distribution_seed": distribution_seed, "issue_amount": Decimal(10000000), } ) def get_or_create_accounts(self, account_keypairs: list): """ Get account details from horizon for the specified account_keypairs, if the account doesnt exist, create the account """ accounts = {} for kp in account_keypairs: if not kp: continue try: accounts[kp.public_key] = ( self.server.accounts().account_id(kp.public_key).call() ) except NotFoundError: print(f"Funding {kp.public_key} ...") self.http.request( "GET", f"https://friendbot.stellar.org?addr={kp.public_key}" ) accounts[kp.public_key] = ( self.server.accounts().account_id(kp.public_key).call() ) return accounts def issue(self, **options): """ Issue the asset specified using the `options` passed to the subcommand or function call. The code here is a port of the following project: https://github.com/stellar/create-stellar-token Users can setup distribution and client accounts for the asset being issued as well. """ code = options.get("asset") or "TEST" issuer = Keypair.from_secret( options.get("issuer_seed") or Keypair.random().secret ) distributor = Keypair.from_secret( options.get("distribution_seed") or Keypair.random().secret ) client, client_amount = None, None if options.get("client_seed") or options.get("client_amount"): client = Keypair.from_secret( options.get("client_seed") or Keypair.random().secret ) client_amount = options.get("client_amount") or Decimal(1000) issue_amount = options.get("issue_amount") or Decimal(100000) print("\nIssuer account public and private keys:") print(f"public key: {issuer.public_key}") print(f"secret key: {issuer.secret}\n") print("Distribution account public and private keys:") print(f"public key: {distributor.public_key}") print(f"secret key: {distributor.secret}\n") print(f"Issuing {issue_amount} {code} to the distribution account") if client: print("\nClient account public and private keys:") print(f"public key: {client.public_key}") print(f"secret key: {client.secret}\n") print(f"Sending {client_amount} to the client account\n") accounts = self.get_or_create_accounts([issuer, distributor, client]) self.add_balance(code, issue_amount, accounts, distributor, issuer, issuer) if client: self.add_balance(code, client_amount, accounts, client, distributor, issuer) home_domain = input("Home domain for the issuing account (enter to skip): ") if home_domain: self.set_home_domain(issuer, home_domain) def set_home_domain(self, issuer: Keypair, home_domain: str): envelope = ( TransactionBuilder( self.server.load_account(issuer.public_key), base_fee=settings.MAX_TRANSACTION_FEE_STROOPS or self.server.fetch_base_fee(), network_passphrase="Test SDF Network ; September 2015", ) .append_set_options_op(home_domain=home_domain) .set_timeout(30) .build() ) envelope.sign(issuer) try: self.server.submit_transaction(envelope) except BaseHorizonError as e: print( f"Failed to set {home_domain} as home_domain for {issuer.public_key}." f"Result codes: {e.extras.get('result_codes')}" ) else: print("Success!") def add_balance(self, code, amount, accounts, dest, src, issuer): tb = TransactionBuilder( self.server.load_account(src.public_key), base_fee=settings.MAX_TRANSACTION_FEE_STROOPS or self.server.fetch_base_fee(), network_passphrase="Test SDF Network ; September 2015", ) balance = self.get_balance(code, issuer.public_key, accounts[dest.public_key]) if not balance: print(f"\nCreating {code} trustline for {dest.public_key}") if settings.MAX_TRANSACTION_FEE_STROOPS: # halve the base_fee because there are 2 operations tb.base_fee = tb.base_fee // 2 tb.append_change_trust_op( asset=StellarSdkAsset(code=code, issuer=issuer.public_key), source=dest.public_key, ) payment_amount = amount elif Decimal(balance) < amount: print(f"\nReplenishing {code} balance to {amount} for {dest.public_key}") payment_amount = amount - Decimal(balance) else: print( "Destination account already has more than the amount " "specified, skipping\n" ) return print(f"Sending {code} payment of {payment_amount} to {dest.public_key}") tb.append_payment_op( destination=dest.public_key, amount=payment_amount, asset=StellarSdkAsset(code=code, issuer=issuer.public_key), source=src.public_key, ) envelope = tb.set_timeout(30).build() if len(tb.operations) == 2: # add destination's signature if we're adding a trustline envelope.sign(dest) envelope.sign(src) try: self.server.submit_transaction(envelope) except BaseHorizonError as e: print( f"Failed to send {code} payment to {dest.public_key}. " f"Result codes: {e.extras.get('result_codes')}" ) else: print("Success!") def get_balance(self, code, issuer_public_key, json) -> Optional[str]: for balance_obj in json["balances"]: if ( balance_obj.get("asset_code") == code and balance_obj.get("asset_issuer") == issuer_public_key ): return balance_obj["balance"] def add_asset_to_db(self, **options): """ Add an asset to the database """ asset_code = options.get("asset") issuer = options.get("issuer_public") distribution_seed = options.get("distribution_seed") if not (issuer and asset_code): print(f"\ninvalid value in options {options}") return for asset in Asset.objects.filter(issuer__isnull=False): if asset.code == asset_code: print( f"\nAsset {asset.code}:{asset.issuer} already exists in the database, skipping..." ) return print(f"\nAdding asset {asset_code}:{issuer} to database") Asset.objects.create( code=asset_code, issuer=issuer, distribution_seed=distribution_seed, sep6_enabled=options.get("sep6_enabled") or False, sep24_enabled=options.get("sep24_enabled") or False, sep31_enabled=options.get("sep31_enabled") or False, sep38_enabled=options.get("sep38_enabled") or False, deposit_enabled=options.get("deposit_enabled") or False, withdrawal_enabled=options.get("withdrawal_enabled") or False, symbol=options.get("symbol") or "$", ) print(f"\nAsset {asset_code}:{issuer} added to database!") def delete_asset_from_db(self, **options): """ Delete an asset from the database """ asset_code = options.get("asset") issuer = options.get("issuer_public") asset_to_delete = Asset.objects.filter(code=asset_code, issuer=issuer).first() if not asset_to_delete: print(f"\nNo asset matching {asset_code}:{issuer} found to delete") return asset_to_delete.delete() print(f"\nAsset: {asset_code}:{issuer} deleted from the database")