Feature: CLI and a publishing model for extensions (#1566)

* introduction of url-prefixes and defaults

* starting a cli for extensions

* more verbose

* extension-test-data

* logger improvements

* bugfixes and testing

* adding  clasification

* rename spext -> specterext

* distinguish isolated_client and refactoring

* improved error-message

* better error_management

* fix tests

* ext gen completion

* fix endpoint issue

* added .gitignore  and txs

* formatting

* very basic callback-infra

* beauty

* fix tests, tidy up, follow-links

* proper mocking for extgen

* adding a test for plugins

* fix tests + docs

* error-handling for non existing extensions

* extensiongen config and making config work for new style

* avoid pollition

* ups

* kick

* kick

* proper renaming of spext

* fix tests

* ToDo remark

* fix test finally

* fix the fixing fixing test

* documentation

* revert mounting for all config see #1595

* fix tests

* fix cypress-test

* prevent pollution

* revert address-data to fix test

* added ID

* bugfix for test-cypress
This commit is contained in:
Kim Neunert 2022-02-25 16:38:41 +01:00 committed by GitHub
parent 22042f4b12
commit b73e398c36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
70 changed files with 3659 additions and 154 deletions

View file

@ -7,6 +7,7 @@
"spec_node_configured.js",
"spec_wallet_send.js",
"spec_wallet_utxo.js",
"spec_plugins.js",
"spec_elm_single_segwit_wallet.js",
"spec_elm_multi_segwit_wallet.js"
],

View file

@ -0,0 +1,41 @@
describe('plugins are working', () => {
it('can create the associate button', () => {
cy.viewport(1200,660)
cy.visit('/')
// choose address
cy.get('#test_hot_wallet_1-sidebar-list-item').click()
cy.get('[href="/wallets/wallet/test_hot_wallet_1/addresses/"]').click()
cy.get('addresses-table').shadow().find('address-row').eq(0).shadow().find('.explorer-link').click()
cy.get('address-data').shadow().find('#associate-btn').click()
cy.contains("Service integration requires an authentication method that includes a password")
// ok, let's set a password
cy.get('select').select("passwordonly")
cy.get('#specter_password_only').type("mySecretPassword")
cy.get('#submit-btn').click()
cy.contains("Admin password successfully updated")
// choose address again
cy.get('#test_hot_wallet_1-sidebar-list-item').click()
cy.get('[href="/wallets/wallet/test_hot_wallet_1/addresses/"]').click()
cy.get('addresses-table').shadow().find('address-row').eq(0).shadow().find('.explorer-link').click()
cy.get('address-data').shadow().find('#associate-btn').click()
cy.contains("Associating an address with a service will")
})
it('deactivates the password protection again', () => {
cy.viewport(1200,660)
cy.visit('/')
cy.get('#password').type("mySecretPassword")
cy.get('#login-btn').click()
cy.get('[href="/settings/"] > .svg-white').click()
cy.get('[href="/settings/auth"]').click()
cy.get('select').select("none")
cy.get('#submit-btn').click()
})
})

View file

