mirror of
https://github.com/status-im/status-python-sdk.git
synced 2026-08-30 21:51:14 +00:00
wallet: Add Infura Token
- Wallet usage can be broken down into 3 parts - transactions (Alchemy), prices (Coingecko) and Ethereum RPC calls (Infura). - Related to https://github.com/status-im/status-python-sdk/issues/3
This commit is contained in:
@@ -33,7 +33,8 @@ graph TB
|
||||
|
||||
subgraph external[External Services]
|
||||
COINGECKO[CoinGecko]
|
||||
EVM
|
||||
ALCHEMY[Alchemy]
|
||||
INFURA[Infura]
|
||||
end
|
||||
|
||||
SDK --> SIGNAL
|
||||
@@ -43,7 +44,8 @@ graph TB
|
||||
SDK --> Vol1
|
||||
SDK --> Vol2
|
||||
RPC --> |coingecko_api_key| COINGECKO
|
||||
RPC --> |alchemy_token| EVM
|
||||
RPC --> |infura_token| INFURA
|
||||
HTTP --> |alchemy_token| ALCHEMY
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
+43
-24
@@ -101,7 +101,7 @@ class Account:
|
||||
# In case if there is a hanging logged in session
|
||||
self.logout()
|
||||
|
||||
def login(self, password: str, key_uid: Optional[str] = None, display_name: Optional[str] = None, mnemonic: Optional[str] = None, alchemy_token: Optional[str] = None, coingecko_api_key: Optional[str] = None):
|
||||
def login(self, password: str, key_uid: Optional[str] = None, display_name: Optional[str] = None, mnemonic: Optional[str] = None, infura_token: Optional[str] = None, alchemy_token: Optional[str] = None, coingecko_api_key: Optional[str] = None):
|
||||
"""
|
||||
Login to the given account. If it does not exist,
|
||||
it will be created and automatically logged in.
|
||||
@@ -111,6 +111,7 @@ class Account:
|
||||
- `key_uid` - your key unique identifier. If not provided `display_name` will be used to fetch it. This means that each `display_name` can be linked to one `key_uid`
|
||||
- `display_name` - your Status display name. Use `display_name` and `password` parameter combination if you have a 1 to 1 mapping (each display name has a unique `key_uid`)
|
||||
- `mnemonic` - the mnemonic when creating an account. Use this field with `password` and `display_name` to recover an account
|
||||
- `infura_token` - https://www.infura.io/ RPC token to allow Status Backend to use a wallet
|
||||
- `alchemy_token` - https://alchemy.com/ RPC token to allow Status Backend to use a wallet
|
||||
- `coingecko_api_key` - https://www.coingecko.com/ API key to allow Status Backend to use a wallet
|
||||
"""
|
||||
@@ -168,15 +169,24 @@ class Account:
|
||||
|
||||
self.logout()
|
||||
|
||||
# Wallet usage
|
||||
# Wallet usage is broken down into 3 components:
|
||||
# - transactions
|
||||
# - prices
|
||||
# - Ethereum RPC
|
||||
|
||||
# Necessary for user transactions
|
||||
if alchemy_token:
|
||||
params["infuraToken"] = alchemy_token
|
||||
self.__alchemy_token = alchemy_token
|
||||
|
||||
# Necessary for prices
|
||||
if coingecko_api_key:
|
||||
params["coingeckoApiKey"] = coingecko_api_key
|
||||
params["coingeckoDemoAPIKey"] = coingecko_api_key
|
||||
|
||||
if alchemy_token and coingecko_api_key:
|
||||
# Necessary for Ethereum RPC
|
||||
if infura_token:
|
||||
params["infuraToken"] = infura_token
|
||||
|
||||
if alchemy_token and coingecko_api_key and infura_token:
|
||||
self.__is_wallet_set = True
|
||||
|
||||
url = self.__urls["http"][url_key]
|
||||
@@ -624,22 +634,27 @@ class Account:
|
||||
|
||||
balance = self.balance
|
||||
tokens = (balance["chain_id"].astype(str) + "-" + balance["address"]).to_list()
|
||||
|
||||
result = self.__call_rpc("wallets", "fetchPrices", [tokens, [ccy]]).get("result", {})
|
||||
if not result:
|
||||
return pd.DataFrame()
|
||||
rates = pd.DataFrame([
|
||||
{
|
||||
"chain_id": int(address.split("-")[0]),
|
||||
"address": address.split("-")[1],
|
||||
"rate": price,
|
||||
"ccy": ccy,
|
||||
}
|
||||
for address, prices in result.items()
|
||||
for ccy, price in prices.items()
|
||||
])
|
||||
balance = balance.merge(rates, "left", ["chain_id", "address"])
|
||||
balance["fiat_value"] = balance["amount"] * balance["rate"]
|
||||
if result:
|
||||
rates = pd.DataFrame([
|
||||
{
|
||||
"chain_id": int(address.split("-")[0]),
|
||||
"address": address.split("-")[1],
|
||||
"rate": price,
|
||||
"ccy": ccy,
|
||||
}
|
||||
for address, prices in result.items()
|
||||
for ccy, price in prices.items()
|
||||
])
|
||||
balance = balance.merge(rates, "left", ["chain_id", "address"])
|
||||
balance["fiat_value"] = balance["amount"] * balance["rate"]
|
||||
else:
|
||||
balance = balance.assign(
|
||||
rate = None,
|
||||
ccy = ccy,
|
||||
fiat_value = None,
|
||||
)
|
||||
|
||||
return balance.copy()
|
||||
|
||||
def send_message(self, chat_id: str, message: str):
|
||||
@@ -1074,8 +1089,7 @@ class Account:
|
||||
|
||||
def get_transactions(self, refresh: bool = False) -> pd.DataFrame:
|
||||
"""
|
||||
Get wallet transactions from all Alchemy chains. To fetch wallet transactions, make sure you pass
|
||||
a `etherscan_api_key` when calling `def login`.
|
||||
Get wallet transactions from all Alchemy chains.
|
||||
|
||||
Parameters:
|
||||
- `refresh` - if `True` then the data will be refetched from scratch. If `False` then the data will be cached after the first call.
|
||||
@@ -1083,7 +1097,7 @@ class Account:
|
||||
Output:
|
||||
- Wallet's transactions
|
||||
"""
|
||||
if not self.__is_wallet_set:
|
||||
if not self.__alchemy_token:
|
||||
raise exceptions.WalletNotConfiguredError()
|
||||
|
||||
if not refresh and isinstance(self.__transactions, pd.DataFrame):
|
||||
@@ -1116,9 +1130,14 @@ class Account:
|
||||
transfers += current_transfers
|
||||
page_key = result.get("pageKey")
|
||||
|
||||
if len(transfers) == 0:
|
||||
continue
|
||||
transfers = pd.DataFrame(transfers).assign(chain_id = chain_id)
|
||||
final.append(transfers)
|
||||
|
||||
if len(final) == 0:
|
||||
return pd.DataFrame()
|
||||
|
||||
final: pd.DataFrame = pd.concat(final, ignore_index=True)
|
||||
columns = {
|
||||
"blockNum": "block_number",
|
||||
@@ -1128,6 +1147,7 @@ class Account:
|
||||
"value": "amount",
|
||||
"asset": "symbol",
|
||||
"category": "trx_type",
|
||||
"chain_id": "chain_id"
|
||||
}
|
||||
final = final[list(columns.keys())].rename(columns=columns)
|
||||
|
||||
@@ -1214,7 +1234,6 @@ class Account:
|
||||
os.remove(sdk_file_path)
|
||||
|
||||
if len(error) == 0:
|
||||
self.__signal.get("messages.new")
|
||||
self.logger.info(f"Successfully loaded file!")
|
||||
else:
|
||||
self.logger.warning(error)
|
||||
|
||||
+5
-3
@@ -1,3 +1,5 @@
|
||||
from typing import Optional
|
||||
|
||||
class BackendError(Exception):
|
||||
pass
|
||||
|
||||
@@ -6,14 +8,14 @@ class NotLoggedInError(Exception):
|
||||
super().__init__("Make sure you are logged in to your Status account with login() first...")
|
||||
|
||||
class WalletNotConfiguredError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("Cannot use this method without setting an `alchemy_token` and `coingecko_api_key` when calling `login`.")
|
||||
def __init__(self, msg: Optional[str] = None):
|
||||
super().__init__(msg or "Cannot use this method without setting an `infura_token` and `coingecko_api_key` when calling `login`.")
|
||||
|
||||
class InvalidDisplayNameError(ValueError):
|
||||
pass
|
||||
|
||||
class InvalidContactError(ValueError):
|
||||
def __init__(self, msg=None):
|
||||
def __init__(self, msg: Optional[str] = None):
|
||||
super().__init__(msg or "Please provide either a Key Unique Identifier (key_uid) or a Display Name (display_name)...")
|
||||
|
||||
class InvalidCurrencyError(Exception):
|
||||
|
||||
+27
-12
@@ -6,7 +6,7 @@ The account class allows you to easily work with a Status account.
|
||||
|
||||
## Display name
|
||||
|
||||
The **display name** is the human‑readable identifier for a Status account. It is used when creating an account, resolving an existing account during [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-alchemy_tokennone-coingecko_api_keynone), and when updating the account name through the [`display_name`](./account.md#display_name) property.
|
||||
The **display name** is the human‑readable identifier for a Status account. It is used when creating an account, resolving an existing account during [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone), and when updating the account name through the [`display_name`](./account.md#display_name) property.
|
||||
|
||||
Display names must follow strict validation rules enforced by the library and expected by the Status application. A valid display name must satisfy all of the following conditions:
|
||||
|
||||
@@ -50,7 +50,7 @@ Backup files (`.bkp`) can be both created in [Status App](https://our.status.im/
|
||||
|
||||
[Status Backend](https://github.com/status-im/status-go) backup folder is exposed in a Docker volume so users can:
|
||||
|
||||
- **Upload backup** - by dropping `.bkp` files in the `backups` folder locally (linked to Status Backend Docker container). Backups are automatically uploaded if a [`mnemonic` is provided during `login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-alchemy_tokennone-coingecko_api_keynone).
|
||||
- **Upload backup** - by dropping `.bkp` files in the `backups` folder locally (linked to Status Backend Docker container). Backups are automatically uploaded if a [`mnemonic` is provided during `login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone).
|
||||
- **Create backup** - by using [`backup()`](./account.md#backup) or creating one in [Status App](https://our.status.im/status-desktop-v2-35-local-backups-new-home-page-performance-boosts-and-more/).
|
||||
|
||||
**Note**: Status App will not automatically backup messages. This has to be manually overridden on the app (above screenshot). When using the Python SDK, the messages are automatically stored in the `.bkp` files.
|
||||
@@ -95,9 +95,7 @@ Create a new `Account` instance ready to be logged in. The constructor wires the
|
||||
| `is_secure` | `bool` | No | When `True`, the SDK communicates over `https`; otherwise `http` is used. Defaults to `False`. |
|
||||
| `backup_folder` | `str` | No | Absolute path on the host machine where `.bkp` files will be stored and loaded from. If not provided, the SDK's own `backups/` folder is used. See [Backups](./account.md#backups). |
|
||||
|
||||
The constructor does not log into any account on its own - call [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-alchemy_tokennone-coingecko_api_keynone) afterwards. To discover what accounts already exist in the configured data directory, use the [`available_accounts`](./account.md#available_accounts) property, which is also populated automatically during initialization.
|
||||
|
||||
Default setup (localhost, port 8080, http):
|
||||
The constructor does not log into any account on its own - call [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) afterwards. To discover what accounts already exist in the configured data directory, use the [`available_accounts`](./account.md#available_accounts) property, which is also populated automatically during initialization.
|
||||
|
||||
```python
|
||||
from bot import Account
|
||||
@@ -125,13 +123,13 @@ account = Account(
|
||||
)
|
||||
```
|
||||
|
||||
**Note**: Status Backend must be running before initializing `Account`. You can launch the backend container with [`launch_docker_container`](./utils.md#launch_docker_container). If the backend is not reachable on `domain:port`, calls to [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-alchemy_tokennone-coingecko_api_keynone) will fail.
|
||||
**Note**: Status Backend must be running before initializing `Account`. You can launch the backend container with [`launch_docker_container`](./utils.md#launch_docker_container). If the backend is not reachable on `domain:port`, calls to [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) will fail.
|
||||
|
||||
**Note**: When `backup_folder` is set, [`backup`](./account.md#backup) moves the generated `.bkp` file out of the SDK's internal `backups/` folder into the provided path, and recovery via `mnemonic` will look in this same folder for `.bkp` files to auto-load. Make sure the folder exists and is writable.
|
||||
|
||||
## Methods
|
||||
|
||||
### `login(password, key_uid=None, display_name=None, mnemonic=None, alchemy_token=None, coingecko_api_key=None)`
|
||||
### `login(password, key_uid=None, display_name=None, mnemonic=None, infura_token=None, alchemy_token=None, coingecko_api_key=None)`
|
||||
|
||||
Login to an existing Status account. If the account does not exist in the initialized data directory, a new account will be created and automatically logged in.
|
||||
|
||||
@@ -147,8 +145,11 @@ An account can also be recovered if the [`mnemonic`](https://status.app/help/pro
|
||||
| `key_uid` | `str` | Yes* | Unique key identifier of the account. If provided, the account will be logged in directly using this identifier. If not provided, then you must use `display_name` and `password` to login. |
|
||||
| `display_name` | `str` | Yes* | Display name of the account. Used to resolve the `key_uid` if it is not provided, or to create a new account if one does not already exist. This field is required if an account needs to be recovered with `mnemonic`. |
|
||||
| `mnemonic` | `str` | No | The [mnemonic](https://status.app/help/profile/understand-your-status-keys-and-recovery-phrase#about-your-recovery-phrase) from [`info`](./account.md#info). Use this field with `password` and `display_name` to recover the account. If you have [`.bkp`](./account.md#backup) files, in the backup Docker volume they will be automatically picked up and loaded.<br><br>**Note**: You can pass a different `display_name` but that will be internal only. When an account is recovered setting [`display_name`](./account.md#display_name) can be buggy. Ideally when recovering the account, use the original `display_name` of the account. |
|
||||
| `alchemy_token` | `str` | No | [API token](https://www.alchemy.com/) to allow Status Backend to use a wallet. Also used by [`get_transactions`](./account.md#get_transactionsrefreshfalse) to fetch wallet transaction history via the Alchemy REST API, so no separate key is needed for transactions. |
|
||||
| `coingecko_api_key` | `str` | No | [API token](https://www.coingecko.com/) to allow Status Backend to use a wallet. |
|
||||
| `infura_token` | `str` | No | [RPC token](https://www.infura.io/) used by Status Backend for the Ethereum RPC component of the wallet. |
|
||||
| `alchemy_token` | `str` | No | Used to fetch [wallet transactions](./account.md#get_transactionsrefreshfalse) to fetch wallet transaction history via the Alchemy REST API, so no separate key is needed for transactions. |
|
||||
| `coingecko_api_key` | `str` | No | [API key](https://www.coingecko.com/) used by Status Backend to fetch token prices. |
|
||||
|
||||
Wallet functionality is split into three components, each backed by a token: Ethereum RPC (`infura_token`), transactions (`alchemy_token`) and prices (`coingecko_api_key`). All three must be provided for wallet RPC methods to work — if any is missing, wallet calls raise a `WalletNotConfiguredError`.
|
||||
|
||||
Returns the current `Account` instance, allowing method chaining.
|
||||
|
||||
@@ -213,13 +214,14 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
account.login(**params)
|
||||
```
|
||||
|
||||
**Note**: `alchemy_token` and `coingecko_api_key` can be used when creating, recovering and logging in to an account. `alchemy_token` covers both the wallet RPC and transaction history via [`get_transactions`](./account.md#get_transactionsrefreshfalse), so no additional key is needed.
|
||||
**Note**: `infura_token`, `alchemy_token` and `coingecko_api_key` can be used when creating, recovering and logging in to an account. All three are required to enable wallet functionality — if any is missing, those calls raise a `WalletNotConfiguredError`.
|
||||
|
||||
### `logout()`
|
||||
|
||||
@@ -491,6 +493,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -523,6 +526,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -549,6 +553,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -576,6 +581,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -607,6 +613,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -653,7 +660,7 @@ Returns `pd.DataFrame`.
|
||||
|
||||
#### `get_transactions(refresh=False)`
|
||||
|
||||
Retrieve the historical transactions for the **logged-in account wallet** across all chains in [`chains`](./account.md#chains). Data is fetched from the [Alchemy REST API](https://www.alchemy.com/) using the `alchemy_token` provided during [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-alchemy_tokennone-coingecko_api_keynone) and combines three transaction types into a single `DataFrame`: regular transactions (`transaction`), internal transactions (`internal`) and ERC-20 token transfers (`ERC-20`).
|
||||
Retrieve the historical transactions for the **logged-in account wallet** across all chains in [`chains`](./account.md#chains). Data is fetched from the [Alchemy REST API](https://www.alchemy.com/) using the `alchemy_token` provided during [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone) and combines three transaction types into a single `DataFrame`: regular transactions (`transaction`), internal transactions (`internal`) and ERC-20 token transfers (`ERC-20`).
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|-----|-----|-----|-------------|
|
||||
@@ -685,6 +692,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -723,6 +731,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -748,6 +757,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -772,6 +782,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -797,6 +808,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -812,7 +824,7 @@ tx_hash = account.send_transaction(
|
||||
)
|
||||
```
|
||||
|
||||
**Note**: This is a wallet method, so it requires both `alchemy_token` and `coingecko_api_key` to be provided in [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-alchemy_tokennone-coingecko_api_keynone). If either is missing, an exception will be raised when this method is called.
|
||||
**Note**: This is a wallet method, so it requires `infura_token`, `alchemy_token` and `coingecko_api_key` to all be provided in [`login`](./account.md#loginpassword-key_uidnone-display_namenone-mnemonicnone-infura_tokennone-alchemy_tokennone-coingecko_api_keynone). If any is missing, a `WalletNotConfiguredError` is raised when this method is called.
|
||||
|
||||
**Note**: The sender and receiver must be on the **same chain**. Cross-chain transfers are not supported by this method - set `chain_id` to the chain where the funds currently exist.
|
||||
|
||||
@@ -1259,6 +1271,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -1288,6 +1301,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
@@ -1305,6 +1319,7 @@ account = Account()
|
||||
params = {
|
||||
"display_name": "status-app-bot",
|
||||
"password": "SNTPUMP",
|
||||
"infura_token": "token from https://www.infura.io/",
|
||||
"alchemy_token": "token from https://www.alchemy.com/",
|
||||
"coingecko_api_key": "API key from https://www.coingecko.com/"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user