kopia lustrzana https://github.com/bugout-dev/moonstream
Merge branch 'main' into alpha
commit
9f1424a2b6
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
.venv/
|
||||
.mooncrawl/
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
Moonstream crawlers version.
|
||||
"""
|
||||
|
||||
MOONCRAWL_VERSION = "0.0.3"
|
||||
MOONCRAWL_VERSION = "0.0.4"
|
||||
|
|
|
|||
|
|
@ -8,3 +8,6 @@ ignore_missing_imports = True
|
|||
|
||||
[mypy-pyevmasm.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-tqdm.*]
|
||||
ignore_missing_imports = True
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
Moonstream database version.
|
||||
"""
|
||||
|
||||
MOONSTREAMDB_VERSION = "0.0.3"
|
||||
MOONSTREAMDB_VERSION = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
module moonstreamdb
|
||||
|
||||
go 1.17
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Ładowanie…
Reference in New Issue