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
1 change: 1 addition & 0 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
python3 -m pip install --upgrade pip
pip3 install -r requirements.txt
pip3 install pytest
pip3 install pytest-mock
- 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
51 changes: 50 additions & 1 deletion CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ See:


COMMAND GROUPS:
{contract,tx,validator,account,ledger,wallet,deps,config,localnet,data,staking-provider,dns}
{contract,tx,validator,account,ledger,wallet,deps,config,localnet,data,staking-provider,dns,faucet}

TOP-LEVEL OPTIONS:
-h, --help show this help message and exit
Expand All @@ -45,6 +45,7 @@ localnet Set up, start and control localnets
data Data manipulation omnitool
staking-provider Staking provider omnitool
dns Operations related to the Domain Name Service
faucet Get xEGLD on Devnet or Testnet

```
## Group **Contract**
Expand Down Expand Up @@ -1093,6 +1094,7 @@ Remove nodes must be called by the contract owner
options:
-h, --help show this help message and exit
--bls-keys BLS_KEYS a list with the bls keys of the nodes
--validators-file VALIDATORS_FILE a JSON file describing the Nodes
--delegation-contract DELEGATION_CONTRACT address of the delegation contract
--proxy PROXY 🔗 the URL of the proxy
--pem PEM 🔑 the PEM file, if keyfile not provided
Expand Down Expand Up @@ -1142,6 +1144,7 @@ Stake nodes must be called by the contract owner
options:
-h, --help show this help message and exit
--bls-keys BLS_KEYS a list with the bls keys of the nodes
--validators-file VALIDATORS_FILE a JSON file describing the Nodes
--delegation-contract DELEGATION_CONTRACT address of the delegation contract
--proxy PROXY 🔗 the URL of the proxy
--pem PEM 🔑 the PEM file, if keyfile not provided
Expand Down Expand Up @@ -1191,6 +1194,7 @@ Unbond nodes must be called by the contract owner
options:
-h, --help show this help message and exit
--bls-keys BLS_KEYS a list with the bls keys of the nodes
--validators-file VALIDATORS_FILE a JSON file describing the Nodes
--delegation-contract DELEGATION_CONTRACT address of the delegation contract
--proxy PROXY 🔗 the URL of the proxy
--pem PEM 🔑 the PEM file, if keyfile not provided
Expand Down Expand Up @@ -1240,6 +1244,7 @@ Unstake nodes must be called by the contract owner
options:
-h, --help show this help message and exit
--bls-keys BLS_KEYS a list with the bls keys of the nodes
--validators-file VALIDATORS_FILE a JSON file describing the Nodes
--delegation-contract DELEGATION_CONTRACT address of the delegation contract
--proxy PROXY 🔗 the URL of the proxy
--pem PEM 🔑 the PEM file, if keyfile not provided
Expand Down Expand Up @@ -1289,6 +1294,7 @@ Unjail nodes must be called by the contract owner
options:
-h, --help show this help message and exit
--bls-keys BLS_KEYS a list with the bls keys of the nodes
--validators-file VALIDATORS_FILE a JSON file describing the Nodes
--delegation-contract DELEGATION_CONTRACT address of the delegation contract
--proxy PROXY 🔗 the URL of the proxy
--pem PEM 🔑 the PEM file, if keyfile not provided
Expand Down Expand Up @@ -2116,3 +2122,46 @@ options:
--use-global use the global storage (default: False)

```
## Group **Faucet**


```
$ mxpy faucet --help
usage: mxpy faucet COMMAND [-h] ...

Get xEGLD on Devnet or Testnet

COMMANDS:
{request}

OPTIONS:
-h, --help show this help message and exit

----------------
COMMANDS summary
----------------
request Request xEGLD.

```
### Faucet.Request


```
$ mxpy faucet request --help
usage: mxpy faucet request [-h] ...

Request xEGLD.

options:
-h, --help show this help message and exit
--pem PEM 🔑 the PEM file, if keyfile not provided
--pem-index PEM_INDEX 🔑 the index in the PEM file (default: 0)
--keyfile KEYFILE 🔑 a JSON keyfile, if PEM not provided
--passfile PASSFILE 🔑 a file containing keyfile's password, if keyfile provided
--ledger 🔐 bool flag for signing transaction using ledger
--ledger-account-index LEDGER_ACCOUNT_INDEX 🔐 the index of the account when using Ledger
--ledger-address-index LEDGER_ADDRESS_INDEX 🔐 the index of the address when using Ledger
--sender-username SENDER_USERNAME 🖄 the username of the sender
--chain CHAIN the chain identifier

```
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
37 changes: 37 additions & 0 deletions Dockerfile
Copy link
Contributor

