A Python library for interacting and working with the Woop blockchain.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pywiki/pyhmy/staking_signing.py

521 lines
19 KiB

"""
Sign Harmony staking transactions
"""
import math
from decimal import Decimal
from functools import partial
from toolz import ( pipe, dissoc, merge, identity, )
4 years ago
from hexbytes import HexBytes
4 years ago
import rlp
from eth_account.datastructures import SignedTransaction
4 years ago
from eth_account._utils.signing import sign_transaction_hash
4 years ago
from eth_account._utils.legacy_transactions import chain_id_to_v
4 years ago
from eth_utils.curried import (
hexstr_if_str,
to_bytes,
keccak,
apply_formatters_to_dict,
to_int,
apply_formatters_to_sequence,
apply_formatter_to_array,
)
4 years ago
from .constants import PRECISION, MAX_DECIMAL
from .signing import sanitize_transaction
4 years ago
from .staking_structures import (
FORMATTERS,
Directive,
CreateValidator,
EditValidator,
DelegateOrUndelegate,
CollectRewards,
)
4 years ago
from .util import convert_one_to_hex
4 years ago
# https://github.com/harmony-one/sdk/blob/99a827782fabcd5f91f025af0d8de228956d42b4/packages/harmony-staking/src/stakingTransaction.ts#L335
def _convert_staking_percentage_to_number( value, ):
"""Convert from staking percentage to integer For example, 0.1 becomes
1000000000000000000. Since Python floats are problematic with precision,
this function is used as a workaround.
4 years ago
Parameters
---------
value: :obj:`str` or :obj:`Decimal`
the value to convert
Returns
-------
int, converted as above
Raises
------
AssertionError, if data types are not as expected
ValueError, if the input type is not supported
"""
assert isinstance( value, ( str, Decimal ) ), "Only strings or decimals are supported"
if isinstance( value, Decimal ):
value = str( value )
value1 = value
if value[ 0 ] == "-":
raise ValueError( "Negative numbers are not accepted" )
if value[ 0 ] == "+":
value1 = value[ 1 : ]
if len( value1 ) == 0:
raise ValueError( "StakingDecimal string is empty" )
spaced = value1.split( " " )
if len( spaced ) > 1:
raise ValueError( "Bad decimal string" )
splitted = value1.split( "." )
combined_str = splitted[ 0 ]
if len( splitted ) == 2:
length = len( splitted[ 1 ] )
if length == 0 or len( combined_str ) == 0:
raise ValueError( "Bad StakingDecimal length" )
if splitted[ 1 ][ 0 ] == "-":
raise ValueError( "Bad StakingDecimal string" )
combined_str += splitted[ 1 ]
elif len( splitted ) > 2:
raise ValueError( "Too many periods to be a StakingDecimal string" )
if length > PRECISION:
raise ValueError( "Too much precision, must be less than {PRECISION}" )
zeroes_to_add = PRECISION - length
combined_str += (
"0" * zeroes_to_add
) # This will not have any periods, so it is effectively a large integer
val = int( combined_str )
assert val <= MAX_DECIMAL, "Staking percentage is too large"
4 years ago
return val
def _get_account_and_transaction( transaction_dict, private_key ):
"""Create account from private key and sanitize the transaction
Sanitization involves removal of 'from' key And conversion of chainId key
from str to int ( if present )
4 years ago
Parameters
----------
transaction_dict: :obj:`dict`
See sign_staking_transaction
private_key: obj:`str`
Private key for the account
Returns
-------
a tuple containing account :obj:`eth_account.Account`
and sanitize_transaction :obj:`dict`
Raises
------
AssertionError, if chainId is not present in util.chain_id_to_int
TypeError, if the value of 'from' key is not the same as account address
"""
account, sanitized_transaction = sanitize_transaction(
transaction_dict, private_key
) # remove from, convert chain id ( if present ) to integer
sanitized_transaction[ "directive" ] = sanitized_transaction[
"directive" ].value # convert to value, like in TypeScript
4 years ago
return account, sanitized_transaction
# pylint: disable=too-many-locals,protected-access,invalid-name
def _sign_transaction_generic(
account,
sanitized_transaction,
parent_serializer
):
"""Sign a generic staking transaction, given the serializer base class and
account.
4 years ago
Paramters
---------
account: :obj:`eth_account.Account`, the account to use for signing
sanitized_transaction: :obj:`dict`, The sanitized transaction ( chainId checks and no from key )
4 years ago
parent_serializer: :obj: The serializer class from staking_structures
Returns
-------
SignedTransaction object, which can be posted to the chain by using
blockchain.send_raw_transaction
Raises
------
Assertion / KeyError, if certain keys are missing from the dict
rlp.exceptions.ObjectSerializationError, if data types are not as expected
"""
# obtain the serializers
if sanitized_transaction.get( "chainId", 0 ) == 0:
unsigned_serializer, signed_serializer = (
parent_serializer.Unsigned( ),
parent_serializer.Signed( ),
) # unsigned, signed
4 years ago
else:
unsigned_serializer, signed_serializer = (
parent_serializer.SignedChainId( ),
parent_serializer.SignedChainId( ),
) # since chain_id_to_v adds v/r/s, unsigned is not used here
4 years ago
# fill the transaction
# https://github.com/ethereum/eth-account/blob/00e7b10005c5fa7090086fcef37a76296c524e17/eth_account/_utils/transactions.py#L39
filled_transaction = pipe(
4 years ago
sanitized_transaction,
dict,
partial( merge, { "chainId": None } ),
chain_id_to_v, # will move chain id to v and add v/r/s
apply_formatters_to_dict( FORMATTERS ),
4 years ago
)
# get the unsigned transaction
for field, _ in unsigned_serializer._meta.fields:
assert field in filled_transaction, f"Could not find {field} in transaction"
unsigned_transaction = unsigned_serializer.from_dict(
{
f: filled_transaction[ f ]
for f,
_ in unsigned_serializer._meta.fields
}
) # drop extras silently
4 years ago
# sign the unsigned transaction
if "v" in unsigned_transaction.as_dict():
4 years ago
chain_id = unsigned_transaction.v
else:
chain_id = None
transaction_hash = unsigned_transaction.hash()
( v,
r,
s
) = sign_transaction_hash( account._key_obj,
transaction_hash,
chain_id )
4 years ago
chain_naive_transaction = dissoc(
unsigned_transaction.as_dict(),
"v",
"r",
"s"
) # remove extra v/r/s added by chain_id_to_v
4 years ago
# serialize it
# https://github.com/harmony-one/sdk/blob/99a827782fabcd5f91f025af0d8de228956d42b4/packages/harmony-staking/src/stakingTransaction.ts#L207
4 years ago
signed_transaction = signed_serializer(
v=v
+ (
8 if chain_id is None else 0
),
4 years ago
r=r,
s=s, # in the below statement, remove everything not expected by signed_serializer
**{
f: chain_naive_transaction[ f ]
for f, _ in signed_serializer._meta.fields
if f not in "vrs"
},
)
4 years ago
# encode it
encoded_transaction = rlp.encode( signed_transaction )
4 years ago
# hash it
signed_transaction_hash = keccak( encoded_transaction )
4 years ago
# return is
return SignedTransaction(
rawTransaction = HexBytes( encoded_transaction ),
hash = HexBytes( signed_transaction_hash ),
r = r,
s = s,
v = v,
4 years ago
)
def _sign_delegate_or_undelegate( transaction_dict, private_key ):
"""Sign a delegate or undelegate transaction See sign_staking_transaction
for details."""
4 years ago
# preliminary steps
if transaction_dict[ "directive" ] not in [
Directive.Delegate,
Directive.Undelegate
]:
raise TypeError(
"Only Delegate or Undelegate are supported by _sign_delegate_or_undelegate"
)
4 years ago
# first common step
account, sanitized_transaction = _get_account_and_transaction(
transaction_dict, private_key
)
4 years ago
# encode the stakeMsg
sanitized_transaction[ "stakeMsg" ] = apply_formatters_to_sequence(
[
hexstr_if_str( to_bytes ),
hexstr_if_str( to_bytes ),
hexstr_if_str( to_int )
],
[
convert_one_to_hex(
sanitized_transaction.pop( "delegatorAddress" )
),
convert_one_to_hex(
sanitized_transaction.pop( "validatorAddress" )
),
sanitized_transaction.pop( "amount" ),
],
)
return _sign_transaction_generic(
account,
sanitized_transaction,
DelegateOrUndelegate
)
4 years ago
def _sign_collect_rewards( transaction_dict, private_key ):
"""Sign a collect rewards transaction See sign_staking_transaction for
details."""
4 years ago
# preliminary steps
if transaction_dict[ "directive" ] != Directive.CollectRewards:
raise TypeError(
"Only CollectRewards is supported by _sign_collect_rewards"
)
4 years ago
# first common step
account, sanitized_transaction = _get_account_and_transaction(
transaction_dict, private_key
)
4 years ago
# encode the stakeMsg
sanitized_transaction[ "stakeMsg" ] = [
hexstr_if_str( to_bytes )(
convert_one_to_hex(
sanitized_transaction.pop( "delegatorAddress" )
)
)
]
return _sign_transaction_generic(
account,
sanitized_transaction,
CollectRewards
)
4 years ago
def _sign_create_validator( transaction_dict, private_key ):
"""Sign a create validator transaction See sign_staking_transaction for
details."""
4 years ago
# preliminary steps
if transaction_dict[ "directive" ] != Directive.CreateValidator:
raise TypeError(
"Only CreateValidator is supported by _sign_create_or_edit_validator"
)
4 years ago
# first common step
account, sanitized_transaction = _get_account_and_transaction(
transaction_dict, private_key
)
4 years ago
# encode the stakeMsg
description = [
sanitized_transaction.pop( "name" ),
sanitized_transaction.pop( "identity" ),
sanitized_transaction.pop( "website" ),
sanitized_transaction.pop( "security-contact" ),
sanitized_transaction.pop( "details" ),
]
commission = apply_formatter_to_array(
hexstr_if_str( to_int ), # formatter
4 years ago
[
_convert_staking_percentage_to_number( sanitized_transaction.pop( "rate" ) ),
_convert_staking_percentage_to_number(
sanitized_transaction.pop( "max-rate" )
),
_convert_staking_percentage_to_number(
sanitized_transaction.pop( "max-change-rate" )
),
],
4 years ago
)
commission = [ [ element ] for element in commission ]
bls_keys = apply_formatter_to_array(
hexstr_if_str( to_bytes ), # formatter
sanitized_transaction.pop( "bls-public-keys" ),
4 years ago
)
bls_key_sigs = apply_formatter_to_array(
hexstr_if_str( to_bytes ),
sanitized_transaction.pop( "bls-key-sigs" ) # formatter
)
sanitized_transaction[ "stakeMsg" ] = apply_formatters_to_sequence(
[
hexstr_if_str( to_bytes ), # address
identity, # description
identity, # commission rates
hexstr_if_str(
to_int
), # min self delegation ( in ONE ), decimals are silently dropped
hexstr_if_str(
to_int
), # max total delegation ( in ONE ), decimals are silently dropped
identity, # bls public keys
identity, # bls key sigs
hexstr_if_str(
to_int
), # amount ( the Hexlify in the SDK drops the decimals, which is what we will do too )
],
[
convert_one_to_hex( sanitized_transaction.pop( "validatorAddress" ) ),
4 years ago
description,
commission,
math.floor(
sanitized_transaction.pop( "min-self-delegation" )
), # Decimal floors it correctly
math.floor( sanitized_transaction.pop( "max-total-delegation" ) ),
4 years ago
bls_keys,
bls_key_sigs,
math.floor( sanitized_transaction.pop( "amount" ) ),
],
)
return _sign_transaction_generic(
account,
sanitized_transaction,
CreateValidator
)
4 years ago
def _sign_edit_validator( transaction_dict, private_key ):
"""Sign an edit validator transaction See sign_staking_transaction for
details."""
4 years ago
# preliminary steps
if transaction_dict[ "directive" ] != Directive.EditValidator:
raise TypeError(
"Only EditValidator is supported by _sign_create_or_edit_validator"
)
4 years ago
# first common step
account, sanitized_transaction = _get_account_and_transaction(
transaction_dict, private_key
)
4 years ago
# encode the stakeMsg
description = [
sanitized_transaction.pop( "name" ),
sanitized_transaction.pop( "identity" ),
sanitized_transaction.pop( "website" ),
sanitized_transaction.pop( "security-contact" ),
sanitized_transaction.pop( "details" ),
]
sanitized_transaction[ "stakeMsg" ] = apply_formatters_to_sequence(
[
hexstr_if_str( to_bytes ), # address
identity, # description
identity, # new rate ( it's in a list so can't do hexstr_if_str )
hexstr_if_str(
to_int
), # min self delegation ( in ONE ), decimals are silently dropped
hexstr_if_str(
to_int
), # max total delegation ( in ONE ), decimals are silently dropped
hexstr_if_str( to_bytes ), # key to remove
hexstr_if_str( to_bytes ), # key to add
hexstr_if_str( to_bytes ), # key to add sig
],
[
convert_one_to_hex( sanitized_transaction.pop( "validatorAddress" ) ),
4 years ago
description,
[ _convert_staking_percentage_to_number( sanitized_transaction.pop( "rate" ) ) ],
math.floor(
sanitized_transaction.pop( "min-self-delegation" )
), # Decimal floors it correctly
math.floor( sanitized_transaction.pop( "max-total-delegation" ) ),
sanitized_transaction.pop( "bls-key-to-remove" ),
sanitized_transaction.pop( "bls-key-to-add" ),
sanitized_transaction.pop( "bls-key-to-add-sig" ),
],
)
return _sign_transaction_generic(
account,
sanitized_transaction,
EditValidator
)
4 years ago
def sign_staking_transaction( transaction_dict, private_key ):
"""Sign a supplied transaction_dict with the private_key.
4 years ago
Parameters
----------
transaction_dict: :obj:`dict`, a dictionary with the following keys
directive :obj:`staking_structures.Directive`, type of transaction
nonce: :obj:`int`, nonce of transaction
gasPrice: :obj:`int`, gas price for the transaction
gasLimit: :obj:`int`, gas limit for the transaction
chainId: :obj:`int`, chain id for the transaction, optional
see util.chain_id_to_int for options
The following keys depend on the directive:
CollectRewards:
delegatorAddress: :obj:`str`, Address of the delegator
Delegate/Undelegate:
delegatorAddress: :obj:`str`, Address of the delegator
validatorAddress: :obj:`str`, Address of the validator
amount: :obj:`int`, Amount to ( un )delegate in ATTO
4 years ago
CreateValidator:
validatorAddress: :obj:`str`, Address of the validator
name: ;obj:`str`, Name of the validator
identity: :obj:`str`, Identity of the validator, must be unique
website: :obj:`str`, Website of the validator
security-contact: :obj:`str`, Security contact
details: :obj:`str` Validator details
rate: :obj:'Decimal' or :obj:`str` Staking commission rate
max-rate: :obj:'Decimal' or :obj:`str` Maximum staking commission rate
max-change-rate: :obj:'Decimal' or :obj:`str` Maximum change in
staking commission rate per epoch
bls-public-keys: :obj:`list` List of strings of BLS public keys
min-self-delegation: :obj:`int` or :obj:`Decimal` Validator min
self delegation in ATTO
max-total-delegation: :obj:`int` or :obj:`Decimal` Validator max
total delegation in ATTO
EditValidator:
validatorAddress: :obj:`str`, Address of the validator
name: ;obj:`str`, Name of the validator
identity: :obj:`str`, Identity of the validator, must be unique
website: :obj:`str`, Website of the validator
security-contact: :obj:`str`, Security contact
details: :obj:`str` Validator details
rate: :obj:'Decimal' or :obj:`str` Staking commission rate
min-self-delegation: :obj:`int` or :obj:`Decimal` Validator min
self delegation in ATTO
max-total-delegation: :obj:`int` or :obj:`Decimal` Validator max
total delegation in ATTO
bls-key-to-remove: :obj:`str` BLS Public key to remove
bls-key-to-add: :obj:`str` BLS Public key to add
private_key: :obj:`str`, the private key to sign the transaction with
Raises
------
AssertionError, if inputs are not as expected
KeyError, if inputs are missing
ValueError, if specifically staking rates are malformed
rlp.exceptions.ObjectSerializationError, if input data types are not as expected
Returns
-------
SignedTransaction object, the hash of which can be used to send the transaction
using transaction.send_raw_transaction
API Reference
-------------
https://github.com/harmony-one/sdk/blob/99a827782fabcd5f91f025af0d8de228956d42b4/packages/harmony-staking/src/stakingTransaction.ts
"""
assert isinstance(
transaction_dict, dict
), "Only dictionaries are supported" # OrderedDict is a subclass
# chain_id missing => results in rlp decoding error for GasLimit
assert "chainId" in transaction_dict, "chainId missing"
assert "directive" in transaction_dict, "Staking transaction type not specified"
assert isinstance(
transaction_dict[ "directive" ], Directive
), "Unknown staking transaction type"
if transaction_dict[ "directive" ] == Directive.CollectRewards:
return _sign_collect_rewards( transaction_dict, private_key )
if transaction_dict[ "directive" ] == Directive.Delegate:
return _sign_delegate_or_undelegate( transaction_dict, private_key )
if transaction_dict[ "directive" ] == Directive.Undelegate:
return _sign_delegate_or_undelegate( transaction_dict, private_key )
if transaction_dict[ "directive" ] == Directive.CreateValidator:
return _sign_create_validator( transaction_dict, private_key )
if transaction_dict[ "directive" ] == Directive.EditValidator:
return _sign_edit_validator( transaction_dict, private_key )
raise ValueError( 'Unknown staking transaction type' )