Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented a native auth client and faucet functionality #418

Merged
merged 15 commits into from
Sep 23, 2024
Merged
2 changes: 1 addition & 1 deletion .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
run: |
python3 -m pip install --upgrade pip
pip3 install -r requirements.txt
pip3 install pytest
pip3 install -r requirements-dev.txt
- name: Set github_api_token
shell: bash
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
run: |
python3 -m pip install --upgrade pip
pip3 install -r requirements.txt
pip3 install pytest
pip3 install -r requirements-dev.txt
- name: Set github_api_token
run: |
mkdir ~/multiversx-sdk
Expand Down
239 changes: 229 additions & 10 deletions CLI.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions CLI.md.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ generate() {
command "Data.Dump" "data parse"
command "Data.Store" "data store"
command "Data.Load" "data load"

group "Faucet" "faucet"
command "Faucet.Request" "faucet request"
}

generate
2 changes: 2 additions & 0 deletions multiversx_sdk_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import multiversx_sdk_cli.cli_delegation
import multiversx_sdk_cli.cli_deps
import multiversx_sdk_cli.cli_dns
import multiversx_sdk_cli.cli_faucet
import multiversx_sdk_cli.cli_ledger
import multiversx_sdk_cli.cli_localnet
import multiversx_sdk_cli.cli_transactions
Expand Down Expand Up @@ -100,6 +101,7 @@ def setup_parser(args: List[str]):
commands.append(multiversx_sdk_cli.cli_data.setup_parser(subparsers))
commands.append(multiversx_sdk_cli.cli_delegation.setup_parser(args, subparsers))
commands.append(multiversx_sdk_cli.cli_dns.setup_parser(args, subparsers))
commands.append(multiversx_sdk_cli.cli_faucet.setup_parser(args, subparsers))