@ -38,7 +38,7 @@ We're no longer using travis-ci due to the abuse-detection-system going wild on
# Cirrus-CI
[Cirrus-CI](https://cirrus-ci.org) is used by Bitcoin-Core and HWI and is a quite good replacement for travis. We're using it only for PRs so far. The [../.cirrus.yml] file defines the build. We have two task, one for pytest and one for the cypress-tests.
[Cirrus-CI](https://cirrus-ci.org) is used by Bitcoin-Core and HWI and is a quite good replacement for travis. We're using it only for PRs so far. The [../.cirrus.yml] file defines the build. We have two task, one for pytest and one for the [cypress-tests](./cypress-testing.md).
# Releasing

View file

@ -1,11 +1,47 @@
# Third-Party Service Integrations
A developer's guide for Specter Desktop `Service` integrations.
A developer's guide for the Specter Desktop `Extension` framework.
We currently rework the naming of extensions/plugins/services. If not otherwise stated, you can see those three terms as the same, for now.
## Basic Code Philosophy
As much as possible, each `Service` implementation should be entirely self-contained with little or no custom code altering existing/core Specter functionality.
## Concept
As much as possible, each `Service` implementation should be entirely self-contained with little or no custom code altering existing/core Specter functionality. There is a name for that: Extension-/Pluginframework.
The term `extension` will be used for all sorts extensions whereas `plugin` will be used as a component which can be de-/activated by a user.
All extensions are completely sperated in a specific folder-structure. There are internal extensions which `SHOULD` be located in `cryptoadvance.specterext.id_of_extension` but at least 2 extensions are still at the deprecated location of `cryptoadvance.specter.services`. However that does not mean that an extension needs to be located in the same repository than specter itself. There can and will be extensions which are located in their own repositories.
Independent whether an extension is shipped with the official specter-release-binaries and whether it's an internal (which is shipped) or external extension (which might be shipped), the creation of extensions is already heavily supported and encouraged.
Whether an extension is shipped with the official binary is entirely the choice of the Specter Team. However, you can simply develop extensions and use them on production (only for technical personel) as described in `specterext-dummy` (see below).
A description of how to create your own extension can be found at the [dummy-extension](https://github.com/cryptoadvance/specterext-dummy/). You will need to choose an organisation or username if you create one. This is used for package-structure.
All the attributes of an extension are currently (json-support is planned sooner or later) defined as attributes of a class which is derived from the class `Service` (should be renamed). That class has attributes which are essential. So let's discuss them briefly.
## Extension Attributes
Here is an Example. This class definition MUST be stored in a file called "service.py" within a package with the name `org-id.specterext.extions-id`.
```
class DiceService(Service):
id = "dice"
name = "Specter Dice"
icon = "dice/dice_logo.png"
logo = "dice/dice_logo.png"
desc = "Send your bet!"
has_blueprint = True
blueprint_module = "k9ert.specterext.dice.controller"
isolated_client = False
devstatus = devstatus_alpha
```
So this defines the base `Service` class (to be renamed to "Extension") that all extensions must inherit from. This is wired to enable `Extension` auto-discovery. Any feature that is common to most or all `Service` integrations should be implemented here.
With inheriting from `Service` you get some usefull methods explained later.
The `id` needs to be unique within a specific specter-instance where this extension is part of. The `name` is the displayname as shown to the user in the plugin-area (currently there is not yet a technical difference between extensions and plugins). The `icon` will be used where labels are used to be diplayed if this extension is reserving addresses. The `logo` and the `desc` (ription) is also used in the plugin-area ("choose plugins").
If the extension has a UI (currently all of them have one), `has_blueprint` is True. `The blueprint_module` is referencing the controller-module where endpoints are defined. It's recommended to follow the format `org.specterext.extions-id.controller`.
`isolated_client` Should not be used yet. It is determining where in the url-path-tree the blueprint will be mounted. This might have an impact on whether the extension's frontend-client has access to the cookie used in specter. Check `config.py` for details.
`devstatus` is one of `devstatus_alpha`, `devstatus_beta` or `devstatus_prod` defined in `cryptoadvance.specter.services.service`. Each specter-instance will have a config-variable called `SERVICES_DEVSTATUS_THRESHOLD` (prod in Production and alpha in Development) and depending on that, the plugin will be available to the user.
## Data-Storage
Effort has been taken to provide `Service` data storage that is separate from existing data stores in order to keep those areas clean and simple. Where touchpoints are unavoidable, they are kept to the absolute bare minimum (e.g. `User.services` list, `Address.service_id` field).
@ -19,18 +55,6 @@ Users can also manually associate an existing `Address` with a `Service` (this i
_Note: TODO: manually un-reserve an `Address` from a `Service`._
## Basic Code Structure
All `Service`-related code should be contained within `cryptoadvance.specter.services`. The base components are:
### `Service` Base Class
Defines the base `Service` class that all service integrations must inherit from. This is wired to enable `Service` auto-discovery. Any feature that is common to most or all `Service` integrations should be implemented here.
Each `Service` must specify a unique `Service.id` that is just a short string (e.g. "swan"). This is the main identifier throughout the code.
Includes methods to "reserve" addresses for the `Service` to basically make those not-yet-used addresses somewhat off-limits to the rest of the UI (can still be manually overridden though).
### `Service` Configuration
In order to separate the service-configuration from the main-configuration, you can specify your config in a file called `config.py`. It's structure is similiar to the specter-wide `config.py`, e.g.:
```
@ -42,11 +66,6 @@ class ProductionConfig(BaseConfig):
```
In your code, you can access the correct value as in any other flask-code, like `api_url = app.config.get("SWAN_API_URL")`. If the instance is running a config (e.g. `DevelopmentConfig`) which is not available in your service-specific config (as above), the inheritance-hirarchy from the mainconfig will get traversed and the first hit will get get configured. In this example, it would be `BaseConfig`.
### `ServiceManager`
Simple manager that contains all `Service`s. Performs the `Service` auto-discovery at startup and filters availability by each `Service`'s release level (i.e. alpha, beta, etc).
### `ServiceEncryptedStorage`
Most `Service`s will require user secrets (e.g. API key and secret). Each Specter `User` will have their own on-disk encrypted `ServiceEncryptedStorage` with filename `<username>_services.json`. Note that the user's secrets for all `Service`s will be stored in this one file.
@ -82,7 +101,7 @@ Whenever possible, external code should not directly access these `Service`-rela
### `ServiceAnnotationsStorage`
Annotations are any address-specific or transaction-specific data from a `Service` that we might want to present to the user. Example: a `Service` that integrates with a onchain storefront would have product/order data associated with a utxo. That additional data could be imported by the `Service` and stored as an annotation. This annotation data could then be displayed to the user when viewing the details for that particular address or tx.
Annotations are any address-specific or transaction-specific data from a `Service` that we might want to present to the user (not yet implemented). Example: a `Service` that integrates with a onchain storefront would have product/order data associated with a utxo. That additional data could be imported by the `Service` and stored as an annotation. This annotation data could then be displayed to the user when viewing the details for that particular address or tx.
Annotations are stored on a per-wallet and per-`Service` basis as _unencrypted_ on-disk data (filename: `<wallet_alias>_<service>.json`).
@ -101,7 +120,7 @@ cryptoadvance.specter.services.swan
Each implementation must have the following required components:
```
/static
/static/<service_id>
/templates/<service_id>
controller.py
service.py
@ -110,7 +129,7 @@ service.py
This makes each implementation its own Flask `Blueprint`.
### `/static`
Because of Flask `Blueprint` imports, you can just add static files here and reference them (e.g. "static/img/blah.png") as if they were in the main `/static` files root dir.
Because of Flask `Blueprint` imports, you can just add static files here and reference them (e.g. "static//<service_id>/img/blah.png") as if they were in the main `/static` files root dir.
### `/templates/<service_id>`
Again, Flask `Blueprint`s import the `/templates` directory as-is, but to avoid namespace collisions on the template files (e.g. `/templates/index.html`) they should be contained within a subdirectory named with the `Service.id` (e.g. `/templates/swan/index.html`)

View file

@ -1,5 +1,5 @@
[pytest]
norecursedirs = tests/bitcoin* tests/elements
norecursedirs = tests/bitcoin* tests/elements tests/xtestdata_testextensions
log_format = [%(levelname)8s] %(message)s %(name)s (%(filename)s:%(lineno)s)
addopts = --bitcoind-version v22.0.0 --elementsd-version v0.21.0.1
markers =

View file

@ -5,6 +5,7 @@ from http.client import HTTPConnection
import click
from .cli_noded import bitcoind, elementsd
from .cli_ext import ext
from .cli_server import server
logger = logging.getLogger(__name__)
@ -58,5 +59,6 @@ def entry_point(config_home, debug=False, tracerpc=False, tracerequests=False):
entry_point.add_command(server)
entry_point.add_command(ext)
entry_point.add_command(bitcoind)
entry_point.add_command(elementsd)

View file

@ -0,0 +1,173 @@
import logging
import os
import shutil
import signal
import sys
import time
from os import path
from pathlib import Path
from socket import gethostname
from urllib.parse import urlparse
import click
import requests
from ..server import create_app, init_app
from ..services.extension_gen import ExtGen
from ..specter_error import SpecterError
from ..util.common import snake_case2camelcase
from ..util.reflection_fs import search_dirs_in_path
from ..util.shell import run_shell
from ..util.tor import start_hidden_service, stop_hidden_services
logger = logging.getLogger(__name__)
ext_mark = "specterext"
dummy_ext_url = (
"https://raw.githubusercontent.com/cryptoadvance/specterext-dummy/master"
)
@click.group()
def ext():
pass
@ext.command()
@click.option("--org", "org", default=None, help="Use a specific organsiation")
@click.option("--ext-id", "ext_id", default=None, help="Use a specific Extension ID")
@click.option(
"--isolated-client/--no-isolated-client",
default=None,
help="Whether the extension should be isolated on the client",
)
@click.option(
"--tmpl-fs-source",
"tmpl_fs_source",
help="Use a Filesystem source for the templates e.g. ~/src/specterext-dummy",
)
@click.option(
"--dryrun/--no-dryrun",
default=False,
help="Output content on stdout instead of creating files",
)
def gen(org, ext_id, isolated_client, tmpl_fs_source, dryrun):
# fmt: off
"""Will generate a new extension in a more or less empty directory.
\b
It'll ask you for the missing information if you don't pass the
necessary details (see below).
After creation, you can get the extension to run like this in your Development Environment:
\b
pip3 install -e .
python3 -m cryptoadvance.specter server --config DevelopmentConfig --debug
# point your browser to http://localhost:25441
# "choose Services" --> YourService
If you want to package it, you can build it like this:
\b
python3 -m pip install --upgrade build
python3 -m build
# install it like this:
pip3 install dist/{org}_{ext_id}-0.0.1-py3-none-any.whl
In order to use your extension in production, please refer to the Readme.md in the
https://github.com/cryptoadvance/{ext_mark}-dummy#how-to-get-this-to-production
To publish your package:
\b
python3 -m pip install --upgrade twine
python3 -m twine upload --repository testpypi dist/*
"""
# fmt: on
if ext_id == None:
print(
"""
We need an ID and a prefix for your extension. It'll reflect in the package-layout.
The id should be a short string.
The prefix is usually something like your github-username or github organisation-name.
Both will be used to to create the directory structure ( ./src/mycorpname/specterext/myextension )
and it will be used to prepare the files in order to publish this extension to pypi.
"""
)
ext_id = click.prompt(
"What should be the ID of your extension (lowercase only)", type=str
)
if org == None:
org = click.prompt(
"what should be the prefix?",
type=str,
)
if isolated_client == None:
print(
"""
Should the extension be working in isolated_client-mode? In that case it's won't share the
session-cookie with specter and the integration can only happen on server-side?
"""
)
isolated_client = click.prompt(
"Should the extension work in isolated client mode (y/n)?",
type=bool,
)
result = run_shell(["git", "config", "--get", "user.name"])
if result["code"] == 0:
author = result["out"].decode("ascii").strip()
else:
author = click.prompt("Please type in your Name: ", type=str)
result = run_shell(["git", "config", "--get", "user.email"])
if result["code"] == 0:
email = result["out"].decode("ascii").strip()
else:
email = click.prompt("Please type in your E-Mail: ", type=str)
extgen = ExtGen(
".",
org,
ext_id,
isolated_client,
author,
email,
dry_run=dryrun,
tmpl_fs_source=tmpl_fs_source,
)
extgen.generate()
# piggyback
# replace(f"./{dir}/service.py", "piggyback = False", f"piggyback = {piggyback}")
# if piggyback:
# replace(f"./{dir}/controller.py", "@login_required", "")
print(
f"""
Congratulations, you've created a new extension
Here is how to get it tor run on your Development Environment:
pip3 install -e .
python3 -m cryptoadvance.specter server --config DevelopmentConfig --debug
# point your browser to http://localhost:25441
# "choose Services" --> {ext_id}
If you want to package it, you can build it like this:
python3 -m pip install --upgrade build
python3 -m build
# install it like this:
pip3 install dist/{org}_{ext_id}-0.0.1-py3-none-any.whl
In order to use your extension in production, please refer to the Readme.md in the
https://github.com/cryptoadvance/{ext_mark}-dummy#how-to-get-this-to-production
To publish your package
python3 -m pip install --upgrade twine
python3 -m twine upload --repository testpypi dist/*
You can get all these information again via:
python3 -m cryptoadvance.specter ext gen --help
"""
)

View file

@ -179,6 +179,12 @@ class BaseConfig(object):
os.getenv("REQUEST_TIME_WARNING_THRESHOLD", "20")
)
# As described in https://github.com/cryptoadvance/specter-desktop/pull/1579#issuecomment-1049895972
# This should get removed after v1.9.0 is out.
SPECTER_URL_PREFIX = ""
EXT_URL_PREFIX = "/svc"
SESSION_COOKIE_PATH = SPECTER_URL_PREFIX
class DevelopmentConfig(BaseConfig):
# https://stackoverflow.com/questions/22463939/demystify-flask-app-secret-key
@ -250,13 +256,7 @@ class ProductionConfig(BaseConfig):
# Repeating it here as it's SECURITY CRITICAL. Check comments in BaseConfig
SERVICES_LOAD_FROM_CWD = False
SPECTER_URL_PREFIX = "/spc"
EXT_URL_PREFIX = "/spc/ext"
EXTERNAT_EXT_URL_PREFIX = "/ext"
SESSION_COOKIE_PATH = SPECTER_URL_PREFIX
# As described in https://github.com/cryptoadvance/specter-desktop/pull/1579#issuecomment-1049895972
# This should get removed after v1.9.0 is out.
SPECTER_URL_PREFIX = ""
EXT_URL_PREFIX = "/svc"
SESSION_COOKIE_PATH = SPECTER_URL_PREFIX
# SPECTER_URL_PREFIX = "/spc"
# EXT_URL_PREFIX = "/spc/ext"
# EXTERNAT_EXT_URL_PREFIX = "/ext"
# SESSION_COOKIE_PATH = SPECTER_URL_PREFIX

View file

@ -9,6 +9,7 @@ from typing import Dict, List
from cryptoadvance.specter.config import ProductionConfig
from cryptoadvance.specter.managers.singleton import ConfigurableSingletonException
from cryptoadvance.specter.specter_error import SpecterError
from cryptoadvance.specter.user import User
from cryptoadvance.specter.util.reflection import get_template_static_folder
from flask import current_app as app
@ -16,6 +17,7 @@ from flask import url_for
from flask.blueprints import Blueprint
from ..services.service import Service
from ..services import callbacks, ExtensionException
from ..services.service_encrypted_storage import ServiceEncryptedStorageManager
from ..util.reflection import (
_get_module_from_class,
@ -37,7 +39,7 @@ class ServiceManager:
# Each Service class is stored here, keyed on its Service.id str
self._services: Dict[str, Service] = {}
logger.info("----> starting service discovery <----")
logger.info("----> starting service discovery Static")
# How do we discover services? Two configs are relevant:
# * SERVICES_LOAD_FROM_CWD (boolean, CWD is current working directory)
# * EXTENSION_LIST (array of Fully Qualified module strings like ["cryptoadvance.specter.services.swan.service"])
@ -47,14 +49,16 @@ class ServiceManager:
class_list = get_classlist_of_type_clazz_from_modulelist(
Service, app.config.get("EXTENSION_LIST", [])
)
logger.info("----> starting service discovery Dynamic")
if app.config.get("SERVICES_LOAD_FROM_CWD", False):
class_list.extend(get_subclasses_for_clazz_in_cwd(Service))
logger.info("----> starting service loading")
class_list = set(class_list) # remove duplicates (shouldn't happen but ...)
for clazz in class_list:
compare_map = {"alpha": 1, "beta": 2, "prod": 3}
if compare_map[self.devstatus_threshold] <= compare_map[clazz.devstatus]:
# First configure the service
self.configure_service_for_module(clazz.id)
self.configure_service_for_module(clazz)
# Now activate it
self._services[clazz.id] = clazz(
active=clazz.id in self.specter.config.get("services", []),
@ -76,7 +80,8 @@ class ServiceManager:
except ConfigurableSingletonException as e:
# Test suite triggers multiple calls; ignore for now.
pass
logger.info("----> finished service discovery <----")
logger.info("----> finished service processing")
self.execute_ext_callbacks("afterServiceManagerInit")
@classmethod
def register_blueprint_for_ext(cls, clazz, ext):
@ -112,37 +117,53 @@ class ServiceManager:
ext_prefix = app.config["ISOLATED_CLIENT_EXT_URL_PREFIX"]
else:
ext_prefix = app.config["EXT_URL_PREFIX"]
app.register_blueprint(clazz.blueprint, url_prefix=f"{ext_prefix}/{clazz.id}")
if (
app.testing
and len([vf for vf in app.view_functions if vf.startswith(clazz.id)]) <= 1
): # the swan-static one
# Yet again that nasty workaround which has been described in the archblog.
# The easy variant can be found in server.py
# The good news is, that we'll only do that for testing
import importlib
logger.info("Reloading Extension controller")
importlib.reload(controller_module)
try:
app.register_blueprint(
clazz.blueprint, url_prefix=f"{ext_prefix}/{clazz.id}"
)
logger.info(f" Mounted {clazz.id} to {ext_prefix}/{clazz.id}")
if (
app.testing
and len([vf for vf in app.view_functions if vf.startswith(clazz.id)])
<= 1
): # the swan-static one
# Yet again that nasty workaround which has been described in the archblog.
# The easy variant can be found in server.py
# The good news is, that we'll only do that for testing
import importlib
logger.info(f" Mounting {clazz.id} to {ext_prefix}/{clazz.id}")
logger.info("Reloading Extension controller")
importlib.reload(controller_module)
app.register_blueprint(
clazz.blueprint, url_prefix=f"{ext_prefix}/{clazz.id}"
)
except AssertionError as e:
if str(e).startswith("A name collision"):
raise SpecterError(
f"""
There is a name collision for the {clazz.blueprint.name}. \n
This is probably because you're running in DevelopementConfig and configured
the extension at the same time in the EXTENSION_LIST which currently loks like this:
{app.config['EXTENSION_LIST']})
"""
)
@classmethod
def configure_service_for_module(cls, service_id):
def configure_service_for_module(cls, clazz):
"""searches for ConfigClasses in the module-Directory and merges its config in the global config"""
try:
module = import_module(
f"cryptoadvance.specter.services.{service_id}.config"
)
module = import_module(f"cryptoadvance.specter.services.{clazz.id}.config")
except ModuleNotFoundError:
logger.warning(
f"Service {service_id} does not have a service Configuration! Skipping!"
)
return
# maybe the other style:
org = clazz.__module__.split(".")[0]
try:
module = import_module(f"{org}.specterext.{clazz.id}.config")
except ModuleNotFoundError:
logger.warning(
f"Service {clazz.id} does not have a service Configuration! Skipping!"
)
return
main_config_clazz_name = app.config.get("SPECTER_CONFIGURATION_CLASS_FULLNAME")
main_config_clazz_slug = main_config_clazz_name.split(".")[-1]
potential_config_classes = []
@ -183,6 +204,18 @@ class ServiceManager:
app.config[key] = getattr(clazz, key)
logger.debug(f"setting {key} = {app.config[key]}")
def execute_ext_callbacks(self, callback_id, *args, **kwargs):
"""will execute the callback function for each extension which has defined that method
the callback_id needs to be passed and specify why the callback has been called.
It needs to be one of the constants defined in cryptoadvance.specter.services.callbacks
"""
if callback_id not in dir(callbacks):
raise Exception(f"Non existing callback_id: {callback_id}")
logger.debug(f"Executing callback {callback_id}")
for ext in self.services.values():
if hasattr(ext, "callback"):
ext.callback(callback_id, *args, **kwargs)
@property
def services(self) -> Dict[str, Service]:
return self._services or {}
@ -213,11 +246,11 @@ class ServiceManager:
)
ext.active = ext.id in service_names_active
def get_service(self, service_id: str) -> Service:
if service_id not in self._services:
# TODO: better error handling?
raise Exception(f"No such Service: '{service_id}'")
return self._services[service_id]
def get_service(self, plugin_id: str) -> Service:
"""get an extension-instance by ID. Raises an ExtensionException if it doesn't find it."""
if plugin_id not in self._services:
raise ExtensionException(f"No such plugin: '{plugin_id}'")
return self._services[plugin_id]
def remove_all_services_from_user(self, user: User):
"""

View file

@ -7,6 +7,7 @@ from cryptoadvance.specter.liquid.rpc import LiquidRPC
from cryptoadvance.specter.managers.service_manager import ServiceManager
from cryptoadvance.specter.rpc import BitcoinRPC
from cryptoadvance.specter.util.reflection import get_template_static_folder
from .services.callbacks import after_serverpy_init_app
from dotenv import load_dotenv
from flask import Flask, jsonify, redirect, request, session, url_for
from flask_babel import Babel
@ -114,8 +115,8 @@ def init_app(app, hwibridge=False, specter=None):
Response("Not Found", status=404),
{app.config["APP_URL_PREFIX"]: app.wsgi_app},
)
# First: Migrations
print(f"-----------{app.config['SPECTER_DATA_FOLDER']}")
mm = SpecterMigrator(app.config["SPECTER_DATA_FOLDER"])
mm.execute_migrations()
@ -127,7 +128,9 @@ def init_app(app, hwibridge=False, specter=None):
if specter is None:
# the default. If not None, then it got injected for testing
app.logger.info("Initializing Specter")
app.logger.info(
f"Initializing Specter with data-folder {app.config['SPECTER_DATA_FOLDER']}"
)
specter = Specter(
data_folder=app.config["SPECTER_DATA_FOLDER"],
config=app.config["DEFAULT_SPECTER_CONFIG"],
@ -229,7 +232,7 @@ def init_app(app, hwibridge=False, specter=None):
return jsonify(success=False)
# --------------------- Babel integration ---------------------
specter.service_manager.execute_ext_callbacks(after_serverpy_init_app)
return app

View file

@ -1,22 +1,15 @@
import random
import time
from flask import (
Flask,
Blueprint,
render_template,
request,
redirect,
url_for,
jsonify,
flash,
)
from flask_babel import lazy_gettext as _
from flask_login import login_required, current_user, logout_user
from flask import Blueprint, Flask
from flask import current_app as app
from ..helpers import alias
from ..user import User, hash_password, verify_password
from flask import flash, jsonify, redirect, render_template, request, url_for
from flask_babel import lazy_gettext as _
from flask_login import current_user, login_required, logout_user
from ..helpers import alias
from ..services import ExtensionException
from ..user import User, hash_password, verify_password
rand = random.randint(0, 1e32) # to force style refresh
last_sensitive_request = 0 # to rate limit sensitive requests
@ -222,6 +215,9 @@ def redirect_login(request):
try:
service_cls = app.specter.service_manager.get_service(service_id)
service_cls.on_user_login()
except ExtensionException as ee:
if not str(ee).startswith("No such plugin"):
raise ee
except Exception as e:
app.logger.exception(e)

View file

@ -0,0 +1,5 @@
from ..specter_error import SpecterError
class ExtensionException(SpecterError):
pass

View file

@ -0,0 +1,15 @@
""" Here we have some constants getting an id for extension-points/callbacks. As camelcase
is used, we don't use CAPITAL letters to not loose the meaning of the camelcase.
These constants are expected as parameter to the ServiceManager.callback function
and it'll throw an exception if the constant does not exist.
There are some weak naming conventions:
1. after/before
2. class or file
3. method or function
"""
afterServiceManagerInit = "afterServiceManagerInit"
after_serverpy_init_app = "after_serverpy_init_app"

View file

@ -1,10 +1,13 @@
import logging
from functools import wraps
from flask import Blueprint, render_template, redirect, url_for, flash
from flask import current_app as app, request
from flask import Blueprint
from flask import current_app as app
from flask import flash, redirect, render_template, request, url_for
from flask_babel import lazy_gettext as _
from flask_login import current_user, login_required
from functools import wraps
from ..services import ExtensionException
logger = logging.getLogger(__name__)
@ -73,7 +76,10 @@ def associate_addr(wallet_alias, address):
# Inject the User's active Services
services = []
for service_id in current_user.services:
services.append(app.specter.service_manager.get_service(service_id=service_id))
try:
services.append(app.specter.service_manager.get_service(service_id))
except ExtensionException:
pass
return render_template(
"services/associate_addr.jinja",

View file

@ -0,0 +1,215 @@
import logging
import os
import shutil
import signal
import sys
import time
from datetime import datetime
from os import path
from os.path import exists, getmtime, join
from pathlib import Path
from socket import gethostname
from sre_constants import BRANCH
from urllib.parse import urlparse
import click
import requests
from jinja2 import BaseLoader, Environment, FileSystemLoader, TemplateNotFound
from ..server import create_app, init_app
from ..specter_error import SpecterError
from ..util.common import camelcase2snake_case, snake_case2camelcase
from ..util.reflection_fs import search_dirs_in_path
from ..util.shell import run_shell
from ..util.tor import start_hidden_service, stop_hidden_services
ext_mark = "specterext"
logger = logging.getLogger(__name__)
class ExtGen:
def __init__(
self,
base_path,
ext_org,
ext_id,
isolated_client,
author,
email,
dry_run=False,
branch="master",
tmpl_fs_source=None,
):
self.base_path = base_path
self.org = ext_org
self.id = ext_id
self.isolated_client = isolated_client
self.author = author
self.author_email = email
self.version = "1.8.1" # relevant if tmpl-sources specify a dependency (requirements.txt) #ToDo improve
self.branch = branch
self.tmpl_fs_source = tmpl_fs_source
self.dry_run = dry_run
self.create_envs()
def create_envs(self):
if self.tmpl_fs_source == None:
loader = GithubUrlLoader(branch=self.branch)
else:
loader = FileSystemLoader(self.tmpl_fs_source)
self.jinja_env = Environment(
loader=loader,
trim_blocks=True,
block_start_string="<<",
block_end_string=">>",
variable_start_string="<=",
variable_end_string="=>",
comment_start_string="<#",
comment_end_string="#>",
)
self.jinja_env.filters["camelcase"] = snake_case2camelcase
self.env = Environment(loader=loader, trim_blocks=True)
self.env.filters["camelcase"] = snake_case2camelcase
self.sd_env = Environment(
loader=GithubUrlLoader(
base_url="https://raw.githubusercontent.com/cryptoadvance/specter-desktop/",
branch=self.branch,
)
)
def env_for_template(self, template):
"""chooses the right env for the template"""
if Path(template).name in ["conftest.py", "ghost_machine.py"]:
return self.sd_env
if Path(template).suffix.endswith("jinja"):
return self.jinja_env
return self.env
def generate(self):
self.generate_basics()
self.generate_preppub()
def generate_basics(self):
self.render("requirements.txt", version=self.version)
self.render(".gitignore")
package_path = f"src/dummyorg/specterext/dummy"
self.render(f"{package_path}/service.py")
self.render(f"{package_path}/controller.py")
self.render(f"{package_path}/config.py")
self.render(f"{package_path}/__init__.py")
self.render(f"{package_path}/__main__.py")
self.render(f"{package_path}/templates/dummy/index.jinja")
if not self.isolated_client:
self.render(f"{package_path}/static/dummy/css/styles.css")
self.create_binary_file(f"{package_path}/static/dummy/img/ghost.png")
self.create_binary_file(f"{package_path}/static/dummy/img/logo.jpeg")
self.render(f"{package_path}/templates/dummy/base.jinja")
self.render(f"{package_path}/templates/dummy/transactions.jinja")
self.render(f"{package_path}/templates/dummy/settings.jinja")
self.render(f"{package_path}/templates/dummy/components/dummy_menu.jinja")
self.render(f"{package_path}/templates/dummy/components/dummy_tab.jinja")
self.render(f"tests/conftest.py", env=self.sd_env)
self.render(f"tests/ghost_machine.py", env=self.sd_env)
# after #1591 is merged
# self.render(f"tests/devices_and_wallets.py", env=self.sd_env)
def create_binary_file(self, sourcepath):
"""textfiles can all be rendered. Binaries must be wgettet or copied"""
targetpath = Path(
sourcepath.replace("dummyorg", self.org).replace("dummy", self.id)
)
if self.dry_run:
print(f"-------------------------------------")
print(" Creation of targetpath skipped because dryrun")
print(f"-------------------------------------")
return
if targetpath.is_file():
return
targetpath.parents[0].mkdir(parents=True, exist_ok=True)
if self.tmpl_fs_source != None:
sourcepath = Path(self.tmpl_fs_source, sourcepath)
shutil.copy(sourcepath, targetpath)
print(f" --> Created {targetpath} (copied)")
else:
r = requests.get(self.env.loader.url_for_template(sourcepath))
open(targetpath, "wb").write(r.content)
print(f" --> Created {targetpath} (via Github)")
def generate_preppub(self):
self.render("pyproject.toml", version=self.version)
self.render("setup.py")
# Author and Email
if not self.author:
result = run_shell(["git", "config", "--get", "user.name"])
if result["code"] == 0:
author = result["out"].decode("ascii").strip()
else:
author = click.prompt("Please type in your Name: ", type=str)
if not self.author_email:
result = run_shell(["git", "config", "--get", "user.email"])
if result["code"] == 0:
email = result["out"].decode("ascii").strip()
else:
email = click.prompt("Please type in your E-Mail: ", type=str)
self.render("setup.cfg")
self.render("MANIFEST.in")
def render(self, template, env=None, **kargv):
# The template path is the same that we want to store on disk
file_name = Path(
template.replace("dummyorg", self.org).replace("dummy", self.id)
)
if env == None:
env = self.env_for_template(template)
template = env.get_template(template)
if not self.dry_run:
file_name.parents[0].mkdir(parents=True, exist_ok=True)
rendered_text = template.render(ext=self, **kargv)
if self.dry_run:
print()
print(
f"File: {file_name} ({env.block_start_string} {env.block_end_string})"
)
# print(f"Url: ")
print("-------------------------------------------------")
print(rendered_text)
print("-------------------------------------------------")
print()
else:
fq_fname = Path(self.base_path, file_name)
if not fq_fname.is_file():
with open(fq_fname, "w") as file:
file.write(rendered_text)
print(f" --> Created {fq_fname}")
class GithubUrlLoader(BaseLoader):
"""A Jinja2 TemplateLoader which is loading templates directly from github.
If you don't specify it, it'll be https://github.com/cryptoadvance/specterext-dummy
"""
dummy_base_url = "https://raw.githubusercontent.com/cryptoadvance/specterext-dummy"
def __init__(self, base_url=None, branch=None):
if base_url == None:
base_url = self.dummy_base_url
if branch == None:
branch = "master"
self.base_url = base_url + "/" + branch
def url_for_template(self, template):
if template[0] == "/":
template = template[1:]
url = self.base_url + "/" + template
return url
def get_source(self, environment, template):
url = self.url_for_template(template)
r = requests.get(url)
if r.status_code != 200:
raise TemplateNotFound(f"{url} results in {r.status_code}")
source = r.text
return source, url, None

View file

@ -34,7 +34,7 @@ class Service:
logo = None
desc = None # TODO: rename to "description" to be explicit
has_blueprint = True # the default
# If the blueprint gets a "/svc" prefix (isolated_client = True), the login cookie won't work for all specter core functionality
# If the blueprint gets a "/ext" prefix (isolated_client = True), the login cookie won't work for all specter core functionality
isolated_client = True
devstatus = devstatus_alpha

View file

@ -40,15 +40,6 @@
this.note = clone.querySelector(".note");
this.info = clone.querySelector(".address-data-info");
this.note.innerText = `{{ _("Loading address") }}: ${this.address} {{ _("details") }}...`;
// Attach the created element to the shadow dom
shadow.appendChild(clone);
}
connectedCallback() {
// Read input "data-*" attributes
this.isVerifyQR = this.getAttribute('data-verify-qr') == 'True';
this.isVerifyHwi = this.getAttribute('data-verify-hwi') == 'True';
@ -60,21 +51,12 @@
this.label = this.getAttribute('data-label');
this.serviceId = this.getAttribute('data-service-id');
this.wallet = this.getAttribute('data-address-wallet');
this.fetchAddressData();
}
static get observedAttributes() {return ['data-address-wallet', 'data-address']; }
attributeChangedCallback(name, oldValue, newValue) {
if (name=="data-address") {
this.address = newValue
}
if (name=="data-address-wallet") {
this.wallet = newValue
}
this.info.innerHTML = "<br>"
this.note.innerText = `{{ _("Loading address") }}: ${this.address} {{ _("details") }}...`;
this.fetchAddressData();
// Attach the created element to the shadow dom
shadow.appendChild(clone);
}
async fetchAddressData() {
@ -82,7 +64,6 @@
var formData = new FormData();
formData.append('address', this.address);
formData.append('csrf_token', '{{ csrf_token() }}');
console.log( this.address)
try {
const response = await fetch(
url,
@ -122,7 +103,7 @@
addressInfoHTML += `<img class="service-icon" src='{{ext_url_prefix}}/${this.serviceId}/static/${services[this.serviceId].icon}'>${services[this.serviceId].name}`;
} else {
let associateServiceUrl = `{{ url_for('services_endpoint.associate_addr', wallet_alias='WALLET_ALIAS', address='ADDRESS') }}`.replace('WALLET_ALIAS', this.wallet).replace('ADDRESS', jsonResponse.address);
addressInfoHTML += `<button type="button" class="btn" onclick="location.href='${associateServiceUrl}';">{{ _("Associate with a service") }}</button>`
addressInfoHTML += `<button type="button" id="associate-btn" class="btn" onclick="location.href='${associateServiceUrl}';">{{ _("Associate with a service") }}</button>`
}
addressInfoHTML += `
</td></tr>

View file

@ -16,7 +16,7 @@
<br>
<br>
<div class="row">
<button type="submit" class="btn">{{ _("Login") }}</button>
<button type="submit" id="login-btn" class="btn">{{ _("Login") }}</button>
</div>
</form>
</div>

View file

@ -64,7 +64,7 @@
{% endif %}
</div>
<div class="row">
<button type="submit" class="btn" name="action" value="save">{{ _("Save") }}</button>
<button type="submit" id="submit-btn" class="btn" name="action" value="save">{{ _("Save") }}</button>
</div>
{% if method == "usernamepassword" and current_user.is_admin %}
<br>

View file

@ -4,5 +4,5 @@
<img style="width: 100px;" src="{{ url_for('static', filename='img/party.png') }}"/><br><br>
<h1 style="font-size: 1.8em;">{{ _("Setup Completed Successfully!") }}</h1>
<p>{{ _("Your new node is up and running!") }}</p><br>
<a href="{{url_for('about')}}" class="btn wizard-btn action" id="finish-setup-btn">{{ _("Done") }}</a>
<a href="{{url_for('welcome_endpoint.about')}}" class="btn wizard-btn action" id="finish-setup-btn">{{ _("Done") }}</a>
{% endblock %}

View file

@ -17,3 +17,7 @@ def camelcase2snake_case(name):
pattern = re.compile(r"(?<!^)(?=[A-Z])")
name = pattern.sub("_", name).lower()
return name
def snake_case2camelcase(word):
return "".join(x.capitalize() or "_" for x in word.split("_"))

View file

@ -6,9 +6,12 @@ from pathlib import Path
import pkgutil
from pkgutil import iter_modules
import sys
from typing import List
from .common import camelcase2snake_case
from ..specter_error import SpecterError
from .reflection_fs import detect_extension_style_in_cwd, search_dirs_in_path
logger = logging.getLogger(__name__)
@ -60,6 +63,9 @@ def get_package_dir_for_subclasses_of(clazz):
raise SpecterError("Unknown Class: {clazz}")
# --------------- static discovery ------------------------------
def get_classlist_of_type_clazz_from_modulelist(clazz, modulelist):
"""A helper method converting a List of modules as described in config.py
into a List of classes. In order to make that more util-like, you
@ -76,28 +82,47 @@ def get_classlist_of_type_clazz_from_modulelist(clazz, modulelist):
issubclass(attribute, clazz)
and not attribute.__name__ == clazz.__name__
):
logger.debug(f"Adding {attribute} to {class_list}")
class_list.append(attribute)
logger.info(f" Found class {attribute.__name__}")
return class_list
def get_subclasses_for_clazz_in_cwd(clazz):
def get_subclasses_for_clazz_in_cwd(clazz, cwd=".") -> List[type]:
"""Returns all subclasses of class clazz located in the CWD if the cwd
is not a specter-desktop dev-env-kind-of-dir
is not a specter-desktop dev-env-kind-of-dir or contains any .py-file
So
"""
package_dirs = []
if Path("./src/cryptoadvance").is_dir() or getattr(sys, "frozen", False):
# No discovery in specter-desktop-dev-env (doesn't make sense)
# or appimage-mode (technically difficult on Linux and security-risk for
# appimage-users even on --config DevelopmentConfig)
# security first! No dynamic loading in app-images
if getattr(sys, "frozen", False):
return []
else:
package_dirs.append(".")
logger.info("Running in non-specter-src-folder. Added CWD to Service-Discovery")
return get_subclasses_for_clazz(clazz, package_dirs)
# if not testing but in a folder which looks like specter-desktop/src --> No dynamic extensions
if "PYTEST_CURRENT_TEST" not in os.environ:
if Path("./src/cryptoadvance").is_dir():
return []
# Depending on the style we either add "." or "./src" to the searchpath
extension_style = detect_extension_style_in_cwd()
if extension_style == "adhoc":
package_dirs.append(Path("."))
elif extension_style == "publish-ready":
package_dirs.extend(search_dirs_in_path(Path("./src")))
elif extension_style == "specter-desktop":
if "PYTEST_CURRENT_TEST" in os.environ:
# I admit, ugly hack
logger.info("We're in testing mode. Adding CWD to searchpath")
package_dirs.append(Path("./src"))
else:
raise Exception(f"This should not happen")
logger.info(f"Detected Extension-style: {extension_style}")
logger.info(f"We'll search in those package_dirs {package_dirs}")
return get_subclasses_for_clazz(clazz, package_dirs)
def get_subclasses_for_clazz(clazz, package_dirs=None):
def get_subclasses_for_clazz(clazz, package_dirs: List[str] = None):
"""Returns all subclasses of class clazz located in the CWD
potentially add additional_packagedirs which is usefull for
calculating pyinstaller hiddenimports
@ -105,11 +130,15 @@ def get_subclasses_for_clazz(clazz, package_dirs=None):
if package_dirs == None:
package_dirs = [get_package_dir_for_subclasses_of(clazz)]
class_list = []
logger.info(f"Collecting subclasses of {clazz.__name__} in {package_dirs}...")
for (_, module_name, _) in iter_modules(
package_dirs
logger.info(
f"Collecting subclasses of {clazz.__name__} in {' '.join([ str(dir) for dir in package_dirs]) }..."
)
for (importer, module_name, is_pkg) in iter_modules(
[str(dir) for dir in package_dirs]
): # import the module and iterate through its attributes
logger.debug(f"Iterating on {module_name} ")
logger.debug(
f"Iterating on importer={importer} , module_name={module_name} is_pkg={is_pkg}"
)
if clazz.__name__ == "Service":
try:
@ -117,20 +146,41 @@ def get_subclasses_for_clazz(clazz, package_dirs=None):
f"cryptoadvance.specter.services.{module_name}.service"
)
logger.debug(
f"Imported cryptoadvance.specter.services.{module_name}.service"
f" Imported cryptoadvance.specter.services.{module_name}.service"
)
except ModuleNotFoundError:
logger.debug(
f"No Service Impl found in cryptoadvance.specter.services.{module_name}."
)
# Ignore the stuff lying around in cryptoadvance/specter/services
if importer.path.endswith("cryptoadvance/specter/services"):
continue
try:
module = import_module(f"{module_name}.service")
logger.debug(f"Imported {module_name}.service")
logger.debug(f" Imported {module_name}.service")
except ModuleNotFoundError as e:
logger.debug(
f"No Service Impl found in {module_name}.service. Skipping!"
)
continue
try:
# Another style is orgname.specterext.extensionid, for that we have to guess the orgname:
orgname = str(importer).split("/")[-2]
logger.debug(f"guessing orgname: {orgname}")
module = import_module(
f"{orgname}.specterext.{module_name}.service"
)
logger.debug(
f" Imported {orgname}.specterext.{module_name}.service"
)
except ModuleNotFoundError as e:
if module_name in str(e.name) or orgname in str(e.name):
raise Exception(
f"""
While iterating over {importer} for module {module_name},
a Service implementation could not be found in this places:
* cryptoadvance.specter.services.{module_name}.service
* {module_name}.service
* {orgname}.specterext.{module_name}.service
Maybe you did forget to do this:
$ pip3 install -e .
"""
)
else:
raise e
elif clazz.__name__ == "SpecterMigration":
module = import_module(
f"cryptoadvance.specter.util.migrations.{module_name}"

View file

@ -0,0 +1,70 @@
""" util stuff for searching the filesystem mainly used by the reflection.py """
import logging
import os
import sys
from pathlib import Path
from typing import List
logger = logging.getLogger(__name__)
def detect_extension_style_in_cwd(cwd=".") -> str:
"""don't override the cwd other than in testing!!
if you do, you probably have to mess with sys.path at the same time
"""
if Path(cwd, "src/cryptoadvance/specter").is_dir() or getattr(sys, "frozen", False):
return "specter-desktop"
if Path(cwd, "src").is_dir():
if Path(cwd, "setup.py").is_file():
return "publish-ready"
else:
raise Exception(
f"""
You have an inconsistent project file-layout in folder
{Path(cwd).resolve()}
Either you:
* Have a src-folder and you can have .py-files in your projectroot OR
* you don't have a src-folder but ./extensionid/service.py (+ __init__.py)
But not having a ./src-folder AND some .py-file in the project-root is not allowed.
"""
)
else:
# Ad-Hoc style is: ./extensionid/service.py but no .py-files in cwd
# as this screws up the discovery
for pth in Path(cwd).iterdir():
if pth.suffix == ".py":
raise Exception(
f"""
You have an inconsistent project file-layout in folder
{Path(cwd).resolve()}
Either you:
* Have a src-folder and you can have .py-files in your projectroot OR
* you don't have a src-folder but ./extensionid/service.py (+ __init__.py)
But not having a ./src-folder AND some .py-file in the project-root is not allowed.
"""
)
return "adhoc"
def search_dirs_in_path(
path: Path, search_dirname="specterext", return_without_extid=True
) -> List[Path]:
"""recursively walks the filesystem collecting directories which are called "specterext"
returns a list of PATH all ending with specterext.
If return_without_extid is False, it'll return one level deeper which might be a different length
"""
if isinstance(path, str):
path = Path(path)
plist: List[Path] = []
if not path.is_dir():
raise Exception(f"Search path does not exist: {path}")
for root, dirs, _ in os.walk(path, followlinks=True):
for dirname in dirs:
if dirname == search_dirname:
if return_without_extid:
plist.append(Path(root, dirname))
else:
subdirs = [x for x in Path(root, dirname).iterdir() if x.is_dir()]
plist.extend(subdirs)
return plist

View file

@ -473,8 +473,6 @@ def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder):
def specter_app_with_config(config={}, specter=None):
"""helper-function to create SpecterFlasks"""
if specter == None:
specter = Specter()
if isinstance(config, dict):
tempClass = type("tempClass", (TestConfig,), {})
for key, value in config.items():

View file

@ -1,4 +1,5 @@
import logging
import os
import pytest
import sys
from flask import Blueprint
@ -100,7 +101,12 @@ def test_APP_URL_PREFIX(caplog):
caplog.set_level(logging.INFO)
caplog.set_level(logging.DEBUG, logger="cryptoadvance.specter")
myapp = specter_app_with_config(
config={"APP_URL_PREFIX": "/someprefix", "SPECTER_URL_PREFIX": ""}
config={
"APP_URL_PREFIX": "/someprefix",
"SPECTER_URL_PREFIX": "",
"EXT_URL_PREFIX": "/spc/ext",
"SPECTER_DATA_FOLDER": os.path.expanduser("~/.specter_testing"),
}
)
client = myapp.test_client()
login(client, "secret")
@ -132,6 +138,7 @@ def test_SPECTER_URL_PREFIX(caplog):
"APP_URL_PREFIX": "",
"SPECTER_URL_PREFIX": "/someprefix",
"EXT_URL_PREFIX": "/someprefix/extensions",
"SPECTER_DATA_FOLDER": os.path.expanduser("~/.specter_testing"),
}
)
client = myapp.test_client()

View file

@ -0,0 +1,25 @@
from cryptoadvance.specter.services.extension_gen import ExtGen, GithubUrlLoader
from jinja2 import FileSystemLoader
from mock import patch
def test_GithubUrlLoader():
gh_url_loader: ExtGen = GithubUrlLoader()
assert gh_url_loader.get_source(None, "/Readme.md")
def test_ExtGen(caplog):
with patch("cryptoadvance.specter.services.extension_gen.GithubUrlLoader") as mock:
extgen = ExtGen(
".",
"testorg",
"testext",
False,
"Some Author",
"some@mail",
# uncomment the below line to see a real generation
# tmpl_fs_source="../specterext-dummy",
dry_run=True,
)
extgen.generate()

View file

@ -54,9 +54,7 @@ def test_SwanClient(app):
sc = SwanClient("a_hostname", "a_access_token", 123123, "a_refresh_token")
with app.app_context():
assert not sc.is_access_token_valid()
assert (
sc.calc_callback_url() == "http://a_hostname/spc/ext/swan/oauth2/callback"
)
assert sc.calc_callback_url() == "http://a_hostname/svc/swan/oauth2/callback"
start_url = sc.get_oauth2_start_url("a_hostname")
start_url.startswith(

View file

@ -1,4 +1,8 @@
from cryptoadvance.specter.util.common import camelcase2snake_case, str2bool
from cryptoadvance.specter.util.common import (
camelcase2snake_case,
snake_case2camelcase,
str2bool,
)
def test_str2bool():
@ -20,3 +24,8 @@ def test_str2bool():
def test_camelcase2snake_case():
assert camelcase2snake_case("Service") == "service"
assert camelcase2snake_case("DeviceType") == "device_type"
def test_snake_case2camelcase():
assert snake_case2camelcase("service") == "Service"
assert snake_case2camelcase("device_Type") == "DeviceType"

View file

@ -12,11 +12,11 @@ from cryptoadvance.specter.util.price_providers import (
from mock import MagicMock
def test_underlying_requests():
def test_underlying_requests(empty_data_folder):
"""This test might fail on MacOS
see #1512
"""
specter_mock = Specter()
specter_mock = Specter(data_folder=empty_data_folder)
requests_session = specter_mock.requests_session()
currency = "eur"
price = requests_session.get(
@ -26,8 +26,8 @@ def test_underlying_requests():
assert float(price)
def test_failsafe_request_get():
specter_mock = Specter()
def test_failsafe_request_get(empty_data_folder):
specter_mock = Specter(data_folder=empty_data_folder)
requests_session = specter_mock.requests_session()
currency = "notExisting"
url = "https://www.bitstamp.net/api/v2/ticker/btc{}".format(currency)

View file

@ -1,10 +1,13 @@
import logging
from pathlib import Path
from typing import List
from cryptoadvance.specter.util.reflection import (
get_subclasses_for_clazz,
get_subclasses_for_clazz_in_cwd,
get_classlist_of_type_clazz_from_modulelist,
_get_module_from_class,
get_package_dir_for_subclasses_of,
search_dirs_in_path,
)
from cryptoadvance.specter.util.specter_migrator import SpecterMigration
from cryptoadvance.specter.util.migrations.migration_0000 import SpecterMigration_0000
@ -45,6 +48,15 @@ def test_get_classlist_from_importlist(caplog):
assert BitcoinReserveService in classlist
def test_get_subclasses_for_clazz_in_cwd(caplog):
caplog.set_level(logging.DEBUG)
classlist: List[type] = get_subclasses_for_clazz_in_cwd(
Service, cwd="./tests/xtestdata_testextensions"
)
# damn, this is difficult to test
# assert len(classlist) == 3
def test_get_subclasses_for_class(caplog):
caplog.set_level(logging.INFO)
classlist = get_subclasses_for_clazz(SpecterMigration)

View file

@ -0,0 +1,62 @@
import os
from pathlib import Path
from cryptoadvance.specter.util.reflection_fs import (
search_dirs_in_path,
detect_extension_style_in_cwd,
)
from typing import List
def test_search_dir_in_cwd():
plist: List[Path] = search_dirs_in_path(Path("./tests/xtestdata_testextensions"))
assert len(plist) == 2
assert isinstance(plist[0], Path)
assert (
Path(
"tests/xtestdata_testextensions/ext_root_fully_qualified_1/src/boatacccorp/specterext"
)
in plist
)
assert (
Path(
"tests/xtestdata_testextensions/ext_root_fully_qualified_2/src/boatacccorp/specterext"
)
in plist
)
plist = search_dirs_in_path(
Path("./tests/xtestdata_testextensions"), return_without_extid=False
)
assert len(plist) == 2
assert isinstance(plist[0], Path)
assert (
Path(
"tests/xtestdata_testextensions/ext_root_fully_qualified_1/src/boatacccorp/specterext/tretboot"
)
in plist
)
assert (
Path(
"tests/xtestdata_testextensions/ext_root_fully_qualified_2/src/boatacccorp/specterext/ruderboot"
)
in plist
)
def test_detect_extension_style_in_cwd():
assert (
detect_extension_style_in_cwd(
"tests/xtestdata_testextensions/ext_root_fully_qualified_1"
)
== "publish-ready"
) #
assert (
detect_extension_style_in_cwd(
"tests/xtestdata_testextensions/ext_root_fully_qualified_2"
)
== "publish-ready"
)
assert (
detect_extension_style_in_cwd("tests/xtestdata_testextensions/ext_root_adhoc_1")
== "adhoc"
)

View file

@ -0,0 +1,57 @@
import logging
from flask import redirect, render_template, request, url_for, flash
from flask import current_app as app
from flask_login import login_required, current_user
from cryptoadvance.specter.services.controller import user_secret_decrypted_required
from cryptoadvance.specter.user import User
from cryptoadvance.specter.wallet import Wallet
from .service import BreznguatService
logger = logging.getLogger(__name__)
breznguat_endpoint = BreznguatService.blueprint
@breznguat_endpoint.route("/")
@login_required
@user_secret_decrypted_required
def index():
return render_template(
"breznguat/index.jinja",
)
@breznguat_endpoint.route("/settings", methods=["GET"])
@login_required
@user_secret_decrypted_required
def settings_get():
associated_wallet: Wallet = BreznguatService.get_associated_wallet()
# Get the user's Wallet objs, sorted by Wallet.name
wallet_names = sorted(current_user.wallet_manager.wallets.keys())
wallets = [current_user.wallet_manager.wallets[name] for name in wallet_names]
return render_template(
"breznguat/settings.jinja",
associated_wallet=associated_wallet,
wallets=wallets,
cookies=request.cookies,
)
@breznguat_endpoint.route("/settings", methods=["POST"])
@login_required
@user_secret_decrypted_required
def settings_post():
show_menu = request.form["show_menu"]
user = app.specter.user_manager.get_user()
if show_menu == "yes":
user.add_service(BreznguatService.id)
else:
user.remove_service(BreznguatService.id)
used_wallet_alias = request.form.get("used_wallet")
if used_wallet_alias != None:
wallet = current_user.wallet_manager.get_by_alias(used_wallet_alias)
return redirect(url_for(f"{BreznguatService.get_blueprint_name()}.settings_get"))

View file

@ -0,0 +1,50 @@
import logging
from cryptoadvance.specter.services.service import (
Service,
devstatus_alpha,
devstatus_prod,
)
# A SpecterError can be thrown and will be shown to the user as a red banner
from cryptoadvance.specter.specter_error import SpecterError
from flask import current_app as app
from cryptoadvance.specter.wallet import Wallet
logger = logging.getLogger(__name__)
class BreznguatService(Service):
id = "breznguat"
name = "Breznguat Service"
icon = "breznguat/img/ghost.png"
logo = "breznguat/img/breznguat_logo.jpeg"
desc = "Where a Breznguat grows bigger."
has_blueprint = True
blueprint_module = "breznguat.controller"
devstatus = devstatus_alpha
piggyback = False
# TODO: As more Services are integrated, we'll want more robust categorization and sorting logic
sort_priority = 2
# ServiceEncryptedStorage field names for this service
# Those will end up as keys in a json-file
SPECTER_WALLET_ALIAS = "wallet"
@classmethod
def get_associated_wallet(cls) -> Wallet:
"""Get the Specter `Wallet` that is currently associated with this service"""
service_data = cls.get_current_user_service_data()
if not service_data or cls.SPECTER_WALLET_ALIAS not in service_data:
# Service is not initialized; nothing to do
return
try:
return app.specter.wallet_manager.get_by_alias(
service_data[cls.SPECTER_WALLET_ALIAS]
)
except SpecterError as e:
logger.debug(e)
# Referenced an unknown wallet
# TODO: keep ignoring or remove the unknown wallet from service_data?
return

View file

@ -0,0 +1,16 @@
{% from 'components/menu_item.jinja' import menu_item %}
{#
vaultoro_menu - Tabs menu to navigate between the vaultoro screens.
Parameters:
- active_menuitem: Current active tab. Options: 'general', 'bitcoin_core', 'auth'.
#}
{% macro breznguat_menu(active_menuitem) -%}
<nav class="row collapse-on-mobile">
{{ menu_item(service.id, 'index', 'Main', active_menuitem, isLeft=true) }}
{{ menu_item(service.id, 'settings_get', 'Settings', active_menuitem, isRight=true) }}
<a href="javascript:void(0);" class="mobile-nav-icon" onclick="toggleMobileNav(this, `{{ url_for('static', filename='img/expand-more.svg') }}`, `{{ url_for('static', filename='img/expand-less.svg') }}`)">
<img style="width: 36px;" src="{{ url_for('static', filename='img/expand-more.svg') }}"/>
</a>
</nav>
{%- endmacro %}

View file

@ -0,0 +1,8 @@
{% extends "base.jinja" %}
{% block main %}
<img src="{{ url_for(service.id +'_endpoint' + '.static', filename=service.logo) }}" width="300"/>
{% from 'breznguat/components/breznguat_menu.jinja' import breznguat_menu with context %}
{{ breznguat_menu(tab) }}
{% block content %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,87 @@
{% extends "breznguat/components/breznguat_tab.jinja" %}
{% block title %}Settings{% endblock %}
{% set tab = 'index' %}
{% block content %}
<style>
.tagline {
font-style: italic;
font-size: 1.1em;
text-align: center;
color: #aaa;
padding-left: 4em;
padding-right: 4em;
}
.button_wide {
text-decoration: none;
color: white;
}
.button_wide:hover {
color: white;
}
.big_option {
display: inline-block;
width: 12em;
padding: 1em;
vertical-align: middle;
height: 3.5em;
border: 4px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
font-size: 1.5em;
margin: 1em;
position: relative;
}
.big_option:hover {
background-color: var(--cmap-bg-lightest);
border: 4px solid var(--main-color);
cursor: pointer;
}
.big_option_text {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.bonus {
color: #999;
font-size: 0.7em;
font-style: italic;
}
.highlights {
margin-top: 2em;
margin-bottom: 1.5em;
}
.show_list_bullet {
margin-top: 0.5em;
display: inline-block;
width: auto;
}
.show_list_bullet li {
text-align: left;
list-style-type: disc;
line-height: 1.7em;
}
.plus_sign {
font-size: 3em;
color: #ccc;
vertical-align: text-bottom;
}
</style>
<br><br>
<div class="tagline">&ldquo;Breznguatservice 4thewin.&rdquo;</div>
{% endblock %}
{% block scripts %}
<script>
</script>
{% endblock %}

View file

@ -0,0 +1,109 @@
{% extends "breznguat/components/breznguat_tab.jinja" %}
{% block title %}Settings{% endblock %}
{% set tab = 'settings_get' %}
{% block content %}
<br/>
<style>
.big_option {
display: inline-block;
width: 14em;
padding: 1em;
vertical-align: middle;
height: 3.5em;
border: 4px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
font-size: 1.5em;
margin: 1em;
position: relative;
}
.big_option:hover {
background-color: var(--cmap-bg-lightest);
border: 4px solid var(--main-color);
cursor: pointer;
}
.big_option_text {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.instructions {
color: #999;
font-style: italic;
}
{# TODO: End Remove #}
.css-1rbd7t8 {
box-sizing: border-box;
margin: 60px 0px 24px;
min-width: 0px;
}
.css-yn7azs {
box-sizing: border-box;
margin: 0px;
min-width: 0px;
-moz-box-pack: justify;
justify-content: space-between;
position: relative;
font-family: "Source Serif Pro", serif;
font-weight: 600;
line-height: 1.5;
letter-spacing: -0.02em;
font-variant: common-ligatures;
text-rendering: optimizelegibility;
font-size: 16px;
display: flex;
}
</style>
<div class="card">
<h1>{{ _("Configure your extension") }}</h1>
<br>
<div class="note">
{{ _("- Maybe you want to put some notes for the user") }}<br/>
{{ _("- One important setting is whether your extension gets a menu-point on the left") }}<br/>
{{ _("- Let us assume you want the user to use a specific wallet. That should go here. ") }}<br/>
</div>
<br/>
<form action="{{ url_for(service.get_blueprint_name() + '.settings_post') }}" method="POST" role="form">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div>Show Menu Item:</div>
<select name="show_menu">
<option value="yes" {% if show_menu == 'yes' %}selected{% endif %}>Yes</option>
<option value="no" {% if show_menu == 'no' %}selected{% endif %}>No</option>
</select>
<br/>
<br/>
<br/>
{{ _("Choose which wallet should be used:") }}:<br>
<select name="used_wallet">
{% for wallet in wallets %}
<option value="{{ wallet.alias }}" {% if associated_wallet == wallet %}selected{% endif %}>{{ wallet.name }}</option>
{% endfor %}
</select>
<br/>
<br/>
<br/>
<div class="row">
<button type="submit" class="btn">{{ _("Save") }}</button>
</div>
</form>
</div>
<br/>
<br/>
<br/>
{% endblock %}

View file

@ -0,0 +1 @@
cryptoadvance.specter==1.8.1

View file

@ -0,0 +1,507 @@
import atexit
import json
import logging
import os
import shutil
import subprocess
import tempfile
import time
import docker
import pytest
from cryptoadvance.specter.managers.device_manager import DeviceManager
from cryptoadvance.specter.managers.user_manager import UserManager
from cryptoadvance.specter.process_controller.bitcoind_controller import (
BitcoindPlainController,
)
from cryptoadvance.specter.process_controller.bitcoind_docker_controller import (
BitcoindDockerController,
)
from cryptoadvance.specter.process_controller.elementsd_controller import (
ElementsPlainController,
)
from cryptoadvance.specter.rpc import BitcoinRPC
from cryptoadvance.specter.server import SpecterFlask, create_app, init_app
from cryptoadvance.specter.specter import Specter
from cryptoadvance.specter.specter_error import SpecterError
from cryptoadvance.specter.user import User, hash_password
from cryptoadvance.specter.util.wallet_importer import WalletImporter
from cryptoadvance.specter.util.common import str2bool
from cryptoadvance.specter.util.shell import which
import code, traceback, signal
logger = logging.getLogger(__name__)
pytest_plugins = ["ghost_machine"]
# This is from https://stackoverflow.com/questions/132058/showing-the-stack-trace-from-a-running-python-application
# it enables stopping a hanging test via sending the pytest-process a SIGUSR2 (12)
# kill 12 pid-of-pytest
# In the article they claim to open a debug-console which didn't work for me but at least
# you get a stacktrace in the output.
def debug(sig, frame):
"""Interrupt running process, and provide a python prompt for
interactive debugging."""
d = {"_frame": frame} # Allow access to frame object.
d.update(frame.f_globals) # Unless shadowed by global
d.update(frame.f_locals)
i = code.InteractiveConsole(d)
message = "Signal received : entering python shell.\nTraceback:\n"
message += "".join(traceback.format_stack(frame))
i.interact(message)
def listen():
signal.signal(signal.SIGUSR2, debug) # Register handler
def pytest_addoption(parser):
"""Internally called to add options to pytest
see pytest_generate_tests(metafunc) on how to check that
Also used to register the SIGUSR2 (12) as decribed in conftest.py
"""
parser.addoption("--docker", action="store_true", help="run bitcoind in docker")
parser.addoption(
"--bitcoind-version",
action="store",
default="v0.20.1",
help="Version of bitcoind (something which works with git checkout ...)",
)
parser.addoption(
"--bitcoind-log-stdout",
action="store",
default=False,
help="Whether bitcoind should log to stdout (default:False)",
)
parser.addoption(
"--elementsd-version",
action="store",
default="master",
help="Version of elementsd (something which works with git checkout ...)",
)
listen()
def pytest_generate_tests(metafunc):
# ToDo: use custom compiled version of bitcoind
# E.g. test again bitcoind version [currentRelease] + master-branch
if "docker" in metafunc.fixturenames:
if metafunc.config.getoption("docker"):
# That's a list because we could do both (see above) but currently that doesn't make sense in that context
metafunc.parametrize("docker", [True], scope="session")
else:
metafunc.parametrize("docker", [False], scope="session")
def instantiate_bitcoind_controller(docker, request, rpcport=18543, extra_args=[]):
# logging.getLogger().setLevel(logging.DEBUG)
requested_version = request.config.getoption("--bitcoind-version")
log_stdout = str2bool(request.config.getoption("--bitcoind-log-stdout"))
if docker:
from cryptoadvance.specter.process_controller.bitcoind_docker_controller import (
BitcoindDockerController,
)
bitcoind_controller = BitcoindDockerController(
rpcport=rpcport, docker_tag=requested_version
)
else:
if os.path.isfile("tests/bitcoin/src/bitcoind"):
bitcoind_controller = BitcoindPlainController(
bitcoind_path="tests/bitcoin/src/bitcoind", rpcport=rpcport
) # always prefer the self-compiled bitcoind if existing
elif os.path.isfile("tests/bitcoin/bin/bitcoind"):
bitcoind_controller = BitcoindPlainController(
bitcoind_path="tests/bitcoin/bin/bitcoind", rpcport=rpcport
) # next take the self-installed binary if existing
else:
bitcoind_controller = BitcoindPlainController(
rpcport=rpcport
) # Alternatively take the one on the path for now
bitcoind_controller.start_bitcoind(
cleanup_at_exit=True,
cleanup_hard=True,
extra_args=extra_args,
log_stdout=log_stdout,
)
assert not bitcoind_controller.datadir is None
running_version = bitcoind_controller.version()
requested_version = request.config.getoption("--bitcoind-version")
assert running_version == requested_version, (
"Please make sure that the Bitcoind-version (%s) matches with the version in pytest.ini (%s)"
% (running_version, requested_version)
)
return bitcoind_controller
def instantiate_elementsd_controller(request, rpcport=18643, extra_args=[]):
if os.path.isfile("tests/elements/src/elementsd"):
elementsd_controller = ElementsPlainController(
elementsd_path="tests/elements/src/elementsd", rpcport=rpcport
) # always prefer the self-compiled bitcoind if existing
elif os.path.isfile("tests/elements/bin/elementsd"):
elementsd_controller = ElementsPlainController(
elementsd_path="tests/elements/bin/elementsd", rpcport=rpcport
) # next take the self-installed binary if existing
else:
elementsd_controller = ElementsPlainController(
rpcport=rpcport
) # Alternatively take the one on the path for now
elementsd_controller.start_elementsd(
cleanup_at_exit=True, cleanup_hard=True, extra_args=extra_args
)
assert not elementsd_controller.datadir is None
running_version = elementsd_controller.version()
requested_version = request.config.getoption("--elementsd-version")
assert running_version == requested_version, (
"Please make sure that the elementsd-version (%s) matches with the version in pytest.ini (%s)"
% (running_version, requested_version)
)
return elementsd_controller
# Below this point are fixtures. Fixtures have a scope. Check about scopes here:
# https://docs.pytest.org/en/6.2.x/fixture.html#scope-sharing-fixtures-across-classes-modules-packages-or-session
# possible values: function, class, module, package or session.
# The nodes are of scope session. All else is the default (function)
@pytest.fixture(scope="session")
def bitcoind_path():
if os.path.isfile("tests/bitcoin/src/bitcoind"):
return "tests/bitcoin/src/bitcoind"
elif os.path.isfile("tests/bitcoin/bin/bitcoind"):
return "tests/bitcoin/bin/bitcoind"
else:
return which("bitcoind")
@pytest.fixture(scope="session")
def bitcoin_regtest(docker, request):
bitcoind_regtest = instantiate_bitcoind_controller(docker, request, extra_args=None)
try:
assert bitcoind_regtest.get_rpc().test_connection()
assert not bitcoind_regtest.datadir is None
yield bitcoind_regtest
finally:
bitcoind_regtest.stop_bitcoind()
@pytest.fixture(scope="session")
def elements_elreg(request):
elements_elreg = instantiate_elementsd_controller(request, extra_args=None)
try:
yield elements_elreg
assert not elements_elreg.datadir is None
finally:
elements_elreg.stop_elementsd()
@pytest.fixture
def empty_data_folder():
# Make sure that this folder never ever gets a reasonable non-testing use-case
with tempfile.TemporaryDirectory(prefix="specter_home_tmp_") as data_folder:
yield data_folder
@pytest.fixture
def devices_filled_data_folder(empty_data_folder):
os.makedirs(empty_data_folder + "/devices")
with open(empty_data_folder + "/devices/trezor.json", "w") as text_file:
text_file.write(
"""
{
"name": "Trezor",
"type": "trezor",
"keys": [
{
"derivation": "m/49h/0h/0h",
"original": "ypub6XFn7hfb676MLm6ZsAviuQKXeRDNgT9Bs32KpRDPnkKgKDjKcrhYCXJ88aBfy8co2k9eujugJX5nwq7RPG4sj6yncDEPWN9dQGmFWPy4kFB",
"fingerprint": "1ef4e492",
"type": "sh-wpkh",
"xpub": "xpub6CRWp2zfwRYsVTuT2p96hKE2UT4vjq9gwvW732KWQjwoG7v6NCXyaTdz7NE5yDxsd72rAGK7qrjF4YVrfZervsJBjsXxvTL98Yhc7poBk7K"
},
{
"derivation": "m/84h/0h/0h",
"original": "zpub6rGoJTXEhKw7hUFkjMqNctTzojkzRPa3VFuUWAirqpuj13mRweRmnYpGD1aQVFpxNfp17zVU9r7F6oR3c4zL3DjXHdewVvA7kjugHSqz5au",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "xpub6CcGh8BQPxr9zssX4eG8CiGzToU6Y9b3f2s2wNw65p9xtr8ySL6eYRVzAbfEVSX7ZPaPd3JMEXQ9LEBvAgAJSkNKYxG6L6X9DHnPWNQud4H"
},
{
"derivation": "m/48h/0h/0h/1h",
"original": "Ypub6jtWQ1r2D7EwqNoxERU28MWZH4WdL3pWdN8guFJRBTmGwstJGzMXJe1VaNZEuAAVsZwpKPhs5GzNPEZR77mmX1mjwzEiouxmQYsrxFBNVNN",
"fingerprint": "1ef4e492",
"type": "sh-wsh",
"xpub": "xpub6EA9y7SfVU96ZWTTTQDR6C5FPJKvB59RPyxoCb8zRgYzGbWAFvogbTVRkTeBLpHgETm2hL7BjQFKNnL66CCoaHyUFBRtpbgHF6YLyi7fr6m"
},
{
"derivation": "m/48h/0h/0h/2h",
"original": "Zpub74imhgWwMnnRkSPkiNavCQtSBu1fGo8RP96h9eT2GHCgN5eFU9mZVPhGphvGnG26A1cwJxtkmbHR6nLeTw4okpCDjZCEj2HRLJoVHAEsch9",
"fingerprint": "1ef4e492",
"type": "wsh",
"xpub": "xpub6EA9y7SfVU96dGr96zYgxAMd8AgWBCTqEeQafbPi8VcWdhStCS4AA9X4yb3dE1VM7GKLwRhWy4BpD3VkjK5q1riMAQgz9oBSu8QKv5S7KzD"
},
{
"derivation": "m/49h/1h/0h",
"original": "upub5EKoQv21nQNkhdt4yuLyRnWitA3EGhW1ru1Y8VTG8gdys2JZhqiYkhn4LHp2heHnH41kz95bXPvrYVRuFUrdUMik6YdjFV4uL4EubnesttQ",
"fingerprint": "1ef4e492",
"type": "sh-wpkh",
"xpub": "tpubDDCDr9rSwixeXKeGwAgwFy8bjBaE5wya9sAVqEC4ccXWmcQxY34KmLRJdwmaDsCnHsu5r9P9SUpYtXmCoRwukWDqmAUJgkBbjC2FXUzicn6"
},
{
"derivation": "m/84h/1h/0h",
"original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
},
{
"derivation": "m/48h/1h/0h/1h",
"original": "Upub5Tk9tZtdzVaTGWtygRTKDDmaN5vfB59pn2L5MQyH6BkVpg2Y5J95rtpQndjmXNs3LNFiy8zxpHCTtvxxeePjgipF7moTHQZhe3E5uPzDXh8",
"fingerprint": "1ef4e492",
"type": "sh-wsh",
"xpub": "tpubDFiVCZzdarbyfdVoh2LJDL3eVKRPmxwnkiqN8tSYCLod75a2966anQbjHajqVAZ97j54xZJPr9hf7ogVuNL4pPCfwvXdKGDQ9SjZF7vXQu1"
},
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5naRCEZZ9B7wCKLWuqoNdg6ddWEx8ruztUygXFZDJtW5LRMqUP5HV2TsNw1nc74Ba3QPDSH7qzauZ8LdfNmnmofpfmztCGPgP7vaaYSmpgN",
"fingerprint": "1ef4e492",
"type": "wsh",
"xpub": "tpubDFiVCZzdarbyk8kE65tjRhHCambEo8iTx4xkXL8b33BKZj66HWsDnUb3rg4GZz6Mwm6vTNyzRCjYtiScCQJ77ENedb2deDDtcoNQXiUouJQ"
}
]
}
"""
)
with open(empty_data_folder + "/devices/specter.json", "w") as text_file:
text_file.write(
"""
{
"name": "Specter",
"type": "specter",
"keys": [
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
"fingerprint": "08686ac6",
"type": "wsh",
"xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL"
},
{
"derivation": "m/84h/1h/0h",
"original": "vpub5ZSem3mLXiSJzgDX6pJb2N9L6sJ8m6ejaksLPLSuB53LBzCi2mMsBg19eEUSDkHtyYp75GATjLgt5p3S43WjaVCXAWU9q9H5GhkwJBrMiAb",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUiy4ncDirveTfhmvggdj8nxcW5JgHpGzYz3UVscJY5aEzFvgUPk4YyajadBnsTBmE2YZmAtJC14Q21xncJgVaHQ7UdqMRVRbU"
},
{
"derivation": "m/84h/1h/1h",
"original": "vpub5ZSem3mLXiSK55jPzfLVhbHbTEwGzEFZv3xrGFCw1vGHSNw7WcVuJXysJLWcgENQd3iXSNQaeSXUBW55Hy4GAjSTjrWP4vpKKkUN9jiU1Tc",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj3UJV7ZtqKgoy8JKprrjdHubbBb3r7qmwHsEH69g7h6xyanWaCYdVEEV3Yu7a6s4ceFnp8DjXeeFxY8eXvH7XTAC4gxfDNEW"
},
{
"derivation": "m/84h/1h/2h",
"original": "vpub5ZSem3mLXiSK64v64deytnDCoYqbUSYHvmVurUGVMEnXMyEybtF3FEnNuiFDDC6J18a81fv5ptQXaQaaRiYx8MRxahipgxPLdxubpYt1dkD",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj4TVBBYDKWsjaUcE9M52MJd8emp7QTAJBDTY9BRRFdomVCAFAjWMNcKLe8Cd5HJwg3AJKFyEDcGFTNyryYJgYmNdJMhwB2RG"
},
{
"derivation": "m/84h/1h/3h",
"original": "vpub5ZSem3mLXiSK8cKzh4sHxTvN7mgYQA29HfoAZeCDtX1M2zdejN5XVAtVyqhk8eui18JTtZ9M3VD3AiWCz8VwrybhBUh3HxzS8js3mLVybDT",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj6zu5oyRdaZSjnq56GnWCfXRuUz38zSWztUvpJuFjsjscGHhheyAncK4z15rLVukBdUDwpPBDLtRBykqC9KHeG9akJWRipKK"
}
]
}
"""
)
return empty_data_folder # no longer empty, though
@pytest.fixture
def wallets_filled_data_folder(devices_filled_data_folder):
os.makedirs(os.path.join(devices_filled_data_folder, "wallets", "regtest"))
with open(
os.path.join(devices_filled_data_folder, "wallets", "regtest", "simple.json"),
"w",
) as json_file:
json_file.write(
"""
{
"alias": "simple",
"fullpath": "/home/kim/.specter/wallets/regtest/simple.json",
"name": "Simple",
"address_index": 0,
"keypool": 5,
"address": "bcrt1qcatuhg0gll3h7py4cmn53rjjn9xlsqfwj3zcej",
"change_index": 0,
"change_address": "bcrt1qt28v03278lmmxllys89acddp2p5y4zds94944n",
"change_keypool": 5,
"type": "simple",
"description": "Single (Segwit)",
"keys": [{
"derivation": "m/84h/1h/0h",
"original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
}],
"recv_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr",
"change_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/1/*)#h4z73prm",
"device": "Trezor",
"device_type": "trezor",
"address_type": "bech32"
}
"""
)
return devices_filled_data_folder # and with wallets obviously
@pytest.fixture
def device_manager(devices_filled_data_folder):
return DeviceManager(os.path.join(devices_filled_data_folder, "devices"))
# @pytest.fixture
# def user_manager(empty_data_folder) -> UserManager:
# """A UserManager having users alice, bob and eve"""
# specter = Specter(data_folder=empty_data_folder)
# user_manager = UserManager(specter=specter)
# config = {}
# user_manager.get_user("admin").decrypt_user_secret("admin")
# user_manager.create_user(
# user_id="alice",
# username="alice",
# plaintext_password="plain_pass_alice",
# config=config,
# )
# user_manager.create_user(
# user_id="bob",
# username="bob",
# plaintext_password="plain_pass_bob",
# config=config,
# )
# user_manager.create_user(
# user_id="eve",
# username="eve",
# plaintext_password="plain_pass_eve",
# config=config,
# )
# return user_manager
@pytest.fixture
def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder):
assert bitcoin_regtest.get_rpc().test_connection()
config = {
"rpc": {
"autodetect": False,
"datadir": "",
"user": bitcoin_regtest.rpcconn.rpcuser,
"password": bitcoin_regtest.rpcconn.rpcpassword,
"port": bitcoin_regtest.rpcconn.rpcport,
"host": bitcoin_regtest.rpcconn.ipaddress,
"protocol": "http",
},
"auth": {
"method": "rpcpasswordaspin",
},
}
specter = Specter(data_folder=devices_filled_data_folder, config=config)
assert specter.chain == "regtest"
# Create a User
someuser = specter.user_manager.add_user(
User.from_json(
user_dict={
"id": "someuser",
"username": "someuser",
"password": hash_password("somepassword"),
"config": {},
"is_admin": False,
"services": None,
},
specter=specter,
)
)
specter.user_manager.save()
specter.check()
assert not someuser.wallet_manager.working_folder is None
# Create a Wallet
wallet_json = '{"label": "a_simple_wallet", "blockheight": 0, "descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr", "devices": [{"type": "trezor", "label": "trezor"}]} '
wallet_importer = WalletImporter(
wallet_json, specter, device_manager=someuser.device_manager
)
wallet_importer.create_nonexisting_signers(
someuser.device_manager,
{"unknown_cosigner_0_name": "trezor", "unknown_cosigner_0_type": "trezor"},
)
dm: DeviceManager = someuser.device_manager
wallet = wallet_importer.create_wallet(someuser.wallet_manager)
try:
# fund it with some coins
bitcoin_regtest.testcoin_faucet(address=wallet.getnewaddress())
# make sure it's confirmed
bitcoin_regtest.mine()
# Realize that the wallet has funds:
wallet.update()
except SpecterError as se:
if str(se).startswith("Timeout"):
pytest.fail(
"We got a Bitcoin-RPC timeout while setting up the test, minting some coins. Test Error! Check cpu/mem utilastion and btc/elem logs!"
)
return
else:
raise se
assert wallet.fullbalance >= 20
assert not specter.wallet_manager.working_folder is None
try:
yield specter
finally:
# Deleting all Wallets (this will also purge them on core)
for user in specter.user_manager.users:
for wallet in list(user.wallet_manager.wallets.values()):
user.wallet_manager.delete_wallet(
wallet, bitcoin_datadir=bitcoin_regtest.datadir, chain="regtest"
)
@pytest.fixture
def app(specter_regtest_configured) -> SpecterFlask:
"""the Flask-App, but uninitialized"""
app = create_app(config="cryptoadvance.specter.config.TestConfig")
app.app_context().push()
app.config["TESTING"] = True
app.testing = True
app.tor_service_id = None
app.tor_enabled = False
init_app(app, specter=specter_regtest_configured)
return app
@pytest.fixture
def app_no_node(empty_data_folder) -> SpecterFlask:
specter = Specter(data_folder=empty_data_folder)
app = create_app(config="cryptoadvance.specter.config.TestConfig")
app.app_context().push()
app.config["TESTING"] = True
app.testing = True
app.tor_service_id = None
app.tor_enabled = False
init_app(app, specter=specter)
return app
@pytest.fixture
def client(app):
"""a test_client from an initialized Flask-App"""
return app.test_client()

View file

@ -0,0 +1,67 @@
import pytest
# Using https://iancoleman.io/bip39/ and https://jlopp.github.io/xpub-converter/
# mnemonic = "ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost machine"
# m/44'/0'/0'
@pytest.fixture
def ghost_machine_xpub_44():
xpub = "xpub6CGap5qbgNCEsvXg2gAjEho17zECMA9PbZa7QkrEWTPnPRaubE6qKots5pNwhyFtuYSPa9gQu4jTTZi8WPaXJhtCHrvHQaFRqayN1saQoWv"
return xpub
# m/49'/0'/0'
@pytest.fixture
def ghost_machine_xpub_49():
xpub = "xpub6BtcNhqbaFaoC3oEfKky3Sm22pF48U2jmAf78cB3wdAkkGyAgmsVrgyt1ooSt3bHWgzsdUQh2pTJ867yTeUAMmFDKNSBp8J7WPmp7Df7zjv"
return xpub
@pytest.fixture
def ghost_machine_ypub():
ypub = "ypub6WisgNWWiw8H3LzMVgYbFXrXCnPW562EgHBKv14wKdYdoNnPwS34Uke231m2sxFCvL7gNx1FVUor1NjYBLtB9zvpBi8cQ37bn7qTVqo3fjR"
return ypub
@pytest.fixture
def ghost_machine_tpub_49():
tpub = "tpubDC5CZBbVc15fpTeqkyUBKgHqYCqkeaUtPjvGz7RJEttndfcN29psPcxTSj5RNJaWYaRQq8kqovLBrZA2tju3ThSAP9fY1eiSvorchnseFZu"
return tpub
@pytest.fixture
def ghost_machine_upub():
upub = "upub5DCn7wm4SgVmzmtdoi8DVVfxhBJkqL1L6mmKHNgVky1Fj5VyBxV6NzKD957sr5fWXkY5y8THtqSVWWpjLnomBYw4iXpxaPbkXg5Gn6s5tQf"
return upub
# m/84'/0'/0'
@pytest.fixture
def ghost_machine_xpub_84():
xpub = "xpub6CjsHfiuBnHMPBkxThQ4DDjTw2Qq3VMEVcPBoMBGejZGkj3WQR15LeJLmymPpSzYHX21C8SdFWHgMw2RUBdAQ2Aj4MMS93a68mxPQeS8oHr"
return xpub
@pytest.fixture
def ghost_machine_zpub():
zpub = "zpub6rQPu14jV9NK5n9C8QyJdPvUGxhivjLEKqRdN8y3QkK2rvfxujLCamccpPgZpGJP6oFch5dkApzn8WFYuaTBzVXvo2kHJsD4gE5gBnCBYj1"
return zpub
@pytest.fixture
def ghost_machine_tpub_84():
tpub = "tpubDC4DsqH5rqHqipMNqUbDFtQT3AkKkUrvLsN6miySvortU3s1LGaNVAb7wX2No2VsuxQV82T8s3HJLv3kdx1CPjsJ3onC1Zo5mWCQzRVaWVX"
return tpub
@pytest.fixture
def ghost_machine_vpub():
vpub = "vpub5Y24kG7ZrCFRkRnHia2sdnt5N7MmsrNry1jMrP8XptMEcZZqkjQA6bc1f52RGiEoJmdy1Vk9Qck9tAL1ohKvuq3oFXe3ADVse6UiTHzuyKx"
return vpub

View file

@ -0,0 +1,5 @@
recursive-include src/boatacccorp/specterext/tretboot/templates *
recursive-include src/boatacccorp/specterext/tretboot/static *
recursive-include src/boatacccorp/specterext/tretboot/*/LC_MESSAGES *.mo
recursive-include src/boatacccorp/specterext/tretboot/translations/*/LC_MESSAGES *.po
include requirements.txt

View file

@ -0,0 +1,5 @@
[build-system]
requires = [
"cryptoadvance.specter==1.8.1"
]
build-backend = "setuptools.build_meta"

View file

@ -0,0 +1 @@
cryptoadvance.specter==1.8.1

View file

@ -0,0 +1,24 @@
[metadata]
name = boatacccorp_tretboot
version = 0.0.1
author = Your Name
author_email = some_mail@mail.com
description = A small example package
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/boatacccorp/spex-tretboot
project_urls =
Bug Tracker = https://github.com/pypa/sampleproject/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[options]
package_dir =
= src
packages = find_namespace:
python_requires = >=3.6
[options.packages.find]
where = src

View file

@ -0,0 +1,3 @@
from setuptools import setup
setup()

View file

@ -0,0 +1,57 @@
import logging
from flask import redirect, render_template, request, url_for, flash
from flask import current_app as app
from flask_login import login_required, current_user
from cryptoadvance.specter.services.controller import user_secret_decrypted_required
from cryptoadvance.specter.user import User
from cryptoadvance.specter.wallet import Wallet
from .service import TretbootService
logger = logging.getLogger(__name__)
tretboot_endpoint = TretbootService.blueprint
@tretboot_endpoint.route("/")
@login_required
@user_secret_decrypted_required
def index():
return render_template(
"tretboot/index.jinja",
)
@tretboot_endpoint.route("/settings", methods=["GET"])
@login_required
@user_secret_decrypted_required
def settings_get():
associated_wallet: Wallet = TretbootService.get_associated_wallet()
# Get the user's Wallet objs, sorted by Wallet.name
wallet_names = sorted(current_user.wallet_manager.wallets.keys())
wallets = [current_user.wallet_manager.wallets[name] for name in wallet_names]
return render_template(
"tretboot/settings.jinja",
associated_wallet=associated_wallet,
wallets=wallets,
cookies=request.cookies,
)
@tretboot_endpoint.route("/settings", methods=["POST"])
@login_required
@user_secret_decrypted_required
def settings_post():
show_menu = request.form["show_menu"]
user = app.specter.user_manager.get_user()
if show_menu == "yes":
user.add_service(TretbootService.id)
else:
user.remove_service(TretbootService.id)
used_wallet_alias = request.form.get("used_wallet")
if used_wallet_alias != None:
wallet = current_user.wallet_manager.get_by_alias(used_wallet_alias)
return redirect(url_for(f"{TretbootService.get_blueprint_name()}.settings_get"))

View file

@ -0,0 +1,50 @@
import logging
from cryptoadvance.specter.services.service import (
Service,
devstatus_alpha,
devstatus_prod,
)
# A SpecterError can be thrown and will be shown to the user as a red banner
from cryptoadvance.specter.specter_error import SpecterError
from flask import current_app as app
from cryptoadvance.specter.wallet import Wallet
logger = logging.getLogger(__name__)
class TretbootService(Service):
id = "tretboot"
name = "Tretboot Service"
icon = "tretboot/img/ghost.png"
logo = "tretboot/img/tretboot_logo.jpeg"
desc = "Where a Tretboot grows bigger."
has_blueprint = True
blueprint_module = "boatacccorp.specterext.tretboot.controller"
devstatus = devstatus_alpha
piggyback = False
# TODO: As more Services are integrated, we'll want more robust categorization and sorting logic
sort_priority = 2
# ServiceEncryptedStorage field names for this service
# Those will end up as keys in a json-file
SPECTER_WALLET_ALIAS = "wallet"
@classmethod
def get_associated_wallet(cls) -> Wallet:
"""Get the Specter `Wallet` that is currently associated with this service"""
service_data = cls.get_current_user_service_data()
if not service_data or cls.SPECTER_WALLET_ALIAS not in service_data:
# Service is not initialized; nothing to do
return
try:
return app.specter.wallet_manager.get_by_alias(
service_data[cls.SPECTER_WALLET_ALIAS]
)
except SpecterError as e:
logger.debug(e)
# Referenced an unknown wallet
# TODO: keep ignoring or remove the unknown wallet from service_data?
return

View file

@ -0,0 +1,16 @@
{% from 'components/menu_item.jinja' import menu_item %}
{#
vaultoro_menu - Tabs menu to navigate between the vaultoro screens.
Parameters:
- active_menuitem: Current active tab. Options: 'general', 'bitcoin_core', 'auth'.
#}
{% macro tretboot_menu(active_menuitem) -%}
<nav class="row collapse-on-mobile">
{{ menu_item(service.id, 'index', 'Main', active_menuitem, isLeft=true) }}
{{ menu_item(service.id, 'settings_get', 'Settings', active_menuitem, isRight=true) }}
<a href="javascript:void(0);" class="mobile-nav-icon" onclick="toggleMobileNav(this, `{{ url_for('static', filename='img/expand-more.svg') }}`, `{{ url_for('static', filename='img/expand-less.svg') }}`)">
<img style="width: 36px;" src="{{ url_for('static', filename='img/expand-more.svg') }}"/>
</a>
</nav>
{%- endmacro %}

View file

@ -0,0 +1,8 @@
{% extends "base.jinja" %}
{% block main %}
<img src="{{ url_for(service.id +'_endpoint' + '.static', filename=service.logo) }}" width="300"/>
{% from 'tretboot/components/tretboot_menu.jinja' import tretboot_menu with context %}
{{ tretboot_menu(tab) }}
{% block content %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,87 @@
{% extends "tretboot/components/tretboot_tab.jinja" %}
{% block title %}Settings{% endblock %}
{% set tab = 'index' %}
{% block content %}
<style>
.tagline {
font-style: italic;
font-size: 1.1em;
text-align: center;
color: #aaa;
padding-left: 4em;
padding-right: 4em;
}
.button_wide {
text-decoration: none;
color: white;
}
.button_wide:hover {
color: white;
}
.big_option {
display: inline-block;
width: 12em;
padding: 1em;
vertical-align: middle;
height: 3.5em;
border: 4px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
font-size: 1.5em;
margin: 1em;
position: relative;
}
.big_option:hover {
background-color: var(--cmap-bg-lightest);
border: 4px solid var(--main-color);
cursor: pointer;
}
.big_option_text {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.bonus {
color: #999;
font-size: 0.7em;
font-style: italic;
}
.highlights {
margin-top: 2em;
margin-bottom: 1.5em;
}
.show_list_bullet {
margin-top: 0.5em;
display: inline-block;
width: auto;
}
.show_list_bullet li {
text-align: left;
list-style-type: disc;
line-height: 1.7em;
}
.plus_sign {
font-size: 3em;
color: #ccc;
vertical-align: text-bottom;
}
</style>
<br><br>
<div class="tagline">&ldquo;Tretbootservice 4thewin.&rdquo;</div>
{% endblock %}
{% block scripts %}
<script>
</script>
{% endblock %}

View file

@ -0,0 +1,109 @@
{% extends "tretboot/components/tretboot_tab.jinja" %}
{% block title %}Settings{% endblock %}
{% set tab = 'settings_get' %}
{% block content %}
<br/>
<style>
.big_option {
display: inline-block;
width: 14em;
padding: 1em;
vertical-align: middle;
height: 3.5em;
border: 4px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
font-size: 1.5em;
margin: 1em;
position: relative;
}
.big_option:hover {
background-color: var(--cmap-bg-lightest);
border: 4px solid var(--main-color);
cursor: pointer;
}
.big_option_text {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.instructions {
color: #999;
font-style: italic;
}
{# TODO: End Remove #}
.css-1rbd7t8 {
box-sizing: border-box;
margin: 60px 0px 24px;
min-width: 0px;
}
.css-yn7azs {
box-sizing: border-box;
margin: 0px;
min-width: 0px;
-moz-box-pack: justify;
justify-content: space-between;
position: relative;
font-family: "Source Serif Pro", serif;
font-weight: 600;
line-height: 1.5;
letter-spacing: -0.02em;
font-variant: common-ligatures;
text-rendering: optimizelegibility;
font-size: 16px;
display: flex;
}
</style>
<div class="card">
<h1>{{ _("Configure your extension") }}</h1>
<br>
<div class="note">
{{ _("- Maybe you want to put some notes for the user") }}<br/>
{{ _("- One important setting is whether your extension gets a menu-point on the left") }}<br/>
{{ _("- Let us assume you want the user to use a specific wallet. That should go here. ") }}<br/>
</div>
<br/>
<form action="{{ url_for(service.get_blueprint_name() + '.settings_post') }}" method="POST" role="form">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div>Show Menu Item:</div>
<select name="show_menu">
<option value="yes" {% if show_menu == 'yes' %}selected{% endif %}>Yes</option>
<option value="no" {% if show_menu == 'no' %}selected{% endif %}>No</option>
</select>
<br/>
<br/>
<br/>
{{ _("Choose which wallet should be used:") }}:<br>
<select name="used_wallet">
{% for wallet in wallets %}
<option value="{{ wallet.alias }}" {% if associated_wallet == wallet %}selected{% endif %}>{{ wallet.name }}</option>
{% endfor %}
</select>
<br/>
<br/>
<br/>
<div class="row">
<button type="submit" class="btn">{{ _("Save") }}</button>
</div>
</form>
</div>
<br/>
<br/>
<br/>
{% endblock %}

View file

@ -0,0 +1,507 @@
import atexit
import json
import logging
import os
import shutil
import subprocess
import tempfile
import time
import docker
import pytest
from cryptoadvance.specter.managers.device_manager import DeviceManager
from cryptoadvance.specter.managers.user_manager import UserManager
from cryptoadvance.specter.process_controller.bitcoind_controller import (
BitcoindPlainController,
)
from cryptoadvance.specter.process_controller.bitcoind_docker_controller import (
BitcoindDockerController,
)
from cryptoadvance.specter.process_controller.elementsd_controller import (
ElementsPlainController,
)
from cryptoadvance.specter.rpc import BitcoinRPC
from cryptoadvance.specter.server import SpecterFlask, create_app, init_app
from cryptoadvance.specter.specter import Specter
from cryptoadvance.specter.specter_error import SpecterError
from cryptoadvance.specter.user import User, hash_password
from cryptoadvance.specter.util.wallet_importer import WalletImporter
from cryptoadvance.specter.util.common import str2bool
from cryptoadvance.specter.util.shell import which
import code, traceback, signal
logger = logging.getLogger(__name__)
pytest_plugins = ["ghost_machine"]
# This is from https://stackoverflow.com/questions/132058/showing-the-stack-trace-from-a-running-python-application
# it enables stopping a hanging test via sending the pytest-process a SIGUSR2 (12)
# kill 12 pid-of-pytest
# In the article they claim to open a debug-console which didn't work for me but at least
# you get a stacktrace in the output.
def debug(sig, frame):
"""Interrupt running process, and provide a python prompt for
interactive debugging."""
d = {"_frame": frame} # Allow access to frame object.
d.update(frame.f_globals) # Unless shadowed by global
d.update(frame.f_locals)
i = code.InteractiveConsole(d)
message = "Signal received : entering python shell.\nTraceback:\n"
message += "".join(traceback.format_stack(frame))
i.interact(message)
def listen():
signal.signal(signal.SIGUSR2, debug) # Register handler
def pytest_addoption(parser):
"""Internally called to add options to pytest
see pytest_generate_tests(metafunc) on how to check that
Also used to register the SIGUSR2 (12) as decribed in conftest.py
"""
parser.addoption("--docker", action="store_true", help="run bitcoind in docker")
parser.addoption(
"--bitcoind-version",
action="store",
default="v0.20.1",
help="Version of bitcoind (something which works with git checkout ...)",
)
parser.addoption(
"--bitcoind-log-stdout",
action="store",
default=False,
help="Whether bitcoind should log to stdout (default:False)",
)
parser.addoption(
"--elementsd-version",
action="store",
default="master",
help="Version of elementsd (something which works with git checkout ...)",
)
listen()
def pytest_generate_tests(metafunc):
# ToDo: use custom compiled version of bitcoind
# E.g. test again bitcoind version [currentRelease] + master-branch
if "docker" in metafunc.fixturenames:
if metafunc.config.getoption("docker"):
# That's a list because we could do both (see above) but currently that doesn't make sense in that context
metafunc.parametrize("docker", [True], scope="session")
else:
metafunc.parametrize("docker", [False], scope="session")
def instantiate_bitcoind_controller(docker, request, rpcport=18543, extra_args=[]):
# logging.getLogger().setLevel(logging.DEBUG)
requested_version = request.config.getoption("--bitcoind-version")
log_stdout = str2bool(request.config.getoption("--bitcoind-log-stdout"))
if docker:
from cryptoadvance.specter.process_controller.bitcoind_docker_controller import (
BitcoindDockerController,
)
bitcoind_controller = BitcoindDockerController(
rpcport=rpcport, docker_tag=requested_version
)
else:
if os.path.isfile("tests/bitcoin/src/bitcoind"):
bitcoind_controller = BitcoindPlainController(
bitcoind_path="tests/bitcoin/src/bitcoind", rpcport=rpcport
) # always prefer the self-compiled bitcoind if existing
elif os.path.isfile("tests/bitcoin/bin/bitcoind"):
bitcoind_controller = BitcoindPlainController(
bitcoind_path="tests/bitcoin/bin/bitcoind", rpcport=rpcport
) # next take the self-installed binary if existing
else:
bitcoind_controller = BitcoindPlainController(
rpcport=rpcport
) # Alternatively take the one on the path for now
bitcoind_controller.start_bitcoind(
cleanup_at_exit=True,
cleanup_hard=True,
extra_args=extra_args,
log_stdout=log_stdout,
)
assert not bitcoind_controller.datadir is None
running_version = bitcoind_controller.version()
requested_version = request.config.getoption("--bitcoind-version")
assert running_version == requested_version, (
"Please make sure that the Bitcoind-version (%s) matches with the version in pytest.ini (%s)"
% (running_version, requested_version)
)
return bitcoind_controller
def instantiate_elementsd_controller(request, rpcport=18643, extra_args=[]):
if os.path.isfile("tests/elements/src/elementsd"):
elementsd_controller = ElementsPlainController(
elementsd_path="tests/elements/src/elementsd", rpcport=rpcport
) # always prefer the self-compiled bitcoind if existing
elif os.path.isfile("tests/elements/bin/elementsd"):
elementsd_controller = ElementsPlainController(
elementsd_path="tests/elements/bin/elementsd", rpcport=rpcport
) # next take the self-installed binary if existing
else:
elementsd_controller = ElementsPlainController(
rpcport=rpcport
) # Alternatively take the one on the path for now
elementsd_controller.start_elementsd(
cleanup_at_exit=True, cleanup_hard=True, extra_args=extra_args
)
assert not elementsd_controller.datadir is None
running_version = elementsd_controller.version()
requested_version = request.config.getoption("--elementsd-version")
assert running_version == requested_version, (
"Please make sure that the elementsd-version (%s) matches with the version in pytest.ini (%s)"
% (running_version, requested_version)
)
return elementsd_controller
# Below this point are fixtures. Fixtures have a scope. Check about scopes here:
# https://docs.pytest.org/en/6.2.x/fixture.html#scope-sharing-fixtures-across-classes-modules-packages-or-session
# possible values: function, class, module, package or session.
# The nodes are of scope session. All else is the default (function)
@pytest.fixture(scope="session")
def bitcoind_path():
if os.path.isfile("tests/bitcoin/src/bitcoind"):
return "tests/bitcoin/src/bitcoind"
elif os.path.isfile("tests/bitcoin/bin/bitcoind"):
return "tests/bitcoin/bin/bitcoind"
else:
return which("bitcoind")
@pytest.fixture(scope="session")
def bitcoin_regtest(docker, request):
bitcoind_regtest = instantiate_bitcoind_controller(docker, request, extra_args=None)
try:
assert bitcoind_regtest.get_rpc().test_connection()
assert not bitcoind_regtest.datadir is None
yield bitcoind_regtest
finally:
bitcoind_regtest.stop_bitcoind()
@pytest.fixture(scope="session")
def elements_elreg(request):
elements_elreg = instantiate_elementsd_controller(request, extra_args=None)
try:
yield elements_elreg
assert not elements_elreg.datadir is None
finally:
elements_elreg.stop_elementsd()
@pytest.fixture
def empty_data_folder():
# Make sure that this folder never ever gets a reasonable non-testing use-case
with tempfile.TemporaryDirectory(prefix="specter_home_tmp_") as data_folder:
yield data_folder
@pytest.fixture
def devices_filled_data_folder(empty_data_folder):
os.makedirs(empty_data_folder + "/devices")
with open(empty_data_folder + "/devices/trezor.json", "w") as text_file:
text_file.write(
"""
{
"name": "Trezor",
"type": "trezor",
"keys": [
{
"derivation": "m/49h/0h/0h",
"original": "ypub6XFn7hfb676MLm6ZsAviuQKXeRDNgT9Bs32KpRDPnkKgKDjKcrhYCXJ88aBfy8co2k9eujugJX5nwq7RPG4sj6yncDEPWN9dQGmFWPy4kFB",
"fingerprint": "1ef4e492",
"type": "sh-wpkh",
"xpub": "xpub6CRWp2zfwRYsVTuT2p96hKE2UT4vjq9gwvW732KWQjwoG7v6NCXyaTdz7NE5yDxsd72rAGK7qrjF4YVrfZervsJBjsXxvTL98Yhc7poBk7K"
},
{
"derivation": "m/84h/0h/0h",
"original": "zpub6rGoJTXEhKw7hUFkjMqNctTzojkzRPa3VFuUWAirqpuj13mRweRmnYpGD1aQVFpxNfp17zVU9r7F6oR3c4zL3DjXHdewVvA7kjugHSqz5au",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "xpub6CcGh8BQPxr9zssX4eG8CiGzToU6Y9b3f2s2wNw65p9xtr8ySL6eYRVzAbfEVSX7ZPaPd3JMEXQ9LEBvAgAJSkNKYxG6L6X9DHnPWNQud4H"
},
{
"derivation": "m/48h/0h/0h/1h",
"original": "Ypub6jtWQ1r2D7EwqNoxERU28MWZH4WdL3pWdN8guFJRBTmGwstJGzMXJe1VaNZEuAAVsZwpKPhs5GzNPEZR77mmX1mjwzEiouxmQYsrxFBNVNN",
"fingerprint": "1ef4e492",
"type": "sh-wsh",
"xpub": "xpub6EA9y7SfVU96ZWTTTQDR6C5FPJKvB59RPyxoCb8zRgYzGbWAFvogbTVRkTeBLpHgETm2hL7BjQFKNnL66CCoaHyUFBRtpbgHF6YLyi7fr6m"
},
{
"derivation": "m/48h/0h/0h/2h",
"original": "Zpub74imhgWwMnnRkSPkiNavCQtSBu1fGo8RP96h9eT2GHCgN5eFU9mZVPhGphvGnG26A1cwJxtkmbHR6nLeTw4okpCDjZCEj2HRLJoVHAEsch9",
"fingerprint": "1ef4e492",
"type": "wsh",
"xpub": "xpub6EA9y7SfVU96dGr96zYgxAMd8AgWBCTqEeQafbPi8VcWdhStCS4AA9X4yb3dE1VM7GKLwRhWy4BpD3VkjK5q1riMAQgz9oBSu8QKv5S7KzD"
},
{
"derivation": "m/49h/1h/0h",
"original": "upub5EKoQv21nQNkhdt4yuLyRnWitA3EGhW1ru1Y8VTG8gdys2JZhqiYkhn4LHp2heHnH41kz95bXPvrYVRuFUrdUMik6YdjFV4uL4EubnesttQ",
"fingerprint": "1ef4e492",
"type": "sh-wpkh",
"xpub": "tpubDDCDr9rSwixeXKeGwAgwFy8bjBaE5wya9sAVqEC4ccXWmcQxY34KmLRJdwmaDsCnHsu5r9P9SUpYtXmCoRwukWDqmAUJgkBbjC2FXUzicn6"
},
{
"derivation": "m/84h/1h/0h",
"original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
},
{
"derivation": "m/48h/1h/0h/1h",
"original": "Upub5Tk9tZtdzVaTGWtygRTKDDmaN5vfB59pn2L5MQyH6BkVpg2Y5J95rtpQndjmXNs3LNFiy8zxpHCTtvxxeePjgipF7moTHQZhe3E5uPzDXh8",
"fingerprint": "1ef4e492",
"type": "sh-wsh",
"xpub": "tpubDFiVCZzdarbyfdVoh2LJDL3eVKRPmxwnkiqN8tSYCLod75a2966anQbjHajqVAZ97j54xZJPr9hf7ogVuNL4pPCfwvXdKGDQ9SjZF7vXQu1"
},
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5naRCEZZ9B7wCKLWuqoNdg6ddWEx8ruztUygXFZDJtW5LRMqUP5HV2TsNw1nc74Ba3QPDSH7qzauZ8LdfNmnmofpfmztCGPgP7vaaYSmpgN",
"fingerprint": "1ef4e492",
"type": "wsh",
"xpub": "tpubDFiVCZzdarbyk8kE65tjRhHCambEo8iTx4xkXL8b33BKZj66HWsDnUb3rg4GZz6Mwm6vTNyzRCjYtiScCQJ77ENedb2deDDtcoNQXiUouJQ"
}
]
}
"""
)
with open(empty_data_folder + "/devices/specter.json", "w") as text_file:
text_file.write(
"""
{
"name": "Specter",
"type": "specter",
"keys": [
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
"fingerprint": "08686ac6",
"type": "wsh",
"xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL"
},
{
"derivation": "m/84h/1h/0h",
"original": "vpub5ZSem3mLXiSJzgDX6pJb2N9L6sJ8m6ejaksLPLSuB53LBzCi2mMsBg19eEUSDkHtyYp75GATjLgt5p3S43WjaVCXAWU9q9H5GhkwJBrMiAb",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUiy4ncDirveTfhmvggdj8nxcW5JgHpGzYz3UVscJY5aEzFvgUPk4YyajadBnsTBmE2YZmAtJC14Q21xncJgVaHQ7UdqMRVRbU"
},
{
"derivation": "m/84h/1h/1h",
"original": "vpub5ZSem3mLXiSK55jPzfLVhbHbTEwGzEFZv3xrGFCw1vGHSNw7WcVuJXysJLWcgENQd3iXSNQaeSXUBW55Hy4GAjSTjrWP4vpKKkUN9jiU1Tc",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj3UJV7ZtqKgoy8JKprrjdHubbBb3r7qmwHsEH69g7h6xyanWaCYdVEEV3Yu7a6s4ceFnp8DjXeeFxY8eXvH7XTAC4gxfDNEW"
},
{
"derivation": "m/84h/1h/2h",
"original": "vpub5ZSem3mLXiSK64v64deytnDCoYqbUSYHvmVurUGVMEnXMyEybtF3FEnNuiFDDC6J18a81fv5ptQXaQaaRiYx8MRxahipgxPLdxubpYt1dkD",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj4TVBBYDKWsjaUcE9M52MJd8emp7QTAJBDTY9BRRFdomVCAFAjWMNcKLe8Cd5HJwg3AJKFyEDcGFTNyryYJgYmNdJMhwB2RG"
},
{
"derivation": "m/84h/1h/3h",
"original": "vpub5ZSem3mLXiSK8cKzh4sHxTvN7mgYQA29HfoAZeCDtX1M2zdejN5XVAtVyqhk8eui18JTtZ9M3VD3AiWCz8VwrybhBUh3HxzS8js3mLVybDT",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj6zu5oyRdaZSjnq56GnWCfXRuUz38zSWztUvpJuFjsjscGHhheyAncK4z15rLVukBdUDwpPBDLtRBykqC9KHeG9akJWRipKK"
}
]
}
"""
)
return empty_data_folder # no longer empty, though
@pytest.fixture
def wallets_filled_data_folder(devices_filled_data_folder):
os.makedirs(os.path.join(devices_filled_data_folder, "wallets", "regtest"))
with open(
os.path.join(devices_filled_data_folder, "wallets", "regtest", "simple.json"),
"w",
) as json_file:
json_file.write(
"""
{
"alias": "simple",
"fullpath": "/home/kim/.specter/wallets/regtest/simple.json",
"name": "Simple",
"address_index": 0,
"keypool": 5,
"address": "bcrt1qcatuhg0gll3h7py4cmn53rjjn9xlsqfwj3zcej",
"change_index": 0,
"change_address": "bcrt1qt28v03278lmmxllys89acddp2p5y4zds94944n",
"change_keypool": 5,
"type": "simple",
"description": "Single (Segwit)",
"keys": [{
"derivation": "m/84h/1h/0h",
"original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
}],
"recv_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr",
"change_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/1/*)#h4z73prm",
"device": "Trezor",
"device_type": "trezor",
"address_type": "bech32"
}
"""
)
return devices_filled_data_folder # and with wallets obviously
@pytest.fixture
def device_manager(devices_filled_data_folder):
return DeviceManager(os.path.join(devices_filled_data_folder, "devices"))
# @pytest.fixture
# def user_manager(empty_data_folder) -> UserManager:
# """A UserManager having users alice, bob and eve"""
# specter = Specter(data_folder=empty_data_folder)
# user_manager = UserManager(specter=specter)
# config = {}
# user_manager.get_user("admin").decrypt_user_secret("admin")
# user_manager.create_user(
# user_id="alice",
# username="alice",
# plaintext_password="plain_pass_alice",
# config=config,
# )
# user_manager.create_user(
# user_id="bob",
# username="bob",
# plaintext_password="plain_pass_bob",
# config=config,
# )
# user_manager.create_user(
# user_id="eve",
# username="eve",
# plaintext_password="plain_pass_eve",
# config=config,
# )
# return user_manager
@pytest.fixture
def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder):
assert bitcoin_regtest.get_rpc().test_connection()
config = {
"rpc": {
"autodetect": False,
"datadir": "",
"user": bitcoin_regtest.rpcconn.rpcuser,
"password": bitcoin_regtest.rpcconn.rpcpassword,
"port": bitcoin_regtest.rpcconn.rpcport,
"host": bitcoin_regtest.rpcconn.ipaddress,
"protocol": "http",
},
"auth": {
"method": "rpcpasswordaspin",
},
}
specter = Specter(data_folder=devices_filled_data_folder, config=config)
assert specter.chain == "regtest"
# Create a User
someuser = specter.user_manager.add_user(
User.from_json(
user_dict={
"id": "someuser",
"username": "someuser",
"password": hash_password("somepassword"),
"config": {},
"is_admin": False,
"services": None,
},
specter=specter,
)
)
specter.user_manager.save()
specter.check()
assert not someuser.wallet_manager.working_folder is None
# Create a Wallet
wallet_json = '{"label": "a_simple_wallet", "blockheight": 0, "descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr", "devices": [{"type": "trezor", "label": "trezor"}]} '
wallet_importer = WalletImporter(
wallet_json, specter, device_manager=someuser.device_manager
)
wallet_importer.create_nonexisting_signers(
someuser.device_manager,
{"unknown_cosigner_0_name": "trezor", "unknown_cosigner_0_type": "trezor"},
)
dm: DeviceManager = someuser.device_manager
wallet = wallet_importer.create_wallet(someuser.wallet_manager)
try:
# fund it with some coins
bitcoin_regtest.testcoin_faucet(address=wallet.getnewaddress())
# make sure it's confirmed
bitcoin_regtest.mine()
# Realize that the wallet has funds:
wallet.update()
except SpecterError as se:
if str(se).startswith("Timeout"):
pytest.fail(
"We got a Bitcoin-RPC timeout while setting up the test, minting some coins. Test Error! Check cpu/mem utilastion and btc/elem logs!"
)
return
else:
raise se
assert wallet.fullbalance >= 20
assert not specter.wallet_manager.working_folder is None
try:
yield specter
finally:
# Deleting all Wallets (this will also purge them on core)
for user in specter.user_manager.users:
for wallet in list(user.wallet_manager.wallets.values()):
user.wallet_manager.delete_wallet(
wallet, bitcoin_datadir=bitcoin_regtest.datadir, chain="regtest"
)
@pytest.fixture
def app(specter_regtest_configured) -> SpecterFlask:
"""the Flask-App, but uninitialized"""
app = create_app(config="cryptoadvance.specter.config.TestConfig")
app.app_context().push()
app.config["TESTING"] = True
app.testing = True
app.tor_service_id = None
app.tor_enabled = False
init_app(app, specter=specter_regtest_configured)
return app
@pytest.fixture
def app_no_node(empty_data_folder) -> SpecterFlask:
specter = Specter(data_folder=empty_data_folder)
app = create_app(config="cryptoadvance.specter.config.TestConfig")
app.app_context().push()
app.config["TESTING"] = True
app.testing = True
app.tor_service_id = None
app.tor_enabled = False
init_app(app, specter=specter)
return app
@pytest.fixture
def client(app):
"""a test_client from an initialized Flask-App"""
return app.test_client()

View file

@ -0,0 +1,67 @@
import pytest
# Using https://iancoleman.io/bip39/ and https://jlopp.github.io/xpub-converter/
# mnemonic = "ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost machine"
# m/44'/0'/0'
@pytest.fixture
def ghost_machine_xpub_44():
xpub = "xpub6CGap5qbgNCEsvXg2gAjEho17zECMA9PbZa7QkrEWTPnPRaubE6qKots5pNwhyFtuYSPa9gQu4jTTZi8WPaXJhtCHrvHQaFRqayN1saQoWv"
return xpub
# m/49'/0'/0'
@pytest.fixture
def ghost_machine_xpub_49():
xpub = "xpub6BtcNhqbaFaoC3oEfKky3Sm22pF48U2jmAf78cB3wdAkkGyAgmsVrgyt1ooSt3bHWgzsdUQh2pTJ867yTeUAMmFDKNSBp8J7WPmp7Df7zjv"
return xpub
@pytest.fixture
def ghost_machine_ypub():
ypub = "ypub6WisgNWWiw8H3LzMVgYbFXrXCnPW562EgHBKv14wKdYdoNnPwS34Uke231m2sxFCvL7gNx1FVUor1NjYBLtB9zvpBi8cQ37bn7qTVqo3fjR"
return ypub
@pytest.fixture
def ghost_machine_tpub_49():
tpub = "tpubDC5CZBbVc15fpTeqkyUBKgHqYCqkeaUtPjvGz7RJEttndfcN29psPcxTSj5RNJaWYaRQq8kqovLBrZA2tju3ThSAP9fY1eiSvorchnseFZu"
return tpub
@pytest.fixture
def ghost_machine_upub():
upub = "upub5DCn7wm4SgVmzmtdoi8DVVfxhBJkqL1L6mmKHNgVky1Fj5VyBxV6NzKD957sr5fWXkY5y8THtqSVWWpjLnomBYw4iXpxaPbkXg5Gn6s5tQf"
return upub
# m/84'/0'/0'
@pytest.fixture
def ghost_machine_xpub_84():
xpub = "xpub6CjsHfiuBnHMPBkxThQ4DDjTw2Qq3VMEVcPBoMBGejZGkj3WQR15LeJLmymPpSzYHX21C8SdFWHgMw2RUBdAQ2Aj4MMS93a68mxPQeS8oHr"
return xpub
@pytest.fixture
def ghost_machine_zpub():
zpub = "zpub6rQPu14jV9NK5n9C8QyJdPvUGxhivjLEKqRdN8y3QkK2rvfxujLCamccpPgZpGJP6oFch5dkApzn8WFYuaTBzVXvo2kHJsD4gE5gBnCBYj1"
return zpub
@pytest.fixture
def ghost_machine_tpub_84():
tpub = "tpubDC4DsqH5rqHqipMNqUbDFtQT3AkKkUrvLsN6miySvortU3s1LGaNVAb7wX2No2VsuxQV82T8s3HJLv3kdx1CPjsJ3onC1Zo5mWCQzRVaWVX"
return tpub
@pytest.fixture
def ghost_machine_vpub():
vpub = "vpub5Y24kG7ZrCFRkRnHia2sdnt5N7MmsrNry1jMrP8XptMEcZZqkjQA6bc1f52RGiEoJmdy1Vk9Qck9tAL1ohKvuq3oFXe3ADVse6UiTHzuyKx"
return vpub

View file

@ -0,0 +1,5 @@
recursive-include src/boatacccorp/specterext/ruderboot/templates *
recursive-include src/boatacccorp/specterext/ruderboot/static *
recursive-include src/boatacccorp/specterext/ruderboot/*/LC_MESSAGES *.mo
recursive-include src/boatacccorp/specterext/ruderboot/translations/*/LC_MESSAGES *.po
include requirements.txt

View file

@ -0,0 +1,5 @@
[build-system]
requires = [
"cryptoadvance.specter==1.8.1"
]
build-backend = "setuptools.build_meta"

View file

@ -0,0 +1 @@
cryptoadvance.specter==1.8.1

View file

@ -0,0 +1,24 @@
[metadata]
name = boatacccorp_ruderboot
version = 0.0.1
author = Your Name
author_email = some_mail@mail.com
description = A small example package
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/boatacccorp/spex-ruderboot
project_urls =
Bug Tracker = https://github.com/pypa/sampleproject/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[options]
package_dir =
= src
packages = find_namespace:
python_requires = >=3.6
[options.packages.find]
where = src

View file

@ -0,0 +1,3 @@
from setuptools import setup
setup()

View file

@ -0,0 +1,57 @@
import logging
from flask import redirect, render_template, request, url_for, flash
from flask import current_app as app
from flask_login import login_required, current_user
from cryptoadvance.specter.services.controller import user_secret_decrypted_required
from cryptoadvance.specter.user import User
from cryptoadvance.specter.wallet import Wallet
from .service import RuderbootService
logger = logging.getLogger(__name__)
ruderboot_endpoint = RuderbootService.blueprint
@ruderboot_endpoint.route("/")
@login_required
@user_secret_decrypted_required
def index():
return render_template(
"ruderboot/index.jinja",
)
@ruderboot_endpoint.route("/settings", methods=["GET"])
@login_required
@user_secret_decrypted_required
def settings_get():
associated_wallet: Wallet = RuderbootService.get_associated_wallet()
# Get the user's Wallet objs, sorted by Wallet.name
wallet_names = sorted(current_user.wallet_manager.wallets.keys())
wallets = [current_user.wallet_manager.wallets[name] for name in wallet_names]
return render_template(
"ruderboot/settings.jinja",
associated_wallet=associated_wallet,
wallets=wallets,
cookies=request.cookies,
)
@ruderboot_endpoint.route("/settings", methods=["POST"])
@login_required
@user_secret_decrypted_required
def settings_post():
show_menu = request.form["show_menu"]
user = app.specter.user_manager.get_user()
if show_menu == "yes":
user.add_service(RuderbootService.id)
else:
user.remove_service(RuderbootService.id)
used_wallet_alias = request.form.get("used_wallet")
if used_wallet_alias != None:
wallet = current_user.wallet_manager.get_by_alias(used_wallet_alias)
return redirect(url_for(f"{RuderbootService.get_blueprint_name()}.settings_get"))

View file

@ -0,0 +1,50 @@
import logging
from cryptoadvance.specter.services.service import (
Service,
devstatus_alpha,
devstatus_prod,
)
# A SpecterError can be thrown and will be shown to the user as a red banner
from cryptoadvance.specter.specter_error import SpecterError
from flask import current_app as app
from cryptoadvance.specter.wallet import Wallet
logger = logging.getLogger(__name__)
class RuderbootService(Service):
id = "ruderboot"
name = "Ruderboot Service"
icon = "ruderboot/img/ghost.png"
logo = "ruderboot/img/ruderboot_logo.jpeg"
desc = "Where a Ruderboot grows bigger."
has_blueprint = True
blueprint_module = "boatacccorp.specterext.ruderboot.controller"
devstatus = devstatus_alpha
piggyback = False
# TODO: As more Services are integrated, we'll want more robust categorization and sorting logic
sort_priority = 2
# ServiceEncryptedStorage field names for this service
# Those will end up as keys in a json-file
SPECTER_WALLET_ALIAS = "wallet"
@classmethod
def get_associated_wallet(cls) -> Wallet:
"""Get the Specter `Wallet` that is currently associated with this service"""
service_data = cls.get_current_user_service_data()
if not service_data or cls.SPECTER_WALLET_ALIAS not in service_data:
# Service is not initialized; nothing to do
return
try:
return app.specter.wallet_manager.get_by_alias(
service_data[cls.SPECTER_WALLET_ALIAS]
)
except SpecterError as e:
logger.debug(e)
# Referenced an unknown wallet
# TODO: keep ignoring or remove the unknown wallet from service_data?
return

View file

@ -0,0 +1,16 @@
{% from 'components/menu_item.jinja' import menu_item %}
{#
vaultoro_menu - Tabs menu to navigate between the vaultoro screens.
Parameters:
- active_menuitem: Current active tab. Options: 'general', 'bitcoin_core', 'auth'.
#}
{% macro ruderboot_menu(active_menuitem) -%}
<nav class="row collapse-on-mobile">
{{ menu_item(service.id, 'index', 'Main', active_menuitem, isLeft=true) }}
{{ menu_item(service.id, 'settings_get', 'Settings', active_menuitem, isRight=true) }}
<a href="javascript:void(0);" class="mobile-nav-icon" onclick="toggleMobileNav(this, `{{ url_for('static', filename='img/expand-more.svg') }}`, `{{ url_for('static', filename='img/expand-less.svg') }}`)">
<img style="width: 36px;" src="{{ url_for('static', filename='img/expand-more.svg') }}"/>
</a>
</nav>
{%- endmacro %}

View file

@ -0,0 +1,8 @@
{% extends "base.jinja" %}
{% block main %}
<img src="{{ url_for(service.id +'_endpoint' + '.static', filename=service.logo) }}" width="300"/>
{% from 'ruderboot/components/ruderboot_menu.jinja' import ruderboot_menu with context %}
{{ ruderboot_menu(tab) }}
{% block content %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,87 @@
{% extends "ruderboot/components/ruderboot_tab.jinja" %}
{% block title %}Settings{% endblock %}
{% set tab = 'index' %}
{% block content %}
<style>
.tagline {
font-style: italic;
font-size: 1.1em;
text-align: center;
color: #aaa;
padding-left: 4em;
padding-right: 4em;
}
.button_wide {
text-decoration: none;
color: white;
}
.button_wide:hover {
color: white;
}
.big_option {
display: inline-block;
width: 12em;
padding: 1em;
vertical-align: middle;
height: 3.5em;
border: 4px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
font-size: 1.5em;
margin: 1em;
position: relative;
}
.big_option:hover {
background-color: var(--cmap-bg-lightest);
border: 4px solid var(--main-color);
cursor: pointer;
}
.big_option_text {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.bonus {
color: #999;
font-size: 0.7em;
font-style: italic;
}
.highlights {
margin-top: 2em;
margin-bottom: 1.5em;
}
.show_list_bullet {
margin-top: 0.5em;
display: inline-block;
width: auto;
}
.show_list_bullet li {
text-align: left;
list-style-type: disc;
line-height: 1.7em;
}
.plus_sign {
font-size: 3em;
color: #ccc;
vertical-align: text-bottom;
}
</style>
<br><br>
<div class="tagline">&ldquo;Ruderbootservice 4thewin.&rdquo;</div>
{% endblock %}
{% block scripts %}
<script>
</script>
{% endblock %}

View file

@ -0,0 +1,109 @@
{% extends "ruderboot/components/ruderboot_tab.jinja" %}
{% block title %}Settings{% endblock %}
{% set tab = 'settings_get' %}
{% block content %}
<br/>
<style>
.big_option {
display: inline-block;
width: 14em;
padding: 1em;
vertical-align: middle;
height: 3.5em;
border: 4px solid var(--cmap-border);
border-radius: 0.5em;
background-color: var(--cmap-bg-lighter);
font-size: 1.5em;
margin: 1em;
position: relative;
}
.big_option:hover {
background-color: var(--cmap-bg-lightest);
border: 4px solid var(--main-color);
cursor: pointer;
}
.big_option_text {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.instructions {
color: #999;
font-style: italic;
}
{# TODO: End Remove #}
.css-1rbd7t8 {
box-sizing: border-box;
margin: 60px 0px 24px;
min-width: 0px;
}
.css-yn7azs {
box-sizing: border-box;
margin: 0px;
min-width: 0px;
-moz-box-pack: justify;
justify-content: space-between;
position: relative;
font-family: "Source Serif Pro", serif;
font-weight: 600;
line-height: 1.5;
letter-spacing: -0.02em;
font-variant: common-ligatures;
text-rendering: optimizelegibility;
font-size: 16px;
display: flex;
}
</style>
<div class="card">
<h1>{{ _("Configure your extension") }}</h1>
<br>
<div class="note">
{{ _("- Maybe you want to put some notes for the user") }}<br/>
{{ _("- One important setting is whether your extension gets a menu-point on the left") }}<br/>
{{ _("- Let us assume you want the user to use a specific wallet. That should go here. ") }}<br/>
</div>
<br/>
<form action="{{ url_for(service.get_blueprint_name() + '.settings_post') }}" method="POST" role="form">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div>Show Menu Item:</div>
<select name="show_menu">
<option value="yes" {% if show_menu == 'yes' %}selected{% endif %}>Yes</option>
<option value="no" {% if show_menu == 'no' %}selected{% endif %}>No</option>
</select>
<br/>
<br/>
<br/>
{{ _("Choose which wallet should be used:") }}:<br>
<select name="used_wallet">
{% for wallet in wallets %}
<option value="{{ wallet.alias }}" {% if associated_wallet == wallet %}selected{% endif %}>{{ wallet.name }}</option>
{% endfor %}
</select>
<br/>
<br/>
<br/>
<div class="row">
<button type="submit" class="btn">{{ _("Save") }}</button>
</div>
</form>
</div>
<br/>
<br/>
<br/>
{% endblock %}

View file

@ -0,0 +1,507 @@
import atexit
import json
import logging
import os
import shutil
import subprocess
import tempfile
import time
import docker
import pytest
from cryptoadvance.specter.managers.device_manager import DeviceManager
from cryptoadvance.specter.managers.user_manager import UserManager
from cryptoadvance.specter.process_controller.bitcoind_controller import (
BitcoindPlainController,
)
from cryptoadvance.specter.process_controller.bitcoind_docker_controller import (
BitcoindDockerController,
)
from cryptoadvance.specter.process_controller.elementsd_controller import (
ElementsPlainController,
)
from cryptoadvance.specter.rpc import BitcoinRPC
from cryptoadvance.specter.server import SpecterFlask, create_app, init_app
from cryptoadvance.specter.specter import Specter
from cryptoadvance.specter.specter_error import SpecterError
from cryptoadvance.specter.user import User, hash_password
from cryptoadvance.specter.util.wallet_importer import WalletImporter
from cryptoadvance.specter.util.common import str2bool
from cryptoadvance.specter.util.shell import which
import code, traceback, signal
logger = logging.getLogger(__name__)
pytest_plugins = ["ghost_machine"]
# This is from https://stackoverflow.com/questions/132058/showing-the-stack-trace-from-a-running-python-application
# it enables stopping a hanging test via sending the pytest-process a SIGUSR2 (12)
# kill 12 pid-of-pytest
# In the article they claim to open a debug-console which didn't work for me but at least
# you get a stacktrace in the output.
def debug(sig, frame):
"""Interrupt running process, and provide a python prompt for
interactive debugging."""
d = {"_frame": frame} # Allow access to frame object.
d.update(frame.f_globals) # Unless shadowed by global
d.update(frame.f_locals)
i = code.InteractiveConsole(d)
message = "Signal received : entering python shell.\nTraceback:\n"
message += "".join(traceback.format_stack(frame))
i.interact(message)
def listen():
signal.signal(signal.SIGUSR2, debug) # Register handler
def pytest_addoption(parser):
"""Internally called to add options to pytest
see pytest_generate_tests(metafunc) on how to check that
Also used to register the SIGUSR2 (12) as decribed in conftest.py
"""
parser.addoption("--docker", action="store_true", help="run bitcoind in docker")
parser.addoption(
"--bitcoind-version",
action="store",
default="v0.20.1",
help="Version of bitcoind (something which works with git checkout ...)",
)
parser.addoption(
"--bitcoind-log-stdout",
action="store",
default=False,
help="Whether bitcoind should log to stdout (default:False)",
)
parser.addoption(
"--elementsd-version",
action="store",
default="master",
help="Version of elementsd (something which works with git checkout ...)",
)
listen()
def pytest_generate_tests(metafunc):
# ToDo: use custom compiled version of bitcoind
# E.g. test again bitcoind version [currentRelease] + master-branch
if "docker" in metafunc.fixturenames:
if metafunc.config.getoption("docker"):
# That's a list because we could do both (see above) but currently that doesn't make sense in that context
metafunc.parametrize("docker", [True], scope="session")
else:
metafunc.parametrize("docker", [False], scope="session")
def instantiate_bitcoind_controller(docker, request, rpcport=18543, extra_args=[]):
# logging.getLogger().setLevel(logging.DEBUG)
requested_version = request.config.getoption("--bitcoind-version")
log_stdout = str2bool(request.config.getoption("--bitcoind-log-stdout"))
if docker:
from cryptoadvance.specter.process_controller.bitcoind_docker_controller import (
BitcoindDockerController,
)
bitcoind_controller = BitcoindDockerController(
rpcport=rpcport, docker_tag=requested_version
)
else:
if os.path.isfile("tests/bitcoin/src/bitcoind"):
bitcoind_controller = BitcoindPlainController(
bitcoind_path="tests/bitcoin/src/bitcoind", rpcport=rpcport
) # always prefer the self-compiled bitcoind if existing
elif os.path.isfile("tests/bitcoin/bin/bitcoind"):
bitcoind_controller = BitcoindPlainController(
bitcoind_path="tests/bitcoin/bin/bitcoind", rpcport=rpcport
) # next take the self-installed binary if existing
else:
bitcoind_controller = BitcoindPlainController(
rpcport=rpcport
) # Alternatively take the one on the path for now
bitcoind_controller.start_bitcoind(
cleanup_at_exit=True,
cleanup_hard=True,
extra_args=extra_args,
log_stdout=log_stdout,
)
assert not bitcoind_controller.datadir is None
running_version = bitcoind_controller.version()
requested_version = request.config.getoption("--bitcoind-version")
assert running_version == requested_version, (
"Please make sure that the Bitcoind-version (%s) matches with the version in pytest.ini (%s)"
% (running_version, requested_version)
)
return bitcoind_controller
def instantiate_elementsd_controller(request, rpcport=18643, extra_args=[]):
if os.path.isfile("tests/elements/src/elementsd"):
elementsd_controller = ElementsPlainController(
elementsd_path="tests/elements/src/elementsd", rpcport=rpcport
) # always prefer the self-compiled bitcoind if existing
elif os.path.isfile("tests/elements/bin/elementsd"):
elementsd_controller = ElementsPlainController(
elementsd_path="tests/elements/bin/elementsd", rpcport=rpcport
) # next take the self-installed binary if existing
else:
elementsd_controller = ElementsPlainController(
rpcport=rpcport
) # Alternatively take the one on the path for now
elementsd_controller.start_elementsd(
cleanup_at_exit=True, cleanup_hard=True, extra_args=extra_args
)
assert not elementsd_controller.datadir is None
running_version = elementsd_controller.version()
requested_version = request.config.getoption("--elementsd-version")
assert running_version == requested_version, (
"Please make sure that the elementsd-version (%s) matches with the version in pytest.ini (%s)"
% (running_version, requested_version)
)
return elementsd_controller
# Below this point are fixtures. Fixtures have a scope. Check about scopes here:
# https://docs.pytest.org/en/6.2.x/fixture.html#scope-sharing-fixtures-across-classes-modules-packages-or-session
# possible values: function, class, module, package or session.
# The nodes are of scope session. All else is the default (function)
@pytest.fixture(scope="session")
def bitcoind_path():
if os.path.isfile("tests/bitcoin/src/bitcoind"):
return "tests/bitcoin/src/bitcoind"
elif os.path.isfile("tests/bitcoin/bin/bitcoind"):
return "tests/bitcoin/bin/bitcoind"
else:
return which("bitcoind")
@pytest.fixture(scope="session")
def bitcoin_regtest(docker, request):
bitcoind_regtest = instantiate_bitcoind_controller(docker, request, extra_args=None)
try:
assert bitcoind_regtest.get_rpc().test_connection()
assert not bitcoind_regtest.datadir is None
yield bitcoind_regtest
finally:
bitcoind_regtest.stop_bitcoind()
@pytest.fixture(scope="session")
def elements_elreg(request):
elements_elreg = instantiate_elementsd_controller(request, extra_args=None)
try:
yield elements_elreg
assert not elements_elreg.datadir is None
finally:
elements_elreg.stop_elementsd()
@pytest.fixture
def empty_data_folder():
# Make sure that this folder never ever gets a reasonable non-testing use-case
with tempfile.TemporaryDirectory(prefix="specter_home_tmp_") as data_folder:
yield data_folder
@pytest.fixture
def devices_filled_data_folder(empty_data_folder):
os.makedirs(empty_data_folder + "/devices")
with open(empty_data_folder + "/devices/trezor.json", "w") as text_file:
text_file.write(
"""
{
"name": "Trezor",
"type": "trezor",
"keys": [
{
"derivation": "m/49h/0h/0h",
"original": "ypub6XFn7hfb676MLm6ZsAviuQKXeRDNgT9Bs32KpRDPnkKgKDjKcrhYCXJ88aBfy8co2k9eujugJX5nwq7RPG4sj6yncDEPWN9dQGmFWPy4kFB",
"fingerprint": "1ef4e492",
"type": "sh-wpkh",
"xpub": "xpub6CRWp2zfwRYsVTuT2p96hKE2UT4vjq9gwvW732KWQjwoG7v6NCXyaTdz7NE5yDxsd72rAGK7qrjF4YVrfZervsJBjsXxvTL98Yhc7poBk7K"
},
{
"derivation": "m/84h/0h/0h",
"original": "zpub6rGoJTXEhKw7hUFkjMqNctTzojkzRPa3VFuUWAirqpuj13mRweRmnYpGD1aQVFpxNfp17zVU9r7F6oR3c4zL3DjXHdewVvA7kjugHSqz5au",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "xpub6CcGh8BQPxr9zssX4eG8CiGzToU6Y9b3f2s2wNw65p9xtr8ySL6eYRVzAbfEVSX7ZPaPd3JMEXQ9LEBvAgAJSkNKYxG6L6X9DHnPWNQud4H"
},
{
"derivation": "m/48h/0h/0h/1h",
"original": "Ypub6jtWQ1r2D7EwqNoxERU28MWZH4WdL3pWdN8guFJRBTmGwstJGzMXJe1VaNZEuAAVsZwpKPhs5GzNPEZR77mmX1mjwzEiouxmQYsrxFBNVNN",
"fingerprint": "1ef4e492",
"type": "sh-wsh",
"xpub": "xpub6EA9y7SfVU96ZWTTTQDR6C5FPJKvB59RPyxoCb8zRgYzGbWAFvogbTVRkTeBLpHgETm2hL7BjQFKNnL66CCoaHyUFBRtpbgHF6YLyi7fr6m"
},
{
"derivation": "m/48h/0h/0h/2h",
"original": "Zpub74imhgWwMnnRkSPkiNavCQtSBu1fGo8RP96h9eT2GHCgN5eFU9mZVPhGphvGnG26A1cwJxtkmbHR6nLeTw4okpCDjZCEj2HRLJoVHAEsch9",
"fingerprint": "1ef4e492",
"type": "wsh",
"xpub": "xpub6EA9y7SfVU96dGr96zYgxAMd8AgWBCTqEeQafbPi8VcWdhStCS4AA9X4yb3dE1VM7GKLwRhWy4BpD3VkjK5q1riMAQgz9oBSu8QKv5S7KzD"
},
{
"derivation": "m/49h/1h/0h",
"original": "upub5EKoQv21nQNkhdt4yuLyRnWitA3EGhW1ru1Y8VTG8gdys2JZhqiYkhn4LHp2heHnH41kz95bXPvrYVRuFUrdUMik6YdjFV4uL4EubnesttQ",
"fingerprint": "1ef4e492",
"type": "sh-wpkh",
"xpub": "tpubDDCDr9rSwixeXKeGwAgwFy8bjBaE5wya9sAVqEC4ccXWmcQxY34KmLRJdwmaDsCnHsu5r9P9SUpYtXmCoRwukWDqmAUJgkBbjC2FXUzicn6"
},
{
"derivation": "m/84h/1h/0h",
"original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
},
{
"derivation": "m/48h/1h/0h/1h",
"original": "Upub5Tk9tZtdzVaTGWtygRTKDDmaN5vfB59pn2L5MQyH6BkVpg2Y5J95rtpQndjmXNs3LNFiy8zxpHCTtvxxeePjgipF7moTHQZhe3E5uPzDXh8",
"fingerprint": "1ef4e492",
"type": "sh-wsh",
"xpub": "tpubDFiVCZzdarbyfdVoh2LJDL3eVKRPmxwnkiqN8tSYCLod75a2966anQbjHajqVAZ97j54xZJPr9hf7ogVuNL4pPCfwvXdKGDQ9SjZF7vXQu1"
},
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5naRCEZZ9B7wCKLWuqoNdg6ddWEx8ruztUygXFZDJtW5LRMqUP5HV2TsNw1nc74Ba3QPDSH7qzauZ8LdfNmnmofpfmztCGPgP7vaaYSmpgN",
"fingerprint": "1ef4e492",
"type": "wsh",
"xpub": "tpubDFiVCZzdarbyk8kE65tjRhHCambEo8iTx4xkXL8b33BKZj66HWsDnUb3rg4GZz6Mwm6vTNyzRCjYtiScCQJ77ENedb2deDDtcoNQXiUouJQ"
}
]
}
"""
)
with open(empty_data_folder + "/devices/specter.json", "w") as text_file:
text_file.write(
"""
{
"name": "Specter",
"type": "specter",
"keys": [
{
"derivation": "m/48h/1h/0h/2h",
"original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
"fingerprint": "08686ac6",
"type": "wsh",
"xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL"
},
{
"derivation": "m/84h/1h/0h",
"original": "vpub5ZSem3mLXiSJzgDX6pJb2N9L6sJ8m6ejaksLPLSuB53LBzCi2mMsBg19eEUSDkHtyYp75GATjLgt5p3S43WjaVCXAWU9q9H5GhkwJBrMiAb",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUiy4ncDirveTfhmvggdj8nxcW5JgHpGzYz3UVscJY5aEzFvgUPk4YyajadBnsTBmE2YZmAtJC14Q21xncJgVaHQ7UdqMRVRbU"
},
{
"derivation": "m/84h/1h/1h",
"original": "vpub5ZSem3mLXiSK55jPzfLVhbHbTEwGzEFZv3xrGFCw1vGHSNw7WcVuJXysJLWcgENQd3iXSNQaeSXUBW55Hy4GAjSTjrWP4vpKKkUN9jiU1Tc",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj3UJV7ZtqKgoy8JKprrjdHubbBb3r7qmwHsEH69g7h6xyanWaCYdVEEV3Yu7a6s4ceFnp8DjXeeFxY8eXvH7XTAC4gxfDNEW"
},
{
"derivation": "m/84h/1h/2h",
"original": "vpub5ZSem3mLXiSK64v64deytnDCoYqbUSYHvmVurUGVMEnXMyEybtF3FEnNuiFDDC6J18a81fv5ptQXaQaaRiYx8MRxahipgxPLdxubpYt1dkD",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj4TVBBYDKWsjaUcE9M52MJd8emp7QTAJBDTY9BRRFdomVCAFAjWMNcKLe8Cd5HJwg3AJKFyEDcGFTNyryYJgYmNdJMhwB2RG"
},
{
"derivation": "m/84h/1h/3h",
"original": "vpub5ZSem3mLXiSK8cKzh4sHxTvN7mgYQA29HfoAZeCDtX1M2zdejN5XVAtVyqhk8eui18JTtZ9M3VD3AiWCz8VwrybhBUh3HxzS8js3mLVybDT",
"fingerprint": "08686ac6",
"type": "wpkh",
"xpub": "tpubDDUotcvrYMUj6zu5oyRdaZSjnq56GnWCfXRuUz38zSWztUvpJuFjsjscGHhheyAncK4z15rLVukBdUDwpPBDLtRBykqC9KHeG9akJWRipKK"
}
]
}
"""
)
return empty_data_folder # no longer empty, though
@pytest.fixture
def wallets_filled_data_folder(devices_filled_data_folder):
os.makedirs(os.path.join(devices_filled_data_folder, "wallets", "regtest"))
with open(
os.path.join(devices_filled_data_folder, "wallets", "regtest", "simple.json"),
"w",
) as json_file:
json_file.write(
"""
{
"alias": "simple",
"fullpath": "/home/kim/.specter/wallets/regtest/simple.json",
"name": "Simple",
"address_index": 0,
"keypool": 5,
"address": "bcrt1qcatuhg0gll3h7py4cmn53rjjn9xlsqfwj3zcej",
"change_index": 0,
"change_address": "bcrt1qt28v03278lmmxllys89acddp2p5y4zds94944n",
"change_keypool": 5,
"type": "simple",
"description": "Single (Segwit)",
"keys": [{
"derivation": "m/84h/1h/0h",
"original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
"fingerprint": "1ef4e492",
"type": "wpkh",
"xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
}],
"recv_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr",
"change_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/1/*)#h4z73prm",
"device": "Trezor",
"device_type": "trezor",
"address_type": "bech32"
}
"""
)
return devices_filled_data_folder # and with wallets obviously
@pytest.fixture
def device_manager(devices_filled_data_folder):
return DeviceManager(os.path.join(devices_filled_data_folder, "devices"))
# @pytest.fixture
# def user_manager(empty_data_folder) -> UserManager:
# """A UserManager having users alice, bob and eve"""
# specter = Specter(data_folder=empty_data_folder)
# user_manager = UserManager(specter=specter)
# config = {}
# user_manager.get_user("admin").decrypt_user_secret("admin")
# user_manager.create_user(
# user_id="alice",
# username="alice",
# plaintext_password="plain_pass_alice",
# config=config,
# )
# user_manager.create_user(
# user_id="bob",
# username="bob",
# plaintext_password="plain_pass_bob",
# config=config,
# )
# user_manager.create_user(
# user_id="eve",
# username="eve",
# plaintext_password="plain_pass_eve",
# config=config,
# )
# return user_manager
@pytest.fixture
def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder):
assert bitcoin_regtest.get_rpc().test_connection()
config = {
"rpc": {
"autodetect": False,
"datadir": "",
"user": bitcoin_regtest.rpcconn.rpcuser,
"password": bitcoin_regtest.rpcconn.rpcpassword,
"port": bitcoin_regtest.rpcconn.rpcport,
"host": bitcoin_regtest.rpcconn.ipaddress,
"protocol": "http",
},
"auth": {
"method": "rpcpasswordaspin",
},
}
specter = Specter(data_folder=devices_filled_data_folder, config=config)
assert specter.chain == "regtest"
# Create a User
someuser = specter.user_manager.add_user(
User.from_json(
user_dict={
"id": "someuser",
"username": "someuser",
"password": hash_password("somepassword"),
"config": {},
"is_admin": False,
"services": None,
},
specter=specter,
)
)
specter.user_manager.save()
specter.check()
assert not someuser.wallet_manager.working_folder is None
# Create a Wallet
wallet_json = '{"label": "a_simple_wallet", "blockheight": 0, "descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr", "devices": [{"type": "trezor", "label": "trezor"}]} '
wallet_importer = WalletImporter(
wallet_json, specter, device_manager=someuser.device_manager
)
wallet_importer.create_nonexisting_signers(
someuser.device_manager,
{"unknown_cosigner_0_name": "trezor", "unknown_cosigner_0_type": "trezor"},
)
dm: DeviceManager = someuser.device_manager
wallet = wallet_importer.create_wallet(someuser.wallet_manager)
try:
# fund it with some coins
bitcoin_regtest.testcoin_faucet(address=wallet.getnewaddress())
# make sure it's confirmed
bitcoin_regtest.mine()
# Realize that the wallet has funds:
wallet.update()
except SpecterError as se:
if str(se).startswith("Timeout"):
pytest.fail(
"We got a Bitcoin-RPC timeout while setting up the test, minting some coins. Test Error! Check cpu/mem utilastion and btc/elem logs!"
)
return
else:
raise se
assert wallet.fullbalance >= 20
assert not specter.wallet_manager.working_folder is None
try:
yield specter
finally:
# Deleting all Wallets (this will also purge them on core)
for user in specter.user_manager.users:
for wallet in list(user.wallet_manager.wallets.values()):
user.wallet_manager.delete_wallet(
wallet, bitcoin_datadir=bitcoin_regtest.datadir, chain="regtest"
)
@pytest.fixture
def app(specter_regtest_configured) -> SpecterFlask:
"""the Flask-App, but uninitialized"""
app = create_app(config="cryptoadvance.specter.config.TestConfig")
app.app_context().push()
app.config["TESTING"] = True
app.testing = True
app.tor_service_id = None
app.tor_enabled = False
init_app(app, specter=specter_regtest_configured)
return app
@pytest.fixture
def app_no_node(empty_data_folder) -> SpecterFlask:
specter = Specter(data_folder=empty_data_folder)
app = create_app(config="cryptoadvance.specter.config.TestConfig")
app.app_context().push()
app.config["TESTING"] = True
app.testing = True
app.tor_service_id = None
app.tor_enabled = False
init_app(app, specter=specter)
return app
@pytest.fixture
def client(app):
"""a test_client from an initialized Flask-App"""
return app.test_client()

View file

@ -0,0 +1,67 @@
import pytest
# Using https://iancoleman.io/bip39/ and https://jlopp.github.io/xpub-converter/
# mnemonic = "ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost machine"
# m/44'/0'/0'
@pytest.fixture
def ghost_machine_xpub_44():
xpub = "xpub6CGap5qbgNCEsvXg2gAjEho17zECMA9PbZa7QkrEWTPnPRaubE6qKots5pNwhyFtuYSPa9gQu4jTTZi8WPaXJhtCHrvHQaFRqayN1saQoWv"
return xpub
# m/49'/0'/0'
@pytest.fixture
def ghost_machine_xpub_49():
xpub = "xpub6BtcNhqbaFaoC3oEfKky3Sm22pF48U2jmAf78cB3wdAkkGyAgmsVrgyt1ooSt3bHWgzsdUQh2pTJ867yTeUAMmFDKNSBp8J7WPmp7Df7zjv"
return xpub
@pytest.fixture
def ghost_machine_ypub():
ypub = "ypub6WisgNWWiw8H3LzMVgYbFXrXCnPW562EgHBKv14wKdYdoNnPwS34Uke231m2sxFCvL7gNx1FVUor1NjYBLtB9zvpBi8cQ37bn7qTVqo3fjR"
return ypub
@pytest.fixture
def ghost_machine_tpub_49():
tpub = "tpubDC5CZBbVc15fpTeqkyUBKgHqYCqkeaUtPjvGz7RJEttndfcN29psPcxTSj5RNJaWYaRQq8kqovLBrZA2tju3ThSAP9fY1eiSvorchnseFZu"
return tpub
@pytest.fixture
def ghost_machine_upub():
upub = "upub5DCn7wm4SgVmzmtdoi8DVVfxhBJkqL1L6mmKHNgVky1Fj5VyBxV6NzKD957sr5fWXkY5y8THtqSVWWpjLnomBYw4iXpxaPbkXg5Gn6s5tQf"
return upub
# m/84'/0'/0'
@pytest.fixture
def ghost_machine_xpub_84():
xpub = "xpub6CjsHfiuBnHMPBkxThQ4DDjTw2Qq3VMEVcPBoMBGejZGkj3WQR15LeJLmymPpSzYHX21C8SdFWHgMw2RUBdAQ2Aj4MMS93a68mxPQeS8oHr"
return xpub
@pytest.fixture
def ghost_machine_zpub():
zpub = "zpub6rQPu14jV9NK5n9C8QyJdPvUGxhivjLEKqRdN8y3QkK2rvfxujLCamccpPgZpGJP6oFch5dkApzn8WFYuaTBzVXvo2kHJsD4gE5gBnCBYj1"
return zpub
@pytest.fixture
def ghost_machine_tpub_84():
tpub = "tpubDC4DsqH5rqHqipMNqUbDFtQT3AkKkUrvLsN6miySvortU3s1LGaNVAb7wX2No2VsuxQV82T8s3HJLv3kdx1CPjsJ3onC1Zo5mWCQzRVaWVX"
return tpub
@pytest.fixture
def ghost_machine_vpub():
vpub = "vpub5Y24kG7ZrCFRkRnHia2sdnt5N7MmsrNry1jMrP8XptMEcZZqkjQA6bc1f52RGiEoJmdy1Vk9Qck9tAL1ohKvuq3oFXe3ADVse6UiTHzuyKx"
return vpub

View file

@ -263,15 +263,15 @@ function restore_snapshot {
rm -rf ${BTCD_REGTEST_DATA_DIR}
mkdir ${BTCD_REGTEST_DATA_DIR}
echo "--> Unpacking ./cypress/fixtures/${spec_file}_btcdir.tar.gz ... "
echo "--> Unpacking ./cypress/fixtures/${spec_file}_btcdir.tar.gz ... into ${BTCD_REGTEST_DATA_DIR}"
tar -xzf ./cypress/fixtures/${spec_file}_btcdir.tar.gz -C ${BTCD_REGTEST_DATA_DIR} --strip-components=1
rm -rf ${ELMD_REGTEST_DATA_DIR}
mkdir ${ELMD_REGTEST_DATA_DIR}
echo "--> Unpacking ./cypress/fixtures/${spec_file}_elmdir.tar.gz ... "
echo "--> Unpacking ./cypress/fixtures/${spec_file}_elmdir.tar.gz into ${ELMD_REGTEST_DATA_DIR} ..."
tar -xzf ./cypress/fixtures/${spec_file}_elmdir.tar.gz -C ${ELMD_REGTEST_DATA_DIR} --strip-components=1
echo "--> Unpacking ./cypress/fixtures/${spec_file}_specterdir.tar.gz ... "
echo "--> Unpacking ./cypress/fixtures/${spec_file}_specterdir.tar.gz ... into ${SPECTER_DATA_FOLDER} ..."
rm -rf $SPECTER_DATA_FOLDER
mkdir $SPECTER_DATA_FOLDER
tar -xzf ./cypress/fixtures/${spec_file}_specterdir.tar.gz -C $SPECTER_DATA_FOLDER --strip-components=1
@ -305,8 +305,8 @@ function sub_open {
spec_file=$1
if [ -n "${spec_file}" ]; then
restore_snapshot ${spec_file}
start_bitcoind --cleanuphard --reset
start_elementsd --cleanuphard --reset
start_bitcoind
start_elementsd
start_specter
else
start_bitcoind --reset
@ -321,8 +321,8 @@ function sub_run {
spec_file=$1
if [ -f ./cypress/integration/${spec_file} ]; then
restore_snapshot ${spec_file}
start_bitcoind --cleanuphard --reset
start_elementsd --cleanuphard --reset
start_bitcoind
start_elementsd
start_specter
# Run $spec_file and all of the others coming later!
#$(npm bin)/cypress run --spec $(./utils/calc_cypress_test_spec.py --run $spec_file)