Choose a reason for hiding this comment

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

what is the usage of this docker image?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it was committed by accident. removed!

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
FROM ubuntu:22.04

ARG USERNAME=developer
ARG USER_UID=1000
ARG USER_GID=$USER_UID

# Create the user
RUN groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
#
# [Optional] Add sudo support. Omit if you don't need to install software after connecting.
&& apt-get update \
&& apt-get install -y sudo \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME

# Install some dependencies as root
RUN apt-get update && apt-get install -y \
wget \
python3.10 python3-pip python3.10-venv \
git \
pkg-config \
libssl-dev && \
rm -rf /var/lib/apt/lists/*

# Switch to regular user
USER $USERNAME
WORKDIR /home/${USERNAME}

RUN sudo apt-get update
RUN sudo apt-get install git

# RUN sudo apt install pipx -y 8

# RUN pipx ensurepath

# RUN pipx install git+https://github.com/multiversx/mx-sdk-py-cli@fix-deps-all
2 changes: 2 additions & 0 deletions multiversx_sdk_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 @@ -97,6 +98,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
76 changes: 76 additions & 0 deletions multiversx_sdk_cli/cli_faucet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import logging
import webbrowser
from enum import Enum
from typing import Any, List, Tuple

from multiversx_sdk_core import Message, MessageComputer

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()
message = Message(f"{account.address.to_bech32()}{init_token}".encode())

message_computer = MessageComputer()
signature = account.sign_message(message_computer.compute_bytes_for_signing(message))

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 @@ -208,3 +208,8 @@ def __init__(self, message: str, url: str, data: str, code: str):
"code": code
}
super().__init__(message, inner)


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

import requests

from multiversx_sdk_cli.errors import NativeAuthClientError


class NativeAuthClientConfig:
def __init__(
self,
origin: str = '',
api_url: str = "https://api.multiversx.com",
expiry_seconds: int = 60 * 60 * 24,
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe set constants for these default values?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

made some constants for that values

Copy link
Contributor

Choose a reason for hiding this comment

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

here I was referring for these 2 variables: default api url and expiry time in seconds; i think it can be only one var for expiry_seconds

Copy link
Contributor Author

Choose a reason for hiding this comment

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

indeed, makes sense. done!

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: NativeAuthClientConfig = NativeAuthClientConfig()) -> None:
self.config = config

def get_token(self, address: str, token: str, signature: str) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

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

Functions can be re-ordered to match the usual flow. E.g. first "initialize", then ...

encoded_address = self.encode_value(address)
encoded_token = self.encode_value(token)

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

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_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"]

Check failure on line 56 in multiversx_sdk_cli/native_auth_client.py

View workflow job for this annotation

GitHub Actions / runner / mypy

[mypy] reported by reviewdog 🐶 Returning Any from function declared to return "str" [no-any-return] Raw Output: /home/runner/work/mx-sdk-py-cli/mx-sdk-py-cli/multiversx_sdk_cli/native_auth_client.py:56:9: error: Returning Any from function declared to return "str" [no-any-return]

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"]

Check failure on line 69 in multiversx_sdk_cli/native_auth_client.py

View workflow job for this annotation

GitHub Actions / runner / mypy

[mypy] reported by reviewdog 🐶 Returning Any from function declared to return "int" [no-any-return] Raw Output: /home/runner/work/mx-sdk-py-cli/mx-sdk-py-cli/multiversx_sdk_cli/native_auth_client.py:69:9: error: Returning Any from function declared to return "int" [no-any-return]

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:
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this help?

https://docs.python.org/3/library/base64.html#base64.urlsafe_b64encode

= would have to be trimmed, nonetheless.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Might help, but indeed = would have to be trimmed as well. Will leave it as it is for now.

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()
Loading
Loading