parser.epilog = """
----------------------
Expand Down
2 changes: 1 addition & 1 deletion multiversx_sdk_cli/cli_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def setup_parser(args: List[str], subparsers: Any) -> Any:

# create new delegation contract
sub = cli_shared.add_command_subparser(subparsers, "staking-provider", "create-new-delegation-contract",
"Create a new delegation system smart contract, transferred value must be"
"Create a new delegation system smart contract, transferred value must be "
"greater than baseIssuingCost + min deposit value")
_add_common_arguments(args, sub)
sub.add_argument("--total-delegation-cap", required=True, help="the total delegation contract capacity")
Expand Down
72 changes: 72 additions & 0 deletions multiversx_sdk_cli/cli_faucet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import logging
import webbrowser
from enum import Enum
from typing import Any, List, Tuple

from multiversx_sdk_cli import cli_shared
from multiversx_sdk_cli.errors import BadUserInput
from multiversx_sdk_cli.native_auth_client import (NativeAuthClient,
NativeAuthClientConfig)

logger = logging.getLogger("cli.faucet")


class WebWalletUrls(Enum):
DEVNET = "https://devnet-wallet.multiversx.com"
TESTNET = "https://testnet-wallet.multiversx.com"


class ApiUrls(Enum):
DEVNET = "https://devnet-api.multiversx.com"
TESTNET = "https://testnet-api.multiversx.com"


def setup_parser(args: List[str], subparsers: Any) -> Any:
parser = cli_shared.add_group_subparser(subparsers, "faucet", "Get xEGLD on Devnet or Testnet")
subparsers = parser.add_subparsers()

sub = cli_shared.add_command_subparser(subparsers, "faucet", "request", "Request xEGLD.")
cli_shared.add_wallet_args(args, sub)
sub.add_argument("--chain", required=True, help="the chain identifier")
Comment on lines +29 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here it lists --pem, --keyfile, --ledger, --chain as required, but only one of the first three are required, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one of --pem, --keyfile, --ledger is required, but also the --chain is required to specify on which network you wish to receive the funds

sub.set_defaults(func=faucet)

parser.epilog = cli_shared.build_group_epilog(subparsers)
return subparsers


def faucet(args: Any):
account = cli_shared.prepare_account(args)
wallet, api = get_wallet_and_api_urls(args)

config = NativeAuthClientConfig(origin=wallet, api_url=api)
client = NativeAuthClient(config)

init_token = client.initialize()
token_for_siginig = f"{account.address.to_bech32()}{init_token}"
signature = account.sign_message(token_for_siginig.encode())

access_token = client.get_token(
address=account.address.to_bech32(),
token=init_token,
signature=signature
)

logger.info(f"Requesting funds for address: {account.address.to_bech32()}")
call_web_wallet_faucet(wallet_url=wallet, access_token=access_token)


def call_web_wallet_faucet(wallet_url: str, access_token: str):
faucet_url = f"{wallet_url}/faucet?accessToken={access_token}"
webbrowser.open_new_tab(faucet_url)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm thinking of the main benefits of having this command from cli, since we still have to check in the browser

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we've had people asking about it and i think it's a little bit easier to use the cli instead of doing it manually even though you have to pass the recaptcha and then press the request button. This is just an initial implementation/poc. We were thinking about doing it another way, so the user does not have to do anything else but type the command but we have not yet found a non-exploitable way to do it and we'll also need some help from the colleagues working on the api.



def get_wallet_and_api_urls(args: Any) -> Tuple[str, str]:
chain: str = args.chain

if chain.upper() == "D":
return WebWalletUrls.DEVNET.value, ApiUrls.DEVNET.value

if chain.upper() == "T":
return WebWalletUrls.TESTNET.value, ApiUrls.TESTNET.value

raise BadUserInput("Invalid chain id. Choose between 'D' for devnet and 'T' for testnet.")
5 changes: 5 additions & 0 deletions multiversx_sdk_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,8 @@ def __init__(self, message: str):
class QueryContractError(KnownError):
def __init__(self, message: str, inner: Any = None):
super().__init__(message, str(inner))


class NativeAuthClientError(KnownError):
def __init__(self, message: str):
super().__init__(message)
104 changes: 104 additions & 0 deletions multiversx_sdk_cli/native_auth_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import base64
import json
from typing import Any, Dict, Optional

import requests

from multiversx_sdk_cli.errors import NativeAuthClientError

DEFAULT_EXPIRY_TIME_IN_SECONDS = 60 * 60 * 2
DEFAULT_API_URL = "https://api.multiversx.com"


class NativeAuthClientConfig:
def __init__(
self,
origin: str = '',
api_url: str = DEFAULT_API_URL,
expiry_seconds: int = DEFAULT_EXPIRY_TIME_IN_SECONDS,
block_hash_shard: Optional[int] = None,
gateway_url: Optional[str] = None,
extra_request_headers: Optional[Dict[str, str]] = None
) -> None:
self.origin = origin
self.api_url = api_url
self.expiry_seconds = expiry_seconds
self.block_hash_shard = block_hash_shard
self.gateway_url = gateway_url
self.extra_request_headers = extra_request_headers


class NativeAuthClient:
def __init__(self, config: Optional[NativeAuthClientConfig] = None) -> None:
self.config = config or NativeAuthClientConfig()

def initialize(self, extra_info: Dict[Any, Any] = {}) -> str:
block_hash = self.get_current_block_hash()
encoded_extra_info = self._encode_value(json.dumps(extra_info))
encoded_origin = self._encode_value(self.config.origin)

return f"{encoded_origin}.{block_hash}.{self.config.expiry_seconds}.{encoded_extra_info}"

def get_token(self, address: str, token: str, signature: str) -> str:
encoded_address = self._encode_value(address)
encoded_token = self._encode_value(token)

return f"{encoded_address}.{encoded_token}.{signature}"

def get_current_block_hash(self) -> str:
if self.config.gateway_url:
return self._get_current_block_hash_using_gateway()
return self._get_current_block_hash_using_api()

def _get_current_block_hash_using_gateway(self) -> str:
round = self._get_current_round()
url = f"{self.config.gateway_url}/blocks/by-round/{round}"
response = self._execute_request(url)
blocks = response["data"]["blocks"]
block = [b for b in blocks if b["shard"] == self.config.block_hash_shard][0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit fragile - it's possible to have a round where not all shards propose a block (missed block).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, in the context of mxpy, we don't provide the shard.

return block["hash"]

def _get_current_round(self) -> int:
if self.config.gateway_url is None:
raise NativeAuthClientError("Gateway URL not set")

if self.config.block_hash_shard is None:
raise NativeAuthClientError("Blockhash shard not set")

url = f"{self.config.gateway_url}/network/status/{self.config.block_hash_shard}"
response = self._execute_request(url)
status = response["data"]["status"]

return status["erd_current_round"]

def _get_current_block_hash_using_api(self) -> str:
try:
url = f"{self.config.api_url}/blocks/latest?ttl={self.config.expiry_seconds}&fields=hash"
response = self._execute_request(url)
if response["hash"]:
return response["hash"]
except Exception:
pass

return self._get_current_block_hash_using_api_fallback()

def _get_current_block_hash_using_api_fallback(self) -> str:
url = f"{self.config.api_url}/blocks?size=1&fields=hash"

if self.config.block_hash_shard:
url += f"&shard={self.config.block_hash_shard}"

response = self._execute_request(url)
return response[0]["hash"]

def _encode_value(self, string: str) -> str:
encoded = base64.b64encode(string.encode('utf-8')).decode('utf-8')
return self._escape(encoded)

def _escape(self, string: str) -> str:
return string.replace("+", "-").replace("/", "_").replace("=", "")

def _execute_request(self, url: str) -> Any:
response = requests.get(url=url, headers=self.config.extra_request_headers)
response.raise_for_status()
return response.json()
128 changes: 128 additions & 0 deletions multiversx_sdk_cli/tests/test_native_auth_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from typing import Any, List

import pytest

from multiversx_sdk_cli.native_auth_client import (NativeAuthClient,
NativeAuthClientConfig)


def mock(mocker: Any, code: int, response: Any):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 🚀

mock_response = mocker.Mock()
mock_response.status_code = code
mock_response.json.return_value = response
mocker.patch("requests.get", return_value=mock_response)


def mock_side_effect(mocker: Any, responses: List[Any]):
def side_effect(*args: Any, **kwargs: Any):
response = responses.pop(0)
mock_response = mocker.Mock()
mock_response.status_code = response["code"]
mock_response.json.return_value = response["response"]
return mock_response

mocker.patch("requests.get", side_effect=side_effect)


class TestNativeAuth:
ADDRESS = 'erd1qnk2vmuqywfqtdnkmauvpm8ls0xh00k8xeupuaf6cm6cd4rx89qqz0ppgl'
SIGNATURE = '906e79d54e69e688680abee54ec0c49ce2561eb5abfd01865b31cb3ed738272c7cfc4fc8cc1c3590dd5757e622639b01a510945d7f7c9d1ceda20a50a817080d'
BLOCK_HASH = 'ab459013b27fdc6fe98eed567bd0c1754e0628a4cc16883bf0170a29da37ad46'
TTL = 86400
ORIGIN = 'https://api.multiversx.com'
TOKEN = f"aHR0cHM6Ly9hcGkubXVsdGl2ZXJzeC5jb20.{BLOCK_HASH}.{TTL}.e30"
ACCESS_TOKEN = 'ZXJkMXFuazJ2bXVxeXdmcXRkbmttYXV2cG04bHMweGgwMGs4eGV1cHVhZjZjbTZjZDRyeDg5cXF6MHBwZ2w.YUhSMGNITTZMeTloY0drdWJYVnNkR2wyWlhKemVDNWpiMjAuYWI0NTkwMTNiMjdmZGM2ZmU5OGVlZDU2N2JkMGMxNzU0ZTA2MjhhNGNjMTY4ODNiZjAxNzBhMjlkYTM3YWQ0Ni44NjQwMC5lMzA.906e79d54e69e688680abee54ec0c49ce2561eb5abfd01865b31cb3ed738272c7cfc4fc8cc1c3590dd5757e622639b01a510945d7f7c9d1ceda20a50a817080d'
INVALID_HASH_ERROR = 'Validation failed for block hash \'hash\'. Length should be 64.'

def test_latest_block_should_return_signable_token(self, mocker: Any):
mock(mocker, 200, [{"hash": self.BLOCK_HASH}])
config = NativeAuthClientConfig(origin=self.ORIGIN, expiry_seconds=self.TTL)
client = NativeAuthClient(config)
token = client.initialize()
assert token == self.TOKEN

def test_throws_internal_server_error(self, mocker: Any):
mock(mocker, 500, {})
client = NativeAuthClient()
with pytest.raises(Exception):
client.initialize()

# if `/blocks/latest` raises error should fallback to `/blocks?size=1`
def test_fallback_mechanism(self, mocker: Any):
mock(mocker, 400, [{"statusCode": 400,
"message": self.INVALID_HASH_ERROR,
"error": "Bad request"}])
mock(mocker, 200, {"hash": self.BLOCK_HASH})

config = NativeAuthClientConfig(origin=self.ORIGIN, expiry_seconds=self.TTL)
client = NativeAuthClient(config)

token = client.initialize()
assert token == self.TOKEN

def test_generate_access_token(self):
client = NativeAuthClient()
access_token = client.get_token(self.ADDRESS, self.TOKEN, self.SIGNATURE)
assert access_token == self.ACCESS_TOKEN


class TestNativeAuthWithGateway:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cases, nice coverage of flows 🙌

ADDRESS = 'erd1qnk2vmuqywfqtdnkmauvpm8ls0xh00k8xeupuaf6cm6cd4rx89qqz0ppgl'
SIGNATURE = '906e79d54e69e688680abee54ec0c49ce2561eb5abfd01865b31cb3ed738272c7cfc4fc8cc1c3590dd5757e622639b01a510945d7f7c9d1ceda20a50a817080d'
BLOCK_HASH = 'ab459013b27fdc6fe98eed567bd0c1754e0628a4cc16883bf0170a29da37ad46'
TTL = 86400
ORIGIN = 'https://api.multiversx.com'
TOKEN = f"aHR0cHM6Ly9hcGkubXVsdGl2ZXJzeC5jb20.{BLOCK_HASH}.{TTL}.e30"
ACCESS_TOKEN = 'ZXJkMXFuazJ2bXVxeXdmcXRkbmttYXV2cG04bHMweGgwMGs4eGV1cHVhZjZjbTZjZDRyeDg5cXF6MHBwZ2w.YUhSMGNITTZMeTloY0drdWJYVnNkR2wyWlhKemVDNWpiMjAuYWI0NTkwMTNiMjdmZGM2ZmU5OGVlZDU2N2JkMGMxNzU0ZTA2MjhhNGNjMTY4ODNiZjAxNzBhMjlkYTM3YWQ0Ni44NjQwMC5lMzA.906e79d54e69e688680abee54ec0c49ce2561eb5abfd01865b31cb3ed738272c7cfc4fc8cc1c3590dd5757e622639b01a510945d7f7c9d1ceda20a50a817080d'
LATEST_ROUND = 115656
METASHARD = 4294967295
GATEWAY = 'https://gateway.multiversx.com'

def test_latest_block_should_return_signable_token(self, mocker: Any):
responses = [
{"code": 200, "response": {"data": {"status": {"erd_current_round": self.LATEST_ROUND}}}},
{"code": 200, "response": {"data": {"blocks": [{"shard": self.METASHARD, "hash": self.BLOCK_HASH}]}}}
]
mock_side_effect(mocker, responses)

config = NativeAuthClientConfig(origin=self.ORIGIN, gateway_url=self.GATEWAY, block_hash_shard=self.METASHARD, expiry_seconds=self.TTL)
client = NativeAuthClient(config)
token = client.initialize()
assert token == self.TOKEN

def test_should_raise_internal_server_error(self, mocker: Any):
responses = [
{"code": 500, "response": {"data": {"status": {"erd_current_round": self.LATEST_ROUND}}}}
]
mock_side_effect(mocker, responses)

config = NativeAuthClientConfig(gateway_url=self.GATEWAY, block_hash_shard=self.METASHARD)
client = NativeAuthClient(config)

with pytest.raises(Exception):
client.initialize()

def test_raises_internal_server_error_on_second_request(self, mocker: Any):
responses = [
{"code": 200, "response": {"data": {"status": {"erd_current_round": self.LATEST_ROUND}}}},
{"code": 500, "response": {""}}
]
mock_side_effect(mocker, responses)

config = NativeAuthClientConfig(gateway_url=self.GATEWAY, block_hash_shard=self.METASHARD)
client = NativeAuthClient(config)

with pytest.raises(Exception):
client.initialize()

def test_generate_access_token(self):
config = NativeAuthClientConfig(gateway_url=self.GATEWAY, block_hash_shard=self.METASHARD)
client = NativeAuthClient(config)

access_token = client.get_token(
address=self.ADDRESS,
token=self.TOKEN,
signature=self.SIGNATURE
)

assert access_token == self.ACCESS_TOKEN
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pytest
flake8
autopep8
pytest-mock
Loading