Merge branch 'main' into alpha

pull/317/head^2
Neeraj Kashyap 2021-09-15 10:13:56 -07:00
commit 9f1424a2b6
41 zmienionych plików z 1695 dodań i 362 usunięć

Wyświetl plik

@ -71,6 +71,13 @@ This CLI is configured to work with the following API URLs:
type=str,
help="Detailed description of the subscription type",
)
parser_subscription_types_create.add_argument(
"-c",
"--choices",
nargs="*",
help="Available subscription options for from builder.",
required=True,
)
parser_subscription_types_create.add_argument(
"--icon",
required=True,
@ -146,6 +153,13 @@ This CLI is configured to work with the following API URLs:
type=str,
help="Detailed description of the subscription type",
)
parser_subscription_types_update.add_argument(
"-c",
"--choices",
nargs="*",
help="Available subscription options for form builder.",
required=False,
)
parser_subscription_types_update.add_argument(
"--icon",
required=False,

Wyświetl plik

@ -20,6 +20,7 @@ CANONICAL_SUBSCRIPTION_TYPES = {
"ethereum_blockchain": SubscriptionTypeResourceData(
id="ethereum_blockchain",
name="Ethereum transactions",
choices=["input:address", "tag:erc721"],
description="Transactions that have been mined into the Ethereum blockchain",
icon_url="https://s3.amazonaws.com/static.simiotics.com/moonstream/assets/ethereum/eth-diamond-purple.png",
stripe_product_id=None,
@ -30,6 +31,7 @@ CANONICAL_SUBSCRIPTION_TYPES = {
id="ethereum_whalewatch",
name="Ethereum whale watch",
description="Ethereum accounts that have experienced a lot of recent activity",
choices=[],
# Icon taken from: https://www.maxpixel.net/Whale-Cetacean-Wildlife-Symbol-Ocean-Sea-Black-99310
icon_url="https://s3.amazonaws.com/static.simiotics.com/moonstream/assets/whalewatch.png",
stripe_product_id=None,
@ -40,6 +42,7 @@ CANONICAL_SUBSCRIPTION_TYPES = {
id="ethereum_txpool",
name="Ethereum transaction pool",
description="Transactions that have been submitted into the Ethereum transaction pool but not necessarily mined yet",
choices=["input:address", "tag:erc721"],
icon_url="https://s3.amazonaws.com/static.simiotics.com/moonstream/assets/ethereum/eth-diamond-rainbow.png",
stripe_product_id=None,
stripe_price_id=None,
@ -74,6 +77,7 @@ def create_subscription_type(
name: str,
description: str,
icon_url: str,
choices: List[str] = [],
stripe_product_id: Optional[str] = None,
stripe_price_id: Optional[str] = None,
active: bool = False,
@ -111,6 +115,7 @@ def create_subscription_type(
"id": id,
"name": name,
"description": description,
"choices": choices,
"icon_url": icon_url,
"stripe_product_id": stripe_product_id,
"stripe_price_id": stripe_price_id,
@ -135,6 +140,7 @@ def cli_create_subscription_type(args: argparse.Namespace) -> None:
args.name,
args.description,
args.icon,
args.choices,
args.stripe_product_id,
args.stripe_price_id,
args.active,
@ -220,6 +226,7 @@ def update_subscription_type(
id: str,
name: Optional[str] = None,
description: Optional[str] = None,
choices: Optional[List[str]] = None,
icon_url: Optional[str] = None,
stripe_product_id: Optional[str] = None,
stripe_price_id: Optional[str] = None,
@ -254,6 +261,8 @@ def update_subscription_type(
updated_resource_data["name"] = name
if description is not None:
updated_resource_data["description"] = description
if choices is not None:
updated_resource_data["choices"] = choices
if icon_url is not None:
updated_resource_data["icon_url"] = icon_url
if stripe_product_id is not None:
@ -266,19 +275,15 @@ def update_subscription_type(
# TODO(zomglings): This was written with an outdated bugout-python client.
# New client has an update_resource method which is what we should be using
# here.
new_resource = bc.create_resource(
token=MOONSTREAM_ADMIN_ACCESS_TOKEN,
application_id=MOONSTREAM_APPLICATION_ID,
resource_data=updated_resource_data,
timeout=BUGOUT_REQUEST_TIMEOUT_SECONDS,
)
try:
bc.delete_resource(
new_resource = bc.update_resource(
token=MOONSTREAM_ADMIN_ACCESS_TOKEN,
resource_id=brood_resource_id,
resource_data={"update": updated_resource_data},
timeout=BUGOUT_REQUEST_TIMEOUT_SECONDS,
)
except Exception as e:
raise ConflictingSubscriptionTypesError(
f"Unable to delete old subscription type with ID: {id}. Error:\n{repr(e)}"
@ -295,6 +300,7 @@ def cli_update_subscription_type(args: argparse.Namespace) -> None:
args.id,
args.name,
args.description,
args.choices,
args.icon,
args.stripe_product_id,
args.stripe_price_id,
@ -366,6 +372,20 @@ def ensure_canonical_subscription_types() -> BugoutResources:
canonical_subscription_type.name,
canonical_subscription_type.description,
canonical_subscription_type.icon_url,
canonical_subscription_type.choices,
canonical_subscription_type.stripe_product_id,
canonical_subscription_type.stripe_price_id,
canonical_subscription_type.active,
)
existing_canonical_subscription_types[id] = resource
else:
canonical_subscription_type = CANONICAL_SUBSCRIPTION_TYPES[id]
resource = update_subscription_type(
id,
canonical_subscription_type.name,
canonical_subscription_type.description,
canonical_subscription_type.choices,
canonical_subscription_type.icon_url,
canonical_subscription_type.stripe_product_id,
canonical_subscription_type.stripe_price_id,
canonical_subscription_type.active,

Wyświetl plik

@ -4,6 +4,7 @@ Pydantic schemas for the Moonstream HTTP API
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
USER_ONBOARDING_STATE = "onboarding_state"
@ -12,6 +13,7 @@ class SubscriptionTypeResourceData(BaseModel):
id: str
name: str
description: str
choices: List[str] = Field(default_factory=list)
icon_url: str
stripe_product_id: Optional[str] = None
stripe_price_id: Optional[str] = None
@ -29,6 +31,8 @@ class SubscriptionResourceData(BaseModel):
label: Optional[str]
user_id: str
subscription_type_id: str
created_at: datetime
updated_at: datetime
class CreateSubscriptionRequest(BaseModel):

Wyświetl plik

@ -296,9 +296,9 @@ class EthereumTXPoolProvider(BugoutEventProvider):
for subscription in relevant_subscriptions
]
subscriptions_filters = []
for adress in addresses:
for address in addresses:
subscriptions_filters.extend(
[f"?#from_address:{adress}", f"?#to_address:{adress}"]
[f"?#from_address:{address}", f"?#to_address:{address}"]
)
return subscriptions_filters

Wyświetl plik

@ -174,7 +174,8 @@ def query_ethereum_transactions(
EthereumTransaction.value,
EthereumBlock.timestamp.label("timestamp"),
).join(
EthereumBlock, EthereumTransaction.block_number == EthereumBlock.block_number
EthereumBlock,
EthereumTransaction.block_number == EthereumBlock.block_number,
)
if stream_boundary.include_start:

Wyświetl plik

@ -113,6 +113,8 @@ async def add_subscription_handler(
color=resource.resource_data["color"],
label=resource.resource_data["label"],
subscription_type_id=resource.resource_data["subscription_type_id"],
updated_at=resource.updated_at,
created_at=resource.created_at,
)
@ -141,6 +143,8 @@ async def delete_subscription_handler(request: Request, subscription_id: str):
color=deleted_resource.resource_data["color"],
label=deleted_resource.resource_data["label"],
subscription_type_id=deleted_resource.resource_data["subscription_type_id"],
updated_at=deleted_resource.updated_at,
created_at=deleted_resource.created_at,
)
@ -174,6 +178,8 @@ async def get_subscriptions_handler(request: Request) -> data.SubscriptionsListR
color=resource.resource_data["color"],
label=resource.resource_data["label"],
subscription_type_id=resource.resource_data["subscription_type_id"],
updated_at=resource.updated_at,
created_at=resource.created_at,
)
for resource in resources.resources
]
@ -225,6 +231,8 @@ async def update_subscriptions_handler(
color=resource.resource_data["color"],
label=resource.resource_data["label"],
subscription_type_id=resource.resource_data["subscription_type_id"],
updated_at=resource.updated_at,
created_at=resource.created_at,
)

Wyświetl plik

@ -12,7 +12,7 @@ h11==0.12.0
idna==3.2
jmespath==0.10.0
humbug==0.2.7
-e git+https://git@github.com/bugout-dev/moonstream.git@94135b054cabb9dc11b0a2406431619279979469#egg=moonstreamdb&subdirectory=db
-e git+https://git@github.com/bugout-dev/moonstream.git@a4fff6498f66789934d4af26fd42a8cfb6e5eed5#egg=moonstreamdb&subdirectory=db
mypy==0.910
mypy-extensions==0.4.3
pathspec==0.9.0

Wyświetl plik

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Deployment script - intended to run on Moonstream crawlers server
# Main
AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
APP_DIR="${APP_DIR:-/home/ubuntu/moonstream}"
APP_CRAWLERS_DIR="${APP_DIR}/crawlers"
PYTHON_ENV_DIR="${PYTHON_ENV_DIR:-/home/ubuntu/moonstream-env}"
PYTHON="${PYTHON_ENV_DIR}/bin/python"
PIP="${PYTHON_ENV_DIR}/bin/pip"
SECRETS_DIR="${SECRETS_DIR:-/home/ubuntu/moonstream-secrets}"
PARAMETERS_ENV_PATH="${SECRETS_DIR}/app.env"
AWS_SSM_PARAMETER_PATH="${AWS_SSM_PARAMETER_PATH:-/moonstream/prod}"
SCRIPT_DIR="$(realpath $(dirname $0))"
PARAMETERS_SCRIPT="${SCRIPT_DIR}/parameters.py"
ETHEREUM_TRENDING_SERVICE="ethereum-trending.service"
ETHEREUM_TRENDING_TIMER="ethereum-trending.service"
ETHEREUM_TXPOOL_SERVICE="ethereum-txpool.service"
set -eu
echo
echo
echo "Building executable Ethereum transaction pool crawler script with Go"
HOME=/root /usr/local/go/bin/go build -o "${APP_CRAWLERS_DIR}/ethtxpool" "${APP_CRAWLERS_DIR}/main.go"
echo
echo
echo "Updating Python dependencies"
"${PIP}" install --upgrade pip
"${PIP}" install -r "${APP_CRAWLERS_DIR}/mooncrawl/requirements.txt"
echo
echo
echo "Retrieving deployment parameters"
mkdir -p "${SECRETS_DIR}"
AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION}" "${PYTHON}" "${PARAMETERS_SCRIPT}" extract -p "${AWS_SSM_PARAMETER_PATH}" -o "${PARAMETERS_ENV_PATH}"
echo
echo
echo "Replacing existing Ethereum trending service and timer with: ${ETHEREUM_TRENDING_SERVICE}, ${ETHEREUM_TRENDING_TIMER}"
chmod 644 "${SCRIPT_DIR}/${ETHEREUM_TRENDING_SERVICE}" "${SCRIPT_DIR}/${ETHEREUM_TRENDING_TIMER}"
cp "${SCRIPT_DIR}/${ETHEREUM_TRENDING_SERVICE}" "/etc/systemd/system/${ETHEREUM_TRENDING_SERVICE}"
cp "${SCRIPT_DIR}/${ETHEREUM_TRENDING_TIMER}" "/etc/systemd/system/${ETHEREUM_TRENDING_TIMER}"
systemctl daemon-reload
systemctl restart "${ETHEREUM_TRENDING_TIMER}"
echo
echo
echo "Replacing existing Ethereum transaction pool crawler service definition with ${ETHEREUM_TXPOOL_SERVICE}"
chmod 644 "${SCRIPT_DIR}/${ETHEREUM_TXPOOL_SERVICE}"
cp "${SCRIPT_DIR}/${ETHEREUM_TXPOOL_SERVICE}" "/etc/systemd/system/${ETHEREUM_TXPOOL_SERVICE}"
systemctl daemon-reload
systemctl restart "${ETHEREUM_TXPOOL_SERVICE}"

Wyświetl plik

@ -0,0 +1,13 @@
[Unit]
Description=Ethereum txpool crawler
After=network.target
[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/moonstream/crawlers/ethtxpool
ExecStart=/home/ubuntu/moonstream/crawlers/ethtxpool/ethtxpool
SyslogIdentifier=ethereum-txpool
[Install]
WantedBy=multi-user.target

Wyświetl plik

@ -0,0 +1,102 @@
"""
Collect secrets from AWS SSM Parameter Store and output as environment variable exports.
"""
import argparse
from dataclasses import dataclass
import sys
from typing import Any, Dict, Iterable, List, Optional
import boto3
@dataclass
class EnvironmentVariable:
name: str
value: str
def get_parameters(path: str) -> List[Dict[str, Any]]:
"""
Retrieve parameters from AWS SSM Parameter Store. Decrypts any encrypted parameters.
Relies on the appropriate environment variables to authenticate against AWS:
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html
"""
ssm = boto3.client("ssm")
next_token: Optional[bool] = True
parameters: List[Dict[str, Any]] = []
while next_token is not None:
kwargs = {"Path": path, "Recursive": False, "WithDecryption": True}
if next_token is not True:
kwargs["NextToken"] = next_token
response = ssm.get_parameters_by_path(**kwargs)
new_parameters = response.get("Parameters", [])
parameters.extend(new_parameters)
next_token = response.get("NextToken")
return parameters
def parameter_to_env(parameter_object: Dict[str, Any]) -> EnvironmentVariable:
"""
Transforms parameters returned by the AWS SSM API into EnvironmentVariables.
"""
parameter_path = parameter_object.get("Name")
if parameter_path is None:
raise ValueError('Did not find "Name" in parameter object')
name = parameter_path.split("/")[-1].upper()
value = parameter_object.get("Value")
if value is None:
raise ValueError('Did not find "Value" in parameter object')
return EnvironmentVariable(name, value)
def env_string(env_vars: Iterable[EnvironmentVariable], with_export: bool) -> str:
"""
Produces a string which, when executed in a shell, exports the desired environment variables as
specified by env_vars.
"""
prefix = "export " if with_export else ""
return "\n".join([f'{prefix}{var.name}="{var.value}"' for var in env_vars])
def extract_handler(args: argparse.Namespace) -> None:
"""
Save environment variables to file.
"""
result = env_string(map(parameter_to_env, get_parameters(args.path)), args.export)
with args.outfile as ofp:
print(result, file=ofp)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Materialize environment variables from AWS SSM Parameter Store"
)
parser.set_defaults(func=lambda _: parser.print_help())
subcommands = parser.add_subparsers(description="Parameters commands")
parser_extract = subcommands.add_parser(
"extract", description="Parameters extract commands"
)
parser_extract.set_defaults(func=lambda _: parser_extract.print_help())
parser_extract.add_argument(
"-o", "--outfile", type=argparse.FileType("w"), default=sys.stdout
)
parser_extract.add_argument(
"--export",
action="store_true",
help="Set to output environment strings with export statements",
)
parser_extract.add_argument(
"-p",
"--path",
default=None,
help="SSM path from which to pull environment variables (pull is NOT recursive)",
)
parser_extract.set_defaults(func=extract_handler)
args = parser.parse_args()
args.func(args)

2
crawlers/mooncrawl/.gitignore vendored 100644
Wyświetl plik

@ -0,0 +1,2 @@
.venv/
.mooncrawl/

Wyświetl plik

@ -22,7 +22,7 @@ from .ethereum import (
trending,
)
from .publish import publish_json
from .settings import MOONSTREAM_CRAWL_WORKERS
from .settings import MOONSTREAM_CRAWL_WORKERS, MOONSTREAM_IPC_PATH
from .version import MOONCRAWL_VERSION
@ -92,7 +92,7 @@ def ethcrawler_blocks_sync_handler(args: argparse.Namespace) -> None:
while True:
bottom_block_number, top_block_number = get_latest_blocks(args.confirmations)
if bottom_block_number is None:
raise ValueError("Variable bottom_block_number can't be None")
bottom_block_number = 0
bottom_block_number = max(bottom_block_number + 1, starting_block)
if bottom_block_number >= top_block_number:
print(

Wyświetl plik

@ -1,7 +1,6 @@
from concurrent.futures import Future, ProcessPoolExecutor, wait
from dataclasses import dataclass
from datetime import datetime
from os import close
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from sqlalchemy import desc, Column

Wyświetl plik

@ -0,0 +1,226 @@
"""
A command line tool to crawl information about NFTs from various sources.
"""
import argparse
from datetime import datetime, timedelta, timezone
import json
import os
import sys
import time
from typing import Any, Dict, cast
import dateutil.parser
from moonstreamdb.db import yield_db_session_ctx
from moonstreamdb.models import EthereumBlock
from sqlalchemy.orm.session import Session
from web3 import Web3
from ..ethereum import connect
from .ethereum import summary as ethereum_summary, add_labels
from ..publish import publish_json
from ..settings import MOONSTREAM_IPC_PATH
from ..version import MOONCRAWL_VERSION
def web3_client_from_cli_or_env(args: argparse.Namespace) -> Web3:
"""
Returns a web3 client either by parsing "--web3" argument on the given arguments or by looking up
the MOONSTREAM_IPC_PATH environment variable.
"""
web3_connection_string = MOONSTREAM_IPC_PATH
args_web3 = vars(args).get("web3")
if args_web3 is not None:
web3_connection_string = cast(str, args_web3)
if web3_connection_string is None:
raise ValueError(
"Could not find Web3 connection information in arguments or in MOONSTREAM_IPC_PATH environment variable"
)
return connect(web3_connection_string)
def get_latest_block_from_db(db_session: Session):
return (
db_session.query(EthereumBlock)
.order_by(EthereumBlock.timestamp.desc())
.limit(1)
.one()
)
def ethereum_sync_handler(args: argparse.Namespace) -> None:
web3_client = web3_client_from_cli_or_env(args)
humbug_token = os.environ.get("MOONSTREAM_HUMBUG_TOKEN")
if humbug_token is None:
raise ValueError("MOONSTREAM_HUMBUG_TOKEN env variable is not set")
with yield_db_session_ctx() as db_session:
start = args.start
if start is None:
time_now = datetime.now(timezone.utc)
week_ago = time_now - timedelta(weeks=1)
start = (
db_session.query(EthereumBlock)
.filter(EthereumBlock.timestamp >= week_ago.timestamp())
.order_by(EthereumBlock.timestamp.asc())
.limit(1)
.one()
).block_number
latest_block = get_latest_block_from_db(db_session)
end = latest_block.block_number
assert (
start <= end
), f"Start block {start} is greater than latest_block {end} in db"
while True:
print(f"Labeling blocks {start}-{end}")
add_labels(web3_client, db_session, start, end)
end_time = datetime.fromtimestamp(latest_block.timestamp, timezone.utc)
print(f"Creating summary with endtime={end_time}")
result = ethereum_summary(db_session, end_time)
push_summary(result, end, humbug_token)
sleep_time = 60 * 60
print(f"Going to sleep for:{sleep_time}s")
time.sleep(sleep_time)
start = end + 1
latest_block = get_latest_block_from_db(db_session)
end = latest_block.block_number
def ethereum_label_handler(args: argparse.Namespace) -> None:
web3_client = web3_client_from_cli_or_env(args)
with yield_db_session_ctx() as db_session:
add_labels(web3_client, db_session, args.start, args.end, args.address)
def push_summary(result: Dict[str, Any], end_block_no: int, humbug_token: str):
title = f"NFT activity on the Ethereum blockchain: end time: {result['crawled_at'] } (block {end_block_no})"
publish_json(
"nft_ethereum",
humbug_token,
title,
result,
tags=[f"crawler_version:{MOONCRAWL_VERSION}", f"end_block:{end_block_no}"],
wait=False,
)
def ethereum_summary_handler(args: argparse.Namespace) -> None:
with yield_db_session_ctx() as db_session:
result = ethereum_summary(db_session, args.end)
# humbug_token = args.humbug
# if humbug_token is None:
# humbug_token = os.environ.get("MOONSTREAM_HUMBUG_TOKEN")
# if humbug_token:
# title = f"NFT activity on the Ethereum blockchain: {start_time} (block {start_block}) to {end_time} (block {end_block})"
# publish_json(
# "nft_ethereum",
# humbug_token,
# title,
# result,
# tags=[f"crawler_version:{MOONCRAWL_VERSION}"],
# wait=False,
# )
with args.outfile as ofp:
json.dump(result, ofp)
def main() -> None:
time_now = datetime.now(timezone.utc)
parser = argparse.ArgumentParser(description="Moonstream NFT crawlers")
parser.set_defaults(func=lambda _: parser.print_help())
subcommands = parser.add_subparsers(description="Subcommands")
parser_ethereum = subcommands.add_parser(
"ethereum",
description="Collect information about NFTs from Ethereum blockchains",
)
parser_ethereum.set_defaults(func=lambda _: parser_ethereum.print_help())
subparsers_ethereum = parser_ethereum.add_subparsers()
parser_ethereum_label = subparsers_ethereum.add_parser(
"label",
description="Label addresses and transactions in databse using crawled NFT transfer information",
)
parser_ethereum_label.add_argument(
"-s",
"--start",
type=int,
default=None,
help="Starting block number (inclusive if block available)",
)
parser_ethereum_label.add_argument(
"-e",
"--end",
type=int,
default=None,
help="Ending block number (inclusive if block available)",
)
parser_ethereum_label.add_argument(
"-a",
"--address",
type=str,
default=None,
help="(Optional) NFT contract address that you want to limit the crawl to, e.g. 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d for CryptoKitties.",
)
parser_ethereum_label.add_argument(
"--web3",
type=str,
default=None,
help="(Optional) Web3 connection string. If not provided, uses the value specified by MOONSTREAM_IPC_PATH environment variable.",
)
parser_ethereum_label.set_defaults(func=ethereum_label_handler)
parser_ethereum_summary = subparsers_ethereum.add_parser(
"summary", description="Generate Ethereum NFT summary"
)
parser_ethereum_summary.add_argument(
"-e",
"--end",
type=dateutil.parser.parse,
default=time_now.isoformat(),
help=f"End time for window to calculate NFT statistics (default: {time_now.isoformat()})",
)
parser_ethereum_summary.add_argument(
"--humbug",
default=None,
help=(
"If you would like to write this data to a Moonstream journal, please provide a Humbug "
"token for that here. (This argument overrides any value set in the "
"MOONSTREAM_HUMBUG_TOKEN environment variable)"
),
)
parser_ethereum_summary.add_argument(
"-o",
"--outfile",
type=argparse.FileType("w"),
default=sys.stdout,
help="Optional file to write output to. By default, prints to stdout.",
)
parser_ethereum_summary.set_defaults(func=ethereum_summary_handler)
parser_ethereum_sync = subparsers_ethereum.add_parser(
"sync",
description="Label addresses and transactions in databse using crawled NFT transfer information, sync mode",
)
parser_ethereum_sync.add_argument(
"-s",
"--start",
type=int,
required=False,
help="Starting block number (inclusive if block available)",
)
parser_ethereum_sync.set_defaults(func=ethereum_sync_handler)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()

Wyświetl plik

@ -0,0 +1,659 @@
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta
import json
import logging
from hexbytes.main import HexBytes
from typing import Any, cast, Dict, List, Optional, Set, Tuple
from eth_typing.encoding import HexStr
from moonstreamdb.models import (
EthereumAddress,
EthereumBlock,
EthereumLabel,
EthereumTransaction,
)
from sqlalchemy import and_, func, text
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session, Query
from tqdm import tqdm
from web3 import Web3
from web3.types import FilterParams, LogReceipt
from web3._utils.events import get_event_data
# Default length (in blocks) of an Ethereum NFT crawl.
DEFAULT_CRAWL_LENGTH = 100
NFT_LABEL = "erc721"
MINT_LABEL = "nft_mint"
TRANSFER_LABEL = "nft_transfer"
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# Summary keys
SUMMARY_KEY_BLOCKS = "blocks"
SUMMARY_KEY_NUM_TRANSACTIONS = "num_transactions"
SUMMARY_KEY_TOTAL_VALUE = "total_value"
SUMMARY_KEY_NFT_TRANSFERS = "nft_transfers"
SUMMARY_KEY_NFT_TRANSFER_VALUE = "nft_transfer_value"
SUMMARY_KEY_NFT_MINTS = "nft_mints"
SUMMARY_KEY_NFT_PURCHASERS = "nft_owners"
SUMMARY_KEY_NFT_MINTERS = "nft_minters"
SUMMARY_KEYS = [
SUMMARY_KEY_BLOCKS,
SUMMARY_KEY_NUM_TRANSACTIONS,
SUMMARY_KEY_TOTAL_VALUE,
SUMMARY_KEY_NFT_TRANSFERS,
SUMMARY_KEY_NFT_TRANSFER_VALUE,
SUMMARY_KEY_NFT_MINTS,
SUMMARY_KEY_NFT_PURCHASERS,
SUMMARY_KEY_NFT_MINTERS,
]
# First abi is for old NFT's like crypto kitties
# The erc721 standart requieres that Transfer event is indexed for all arguments
# That is how we get distinguished from erc20 transfer events
erc721_transfer_event_abis = [
{
"anonymous": False,
"inputs": [
{"indexed": False, "name": "from", "type": "address"},
{"indexed": False, "name": "to", "type": "address"},
{"indexed": False, "name": "tokenId", "type": "uint256"},
],
"name": "Transfer",
"type": "event",
},
{
"anonymous": False,
"inputs": [
{"indexed": True, "name": "from", "type": "address"},
{"indexed": True, "name": "to", "type": "address"},
{"indexed": True, "name": "tokenId", "type": "uint256"},
],
"name": "Transfer",
"type": "event",
},
]
erc721_functions_abi = [
{
"inputs": [{"internalType": "address", "name": "owner", "type": "address"}],
"name": "balanceOf",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"payable": False,
"stateMutability": "view",
"type": "function",
"constant": True,
},
{
"inputs": [],
"name": "name",
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
"stateMutability": "view",
"type": "function",
"constant": True,
},
{
"inputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}],
"name": "ownerOf",
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
"payable": False,
"stateMutability": "view",
"type": "function",
"constant": True,
},
{
"inputs": [],
"name": "symbol",
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
"stateMutability": "view",
"type": "function",
"constant": True,
},
{
"inputs": [],
"name": "totalSupply",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function",
"constant": True,
},
]
@dataclass
class NFTContract:
address: str
name: Optional[str] = None
symbol: Optional[str] = None
total_supply: Optional[str] = None
def get_erc721_contract_info(w3: Web3, address: str) -> NFTContract:
contract = w3.eth.contract(
address=w3.toChecksumAddress(address), abi=erc721_functions_abi
)
name: Optional[str] = None
try:
name = contract.functions.name().call()
except:
logger.error(f"Could not get name for potential NFT contract: {address}")
symbol: Optional[str] = None
try:
symbol = contract.functions.symbol().call()
except:
logger.error(f"Could not get symbol for potential NFT contract: {address}")
totalSupply: Optional[str] = None
try:
totalSupply = contract.functions.totalSupply().call()
except:
logger.error(f"Could not get totalSupply for potential NFT contract: {address}")
return NFTContract(
address=address, name=name, symbol=symbol, total_supply=totalSupply
)
# SHA3 hash of the string "Transfer(address,address,uint256)"
TRANSFER_EVENT_SIGNATURE = HexBytes(
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
)
@dataclass
class NFTTransferRaw:
contract_address: str
transfer_from: str
transfer_to: str
tokenId: int
transfer_tx: HexBytes
@dataclass
class NFTTransfer:
contract_address: str
transfer_from: str
transfer_to: str
tokenId: int
transfer_tx: str
value: Optional[int] = None
is_mint: bool = False
def get_value_by_tx(w3: Web3, tx_hash: HexBytes):
print(f"Trying to get tx: {tx_hash.hex()}")
tx = w3.eth.get_transaction(tx_hash)
print("got it")
return tx["value"]
def decode_nft_transfer_data(w3: Web3, log: LogReceipt) -> Optional[NFTTransferRaw]:
for abi in erc721_transfer_event_abis:
try:
transfer_data = get_event_data(w3.codec, abi, log)
nft_transfer = NFTTransferRaw(
contract_address=transfer_data["address"],
transfer_from=transfer_data["args"]["from"],
transfer_to=transfer_data["args"]["to"],
tokenId=transfer_data["args"]["tokenId"],
transfer_tx=transfer_data["transactionHash"],
)
return nft_transfer
except:
continue
return None
def get_nft_transfers(
w3: Web3,
from_block: Optional[int] = None,
to_block: Optional[int] = None,
contract_address: Optional[str] = None,
) -> List[NFTTransfer]:
filter_params = FilterParams(topics=[cast(HexStr, TRANSFER_EVENT_SIGNATURE.hex())])
if from_block is not None:
filter_params["fromBlock"] = from_block
if to_block is not None:
filter_params["toBlock"] = to_block
if contract_address is not None:
filter_params["address"] = w3.toChecksumAddress(contract_address)
logs = w3.eth.get_logs(filter_params)
nft_transfers: List[NFTTransfer] = []
for log in tqdm(logs, desc=f"Processing logs for blocks {from_block}-{to_block}"):
nft_transfer = decode_nft_transfer_data(w3, log)
if nft_transfer is not None:
kwargs = {
**asdict(nft_transfer),
"transfer_tx": nft_transfer.transfer_tx.hex(),
"is_mint": nft_transfer.transfer_from
== "0x0000000000000000000000000000000000000000",
}
parsed_transfer = NFTTransfer(**kwargs) # type: ignore
nft_transfers.append(parsed_transfer)
return nft_transfers
def get_block_bounds(
w3: Web3, from_block: Optional[int] = None, to_block: Optional[int] = None
) -> Tuple[int, int]:
"""
Returns starting and ending blocks for an "nft ethereum" crawl subject to the following rules:
1. Neither start nor end can be None.
2. If both from_block and to_block are None, then start = end - DEFAULT_CRAWL_LENGTH + 1
"""
end = to_block
if end is None:
end = w3.eth.get_block_number()
start = from_block
if start is None:
start = end - DEFAULT_CRAWL_LENGTH + 1
return start, end
def ensure_addresses(db_session: Session, addresses: Set[str]) -> Dict[str, int]:
"""
Ensures that the given addresses are registered in the ethereum_addresses table of the given
moonstreamdb database connection. Returns a mapping from the addresses to the ids of their
corresponding row in the ethereum_addresses table.
Returns address_ids for *every* address, not just the new ones.
"""
if len(addresses) == 0:
return {}
# SQLAlchemy reference:
# https://docs.sqlalchemy.org/en/14/orm/persistence_techniques.html#using-postgresql-on-conflict-with-returning-to-return-upserted-orm-objects
stmt = (
insert(EthereumAddress)
.values([{"address": address} for address in addresses])
.on_conflict_do_nothing(index_elements=[EthereumAddress.address])
)
try:
db_session.execute(stmt)
db_session.commit()
except Exception:
db_session.rollback()
raise
rows = (
db_session.query(EthereumAddress)
.filter(EthereumAddress.address.in_(addresses))
.all()
)
address_ids = {address.address: address.id for address in rows}
return address_ids
def label_erc721_addresses(
w3: Web3, db_session: Session, address_ids: List[Tuple[str, int]]
) -> None:
labels: List[EthereumLabel] = []
for address, id in address_ids:
try:
contract_info = get_erc721_contract_info(w3, address)
labels.append(
EthereumLabel(
address_id=id,
label=NFT_LABEL,
label_data={
"name": contract_info.name,
"symbol": contract_info.symbol,
"totalSupply": contract_info.total_supply,
},
)
)
except Exception as e:
logger.error(f"Failed to get metadata of contract {address}")
logger.error(e)
try:
db_session.bulk_save_objects(labels)
db_session.commit()
except Exception as e:
db_session.rollback()
logger.error(f"Failed to save labels to db:\n{e}")
def label_key(label: EthereumLabel) -> Tuple[str, int, int, str, str]:
return (
label.transaction_hash,
label.address_id,
label.label_data["tokenId"],
label.label_data["from"],
label.label_data["to"],
)
def label_transfers(
db_session: Session, transfers: List[NFTTransfer], address_ids: Dict[str, int]
) -> None:
"""
Adds "nft_mint" or "nft_transfer" to the (transaction, address) pair represented by each of the
given NFTTransfer objects.
"""
transaction_hashes: List[str] = []
labels: List[EthereumLabel] = []
for transfer in transfers:
transaction_hash = transfer.transfer_tx
transaction_hashes.append(transaction_hash)
address_id = address_ids.get(transfer.contract_address)
label = MINT_LABEL if transfer.is_mint else TRANSFER_LABEL
row = EthereumLabel(
address_id=address_id,
transaction_hash=transaction_hash,
label=label,
label_data={
"tokenId": transfer.tokenId,
"from": transfer.transfer_from,
"to": transfer.transfer_to,
},
)
labels.append(row)
existing_labels = (
db_session.query(EthereumLabel)
.filter(EthereumLabel.address_id.in_(address_ids.values()))
.filter(EthereumLabel.transaction_hash.in_(transaction_hashes))
).all()
existing_label_keys = {label_key(label) for label in existing_labels}
new_labels = [
label for label in labels if label_key(label) not in existing_label_keys
]
try:
db_session.bulk_save_objects(new_labels)
db_session.commit()
except Exception as e:
db_session.rollback()
logger.error("Could not write transfer/mint labels to database")
logger.error(e)
def add_labels(
w3: Web3,
db_session: Session,
from_block: Optional[int] = None,
to_block: Optional[int] = None,
contract_address: Optional[str] = None,
batch_size: int = 100,
) -> None:
"""
Crawls blocks between from_block and to_block checking for NFT mints and transfers.
For each mint/transfer, if the contract address involved in the operation has not already been
added to the ethereum_addresses table, this method adds it and labels the address with the NFT
collection metadata.
It also adds mint/transfer labels to each (transaction, contract address) pair describing the
NFT operation they represent.
## NFT collection metadata labels
Label has type "erc721".
Label data:
{
"name": "<name of contract>",
"symbol": "<symbol of contract>",
"totalSupply": "<totalSupply of contract>"
}
## Mint and transfer labels
Adds labels to the database for each transaction that involved an NFT transfer. Adds the contract
address in the address_id column of ethereum_labels.
Labels (transaction, contract address) pair as:
- "nft_mint" if the transaction minted a token on the NFT contract
- "nft_transfer" if the transaction transferred a token on the NFT contract
Label data will always be of the form:
{
"tokenId": "<ID of token minted/transferred on NFT contract>",
"from": "<previous owner address>",
"to": "<new owner address>"
}
Arguments:
- w3: Web3 client
- db_session: Connection to Postgres database with moonstreamdb schema
- from_block and to_block: Blocks to crawl
- address: Optional contract address representing an NFT collection to restrict the crawl to
- batch_size: Number of mint/transfer transactions to label at a time (per database transaction)
"""
assert batch_size > 0, f"Batch size must be positive (received {batch_size})"
start, end = get_block_bounds(w3, from_block, to_block)
batch_start = start
batch_end = min(start + batch_size - 1, end)
address_ids: Dict[str, int] = {}
pbar = tqdm(total=(end - start + 1))
pbar.set_description(f"Labeling blocks {start}-{end}")
while batch_start <= batch_end:
job = get_nft_transfers(
w3,
from_block=batch_start,
to_block=batch_end,
contract_address=contract_address,
)
contract_addresses = {transfer.contract_address for transfer in job}
updated_address_ids = ensure_addresses(db_session, contract_addresses)
for address, address_id in updated_address_ids.items():
address_ids[address] = address_id
labelled_address_ids = [
label.address_id
for label in (
db_session.query(EthereumLabel)
.filter(EthereumLabel.label == NFT_LABEL)
.filter(EthereumLabel.address_id.in_(address_ids.values()))
.all()
)
]
unlabelled_address_ids = [
(address, address_id)
for address, address_id in address_ids.items()
if address_id not in labelled_address_ids
]
# Add 'erc721' labels
label_erc721_addresses(w3, db_session, unlabelled_address_ids)
# Add mint/transfer labels to (transaction, contract_address) pairs
label_transfers(db_session, job, updated_address_ids)
# Update batch at end of iteration
pbar.update(batch_end - batch_start + 1)
batch_start = batch_end + 1
batch_end = min(batch_end + batch_size, end)
pbar.close()
def time_bounded_summary(
db_session: Session,
start_time: datetime,
end_time: datetime,
) -> Dict[str, Any]:
"""
Produces a summary of Ethereum NFT activity between the given start_time and end_time (inclusive).
"""
start_timestamp = int(start_time.timestamp())
end_timestamp = int(end_time.timestamp())
time_filter = and_(
EthereumBlock.timestamp >= start_timestamp,
EthereumBlock.timestamp <= end_timestamp,
)
transactions_query = (
db_session.query(EthereumTransaction)
.join(
EthereumBlock,
EthereumTransaction.block_number == EthereumBlock.block_number,
)
.filter(time_filter)
)
def nft_query(label: str) -> Query:
query = (
db_session.query(
EthereumLabel.label,
EthereumLabel.label_data,
EthereumLabel.address_id,
EthereumTransaction.hash,
EthereumTransaction.value,
EthereumBlock.block_number,
EthereumBlock.timestamp,
)
.join(
EthereumTransaction,
EthereumLabel.transaction_hash == EthereumTransaction.hash,
)
.join(
EthereumBlock,
EthereumTransaction.block_number == EthereumBlock.block_number,
)
.filter(time_filter)
.filter(EthereumLabel.label == label)
)
return query
transfer_query = nft_query(TRANSFER_LABEL)
mint_query = nft_query(MINT_LABEL)
def holder_query(label: str) -> Query:
query = (
db_session.query(
EthereumLabel.address_id.label("address_id"),
EthereumLabel.label_data["to"].astext.label("owner_address"),
EthereumLabel.label_data["tokenId"].astext.label("token_id"),
EthereumTransaction.block_number.label("block_number"),
EthereumTransaction.transaction_index.label("transaction_index"),
EthereumTransaction.value.label("transfer_value"),
)
.join(
EthereumTransaction,
EthereumLabel.transaction_hash == EthereumTransaction.hash,
)
.join(
EthereumBlock,
EthereumTransaction.block_number == EthereumBlock.block_number,
)
.filter(EthereumLabel.label == label)
.filter(time_filter)
.order_by(
# Without "transfer_value" and "owner_address" as sort keys, the final distinct query
# does not seem to be deterministic.
# Maybe relevant Stackoverflow post: https://stackoverflow.com/a/59410440
text(
"address_id, token_id, block_number desc, transaction_index desc, transfer_value, owner_address"
)
)
.distinct("address_id", "token_id")
)
return query
purchaser_query = holder_query(TRANSFER_LABEL)
minter_query = holder_query(MINT_LABEL)
blocks_result: Dict[str, int] = {}
min_block = (
db_session.query(func.min(EthereumBlock.block_number))
.filter(time_filter)
.scalar()
)
max_block = (
db_session.query(func.max(EthereumBlock.block_number))
.filter(time_filter)
.scalar()
)
if min_block is not None:
blocks_result["start"] = min_block
if max_block is not None:
blocks_result["end"] = max_block
num_transactions = transactions_query.distinct(EthereumTransaction.hash).count()
num_transfers = transfer_query.distinct(EthereumTransaction.hash).count()
total_value = db_session.query(
func.sum(transactions_query.subquery().c.value)
).scalar()
transfer_value = db_session.query(
func.sum(transfer_query.subquery().c.value)
).scalar()
num_minted = mint_query.count()
num_purchasers = (
db_session.query(purchaser_query.subquery())
.distinct(text("owner_address"))
.count()
)
num_minters = (
db_session.query(minter_query.subquery())
.distinct(text("owner_address"))
.count()
)
result = {
"date_range": {
"start_time": start_time.isoformat(),
"include_start": True,
"end_time": end_time.isoformat(),
"include_end": True,
},
SUMMARY_KEY_BLOCKS: blocks_result,
SUMMARY_KEY_NUM_TRANSACTIONS: f"{num_transactions}",
SUMMARY_KEY_TOTAL_VALUE: f"{total_value}",
SUMMARY_KEY_NFT_TRANSFERS: f"{num_transfers}",
SUMMARY_KEY_NFT_TRANSFER_VALUE: f"{transfer_value}",
SUMMARY_KEY_NFT_MINTS: f"{num_minted}",
SUMMARY_KEY_NFT_PURCHASERS: f"{num_purchasers}",
SUMMARY_KEY_NFT_MINTERS: f"{num_minters}",
}
return result
def summary(db_session: Session, end_time: datetime) -> Dict[str, Any]:
"""
Produces a summary of all Ethereum NFT activity:
1. From 1 hour before end_time to end_time
2. From 1 day before end_time to end_time
3. From 1 week before end_time to end_time
"""
start_times = {
"hour": end_time - timedelta(hours=1),
"day": end_time - timedelta(days=1),
"week": end_time - timedelta(weeks=1),
}
summaries = {
period: time_bounded_summary(db_session, start_time, end_time)
for period, start_time in start_times.items()
}
def aggregate_summary(key: str) -> Dict[str, Any]:
return {period: summary.get(key) for period, summary in summaries.items()}
result: Dict[str, Any] = {
summary_key: aggregate_summary(summary_key) for summary_key in SUMMARY_KEYS
}
result["crawled_at"] = end_time.isoformat()
return result

Wyświetl plik

@ -11,6 +11,7 @@ def publish_json(
title: str,
content: Dict[str, Any],
tags: Optional[List[str]] = None,
wait: bool = True,
) -> None:
spire_api_url = os.environ.get(
"MOONSTREAM_SPIRE_API_URL", "https://spire.bugout.dev"
@ -26,7 +27,7 @@ def publish_json(
"Authorization": f"Bearer {humbug_token}",
}
request_body = {"title": title, "content": json.dumps(content), "tags": tags}
query_parameters = {"sync": True}
query_parameters = {"sync": wait}
response = requests.post(
report_url, headers=headers, json=request_body, params=query_parameters
)

Wyświetl plik

@ -2,4 +2,4 @@
Moonstream crawlers version.
"""
MOONCRAWL_VERSION = "0.0.3"
MOONCRAWL_VERSION = "0.0.4"

Wyświetl plik

@ -8,3 +8,6 @@ ignore_missing_imports = True
[mypy-pyevmasm.*]
ignore_missing_imports = True
[mypy-tqdm.*]
ignore_missing_imports = True

Wyświetl plik

@ -0,0 +1,58 @@
aiohttp==3.7.4.post0
async-timeout==3.0.1
attrs==21.2.0
base58==2.1.0
bitarray==1.2.2
black==21.8b0
boto3==1.18.40
botocore==1.21.40
certifi==2021.5.30
chardet==4.0.0
charset-normalizer==2.0.4
click==8.0.1
cytoolz==0.11.0
-e git+https://git@github.com/bugout-dev/moonstream.git@a4fff6498f66789934d4af26fd42a8cfb6e5eed5#egg=moonstreamdb&subdirectory=db
eth-abi==2.1.1
eth-account==0.5.5
eth-hash==0.3.2
eth-keyfile==0.5.1
eth-keys==0.3.3
eth-rlp==0.2.1
eth-typing==2.2.2
eth-utils==1.10.0
hexbytes==0.2.2
humbug==0.2.7
idna==3.2
ipfshttpclient==0.8.0a2
jmespath==0.10.0
jsonschema==3.2.0
lru-dict==1.1.7
multiaddr==0.0.9
multidict==5.1.0
mypy==0.910
mypy-extensions==0.4.3
netaddr==0.8.0
parsimonious==0.8.1
pathspec==0.9.0
platformdirs==2.3.0
protobuf==3.17.3
pycryptodome==3.10.1
pyrsistent==0.18.0
python-dateutil==2.8.2
regex==2021.8.28
requests==2.26.0
rlp==2.0.1
s3transfer==0.5.0
six==1.16.0
toml==0.10.2
tomli==1.2.1
toolz==0.11.1
tqdm==4.62.2
types-python-dateutil==2.8.0
types-requests==2.25.6
typing-extensions==3.10.0.2
urllib3==1.26.6
varint==1.0.2
web3==5.23.1
websockets==9.1
yarl==1.6.3

Wyświetl plik

@ -32,7 +32,7 @@ setup(
package_data={"mooncrawl": ["py.typed"]},
zip_safe=False,
install_requires=[
"moonstreamdb @ git+https://git@github.com/bugout-dev/moonstream.git@39d2b8e36a49958a9ae085ec2cc1be3fc732b9d0#egg=moonstreamdb&subdirectory=db",
"moonstreamdb @ git+https://git@github.com/bugout-dev/moonstream.git@a4fff6498f66789934d4af26fd42a8cfb6e5eed5#egg=moonstreamdb&subdirectory=db",
"humbug",
"python-dateutil",
"requests",
@ -49,6 +49,7 @@ setup(
"esd=mooncrawl.esd:main",
"identity=mooncrawl.identity:main",
"etherscan=mooncrawl.etherscan:main",
"nft=mooncrawl.nft.cli:main",
]
},
)

Wyświetl plik

@ -0,0 +1,60 @@
"""Support labels on transactions
Revision ID: 72f1ad512b2e
Revises: ecb7817db377
Create Date: 2021-09-02 22:59:46.408595
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "72f1ad512b2e"
down_revision = "ecb7817db377"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"ethereum_labels",
sa.Column("transaction_hash", sa.VARCHAR(length=256), nullable=True),
)
op.alter_column(
"ethereum_labels", "address_id", existing_type=sa.INTEGER(), nullable=True
)
op.create_index(
op.f("ix_ethereum_labels_transaction_hash"),
"ethereum_labels",
["transaction_hash"],
unique=False,
)
op.create_foreign_key(
op.f("fk_ethereum_labels_transaction_hash_ethereum_transactions"),
"ethereum_labels",
"ethereum_transactions",
["transaction_hash"],
["hash"],
ondelete="CASCADE",
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
op.f("fk_ethereum_labels_transaction_hash_ethereum_transactions"),
"ethereum_labels",
type_="foreignkey",
)
op.drop_index(
op.f("ix_ethereum_labels_transaction_hash"), table_name="ethereum_labels"
)
op.execute("DELETE FROM ethereum_labels WHERE address_id IS NULL;")
op.alter_column(
"ethereum_labels", "address_id", existing_type=sa.INTEGER(), nullable=False
)
op.drop_column("ethereum_labels", "transaction_hash")
# ### end Alembic commands ###

Wyświetl plik

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Deployment script - intended to run on Moonstream database server
# Main
APP_DIR="${APP_DIR:-/home/ubuntu/moonstream}"
APP_DB_SERVER_DIR="${APP_DIR}/db/server"
SCRIPT_DIR="$(realpath $(dirname $0))"
SERVICE_FILE="${SCRIPT_DIR}/moonstreamdb.service"
set -eu
echo
echo
echo "Building executable database server script with Go"
HOME=/root /usr/local/go/bin/go build -o "${APP_DB_SERVER_DIR}/moonstreamdb" "${APP_DB_SERVER_DIR}/main.go"
echo
echo
echo "Replacing existing moonstreamdb service definition with ${SERVICE_FILE}"
chmod 644 "${SERVICE_FILE}"
cp "${SERVICE_FILE}" /etc/systemd/system/moonstreamdb.service
systemctl daemon-reload
systemctl restart moonstreamdb.service
systemctl status moonstreamdb.service

Wyświetl plik

@ -0,0 +1,13 @@
[Unit]
Description=moonstreamdb-service
After=network.target
[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/moonstream/db/server
ExecStart=/home/ubuntu/moonstream/db/server/moonstreamdb
SyslogIdentifier=moonstreamdb
[Install]
WantedBy=multi-user.target

Wyświetl plik

@ -147,7 +147,13 @@ class EthereumLabel(Base): # type: ignore
address_id = Column(
Integer,
ForeignKey("ethereum_addresses.id", ondelete="CASCADE"),
nullable=False,
nullable=True,
index=True,
)
transaction_hash = Column(
VARCHAR(256),
ForeignKey("ethereum_transactions.hash", ondelete="CASCADE"),
nullable=True,
index=True,
)
label_data = Column(JSONB, nullable=True)

Wyświetl plik

@ -2,4 +2,4 @@
Moonstream database version.
"""
MOONSTREAMDB_VERSION = "0.0.3"
MOONSTREAMDB_VERSION = "0.1.0"

3
db/server/go.mod 100644
Wyświetl plik

@ -0,0 +1,3 @@
module moonstreamdb
go 1.17

26
db/server/main.go 100644
Wyświetl plik

@ -0,0 +1,26 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type PingResponse struct {
Status string `json:"status"`
}
func ping(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
response := PingResponse{Status: "ok"}
json.NewEncoder(w).Encode(response)
}
func main() {
address := "0.0.0.0:8931"
fmt.Printf("Starting server at %s\n", address)
http.HandleFunc("/ping", ping)
http.ListenAndServe(address, nil)
}

Wyświetl plik

@ -12,10 +12,13 @@ import {
Button,
Modal,
useDisclosure,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalOverlay,
ModalContent,
} from "@chakra-ui/react";
import NewSubscription from "../src/components/NewModalSubscripton";
import NewSubscription from "../src/components/NewSubscription";
import { AiOutlinePlusCircle } from "react-icons/ai";
const Subscriptions = () => {
@ -52,10 +55,15 @@ const Subscriptions = () => {
<ModalOverlay />
<ModalContent>
<NewSubscription
isFreeOption={isAddingFreeSubscription}
onClose={onClose}
/>
<ModalHeader>Subscribe to a new address</ModalHeader>
<ModalCloseButton />
<ModalBody>
<NewSubscription
isFreeOption={isAddingFreeSubscription}
onClose={onClose}
isModal={true}
/>
</ModalBody>
</ModalContent>
</Modal>
{subscriptionsCache.isLoading ? (

Wyświetl plik

@ -23,6 +23,7 @@ import {
AccordionButton,
AccordionPanel,
AccordionIcon,
Divider,
} from "@chakra-ui/react";
import StepProgress from "../src/components/StepProgress";
import { ArrowLeftIcon, ArrowRightIcon } from "@chakra-ui/icons";
@ -95,7 +96,7 @@ const Welcome = () => {
<Fade in>
<Stack spacing={4}>
<Stack
px={12}
px={[0, 12, null]}
// mt={24}
bgColor="gray.50"
borderRadius="xl"
@ -119,7 +120,7 @@ const Welcome = () => {
</Text>
</Stack>
<Stack
px={12}
px={[0, 12, null]}
// mt={24}
bgColor="gray.50"
borderRadius="xl"
@ -176,7 +177,7 @@ const Welcome = () => {
</Accordion>
</Stack>
<Stack
px={12}
px={[0, 12, null]}
// mt={24}
bgColor="gray.50"
borderRadius="xl"
@ -298,7 +299,7 @@ const Welcome = () => {
<Fade in>
<Stack px="7%">
<Stack
px={12}
px={[0, 12, null]}
// mt={24}
bgColor="gray.50"
borderRadius="xl"
@ -345,14 +346,18 @@ const Welcome = () => {
)}
<SubscriptionsList />
{showSubscriptionForm && (
<>
<Heading pt={12}>{`Let's add new subscription!`}</Heading>
<Flex direction="column" pt={6}>
<Divider bgColor="gray.500" borderWidth="2px" />
<Heading
size="md"
pt={2}
>{`Let's add new subscription!`}</Heading>
<NewSubscription
isFreeOption={false}
onClose={SubscriptonCreatedCallback}
/>
</>
</Flex>
)}
{!showSubscriptionForm && (
<Button
@ -370,7 +375,7 @@ const Welcome = () => {
<Fade in>
<Stack>
<Stack
px={12}
px={[0, 12, null]}
// mt={24}
bgColor="gray.50"
borderRadius="xl"

Wyświetl plik

@ -18,6 +18,24 @@ const baseStyle = (props) => {
};
};
const variantSuggestion = (props) => {
const bg = mode("primary.700", "primary.300")(props);
return {
"--tooltip-bg": `colors.${bg}`,
px: "8px",
py: "2px",
bg: "var(--tooltip-bg)",
"--popper-arrow-bg": "var(--tooltip-bg)",
color: mode("whiteAlpha.900", "gray.900")(props),
borderRadius: "md",
fontWeight: "medium",
fontSize: "sm",
boxShadow: "md",
maxW: "320px",
zIndex: "tooltip",
};
};
const variantOnboarding = (props) => {
const bg = mode("secondary.700", "secondary.300")(props);
return {
@ -38,6 +56,7 @@ const variantOnboarding = (props) => {
const variants = {
onboarding: variantOnboarding,
suggestion: variantSuggestion,
};
export default {

Wyświetl plik

@ -89,7 +89,7 @@ const LandingNavbar = () => {
</Button>
</RouterLink>
)}
{!ui.isLoggedIn && (
{/* {!ui.isLoggedIn && (
<Button
colorScheme="whiteAlpha"
variant="outline"
@ -100,7 +100,7 @@ const LandingNavbar = () => {
>
Get started
</Button>
)}
)} */}
{!ui.isLoggedIn && (
<Button
color="white"

Wyświetl plik

@ -6,7 +6,6 @@ import { Flex } from "@chakra-ui/react";
import UIContext from "../core/providers/UIProvider/context";
const ForgotPassword = React.lazy(() => import("./ForgotPassword"));
const SignIn = React.lazy(() => import("./SignIn"));
const SignUp = React.lazy(() => import("./SignUp"));
const LandingNavbar = React.lazy(() => import("./LandingNavbar"));
const AppNavbar = React.lazy(() => import("./AppNavbar"));
const HubspotForm = React.lazy(() => import("./HubspotForm"));
@ -27,7 +26,7 @@ const Navbar = () => {
overflow="hidden"
>
<Suspense fallback={""}>
{modal === "register" && <SignUp toggleModal={toggleModal} />}
{/* {modal === "register" && <SignUp toggleModal={toggleModal} />} */}
{modal === "login" && <SignIn toggleModal={toggleModal} />}
{modal === "forgot" && <ForgotPassword toggleModal={toggleModal} />}
{modal === "hubspot-trader" && (

Wyświetl plik

@ -1,182 +0,0 @@
import React, { useState, useEffect } from "react";
import { useSubscriptions } from "../core/hooks";
import {
Input,
Stack,
Text,
HStack,
useRadioGroup,
FormControl,
FormErrorMessage,
ModalBody,
ModalCloseButton,
ModalHeader,
Button,
ModalFooter,
Spinner,
IconButton,
} from "@chakra-ui/react";
import RadioCard from "./RadioCard";
import { useForm } from "react-hook-form";
import { GithubPicker } from "react-color";
import { BiRefresh } from "react-icons/bi";
import { makeColor } from "../core/utils/makeColor";
const NewSubscription = ({
isFreeOption,
onClose,
initialAddress,
initialType,
}) => {
const [color, setColor] = useState(makeColor());
const { typesCache, createSubscription } = useSubscriptions();
const { handleSubmit, errors, register } = useForm({});
const [radioState, setRadioState] = useState(
initialType ?? "ethereum_blockchain"
);
let { getRootProps, getRadioProps } = useRadioGroup({
name: "type",
defaultValue: radioState,
onChange: setRadioState,
});
const group = getRootProps();
useEffect(() => {
if (createSubscription.isSuccess) {
onClose();
}
}, [createSubscription.isSuccess, onClose]);
if (typesCache.isLoading) return <Spinner />;
const createSubscriptionWrap = (props) => {
createSubscription.mutate({
...props,
color: color,
type: isFreeOption ? "free" : radioState,
});
};
const handleChangeColorComplete = (color) => {
setColor(color.hex);
};
return (
<form onSubmit={handleSubmit(createSubscriptionWrap)}>
<ModalHeader>Subscribe to a new address</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl isInvalid={errors.label}>
<Input
my={2}
type="text"
autoComplete="off"
placeholder="Enter label"
name="label"
ref={register({ required: "label is required!" })}
></Input>
<FormErrorMessage color="unsafe.400" pl="1">
{errors.label && errors.label.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={errors.address}>
<Input
type="text"
autoComplete="off"
my={2}
placeholder="Enter address"
defaultValue={initialAddress ?? undefined}
isReadOnly={!!initialAddress}
name="address"
ref={register({ required: "address is required!" })}
></Input>
<FormErrorMessage color="unsafe.400" pl="1">
{errors.address && errors.address.message}
</FormErrorMessage>
</FormControl>
<Stack my={16} direction="column">
<Text fontWeight="600">
{isFreeOption
? `Free subscription is only availible Ethereum blockchain source`
: `On which source?`}
</Text>
<FormControl isInvalid={errors.subscription_type}>
<HStack {...group} alignItems="stretch">
{typesCache.data.map((type) => {
const radio = getRadioProps({
value: type.id,
isDisabled:
(initialAddress && initialType) ||
!type.active ||
(isFreeOption && type.id !== "ethereum_blockchain"),
});
return (
<RadioCard key={`subscription_type_${type.id}`} {...radio}>
{type.name}
</RadioCard>
);
})}
</HStack>
<Input
type="hidden"
placeholder="subscription_type"
name="subscription_type"
ref={register({ required: "select type" })}
value={radioState}
onChange={() => null}
></Input>
<FormErrorMessage color="unsafe.400" pl="1">
{errors.subscription_type && errors.subscription_type.message}
</FormErrorMessage>
</FormControl>
</Stack>
<FormControl isInvalid={errors.color}>
<Stack direction="row" pb={2}>
<Text fontWeight="600" alignSelf="center">
Label color
</Text>{" "}
<IconButton
size="md"
color={"white.100"}
_hover={{ bgColor: { color } }}
bgColor={color}
variant="outline"
onClick={() => setColor(makeColor())}
icon={<BiRefresh />}
/>
<Input
type="input"
placeholder="color"
name="color"
ref={register({ required: "color is required!" })}
value={color}
onChange={() => null}
w="200px"
></Input>
</Stack>
<GithubPicker onChangeComplete={handleChangeColorComplete} />
<FormErrorMessage color="unsafe.400" pl="1">
{errors.color && errors.color.message}
</FormErrorMessage>
</FormControl>
</ModalBody>
<ModalFooter>
<Button
type="submit"
colorScheme="suggested"
isLoading={createSubscription.isLoading}
>
Confirm
</Button>
<Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>
</ModalFooter>
</form>
);
};
export default NewSubscription;

Wyświetl plik

@ -12,13 +12,13 @@ import {
Spinner,
IconButton,
ButtonGroup,
Spacer,
Flex,
} from "@chakra-ui/react";
import RadioCard from "./RadioCard";
// import { useForm } from "react-hook-form";
import { CirclePicker } from "react-color";
import { BiRefresh } from "react-icons/bi";
import { GithubPicker } from "react-color";
import { makeColor } from "../core/utils/makeColor";
import { useForm } from "react-hook-form";
const _NewSubscription = ({
@ -27,21 +27,35 @@ const _NewSubscription = ({
setIsLoading,
initialAddress,
initialType,
isModal,
}) => {
const [color, setColor] = useState(makeColor());
const { handleSubmit, errors, register } = useForm({});
const { typesCache, createSubscription } = useSubscriptions();
// const { handleSubmit, errors, register } = useForm({});
const [radioState, setRadioState] = useState(
initialType ?? "ethereum_blockchain"
);
let { getRootProps, getRadioProps } = useRadioGroup({
const mapper = {
"tag:erc721": "NFTs",
"input:address": "Address",
};
const [subscriptionAdressFormatRadio, setsubscriptionAdressFormatRadio] =
useState("input:address");
let { getRadioProps } = useRadioGroup({
name: "type",
defaultValue: radioState,
onChange: setRadioState,
});
const group = getRootProps();
let { getRadioProps: getRadioPropsSubscription } = useRadioGroup({
name: "subscription",
defaultValue: subscriptionAdressFormatRadio,
onChange: setsubscriptionAdressFormatRadio,
});
useEffect(() => {
if (setIsLoading) {
@ -57,17 +71,40 @@ const _NewSubscription = ({
const createSubscriptionWrapper = useCallback(
(props) => {
props.label = "Address";
if (
subscriptionAdressFormatRadio.startsWith("tag") &&
radioState != "ethereum_whalewatch"
) {
props.address = subscriptionAdressFormatRadio;
props.label = "Tag";
}
createSubscription.mutate({
...props,
color: color,
type: isFreeOption ? "ethereum_blockchain" : radioState,
});
},
[createSubscription, isFreeOption, color, radioState]
[
createSubscription,
isFreeOption,
color,
radioState,
subscriptionAdressFormatRadio,
]
);
if (typesCache.isLoading) return <Spinner />;
function search(nameKey, myArray) {
for (var i = 0; i < myArray.length; i++) {
if (myArray[i].id === nameKey) {
return myArray[i];
}
}
}
const handleChangeColorComplete = (color) => {
setColor(color.hex);
};
@ -76,110 +113,177 @@ const _NewSubscription = ({
return (
<form onSubmit={handleSubmit(createSubscriptionWrapper)}>
<FormControl isInvalid={errors?.label}>
<Input
my={2}
type="text"
autoComplete="off"
placeholder="Name of subscription (you can change it later)"
name="label"
ref={register({ required: "label is required!" })}
></Input>
<FormErrorMessage color="unsafe.400" pl="1">
{errors?.label && errors?.label.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={errors?.address}>
<Input
type="text"
autoComplete="off"
my={2}
placeholder="Address to subscribe to"
name="address"
ref={register({ required: "address is required!" })}
></Input>
<FormErrorMessage color="unsafe.400" pl="1">
{errors?.address && errors?.address.message}
</FormErrorMessage>
</FormControl>
<Stack my={4} direction="column">
{/* <Text fontWeight="600">
{isFreeOption
? `Right now you can subscribe only to ethereum blockchain`
: `On which source?`}
</Text> */}
<Stack spacing={1} w="100%">
<Text fontWeight="600">Source:</Text>
{/* position must be relative otherwise radio boxes add strange spacing on selection */}
<Stack
spacing={1}
w="100%"
direction="row"
flexWrap="wrap"
position="relative"
>
{typesCache.data
.sort((a, b) =>
a?.name > b?.name ? 1 : b?.name > a?.name ? -1 : 0
)
.map((type) => {
const radio = getRadioProps({
value: type.id,
isDisabled:
(initialAddress && initialType) ||
!type.active ||
(isFreeOption && type.id !== "ethereum_blockchain"),
});
<FormControl isInvalid={errors?.subscription_type}>
<HStack {...group} alignItems="stretch">
{typesCache.data.map((type) => {
const radio = getRadioProps({
value: type.id,
isDisabled:
(initialAddress && initialType) ||
!type.active ||
(isFreeOption && type.id !== "ethereum_blockchain"),
});
return (
<RadioCard key={`subscription_type_${type.id}`} {...radio}>
{type.name}
</RadioCard>
);
})}
return (
<RadioCard
px="8px"
py="4px"
mt="2px"
w="190px"
{...radio}
key={`subscription_type_${type.id}`}
label={type.description}
iconURL={type.icon_url}
>
{type.name.slice(9, type.name.length)}
</RadioCard>
);
})}
</Stack>
</Stack>
<Flex direction="row" w="100%" flexWrap="wrap" pt={4}>
{/* position must be relative otherwise radio boxes add strange spacing on selection */}
<HStack flexGrow={0} flexBasis="140px" position="relative">
{search(radioState, typesCache.data).choices.length > 0 && (
<Text fontWeight="600">Type:</Text>
)}
{search(radioState, typesCache.data).choices.map(
(addition_selects) => {
const radio = getRadioPropsSubscription({
value: addition_selects,
isDisabled: addition_selects.startsWith("tag"),
});
return (
<RadioCard
px="4px"
py="2px"
key={`subscription_tags_${addition_selects}`}
{...radio}
>
{mapper[addition_selects]}
</RadioCard>
);
}
)}
</HStack>
<Input
type="hidden"
placeholder="subscription_type"
name="subscription_type"
ref={register({ required: "select type" })}
value={radioState}
onChange={() => null}
></Input>
<FormErrorMessage color="unsafe.400" pl="1">
{errors?.subscription_type && errors?.subscription_type.message}
</FormErrorMessage>
</FormControl>
{subscriptionAdressFormatRadio.startsWith("input") &&
radioState != "ethereum_whalewatch" && (
<Flex flexBasis="240px" flexGrow={1}>
<FormControl isInvalid={errors?.address}>
<Input
type="text"
autoComplete="off"
my={2}
placeholder="Address to subscribe to"
name="address"
ref={register({ required: "address is required!" })}
></Input>
<FormErrorMessage color="unsafe.400" pl="1">
{errors?.address && errors?.address.message}
</FormErrorMessage>
</FormControl>
</Flex>
)}
</Flex>
<Input
type="hidden"
placeholder="subscription_type"
name="subscription_type"
ref={register({ required: "select type" })}
value={radioState}
onChange={() => null}
></Input>
</Stack>
<FormControl isInvalid={errors?.color}>
<Flex direction="row" pb={2} flexWrap="wrap">
<Stack pt={2} direction="row" h="min-content">
{!isModal ? (
<Flex direction="row" pb={2} flexWrap="wrap" alignItems="baseline">
<Text fontWeight="600" alignSelf="center">
Label color
</Text>{" "}
<IconButton
size="md"
// colorScheme="primary"
color={"white.100"}
_hover={{ bgColor: { color } }}
bgColor={color}
variant="outline"
onClick={() => setColor(makeColor())}
icon={<BiRefresh />}
/>
<Input
type="input"
placeholder="color"
name="color"
ref={register({ required: "color is required!" })}
value={color}
onChange={() => null}
w="200px"
></Input>
</Stack>
<Flex p={2}>
<CirclePicker
onChangeComplete={handleChangeColorComplete}
circleSpacing={1}
circleSize={24}
/>
<Stack
// pt={2}
direction={["row", "row", null]}
h="min-content"
alignSelf="center"
>
<IconButton
size="md"
// colorScheme="primary"
color={"white.100"}
_hover={{ bgColor: { color } }}
bgColor={color}
variant="outline"
onClick={() => setColor(makeColor())}
icon={<BiRefresh />}
/>
<Input
type="input"
placeholder="color"
name="color"
ref={register({ required: "color is required!" })}
value={color}
onChange={() => null}
w="200px"
></Input>
</Stack>
<Flex p={2} flexBasis="120px" flexGrow={1} alignSelf="center">
<CirclePicker
width="100%"
onChangeComplete={handleChangeColorComplete}
circleSpacing={1}
circleSize={24}
/>
</Flex>
</Flex>
</Flex>
) : (
<>
<Stack direction="row" pb={2}>
<Text fontWeight="600" alignSelf="center">
Label color
</Text>{" "}
<IconButton
size="md"
color={"white.100"}
_hover={{ bgColor: { color } }}
bgColor={color}
variant="outline"
onClick={() => setColor(makeColor())}
icon={<BiRefresh />}
/>
<Input
type="input"
placeholder="color"
name="color"
ref={register({ required: "color is required!" })}
value={color}
onChange={() => null}
w="200px"
></Input>
</Stack>
<GithubPicker onChangeComplete={handleChangeColorComplete} />
</>
)}
<FormErrorMessage color="unsafe.400" pl="1">
{errors?.color && errors?.color.message}
</FormErrorMessage>
</FormControl>
<ButtonGroup direction="row" justifyContent="space-evenly">
<ButtonGroup direction="row" justifyContent="flex-end" w="100%">
<Button
type="submit"
colorScheme="suggested"
@ -187,7 +291,7 @@ const _NewSubscription = ({
>
Confirm
</Button>
<Spacer />
<Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>

Wyświetl plik

@ -1,5 +1,5 @@
import React from "react";
import { useRadio, Box, Flex } from "@chakra-ui/react";
import { useRadio, Box, Flex, Tooltip, Image } from "@chakra-ui/react";
const RadioCard = (props) => {
const { getInputProps, getCheckboxProps } = useRadio(props);
@ -8,38 +8,48 @@ const RadioCard = (props) => {
const checkbox = getCheckboxProps();
return (
<Flex as="label" h="fill-availible">
<input {...input} />
<Box
justifyContent="left"
alignContent="center"
{...checkbox}
cursor="pointer"
borderWidth="1px"
borderRadius="md"
boxShadow="md"
_disabled={{
bg: "gray.300",
color: "gray.900",
borderColor: "gray.300",
}}
_checked={{
bg: "secondary.900",
color: "white",
borderColor: "secondary.900",
}}
_focus={{
boxShadow: "outline",
}}
px={5}
py={3}
fontWeight="600"
>
{props.children}
</Box>
</Flex>
<Tooltip
hidden={props.label ? false : true}
label={props.label}
variant="suggestion"
openDelay={500}
>
<Flex as="label" h="fill-availible">
<input {...input} />
<Box
alignContent="center"
{...checkbox}
cursor="pointer"
borderWidth="1px"
borderRadius="lg"
boxShadow="md"
_disabled={{
bg: "gray.300",
color: "gray.900",
borderColor: "gray.300",
}}
_checked={{
// bg: "secondary.900",
color: "secondary.900",
borderColor: "secondary.900",
borderWidth: "4px",
}}
justifyContent="center"
px={props.px}
mt={props.mt}
py={props.py}
w={props.w}
fontWeight="600"
>
{props.iconURL && (
<Image display="inline-block" w="16px" src={props.iconURL} />
)}{" "}
{props.children}
</Box>
</Flex>
</Tooltip>
);
};
// const RadioCard = chakra(RadioCard_);
export default RadioCard;

Wyświetl plik

@ -94,7 +94,7 @@ const Sidebar = () => {
)}
{!ui.isLoggedIn && (
<SidebarContent>
<Menu iconShape="square">
{/* <Menu iconShape="square">
<MenuItem
onClick={() => {
ui.toggleModal("register");
@ -103,7 +103,7 @@ const Sidebar = () => {
>
Sign up
</MenuItem>
</Menu>
</Menu> */}
<Menu iconShape="square">
<MenuItem
onClick={() => {

Wyświetl plik

@ -13,6 +13,7 @@ import {
InputGroup,
Button,
Input,
Link,
InputRightElement,
} from "@chakra-ui/react";
import CustomIcon from "./CustomIcon";
@ -84,18 +85,6 @@ const SignIn = ({ toggleModal }) => {
Login
</Button>
</form>
<Box height="1px" width="100%" background="#eaebf8" mb="1.875rem" />
<Text textAlign="center" fontSize="md" color="gray.1200">
Don`t have an account?{" "}
<Box
cursor="pointer"
color="primary.800"
as="span"
onClick={() => toggleModal("register")}
>
Register
</Box>
</Text>
<Text textAlign="center" fontSize="md" color="gray.1200">
{" "}
<Box
@ -106,6 +95,22 @@ const SignIn = ({ toggleModal }) => {
>
Forgot your password?
</Box>
<Box height="1px" width="100%" background="#eaebf8" mb="1.875rem" />
</Text>
<Text textAlign="center" fontSize="md" color="gray.1200">
{/* Don`t have an account?{" "} */}
We are in early access. If you would like to use Moonstream,{" "}
<Link href={"https://discord.gg/V3tWaP36"} color="secondary.900">
contact us on Discord.
</Link>
{/* <Box
cursor="pointer"
color="primary.800"
as="span"
onClick={() => toggleModal("register")}
>
Register
</Box> */}
</Text>
</Modal>
);

Wyświetl plik

@ -21,6 +21,11 @@ import { useSubscriptions } from "../core/hooks";
import ConfirmationRequest from "./ConfirmationRequest";
import ColorSelector from "./ColorSelector";
const mapper = {
"tag:erc721": "NFTs",
"input:address": "Address",
};
const SubscriptionsList = ({ emptyCTA }) => {
const {
subscriptionsCache,
@ -94,7 +99,11 @@ const SubscriptionsList = ({ emptyCTA }) => {
</Editable>
</Td>
<Td mr={4} p={0}>
<CopyButton>{subscription.address}</CopyButton>
{subscription.address?.startsWith("tag") ? (
<CopyButton>{mapper[subscription.address]}</CopyButton>
) : (
<CopyButton>{subscription.address}</CopyButton>
)}
</Td>
<Td>
<ColorSelector

Wyświetl plik

@ -21,7 +21,7 @@ import { useToast } from "../../core/hooks";
import { useSubscriptions } from "../../core/hooks";
import moment from "moment";
import { AiOutlineMonitor } from "react-icons/ai";
import NewSubscription from "../NewModalSubscripton";
import NewSubscription from "../NewSubscription";
const EthereumWhalewatchCard_ = ({
entry,

Wyświetl plik

@ -1,4 +1,12 @@
import { Flex, Spinner } from "@chakra-ui/react";
import { CloseIcon } from "@chakra-ui/icons";
import {
Flex,
Spinner,
Center,
Text,
Link,
IconButton,
} from "@chakra-ui/react";
import React, { Suspense, useContext, useState, useEffect } from "react";
const Sidebar = React.lazy(() => import("../components/Sidebar"));
const Navbar = React.lazy(() => import("../components/Navbar"));
@ -7,6 +15,7 @@ import UIContext from "../core/providers/UIProvider/context";
const RootLayout = (props) => {
const ui = useContext(UIContext);
const [showSpinner, setSpinner] = useState(true);
const [showBanner, setShowBanner] = useState(true);
useEffect(() => {
if (ui.isAppView && ui.isAppReady) {
@ -39,6 +48,54 @@ const RootLayout = (props) => {
<Suspense fallback="">
<Navbar />
</Suspense>
<Flex
w="100%"
h={showBanner ? ["6.5rem", "4.5rem", "3rem", null] : "0"}
minH={showBanner ? ["6.5rem", "4.5rem", "3rem", null] : "0"}
animation="linear"
transition="1s"
overflow="hidden"
>
<Flex
px="20px"
w="100%"
minH={["6.5rem", "4.5rem", "3rem", null]}
h={["6.5rem", "4.5rem", "3rem", null]}
placeContent="center"
bgColor="suggested.900"
boxShadow="md"
position="relative"
className="banner"
>
<Center w="calc(100% - 60px)">
{" "}
<Text
fontWeight="600"
textColor="primary.900"
fontSize={["sm", "sm", "md", null]}
>
Join early. Our first 1000 users get free lifetime access to
blockchain analytics. Contact our team on{" "}
<Link
href={"https://discord.gg/V3tWaP36"}
color="secondary.900"
>
Discord
</Link>
</Text>
</Center>
{/* <Spacer /> */}
<IconButton
position="absolute"
top="0"
right="0"
icon={<CloseIcon />}
colorScheme="primary"
variant="ghost"
onClick={() => setShowBanner(false)}
/>
</Flex>
</Flex>
{!showSpinner && props.children}
{showSpinner && <Spinner />}
</Flex>