From 5c5bcb794e22711c6d9a154cc90afb18eecc0829 Mon Sep 17 00:00:00 2001 From: Xiao Li Date: Mon, 1 Mar 2021 16:34:22 -0800 Subject: [PATCH] MiniWallet MVP (#227) MiniWallet MVP: 1. Implemented all APIs defined in spec https://github.com/diem/client-sdks/blob/master/specs/mini_wallet.md 2. Created cli for start a miniwallet service 3. Created initial testsuite for testing a MiniWallet API. 4. MiniWallet API openapi spec 5. Refund is not handled yet, will be follow up PRs. --- MANIFEST.in | 2 + Makefile | 9 +- README.md | 5 + conftest.py | 15 + mini-wallet.md | 115 +++++ requirements.txt | 6 +- setup.py | 15 +- src/diem/identifier/__init__.py | 4 + src/diem/local_account.py | 5 +- src/diem/offchain/client.py | 62 ++- src/diem/offchain/payment_command.py | 3 + src/diem/testing/__init__.py | 2 + src/diem/testing/cli/__init__.py | 2 + src/diem/testing/cli/click.py | 78 ++++ src/diem/testing/miniwallet/__init__.py | 6 + src/diem/testing/miniwallet/app/__init__.py | 6 + src/diem/testing/miniwallet/app/app.py | 301 +++++++++++++ .../testing/miniwallet/app/diem_account.py | 32 ++ .../testing/miniwallet/app/event_puller.py | 55 +++ src/diem/testing/miniwallet/app/falcon.py | 102 +++++ src/diem/testing/miniwallet/app/json_input.py | 30 ++ src/diem/testing/miniwallet/app/models.py | 125 ++++++ .../testing/miniwallet/app/static/index.html | 30 ++ .../miniwallet/app/static/openapi.yaml | 402 ++++++++++++++++++ src/diem/testing/miniwallet/app/store.py | 96 +++++ src/diem/testing/miniwallet/client.py | 137 ++++++ src/diem/testing/miniwallet/config.py | 81 ++++ src/diem/testing/suites/__init__.py | 2 + src/diem/testing/suites/clients.py | 13 + src/diem/testing/suites/conftest.py | 59 +++ src/diem/testing/suites/envs.py | 21 + .../testing/suites/test_miniwallet_api.py | 273 ++++++++++++ src/diem/testing/suites/test_payment.py | 164 +++++++ src/diem/utils.py | 14 + tests/conftest.py | 18 +- tests/miniwallet/test_models.py | 67 +++ tests/miniwallet/test_store.py | 127 ++++++ tests/test_local_account.py | 15 + tests/test_offchain_command.py | 6 + tests/test_utils.py | 12 + 40 files changed, 2467 insertions(+), 50 deletions(-) create mode 100644 conftest.py create mode 100644 mini-wallet.md create mode 100644 src/diem/testing/__init__.py create mode 100644 src/diem/testing/cli/__init__.py create mode 100644 src/diem/testing/cli/click.py create mode 100644 src/diem/testing/miniwallet/__init__.py create mode 100644 src/diem/testing/miniwallet/app/__init__.py create mode 100644 src/diem/testing/miniwallet/app/app.py create mode 100644 src/diem/testing/miniwallet/app/diem_account.py create mode 100644 src/diem/testing/miniwallet/app/event_puller.py create mode 100644 src/diem/testing/miniwallet/app/falcon.py create mode 100644 src/diem/testing/miniwallet/app/json_input.py create mode 100644 src/diem/testing/miniwallet/app/models.py create mode 100644 src/diem/testing/miniwallet/app/static/index.html create mode 100644 src/diem/testing/miniwallet/app/static/openapi.yaml create mode 100644 src/diem/testing/miniwallet/app/store.py create mode 100644 src/diem/testing/miniwallet/client.py create mode 100644 src/diem/testing/miniwallet/config.py create mode 100644 src/diem/testing/suites/__init__.py create mode 100644 src/diem/testing/suites/clients.py create mode 100644 src/diem/testing/suites/conftest.py create mode 100644 src/diem/testing/suites/envs.py create mode 100644 src/diem/testing/suites/test_miniwallet_api.py create mode 100644 src/diem/testing/suites/test_payment.py create mode 100644 tests/miniwallet/test_models.py create mode 100644 tests/miniwallet/test_store.py diff --git a/MANIFEST.in b/MANIFEST.in index fc39f5af..0f3126e4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,5 @@ # SPDX-License-Identifier: Apache-2.0 recursive-include src *.py +recursive-include src *.yaml +recursive-include src *.html diff --git a/Makefile b/Makefile index 676f82b4..ea9374ec 100644 --- a/Makefile +++ b/Makefile @@ -13,15 +13,18 @@ black: lint: ./venv/bin/pylama src tests examples - ./venv/bin/pyre --search-path venv/lib/python3.9/site-packages check + ./venv/bin/pyre --search-path venv/lib/python3.7/site-packages --search-path venv/lib/python3.8/site-packages --search-path venv/lib/python3.9/site-packages check format: ./venv/bin/python -m black src tests examples -test: format lint runtest +test: format runtest runtest: - ./venv/bin/pytest tests/test_* examples/* -k "$(t)" $(args) + DMW_SELF_CHECK=Y ./venv/bin/pytest src/diem/testing/suites tests examples -k "$(t)" $(args) + +profile: + ./venv/bin/python -m profile -m pytest tests examples -k "$(t)" $(args) cover: ./venv/bin/pytest --cov-report html --cov=src tests/test_* examples/* diff --git a/README.md b/README.md index c47fafa0..41e27594 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ Example curl to hit the server (should get an error response): curl -X POST -H "X-REQUEST-ID: 3185027f-0574-6f55-2668-3a38fdb5de98" -H "X-REQUEST-SENDER-ADDRESS: tdm1pacrzjajt6vuamzkswyd50e28pg77m6wylnc3spg3xj7r6" -d "invalid-jws-body" http://localhost:8080/v2/command ``` +## MiniWallet and MiniWallet Test Suite + +See [mini_wallet.md](mini-wallet.md) + + ## Build & Test ``` diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..051c1284 --- /dev/null +++ b/conftest.py @@ -0,0 +1,15 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from diem import chain_ids, testnet +import pytest, os + + +@pytest.fixture(scope="session", autouse=True) +def setup_testnet() -> None: + if os.getenv("dt"): + os.system("make docker") + print("swap testnet default values to local testnet launched by docker-compose") + testnet.JSON_RPC_URL = "http://localhost:8080/v1" + testnet.FAUCET_URL = "http://localhost:8000/mint" + testnet.CHAIN_ID = chain_ids.TESTING diff --git a/mini-wallet.md b/mini-wallet.md new file mode 100644 index 00000000..3ffc0788 --- /dev/null +++ b/mini-wallet.md @@ -0,0 +1,115 @@ +## Try MiniWallet + +Install Diem python sdk with MiniWallet and MiniWallet Test Suite: +``` +pip install diem[all] +``` + +`dmw` cli will be installed. You can check it out by: +``` +dmw --help +``` + +Start a MiniWallet server, connects to Diem testnet by default +``` +dmw start-server +``` + +Open http://localhost:8888 to check MiniWallet API specification document (Defined by OpenAPI Specification 3.0.3). +The document includes simple examples to try out the API. + +`start-server` options: + +``` +dmw start-server --help +Usage: dmw start-server [OPTIONS] + +Options: + -n, --name TEXT Application name. [default: mini-wallet] + -h, --host TEXT Start server host. [default: localhost] + -p, --port INTEGER Start server port. [default: 8888] + -j, --jsonrpc TEXT Diem fullnode JSON-RPC URL. [default: + http://testnet.diem.com/v1] + + -f, --faucet TEXT Testnet faucet URL. [default: + http://testnet.diem.com/mint] + + -l, --logfile TEXT Log to a file instead of printing into + console. + + -o, --enable-debug-api BOOLEAN Enable debug API. [default: True] + --help Show this message and exit. +``` + + +## MiniWallet Test Suite + +Try out MiniWallet Test Suite by hitting the target server we started by `dmw start-server` +``` +dmw test --target http://localhost:8888 +``` +You should see something like this: + +``` +> dmw test +Diem JSON-RPC URL: http://testnet.diem.com/v1 +Diem Testnet Faucet URL: http://testnet.diem.com/mint +======================================== test session starts =============================================== +…... +collected 61 items + +src/diem/testing/suites/test_miniwallet_api.py ......................ss. [ 40%] +src/diem/testing/suites/test_payment.py .................................... [100%] + +============================== 59 passed, 2 skipped, 198 deselected in 18.26s =============================== +``` + +Follow MiniWallet API specification to create a proxy server for your wallet application. +Assume you run your application at port 9999, run MiniWallet Test Suite: +``` +dmw test --target http://localhost:9999 +``` + +`test` options: +``` +dmw test --help +Usage: dmw test [OPTIONS] + +Options: + -t, --target TEXT Target mini-wallet application URL. [default: + http://localhost:8888] + + -j, --jsonrpc TEXT Diem fullnode JSON-RPC URL. [default: + http://testnet.diem.com/v1] + + -f, --faucet TEXT Testnet faucet URL. [default: + http://testnet.diem.com/mint] + + --pytest-args TEXT Additional pytest arguments, split by empty + space, e.g. `--pytest-args '-v -s'`. + + -d, --test-debug-api BOOLEAN Run tests for debug APIs. [default: False] + -v, --verbose BOOLEAN Enable verbose log output. [default: False] + --help Show this message and exit. +``` + +### How it works + +1. Test suite is located at diem.testing.suites package, including a pytest conftest.py. +2. `dmw test` will launch a pytest runner to run tests. +3. The conftest.py will start a stub MiniWallet as counterparty service for testing payment with the target server specified by the `--target` option. + + +### Work with local testnet + +1. [Download](https://docs.docker.com/get-docker/) and install Docker and Docker Compose (comes with Docker for Mac and Windows). +2. Download Diem testnet docker compose config: https://github.com/diem/diem/blob/master/docker/compose/validator-testnet/docker-compose.yaml +3. Run `docker-compose -f up --detach` + * Faucet will be available at http://127.0.0.1:8000 + * JSON-RPC will be available at http://127.0.0.1:8080 +4. Test your application with local testnet: `dmw test --jsonrpc http://127.0.0.1:8080 --faucet http://127.0.0.1:8000 --target http://localhost:9999` + +### Test Off-chain API + +As the test counterparty wallet application server is started at local, you need make sure your wallet application's off-chain API can access the stub server by it's base_url: `http://localhost:`. +If your wallet application is not running local, you will need to make sure setup tunnel for your wallet application to access the stub server. diff --git a/requirements.txt b/requirements.txt index 7f958f53..0dfd19d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,10 +2,14 @@ requests==2.20.0 cryptography==3.3.2 numpy==1.18 protobuf==3.12.4 -pytest +pytest==6.2.1 +click==7.1 pylama black pyre-check==0.0.58 pytest-cov mypy-protobuf pdoc3 +falcon +waitress +pygount diff --git a/setup.py b/setup.py index fd11f417..a50a72a5 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,13 @@ long_description_content_type='text/markdown', license="Apache-2.0", url="https://github.com/diem/client-sdk-python", - # author="Diem Open Source", + author="The Diem Core Contributors", + author_email="developers@diem.com", + py_modules=["diem.testing.cli.click"], + entry_points=''' + [console_scripts] + dmw=diem.testing.cli.click:main + ''', python_requires=">=3.7", # requires dataclasses packages=["diem"], # package_data={"": ["src/diem/jsonrpc/*.pyi"]}, @@ -30,10 +36,9 @@ include_package_data=True, # see MANIFEST.in zip_safe=True, install_requires=["requests>=2.20.0", "cryptography>=2.8", "numpy>=1.18", "protobuf>=3.12.4"], - setup_requires=[ - # Setuptools 18.0 properly handles Cython extensions. - "setuptools>=18.0", - ], + extras_require={ + "all": ["falcon>=2.0.0", "waitress>=1.4.4", "pytest>=6.2.1", "click>=7.1"] + }, classifiers=[ 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', diff --git a/src/diem/identifier/__init__.py b/src/diem/identifier/__init__.py index 6dcf5dd9..f94c0673 100644 --- a/src/diem/identifier/__init__.py +++ b/src/diem/identifier/__init__.py @@ -57,6 +57,10 @@ def __init__( self.amount = amount self.hrp = hrp + @property + def subaddress(self) -> typing.Optional[bytes]: + return self.sub_address + @property def account_address_bytes(self) -> bytes: return self.account_address.to_bytes() diff --git a/src/diem/local_account.py b/src/diem/local_account.py index c4a0d39b..64b17afd 100644 --- a/src/diem/local_account.py +++ b/src/diem/local_account.py @@ -12,7 +12,7 @@ from .auth_key import AuthKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey -from typing import Dict, Optional +from typing import Dict, Optional, Tuple from dataclasses import dataclass, field from copy import copy import time @@ -90,6 +90,9 @@ def compliance_public_key_bytes(self) -> bytes: def account_identifier(self, subaddress: Optional[bytes] = None) -> str: return identifier.encode_account(self.account_address, subaddress, self.hrp) + def decode_account_identifier(self, encoded_id: str) -> Tuple[diem_types.AccountAddress, Optional[bytes]]: + return identifier.decode_account(encoded_id, self.hrp) + def sign(self, txn: diem_types.RawTransaction) -> diem_types.SignedTransaction: """Create signed transaction for given raw transaction""" diff --git a/src/diem/offchain/client.py b/src/diem/offchain/client.py index 2a3419df..62e4dcd1 100644 --- a/src/diem/offchain/client.py +++ b/src/diem/offchain/client.py @@ -39,6 +39,14 @@ def __init__(self, resp: CommandResponseObject) -> None: self.resp = resp +class InvalidCurrencyCodeError(ValueError): + pass + + +class UnsupportedCurrencyCodeError(ValueError): + pass + + @dataclasses.dataclass class Client: """Client for communicating with offchain service. @@ -160,7 +168,7 @@ def process_inbound_request(self, request_sender_address: str, request_bytes: by self.validate_addresses(payment, request_sender_address) cmd = self.create_inbound_payment_command(request.cid, payment) if cmd.is_initial(): - self.validate_dual_attestation_limit(cmd.payment.action) + self.validate_dual_attestation_limit_by_action(cmd.payment.action) elif cmd.is_rsend(): self.validate_recipient_signature(cmd, public_key) return cmd @@ -182,34 +190,42 @@ def validate_recipient_signature(self, cmd: PaymentCommand, public_key: Ed25519P ErrorCode.invalid_recipient_signature, str(e), "command.payment.recipient_signature" ) from e - def validate_dual_attestation_limit(self, action: PaymentActionObject) -> None: + def validate_dual_attestation_limit_by_action(self, action: PaymentActionObject) -> None: + msg = self.is_under_dual_attestation_limit(action.currency, action.amount) + if msg: + raise command_error(ErrorCode.no_kyc_needed, msg, "command.payment.action.amount") + + def is_under_dual_attestation_limit(self, currency: str, amount: int) -> typing.Optional[str]: currencies = self.jsonrpc_client.get_currencies() - currency_codes = list(map(lambda c: c.code, currencies)) - supported_codes = _filter_supported_currency_codes(self.supported_currency_codes, currency_codes) - if action.currency not in currency_codes: - raise command_error( - ErrorCode.invalid_field_value, - f"currency code is invalid: {action.currency}", - "command.payment.action.currency", - ) + try: + self.validate_currency_code(currency, currencies) + except InvalidCurrencyCodeError as e: + raise command_error(ErrorCode.invalid_field_value, str(e), "command.payment.action.currency") + except UnsupportedCurrencyCodeError as e: + raise command_error(ErrorCode.unsupported_currency, str(e), "command.payment.action.currency") - if action.currency not in supported_codes: - raise command_error( - ErrorCode.unsupported_currency, - f"currency code is not supported: {action.currency}", - "command.payment.action.currency", - ) limit = self.jsonrpc_client.get_metadata().dual_attestation_limit for info in currencies: - if info.code == action.currency: - if _is_under_the_threshold(limit, info.to_xdx_exchange_rate, action.amount): - raise command_error( - ErrorCode.no_kyc_needed, - "payment amount is %s (rate: %s) under travel rule threshold %s" - % (action.amount, info.to_xdx_exchange_rate, limit), - "command.payment.action.amount", + if info.code == currency: + if _is_under_the_threshold(limit, info.to_xdx_exchange_rate, amount): + return "payment amount is %s (rate: %s) under travel rule threshold %s" % ( + amount, + info.to_xdx_exchange_rate, + limit, ) + def validate_currency_code( + self, currency: str, currencies: typing.Optional[typing.List[jsonrpc.CurrencyInfo]] = None + ) -> None: + if currencies is None: + currencies = self.jsonrpc_client.get_currencies() + currency_codes = list(map(lambda c: c.code, currencies)) + supported_codes = _filter_supported_currency_codes(self.supported_currency_codes, currency_codes) + if currency not in currency_codes: + raise InvalidCurrencyCodeError(f"currency code is invalid: {currency}") + if currency not in supported_codes: + raise UnsupportedCurrencyCodeError(f"currency code is not supported: {currency}") + def validate_addresses(self, payment: PaymentObject, request_sender_address: str) -> None: self.validate_actor_address("sender", payment.sender) self.validate_actor_address("receiver", payment.receiver) diff --git a/src/diem/offchain/payment_command.py b/src/diem/offchain/payment_command.py index 057bd339..efb877a4 100644 --- a/src/diem/offchain/payment_command.py +++ b/src/diem/offchain/payment_command.py @@ -92,6 +92,9 @@ def new_request(self) -> CommandRequestObject: # the followings are PaymentCommand specific methods + def my_subaddress(self, hrp: str) -> typing.Optional[bytes]: + return identifier.decode_account_subaddress(self.my_actor_address, hrp) + def validate_state_trigger_actor(self) -> None: if self.inbound and self.opponent_actor() != self.state_trigger_actor(): raise command_error( diff --git a/src/diem/testing/__init__.py b/src/diem/testing/__init__.py new file mode 100644 index 00000000..50fa65ee --- /dev/null +++ b/src/diem/testing/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/src/diem/testing/cli/__init__.py b/src/diem/testing/cli/__init__.py new file mode 100644 index 00000000..50fa65ee --- /dev/null +++ b/src/diem/testing/cli/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/src/diem/testing/cli/click.py b/src/diem/testing/cli/click.py new file mode 100644 index 00000000..9a75cae9 --- /dev/null +++ b/src/diem/testing/cli/click.py @@ -0,0 +1,78 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from diem import testnet +from diem.testing.miniwallet import AppConfig +from diem.testing.suites import envs +from typing import Optional +import logging, click, functools, pytest, os, sys, re + +log_format: str = "%(name)s [%(asctime)s] %(levelname)s: %(message)s" +click.option = functools.partial(click.option, show_default=True) # pyre-ignore + + +@click.group() +def main() -> None: + pass + + +@click.command() +@click.option("--name", "-n", default="mini-wallet", help="Application name.") +@click.option("--host", "-h", default="localhost", help="Start server host.") +@click.option("--port", "-p", default=8888, help="Start server port.") +@click.option("--jsonrpc", "-j", default=testnet.JSON_RPC_URL, help="Diem fullnode JSON-RPC URL.") +@click.option("--faucet", "-f", default=testnet.FAUCET_URL, help="Testnet faucet URL.") +@click.option("--logfile", "-l", default=None, help="Log to a file instead of printing into console.") +@click.option("--enable-debug-api", "-o", default=True, help="Enable debug API.", type=bool) +def start_server( + name: str, host: str, port: int, jsonrpc: str, faucet: str, enable_debug_api: bool, logfile: Optional[str] = None +) -> None: + logging.basicConfig(level=logging.INFO, format=log_format, filename=logfile) + configure_testnet(jsonrpc, faucet) + + conf = AppConfig(name=name, host=host, port=port, enable_debug_api=enable_debug_api) + print("Server Config: %s" % conf) + + client = testnet.create_client() + print("Diem chain id: %s" % client.get_metadata().chain_id) + + conf.start(client).join() + + +@click.command() +@click.option("--target", "-t", default="http://localhost:8888", help="Target mini-wallet application URL.") +@click.option("--jsonrpc", "-j", default=testnet.JSON_RPC_URL, help="Diem fullnode JSON-RPC URL.") +@click.option("--faucet", "-f", default=testnet.FAUCET_URL, help="Testnet faucet URL.") +@click.option( + "--pytest-args", + default="", + help="Additional pytest arguments, split by empty space, e.g. `--pytest-args '-v -s'`.", + show_default=False, +) +@click.option("--test-debug-api", "-d", default=False, help="Run tests for debug APIs.", type=bool) +@click.option("--verbose", "-v", default=False, help="Enable verbose log output.", type=bool) +def test(target: str, jsonrpc: str, faucet: str, pytest_args: str, test_debug_api: bool, verbose: bool) -> None: + configure_testnet(jsonrpc, faucet) + os.environ[envs.TARGET_URL] = target + if test_debug_api: + os.environ[envs.TEST_DEBUG_API] = "Y" + + args = [arg for arg in re.compile("\\s+").split(pytest_args) if arg] + args = ["--pyargs", "diem.testing.suites"] + args + if verbose: + args.append("--log-level=INFO") + + code = pytest.main(args) + sys.stdout.flush() + raise SystemExit(code) + + +def configure_testnet(jsonrpc: str, faucet: str) -> None: + testnet.JSON_RPC_URL = jsonrpc + testnet.FAUCET_URL = faucet + print("Diem JSON-RPC URL: %s" % testnet.JSON_RPC_URL) + print("Diem Testnet Faucet URL: %s" % testnet.FAUCET_URL) + + +main.add_command(start_server) +main.add_command(test) diff --git a/src/diem/testing/miniwallet/__init__.py b/src/diem/testing/miniwallet/__init__.py new file mode 100644 index 00000000..2f935336 --- /dev/null +++ b/src/diem/testing/miniwallet/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from .app import App, Account, Transaction, PaymentUri, Event, Payment, PaymentCommand, KycSample, falcon_api +from .client import RestClient, AccountResource +from .config import AppConfig diff --git a/src/diem/testing/miniwallet/app/__init__.py b/src/diem/testing/miniwallet/app/__init__.py new file mode 100644 index 00000000..f84a8632 --- /dev/null +++ b/src/diem/testing/miniwallet/app/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from .app import App +from .models import Account, Transaction, PaymentUri, Event, KycSample, Payment, PaymentCommand +from .falcon import falcon_api diff --git a/src/diem/testing/miniwallet/app/app.py b/src/diem/testing/miniwallet/app/app.py new file mode 100644 index 00000000..08f3be64 --- /dev/null +++ b/src/diem/testing/miniwallet/app/app.py @@ -0,0 +1,301 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import asdict +from typing import List, Tuple, Dict, Optional, Any +from json.decoder import JSONDecodeError +from .store import InMemoryStore, NotFoundError +from .diem_account import DiemAccount +from .models import PaymentUri, Subaddress, Account, Transaction, Event, KycSample, Payment, PaymentCommand +from .event_puller import EventPuller +from .json_input import JsonInput +from .... import jsonrpc, offchain, utils, LocalAccount, identifier +from ....offchain import KycDataObject, Status, AbortCode, CommandResponseObject +import threading, logging, os + + +class Base: + def __init__(self, account: LocalAccount, client: jsonrpc.Client, name: str, logger: logging.Logger) -> None: + self.logger = logger + self.diem_account = DiemAccount(account, client) + self.store = InMemoryStore() + self.offchain = offchain.Client(account.account_address, client, account.hrp) + self.kyc_sample: KycSample = KycSample.gen(name) + self.event_puller = EventPuller(client=client, store=self.store, hrp=account.hrp) + self.event_puller.add(account.account_address) + self.event_puller.head() + + def _validate_kyc_data(self, name: str, val: str) -> None: + try: + offchain.from_json(val, KycDataObject) + except (JSONDecodeError, offchain.types.FieldError) as e: + raise ValueError("%r must be JSON-encoded KycDataObject: %s" % (name, e)) + + def _validate_currency_code(self, name: str, val: str) -> None: + try: + self.offchain.validate_currency_code(val) + except ValueError: + raise ValueError("%r is invalid currency code: %s" % (name, val)) + + def _validate_account_identifier(self, name: str, val: str) -> None: + try: + self.diem_account.account.decode_account_identifier(val) + except ValueError as e: + raise ValueError("%r is invalid account identifier: %s" % (name, e)) + + def _validate_amount(self, name: str, val: int) -> None: + if val < 0: + raise ValueError("%r value must be greater than or equal to zero" % name) + + def _validate_account_balance(self, txn: Dict[str, Any]) -> None: + if txn.get("payee"): + balance = self._balances(txn["account_id"]).get(txn["currency"], 0) + if balance < txn["amount"]: + raise ValueError("account balance not enough: %s (%s)" % (balance, txn)) + + def _balances(self, account_id: str) -> Dict[str, int]: + ret = {} + for txn in self.store.find_all(Transaction, account_id=account_id): + if txn.status != Transaction.Status.canceled: + ret[txn.currency] = ret.get(txn.currency, 0) + txn.balance_amount() + return ret + + def _gen_subaddress(self) -> bytes: + return self.store.next_id().to_bytes(8, byteorder="big") + + def _txn_metadata(self, txn: Transaction) -> Tuple[bytes, bytes]: + if self.offchain.is_under_dual_attestation_limit(txn.currency, txn.amount): + if txn.subaddress_hex: + return self.diem_account.general_metadata(txn.subaddress(), str(txn.payee)) + elif txn.reference_id: + cmd = self.store.find(PaymentCommand, reference_id=txn.reference_id) + return self.diem_account.travel_metadata(cmd.to_offchain_command()) + raise ValueError("could not create diem payment transacton metadata: %s" % txn) + + +class OffChainAPI(Base): + def offchain_api(self, request_sender_address: str, request_bytes: bytes) -> CommandResponseObject: + cmd = self.offchain.process_inbound_request(request_sender_address, request_bytes) + getattr(self, "_handle_offchain_%s" % utils.to_snake(cmd))(cmd) + return offchain.reply_request(cid=cmd.id()) + + def jws_serialize(self, resp: CommandResponseObject) -> bytes: + return offchain.jws.serialize(resp, self.diem_account.account.compliance_key.sign) + + def _handle_offchain_payment_command(self, new_offchain_cmd: offchain.PaymentCommand) -> None: + try: + cmd = self.store.find(PaymentCommand, reference_id=new_offchain_cmd.reference_id()) + if new_offchain_cmd != cmd.to_offchain_command(): + self._update_payment_command(cmd, new_offchain_cmd) + except NotFoundError: + subaddress = utils.hex(new_offchain_cmd.my_subaddress(self.diem_account.account.hrp)) + account_id = self.store.find(Subaddress, subaddress_hex=subaddress).account_id + self._create_payment_command(account_id, new_offchain_cmd, validate=True) + + def _create_payment_command(self, account_id: str, cmd: offchain.PaymentCommand, validate: bool = False) -> None: + self.store.create( + PaymentCommand, + before_create=lambda _: cmd.validate(None) if validate else None, + account_id=account_id, + is_sender=cmd.is_sender(), + reference_id=cmd.reference_id(), + is_inbound=cmd.is_inbound(), + cid=cmd.id(), + payment_object=asdict(cmd.payment), + ) + + def _update_payment_command( + self, cmd: PaymentCommand, offchain_cmd: offchain.PaymentCommand, validate: bool = False + ) -> None: + self.store.update( + cmd, + before_update=lambda _: offchain_cmd.validate(cmd.to_offchain_command()) if validate else None, + cid=offchain_cmd.id(), + is_inbound=offchain_cmd.is_inbound(), + is_abort=offchain_cmd.is_abort(), + is_ready=offchain_cmd.is_both_ready(), + payment_object=asdict(offchain_cmd.payment), + ) + + +class BackgroundTasks(OffChainAPI): + def start_sync(self, delay: float = 0.1) -> None: + try: + self._process_offchain_commands() + self._send_pending_payments() + self.event_puller.fetch(self.event_puller.save_payment_txn) + except Exception as e: + self.logger.exception(e) + os._exit(-1) + if threading.main_thread().is_alive(): + threading.Timer(delay, self.start_sync, args=[delay]).start() + + def _send_pending_payments(self) -> None: + for txn in self.store.find_all(Transaction, status=Transaction.Status.pending): + self.logger.info("processing %s" % txn) + try: + if self.offchain.is_my_account_id(str(txn.payee)): + self._send_internal_payment_txn(txn) + else: + self._send_external_payment_txn(txn) + except jsonrpc.JsonRpcError as e: + msg = "ignore error %s when sending transaction %s, retry later" + self.logger.info(msg % (e, txn), exc_info=True) + except Exception as e: + msg = "send pending transaction failed with %s, cancel transaction %s." + self.logger.error(msg % (e, txn), exc_info=True) + self.store.update(txn, status=Transaction.Status.canceled, cancel_reason=str(e)) + + def _send_internal_payment_txn(self, txn: Transaction) -> None: + _, payee_subaddress = self.diem_account.account.decode_account_identifier(str(txn.payee)) + subaddress = self.store.find(Subaddress, subaddress_hex=utils.hex(payee_subaddress)) + self.store.create( + Transaction, + account_id=subaddress.account_id, + currency=txn.currency, + amount=txn.amount, + status=Transaction.Status.completed, + ) + self.store.update(txn, status=Transaction.Status.completed) + + def _send_external_payment_txn(self, txn: Transaction) -> None: + if txn.signed_transaction: + try: + diem_txn = self.diem_account.client.wait_for_transaction(str(txn.signed_transaction), timeout_secs=0.1) + self.store.update(txn, status=Transaction.Status.completed, diem_transaction_version=diem_txn.version) + except jsonrpc.WaitForTransactionTimeout as e: + self.store.create_event(txn.account_id, "info", str(e)) + except jsonrpc.TransactionHashMismatchError as e: + self.store.create_event(txn.account_id, "info", str(e)) + self.store.update(txn, signed_transaction=self.diem_account.submit_p2p(txn, self._txn_metadata(txn))) + except (jsonrpc.TransactionExpired, jsonrpc.TransactionExecutionFailed) as e: + self.store.create_event(txn.account_id, "info", str(e)) + reason = "something went wrong with transaction execution: %s" % e + self.store.update(txn, status=Transaction.Status.canceled, cancel_reason=reason) + else: + self._start_external_payment_txn(txn) + + def _start_external_payment_txn(self, txn: Transaction) -> None: + if not txn.subaddress_hex: + self.store.update(txn, subaddress_hex=self._gen_subaddress().hex()) + if self.offchain.is_under_dual_attestation_limit(txn.currency, txn.amount): + if not txn.signed_transaction: + signed_txn = self.diem_account.submit_p2p(txn, self._txn_metadata(txn)) + self.store.update(txn, signed_transaction=signed_txn) + else: + if txn.reference_id: + cmd = self.store.find(PaymentCommand, reference_id=txn.reference_id) + if cmd.is_sender: + if cmd.is_abort: + status = Transaction.Status.canceled + self.store.update(txn, status=status, cancel_reason="exchange kyc data abort") + elif cmd.is_ready: + signed_txn = self.diem_account.submit_p2p(txn, self._txn_metadata(txn)) + self.store.update(txn, signed_transaction=signed_txn) + else: + account = self.store.find(Account, id=txn.account_id) + command = offchain.PaymentCommand.init( + sender_account_id=self.diem_account.account.account_identifier(txn.subaddress()), + sender_kyc_data=account.kyc_data_object(), + currency=txn.currency, + amount=txn.amount, + receiver_account_id=str(txn.payee), + ) + self.offchain.send_command(command, self.diem_account.account.compliance_key.sign) + self._create_payment_command(txn.account_id, command) + self.store.update(txn, reference_id=command.reference_id()) + + def _process_offchain_commands(self) -> None: + cmds = self.store.find_all(PaymentCommand, is_inbound=True, is_abort=False, is_ready=False, process_error=None) + for cmd in cmds: + try: + offchain_cmd = cmd.to_offchain_command() + action = offchain_cmd.follow_up_action() + if action: + fn = getattr(self, "_offchain_action_%s" % action.value) + new_offchain_cmd = fn(cmd.account_id, offchain_cmd) + self.offchain.send_command(new_offchain_cmd, self.diem_account.account.compliance_key.sign) + self._update_payment_command(cmd, new_offchain_cmd) + except Exception as e: + self.logger.exception(e) + self.store.update(cmd, process_error=str(e)) + + def _offchain_action_evaluate_kyc_data(self, account_id: str, cmd: offchain.PaymentCommand) -> offchain.Command: + op_kyc_data = cmd.opponent_actor_obj().kyc_data + if op_kyc_data is None or self.kyc_sample.match_kyc_data("reject", op_kyc_data): + return self._new_reject_kyc_data(cmd, "KYC data is rejected") + elif self.kyc_sample.match_any_kyc_data(["soft_match", "soft_reject"], op_kyc_data): + return cmd.new_command(status=Status.soft_match) + return self._ready_for_settlement(account_id, cmd) + + def _offchain_action_clear_soft_match(self, account_id: str, cmd: offchain.PaymentCommand) -> offchain.Command: + return cmd.new_command(additional_kyc_data="{%r: %r}" % ("account_id", account_id)) + + def _offchain_action_review_kyc_data(self, account_id: str, cmd: offchain.PaymentCommand) -> offchain.Command: + op_kyc_data = cmd.opponent_actor_obj().kyc_data + if op_kyc_data is None or self.kyc_sample.match_kyc_data("soft_reject", op_kyc_data): + return self._new_reject_kyc_data(cmd, "KYC data review result is reject") + return self._ready_for_settlement(account_id, cmd) + + def _new_reject_kyc_data(self, cmd: offchain.PaymentCommand, msg: str) -> offchain.Command: + return cmd.new_command(status=Status.abort, abort_code=AbortCode.reject_kyc_data, abort_message=msg) + + def _ready_for_settlement(self, account_id: str, cmd: offchain.PaymentCommand) -> offchain.Command: + if cmd.is_sender(): + return cmd.new_command(status=Status.ready_for_settlement) + + sig_msg = cmd.travel_rule_metadata_signature_message(self.diem_account.account.hrp) + sig = self.diem_account.account.compliance_key.sign(sig_msg).hex() + kyc_data = self.store.find(Account, id=account_id).kyc_data_object() + return cmd.new_command(recipient_signature=sig, kyc_data=kyc_data, status=Status.ready_for_settlement) + + +class App(BackgroundTasks): + def create_account(self, data: JsonInput) -> Account: + account = self.store.create( + Account, + kyc_data=data.get_nullable("kyc_data", str, self._validate_kyc_data), + ) + balances = data.get_nullable("balances", dict) + if balances: + for c, a in balances.items(): + self._create_transaction( + account.id, Transaction.Status.completed, JsonInput({"currency": c, "amount": a}) + ) + return account + + def create_account_payment(self, account_id: str, data: JsonInput) -> Payment: + self.store.find(Account, id=account_id) + payee = data.get("payee", str, self._validate_account_identifier) + txn = self._create_transaction(account_id, Transaction.Status.pending, data, payee=payee) + return Payment(id=txn.id, account_id=account_id, payee=payee, currency=txn.currency, amount=txn.amount) + + def create_account_payment_uri(self, account_id: str, data: JsonInput) -> PaymentUri: + currency = data.get_nullable("currency", str, self._validate_currency_code) + amount = data.get_nullable("amount", int) + self.store.find(Account, id=account_id) + sub = self._gen_subaddress() + diem_acc_id = self.diem_account.account.account_identifier(sub) + uri = identifier.encode_intent(diem_acc_id, currency, amount) + sub = self.store.create(Subaddress, account_id=account_id, subaddress_hex=sub.hex()) + return PaymentUri(id=sub.id, account_id=account_id, currency=currency, amount=amount, payment_uri=uri) + + def get_account_balances(self, account_id: str) -> Dict[str, int]: + self.store.find(Account, id=account_id) + return self._balances(account_id) + + def get_account_events(self, account_id: str) -> List[Event]: + return self.store.find_all(Event, account_id=account_id) + + def _create_transaction( + self, account_id: str, status: str, data: JsonInput, payee: Optional[str] = None + ) -> Transaction: + return self.store.create( + Transaction, + account_id=account_id, + currency=data.get("currency", str, self._validate_currency_code), + amount=data.get("amount", int, self._validate_amount), + payee=payee, + status=status, + before_create=self._validate_account_balance, + ) diff --git a/src/diem/testing/miniwallet/app/diem_account.py b/src/diem/testing/miniwallet/app/diem_account.py new file mode 100644 index 00000000..449b1b4e --- /dev/null +++ b/src/diem/testing/miniwallet/app/diem_account.py @@ -0,0 +1,32 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass +from typing import Tuple +from .models import Transaction +from .... import jsonrpc, identifier, offchain, stdlib, utils, txnmetadata, LocalAccount + + +@dataclass +class DiemAccount: + account: LocalAccount + client: jsonrpc.Client + + def general_metadata(self, from_subaddress: bytes, payee: str) -> Tuple[bytes, bytes]: + to_account, to_subaddress = identifier.decode_account(payee, self.account.hrp) + return (txnmetadata.general_metadata(from_subaddress, to_subaddress), b"") + + def travel_metadata(self, cmd: offchain.PaymentCommand) -> Tuple[bytes, bytes]: + metadata = cmd.travel_rule_metadata(self.account.hrp) + return (metadata, bytes.fromhex(str(cmd.payment.recipient_signature))) + + def submit_p2p(self, txn: Transaction, metadata: Tuple[bytes, bytes]) -> str: + to_account, to_subaddress = identifier.decode_account(str(txn.payee), self.account.hrp) + script = stdlib.encode_peer_to_peer_with_metadata_script( + currency=utils.currency_code(txn.currency), + amount=txn.amount, + payee=to_account, + metadata=metadata[0], + metadata_signature=metadata[1], + ) + return self.account.submit_txn(self.client, script).bcs_serialize().hex() diff --git a/src/diem/testing/miniwallet/app/event_puller.py b/src/diem/testing/miniwallet/app/event_puller.py new file mode 100644 index 00000000..9e6ec39c --- /dev/null +++ b/src/diem/testing/miniwallet/app/event_puller.py @@ -0,0 +1,55 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from copy import copy +from dataclasses import dataclass, field +from typing import Dict, Callable, Any +from .store import InMemoryStore +from .models import Transaction, Subaddress, PaymentCommand +from .... import jsonrpc, diem_types, txnmetadata + + +@dataclass +class EventPuller: + client: jsonrpc.Client + store: InMemoryStore + hrp: str + state: Dict[str, int] = field(default_factory=dict) + + def add(self, address: diem_types.AccountAddress) -> None: + account = self.client.must_get_account(address) + self.state[account.received_events_key] = 0 + + def fetch(self, process: Callable[[jsonrpc.Event], None], batch_size: int = 100) -> None: + for key, seq in self.state.items(): + events = self.client.get_events(key, seq, batch_size) + if events: + for event in events: + process(event) + self.state[key] = event.sequence_number + 1 + + def head(self) -> None: + state = None + while state != self.state: + state = copy(self.state) + self.fetch(lambda _: None) + + def save_payment_txn(self, event: jsonrpc.Event) -> None: + metadata = txnmetadata.decode_structure(event.data.metadata) + if isinstance(metadata, diem_types.GeneralMetadataV0) and metadata.to_subaddress: + res = self.store.find(Subaddress, subaddress_hex=metadata.to_subaddress.hex()) + return self._create_txn(event, account_id=res.account_id, subaddress_hex=res.subaddress_hex) + if isinstance(metadata, diem_types.TravelRuleMetadataV0) and metadata.off_chain_reference_id: + cmd = self.store.find(PaymentCommand, reference_id=metadata.off_chain_reference_id) + return self._create_txn(event, account_id=cmd.account_id, reference_id=cmd.reference_id) + raise ValueError("unsupported metadata: %s" % metadata) + + def _create_txn(self, event: jsonrpc.Event, **kwargs: Any) -> None: + self.store.create( + Transaction, + currency=event.data.amount.currency, + amount=event.data.amount.amount, + diem_transaction_version=event.transaction_version, + status=Transaction.Status.completed, + **kwargs, + ) diff --git a/src/diem/testing/miniwallet/app/falcon.py b/src/diem/testing/miniwallet/app/falcon.py new file mode 100644 index 00000000..a6b862cd --- /dev/null +++ b/src/diem/testing/miniwallet/app/falcon.py @@ -0,0 +1,102 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass, asdict +from typing import Dict, List, Any, Tuple +from .app import App +from .json_input import JsonInput +from .... import offchain +import falcon, json, traceback, logging, pathlib + + +base_path: str = str(pathlib.Path(__file__).resolve().parent.joinpath("static")) + + +@dataclass +class LoggerMiddleware: + logger: logging.Logger + + def process_request(self, req, resp): # pyre-ignore + self.logger.info("%s %s" % (req.method, req.relative_uri)) + + def process_response(self, req, resp, *args, **kwargs): # pyre-ignore + self.logger.info(resp.status) + + +def rest_handler(fn: Any): # pyre-ignore + def wrapper(self, req, resp, **kwargs): # pyre-ignore + try: + try: + data = json.load(req.stream) + self.app.logger.info("request body: %s" % data) + except Exception: + data = {} + status, body = fn(self, input=JsonInput(data), **kwargs) + resp.status = status + resp.body = json.dumps(body) + except ValueError as e: + resp.status = falcon.HTTP_400 + resp.body = json.dumps({"error": str(e), "stacktrace": traceback.format_exc()}) + + return wrapper + + +@dataclass +class Endpoints: + app: App + + @rest_handler + def on_post_accounts(self, input: JsonInput) -> Tuple[str, Dict[str, str]]: + return (falcon.HTTP_201, asdict(self.app.create_account(input))) + + @rest_handler + def on_post_payments(self, account_id: str, input: JsonInput) -> Tuple[str, Dict[str, Any]]: + return (falcon.HTTP_202, asdict(self.app.create_account_payment(account_id, input))) + + @rest_handler + def on_post_payment_uris(self, account_id: str, input: JsonInput) -> Tuple[str, Dict[str, Any]]: + return (falcon.HTTP_200, asdict(self.app.create_account_payment_uri(account_id, input))) + + @rest_handler + def on_get_balances(self, account_id: str, input: JsonInput) -> Tuple[str, Dict[str, int]]: + return (falcon.HTTP_200, self.app.get_account_balances(account_id)) + + @rest_handler + def on_get_events(self, account_id: str, input: JsonInput) -> Tuple[str, List[Dict[str, Any]]]: + return (falcon.HTTP_200, [asdict(e) for e in self.app.get_account_events(account_id)]) + + @rest_handler + def on_get_kyc_sample(self, input: JsonInput) -> Tuple[str, Dict[str, str]]: + return (falcon.HTTP_200, asdict(self.app.kyc_sample)) + + def on_post_offchain(self, req: falcon.Request, resp: falcon.Response) -> None: + request_id = req.get_header(offchain.X_REQUEST_ID) + resp.set_header(offchain.X_REQUEST_ID, request_id) + request_sender_address = req.get_header(offchain.X_REQUEST_SENDER_ADDRESS) + input_data = req.stream.read() + try: + resp_obj = self.app.offchain_api(request_sender_address, input_data) + except offchain.Error as e: + self.app.logger.info(input_data) + self.app.logger.exception(e) + resp_obj = offchain.reply_request(cid=None, err=e.obj) + resp.status = falcon.HTTP_400 + resp.body = self.app.jws_serialize(resp_obj) + + def on_get_index(self, req: falcon.Request, resp: falcon.Response) -> None: + raise falcon.HTTPMovedPermanently("/index.html") + + +def falcon_api(app: App, enable_debug_api: bool = False) -> falcon.API: + endpoints = Endpoints(app=app) + api = falcon.API(middleware=[LoggerMiddleware(logger=app.logger)]) + api.add_static_route("/", base_path) + api.add_route("/", endpoints, suffix="index") + api.add_route("/accounts", endpoints, suffix="accounts") + for res in ["balances", "payments", "payment_uris", "events"]: + if res != "events" or enable_debug_api: + api.add_route("/accounts/{account_id}/%s" % res, endpoints, suffix=res) + api.add_route("/kyc_sample", endpoints, suffix="kyc_sample") + api.add_route("/v2/command", endpoints, suffix="offchain") + app.start_sync() + return api diff --git a/src/diem/testing/miniwallet/app/json_input.py b/src/diem/testing/miniwallet/app/json_input.py new file mode 100644 index 00000000..69849c2a --- /dev/null +++ b/src/diem/testing/miniwallet/app/json_input.py @@ -0,0 +1,30 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass +from typing import Optional, Type, TypeVar, Dict, Callable, Any + + +T = TypeVar("T") +Validator = Callable[[str, T], None] + + +@dataclass +class JsonInput: + data: Dict[str, Any] + + def get_nullable(self, name: str, typ: Type[T], validator: Optional[Validator] = None) -> Optional[T]: + val = self.data.get(name, None) + if val is None: + return None + if isinstance(val, typ): + if validator: + validator(name, val) + return val + raise ValueError("%r type must be %r, but got %r" % (name, typ.__name__, type(val).__name__)) + + def get(self, name: str, typ: Type[T], validator: Optional[Validator] = None) -> T: + val = self.get_nullable(name, typ, validator) + if val is None: + raise ValueError("%r is required" % name) + return val diff --git a/src/diem/testing/miniwallet/app/models.py b/src/diem/testing/miniwallet/app/models.py new file mode 100644 index 00000000..1b2a63b5 --- /dev/null +++ b/src/diem/testing/miniwallet/app/models.py @@ -0,0 +1,125 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass, field, asdict, fields +from enum import Enum +from typing import Optional, List, Dict, Any +from .... import identifier, offchain + + +@dataclass +class Base: + id: str + + +@dataclass +class Account(Base): + kyc_data: Optional[str] = field(default=None) + + def kyc_data_object(self) -> offchain.KycDataObject: + if self.kyc_data: + return offchain.from_json(str(self.kyc_data), offchain.KycDataObject) + else: + return offchain.individual_kyc_data() + + +@dataclass +class PaymentUri(Base): + account_id: str + payment_uri: str + currency: Optional[str] = field(default=None) + amount: Optional[int] = field(default=None) + + def intent(self, hrp: str) -> identifier.Intent: + return identifier.decode_intent(self.payment_uri, hrp) + + +@dataclass +class Subaddress(Base): + account_id: str + subaddress_hex: str + + +@dataclass +class Payment(Base): + account_id: str + currency: str + amount: int + payee: str + + +@dataclass +class Event(Base): + account_id: str + type: str + data: str + timestamp: int + + +@dataclass +class KycSample: + minimum: str + reject: str + soft_match: str + soft_reject: str + + @staticmethod + def gen(surname: str) -> "KycSample": + def gen_kyc_data(name: str) -> str: + return offchain.to_json(offchain.individual_kyc_data(given_name=name, surname=surname)) + + return KycSample(**{f.name: gen_kyc_data("%s-kyc" % f.name) for f in fields(KycSample)}) + + def match_kyc_data(self, field: str, kyc: offchain.KycDataObject) -> bool: + subset = asdict(offchain.from_json(getattr(self, field), offchain.KycDataObject)) + return all(getattr(kyc, k) == v for k, v in subset.items() if v) + + def match_any_kyc_data(self, fields: List[str], kyc: offchain.KycDataObject) -> bool: + return any(self.match_kyc_data(f, kyc) for f in fields) + + +@dataclass +class Transaction(Base): + class Status(str, Enum): + completed = "completed" + canceled = "canceled" + pending = "pending" + + account_id: str + currency: str + amount: int + status: Status + cancel_reason: Optional[str] = field(default=None) + payee: Optional[str] = field(default=None) + subaddress_hex: Optional[str] = field(default=None) + reference_id: Optional[str] = field(default=None) + signed_transaction: Optional[str] = field(default=None) + diem_transaction_version: Optional[int] = field(default=None) + + def subaddress(self) -> bytes: + return bytes.fromhex(str(self.subaddress_hex)) + + def balance_amount(self) -> int: + return -self.amount if self.payee else self.amount + + +@dataclass +class PaymentCommand(Base): + account_id: str + reference_id: str + cid: str + is_sender: bool + payment_object: Dict[str, Any] + is_inbound: bool = field(default=False) + is_abort: bool = field(default=False) + is_ready: bool = field(default=False) + process_error: Optional[str] = field(default=None) + + def to_offchain_command(self) -> offchain.PaymentCommand: + payment = offchain.from_dict(self.payment_object, offchain.PaymentObject) + return offchain.PaymentCommand( + my_actor_address=payment.sender.address if self.is_sender else payment.receiver.address, + payment=payment, + inbound=self.is_inbound, + cid=self.cid, + ) diff --git a/src/diem/testing/miniwallet/app/static/index.html b/src/diem/testing/miniwallet/app/static/index.html new file mode 100644 index 00000000..0c9aa515 --- /dev/null +++ b/src/diem/testing/miniwallet/app/static/index.html @@ -0,0 +1,30 @@ + + + + MiniWallet API Specification + + + + + + + diff --git a/src/diem/testing/miniwallet/app/static/openapi.yaml b/src/diem/testing/miniwallet/app/static/openapi.yaml new file mode 100644 index 00000000..53ddd2e0 --- /dev/null +++ b/src/diem/testing/miniwallet/app/static/openapi.yaml @@ -0,0 +1,402 @@ +openapi: 3.0.3 +info: + title: Mini Wallet API Specification + description: > + **MiniWallet API** is designed as a minimum wallet application API + for testing a wallet application by playing as counterparty wallet application. + An implementation of **MiniWallet API** are free to add any new API for its + own purpose. + + + **MiniWallet Test Suite** is a set of tests built on top of **MiniWallet API** + for validating a wallet application Diem Payment Network integration. + + + To enable **MiniWallet Test Suite** for your wallet application, you need create + a MiniWallet API proxy to your wallet application with the following endpoints: + + * [Create account endpoint](#post-/accounts): required for isolating test data. + * [Get account balances endpoint](#get-/accounts/-account_id-/balances): required for verifying test results. + * [Generate payment URI endpoint](#post-/accounts/-account_id-/payment_uris): required for test receiving payment. + * [Send payment endpoint](#post-/accounts/-account_id-/payments): required for test sending payment. + * [Get KYC sample endpoint](#get-/kyc_sample): required for test sending payment equal / above travel rule threshold limit. + + You can selectively implement endpoints for testing sub-set features. For example, + [get KYC sample endpoint](#get-/kyc_sample) is not required until you need test + payment that triggers travel rule. + + + The **MiniWallet Test Suite** also provides tests to verify teh **MiniWallet API** built + for your application is meeting requirements for running payment integration tests. + + + We will improve and grow **MiniWallet Test Suite** to cover more cases. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 0.0.1 +tags: + - name: Minimum + description: > + Minimum required endpoints for running **MiniWallet Test Suite** to test sending + and receiving payment under travel rule threshold limit. + - name: Off-chain + description: > + Required endpoint for running **MiniWallet Test Suite** to test payment requires + Diem off-chain API, e.g. payment amount is equal or above travel rule threshold limit. + - name: Optional + description: > + Optional debug endpoint that is not required to implement for running + **MiniWallet Test Suite** +paths: + /accounts: + post: + summary: Create a new account + description: > + * When the account is required to provide KYC data during off-chain KYC exchange process, + server should use `kyc_data` property value as KycDataObject and send to counterparty service. + * Server should not raise error for the case `kyc_data` property value is valid but + it does not meet its business criterias. + * `balances` property values are the initial deposit to the account. + * Client should call [get account balances endpoint](#get-/accounts/-account_id-/balances) + to check account balances after the account is created. + * Client should store the response account id for all + the operations to the account. + * MiniWallet Test Suite isolates test data by creating new account for + each test case. + operationId: create-account + tags: + - Minimum + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAccountRequest' + required: true + responses: + 201: + description: Account is created; balances are deposited to account if provided. + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + 400: + $ref: '#/components/responses/ClientError' + /accounts/{account_id}/balances: + get: + summary: Get account balances + operationId: balances + tags: + - Minimum + parameters: + - name: account_id + in: path + description: Account ID + required: true + schema: + type: string + responses: + 200: + description: Returns account balances + content: + application/json: + schema: + $ref: '#/components/schemas/Balances' + 400: + $ref: '#/components/responses/ClientError' + /accounts/{account_id}/payments: + post: + summary: Send payment + operationId: send-payment + tags: + - Minimum + parameters: + - name: account_id + in: path + description: Account ID + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Payment' + required: true + responses: + 202: + description: > + Server accepted the sending payment request. + + + * Server may reflect the result on account balances immediately as the fund is considered as + in the process of transferring out and should not be used for other purpose. + * A client should expect the payment will be sent in short time after received the response, + but it is not a limit to the server to complete the payment before respond the request. + * A client can confirm the payment is completed by the following ways: + 1. Both sender and receiver account balances are updated accordingly. + 2. Sender or receiver application exposes an event by [get account events endpoint](#get-/accounts/-account_id-/events). + * There is no clear way to confirm the action is failed unless server exposes an event by + [get account events endpoint](#get-/accounts/-account_id-/events). + + content: + application/json: + schema: + $ref: '#/components/schemas/Payment' + 400: + $ref: '#/components/responses/ClientError' + /accounts/{account_id}/payment_uris: + post: + summary: Generate payment URI + operationId: gen-payment-uri + tags: + - Minimum + parameters: + - name: account_id + in: path + description: Account ID + required: true + schema: + type: string + responses: + 201: + description: A new payment URI is generated. + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentURI' + 400: + $ref: '#/components/responses/ClientError' + /kyc_sample: + get: + summary: Get KYC sample data. + operationId: kyc-sample + tags: + - Off-chain + description: > + KYC sample data can be used for testing different behaviors during + off-chain KYC data exchanging process. The data is used for counterparty wallet + application to set up their test account's KYC data. + responses: + 200: + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/KycSample' + /accounts/{account_id}/events: + get: + operationId: get-events + summary: Get account events + tags: + - Optional + description: >- + This is an optional debug endpoint for a wallet application to run **MiniWallet Test Suite**. + + + When a **MiniWallet Test Suite** test failed, this endpoint maybe called for collecting context of + the failure, and it will be ignored if the endpoint is not implemented or the call failed. + + The followings are events implemented by the python SDK **MiniWallet** application: + + | Event Type | Data Attribute Type | Description | + |---------------------------|---------------------|-----------------------------------------------------------------------------------| + | `info` | string | Human readable message for whatever happened. | + | `created_account` | JSON-encoded string | An account is created. | + | `created_transaction` | JSON-encoded string | An incoming or outgoing transaction is created. | + | `updated_transaction` | JSON-encoded string | Outgoing transactions are updated due to Diem transactions being submitted or executed. | + | `created_payment_uri` | JSON-encoded string | A PaymentURI is created. | + | `created_payment_command` | JSON-encoded string | Off-chain payment command is created. | + | `updated_payment_command` | JSON-encoded string | Off-chain payment command is updated. | + + parameters: + - name: account_id + in: path + description: Account ID + required: true + schema: + type: string + responses: + 200: + description: success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Event' + 400: + $ref: '#/components/responses/ClientError' +components: + responses: + ClientError: + description: Invalid input + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: error message + stacktrace: + type: string + description: stacktrace of the error + text/plain: + schema: + type: string + example: error message and stacktrace + schemas: + Balances: + type: object + writeOnly: true + properties: + XUS: + type: integer + example: 1000000000 + XDX: + type: integer + KycDataObject: + type: string + description: > + KycDataObject should be valid object that matches Diem off-chain KycDataObject + listed at https://dip.diem.com/dip-1/#kycdataobject. + example: "{\"type\": \"individual\", \"payload_version\": 1, \"given_name\": \"Tom\", \"surname\": \"Jack\"}" + Account: + required: + - id + type: object + properties: + id: + type: string + readOnly: true + kyc_data: + $ref: '#/components/schemas/KycDataObject' + CreateAccountRequest: + type: object + properties: + kyc_data: + $ref: '#/components/schemas/KycDataObject' + balances: + $ref: '#/components/schemas/Balances' + PaymentURI: + required: + - id + - account_id + - payment_uri + type: object + properties: + id: + type: string + readOnly: true + account_id: + type: string + readOnly: true + currency: + type: string + enum: ["XUS", "XDX"] + amount: + type: integer + payment_uri: + type: string + description: > + Diem intent identifier defined in DIP-5. + + Server should create a new subaddress for the account, and then encode it with + an onchain account address as a new payment URI. + The currency and amount in the request body should also be encoded into this + URI if they are provided. + readOnly: true + Payment: + required: + - id + - account_id + - amount + - currency + - payee + type: object + properties: + id: + type: string + readOnly: true + account_id: + type: string + readOnly: true + currency: + type: string + enum: + - XUS + - XDX + amount: + type: integer + payee: + type: string + description: > + The receiver address of the payment. + Only support account identifier defined in DIP-5 for now. + We will add Diem ID support in the future when the protocol related is stabilized. + KycSample: + description: > + KYC data sample for clients to create accounts to do off-chain KYC data exchanging tests. + + 1. `minimum` property value should be minimum valid `KycDataObject` that can pass server's + KYC evaluation without any additional actions during the off-chain KYC data exchange + process. + 2. `reject` property value should be an example of `KycDataObject` that will be rejected + by server if it is presented in a counterparty service's KYC data. + 3. `soft_match` property value should be an example of `KycDataObject` that will trigger + `soft_match` process and pass KYC evaluation after `additional_kyc_data` is provided + by counterparty service. + 4. `soft_reject` provided value should be an example of `KycDataObject` that will trigger + `soft_match` process and then be rejected by KYC evaluation after `additional_kyc_data` + is provided. + required: + - minimum + - reject + - soft_match + - soft_reject + type: object + properties: + minimum: + $ref: '#/components/schemas/KycDataObject' + reject: + $ref: '#/components/schemas/KycDataObject' + soft_match: + $ref: '#/components/schemas/KycDataObject' + soft_reject: + $ref: '#/components/schemas/KycDataObject' + Event: + description: > + Event is optional to implement; it is log of what happened in the + system. Useful when the test failed. It's free to add any kind of event type and + data. + required: + - id + - account_id + - type + - data + - timestamp + type: object + properties: + id: + type: string + readOnly: true + account_id: + type: string + readOnly: true + type: + type: string + description: Event type, used for decoding data. + readOnly: true + data: + type: string + description: > + Event data can be human readable message, JSON-encoded string + or any other format. However, one event type should only have one data + format. + readOnly: true + timestamp: + type: integer + description: Milliseconds since the unix epoch. The time event object is + created by the system. + readOnly: true diff --git a/src/diem/testing/miniwallet/app/store.py b/src/diem/testing/miniwallet/app/store.py new file mode 100644 index 00000000..69adf644 --- /dev/null +++ b/src/diem/testing/miniwallet/app/store.py @@ -0,0 +1,96 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass, field, asdict +from typing import List, Dict, Type, Any, Callable, TypeVar, Generator +from .models import Base, Account, Event +from .... import utils +import json, time, threading + + +T = TypeVar("T", bound=Base) + + +class NotFoundError(ValueError): + pass + + +@dataclass +class InMemoryStore: + """InMemoryStore is a simple in-memory store for resources""" + + resources: Dict[Type[Base], List[Dict[str, Any]]] = field(default_factory=dict) + resources_lock: threading.Lock = field(default_factory=threading.Lock) + gen_id_lock: threading.Lock = field(default_factory=threading.Lock) + gen_id: int = field(default=0) + + def next_id(self) -> int: + with self.gen_id_lock: + self.gen_id += 1 + return self.gen_id + + def create_event(self, account_id: str, type: str, data: str) -> Event: + return self.create(Event, account_id=account_id, type=type, data=data, timestamp=_ts()) + + def find(self, typ: Type[T], **conds: Any) -> T: + list = self._select(typ, **conds) + ret = next(list, None) + if not ret: + raise NotFoundError("%s not found by %s" % (typ.__name__, conds)) + if next(list, None): + raise ValueError("found multiple resources data matches %s" % conds) + return ret + + def find_all(self, typ: Type[T], **conds: Any) -> List[T]: + return list(self._select(typ, **conds)) + + def create(self, typ: Type[T], before_create: Callable[[Dict[str, Any]], None] = lambda _: _, **data: Any) -> T: + with self.resources_lock: + before_create(data) + obj = typ(**self._insert(typ, **data)) + self._record_event(obj, "created", data) + return obj + + def update(self, obj: T, before_update: Callable[[T], None] = lambda _: _, **data: Any) -> None: + for k, v in data.items(): + setattr(obj, k, v) + with self.resources_lock: + before_update(obj) + self._update(obj) + self._record_event(obj, "updated", data) + + def _record_event(self, obj: T, action: str, data: Dict[str, Any]) -> None: + if not isinstance(obj, Event): + type = "%s_%s" % (action, utils.to_snake(obj)) + account_id = obj.id if isinstance(obj, Account) else obj.account_id # pyre-ignore + data["id"] = obj.id + self._insert(Event, account_id=account_id, type=type, data=json.dumps(data), timestamp=_ts()) + + def _update(self, obj: T) -> None: + records = self.resources.get(type(obj), []) + index = next(iter([i for i, res in enumerate(records) if res["id"] == obj.id]), None) + if index is None: + raise NotFoundError("could not find resource by id: %s" % obj.id) + records[index] = asdict(obj) + + def _insert(self, typ: Type[T], **res: Any) -> Dict[str, Any]: + res["id"] = str(self.next_id()) + self.resources.setdefault(typ, []).append(asdict(typ(**res))) + return res + + def _select(self, typ: Type[T], reverse: bool = False, **conds: Any) -> Generator[T, None, None]: + items = reversed(self.resources.get(typ, [])) if reverse else self.resources.get(typ, []) + for res in items: + if _match(res, **conds): + yield typ(**res) + + +def _match(res: Dict[str, Any], **conds: Any) -> bool: + for k, v in conds.items(): + if res.get(k) != v: + return False + return True + + +def _ts() -> int: + return int(time.time() * 1000) diff --git a/src/diem/testing/miniwallet/client.py b/src/diem/testing/miniwallet/client.py new file mode 100644 index 00000000..8bcbeb74 --- /dev/null +++ b/src/diem/testing/miniwallet/client.py @@ -0,0 +1,137 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass, field, replace, asdict +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry +from typing import List, Optional, Any, Dict, Callable +from .app import PaymentUri, KycSample, Event, Payment +from .app.store import _match +from ... import offchain +import requests, logging, random, string, json, time + + +@dataclass +class RestClient: + name: str + server_url: str + session: requests.Session = field(default_factory=requests.Session) + logger: logging.Logger = field(init=False) + + def __post_init__(self) -> None: + self.logger = logging.getLogger(self.name) + + def with_retry(self, retry: Retry = Retry(total=5, connect=5, backoff_factor=0.01)) -> "RestClient": + self.session.mount(self.server_url, HTTPAdapter(max_retries=retry)) + return self + + def create_account( + self, balances: Optional[Dict[str, int]] = None, kyc_data: Optional[str] = None + ) -> "AccountResource": + account = self.create("/accounts", kyc_data=kyc_data, balances=balances) + return AccountResource(client=self, id=account["id"], kyc_data=account["kyc_data"]) + + def new_soft_match_kyc_data(self) -> str: + return self.new_kyc_data(sample="soft_match") + + def new_reject_kyc_data(self) -> str: + return self.new_kyc_data(sample="reject") + + def new_soft_reject_kyc_data(self) -> str: + return self.new_kyc_data(sample="soft_reject") + + def new_valid_kyc_data(self) -> str: + return self.new_kyc_data(sample="minimum") + + def new_kyc_data(self, name: Optional[str] = None, sample: str = "minimum") -> str: + obj = offchain.from_json(getattr(self.kyc_sample(), sample), offchain.KycDataObject) + if not name: + name = "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) + return offchain.to_json(replace(obj, legal_entity_name=name)) + + def kyc_sample(self) -> KycSample: + return KycSample(**self.send("GET", "/kyc_sample").json()) + + def create(self, path: str, **kwargs: Any) -> Dict[str, Any]: + return self.send("POST", path, json.dumps(kwargs) if kwargs else None).json() + + def get(self, path: str) -> Dict[str, Any]: + return self.send("GET", path).json() + + def send(self, method: str, path: str, data: Optional[str] = None) -> requests.Response: + url = "%s/%s" % (self.server_url.rstrip("/"), path.lstrip("/")) + self.logger.info("%s %s: %s" % (method, path, data)) + resp = self.session.request(method=method, url=url.lower(), data=data) + self.logger.info("response status code: %s" % resp.status_code) + self.logger.info("response body: %s" % resp.text) + resp.raise_for_status() + return resp + + +@dataclass +class AccountResource: + + client: RestClient + id: str + kyc_data: str + + def balance(self, currency: str) -> int: + return self.balances().get(currency, 0) + + def send_payment(self, currency: str, amount: int, payee: str) -> Payment: + p = self.client.create(self._resources("payment"), payee=payee, currency=currency, amount=amount) + return Payment(**p) + + def create_payment_uri(self) -> PaymentUri: + return PaymentUri(**self.client.create(self._resources("payment_uri"))) + + def balances(self) -> Dict[str, int]: + return self.client.get(self._resources("balance")) + + def events(self, start: int = 0) -> List[Event]: + ret = self.client.send("GET", self._resources("event")).json() + return [Event(**obj) for obj in ret[start:]] + + def wait_for_balance(self, currency: str, amount: int) -> None: + def fn() -> None: + assert self.balance(currency) == amount, "currency: %s" % currency + + self.wait_for(fn) + + def wait_for_event(self, event_type: str, start_index: int = 0, **kwargs: Any) -> None: + def fn() -> None: + events = [e for e in self.events(start_index) if e.type == event_type] + for e in events: + if _match(json.loads(e.data), **kwargs): + return + assert False, "could not find %s event with %s" % (event_type, kwargs) + + self.wait_for(fn) + + def wait_for(self, fn: Callable[[], None], max_tries: int = 120, delay: float = 0.1) -> None: + tries = 0 + while True: + tries += 1 + try: + return fn() + except AssertionError as e: + if tries >= max_tries: + events = json.dumps(list(map(self.event_asdict, self.events())), indent=2) + raise TimeoutError("%s, events: \n%s" % (e, events)) from e + time.sleep(delay) + + def event_asdict(self, event: Event) -> Dict[str, Any]: + ret = asdict(event) + try: + ret["data"] = json.loads(event.data) + if "kyc_data" in ret["data"]: + ret["data"]["kyc_data"] = json.loads(ret["data"]["kyc_data"]) + except json.decoder.JSONDecodeError: + pass + return ret + + def info(self, *args: Any, **kwargs: Any) -> None: + self.client.logger.info(*args, **kwargs) + + def _resources(self, resource: str) -> str: + return "/accounts/%s/%ss" % (self.id, resource) diff --git a/src/diem/testing/miniwallet/config.py b/src/diem/testing/miniwallet/config.py new file mode 100644 index 00000000..440d25be --- /dev/null +++ b/src/diem/testing/miniwallet/config.py @@ -0,0 +1,81 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any +from .client import RestClient +from .app import App, falcon_api +from ... import offchain, testnet, jsonrpc, LocalAccount +import waitress, threading, logging, falcon, json + + +@dataclass +class AppConfig: + name: str = field(default="mini-wallet") + url_scheme: str = field(default="http") + host: str = field(default="localhost") + port: int = field(default_factory=offchain.http_server.get_available_port) + enable_debug_api: bool = field(default=False) + account_config: Dict[str, Any] = field(default_factory=lambda: LocalAccount().to_dict()) + + initial_amount: int = field(default=1_000_000_000_000) + initial_currency: str = field(default=testnet.TEST_CURRENCY_CODE) + + @property + def logger(self) -> logging.Logger: + return logging.getLogger(self.name) + + @property + def account(self) -> LocalAccount: + return LocalAccount.from_dict(self.account_config) + + @property + def server_url(self) -> str: + return "%s://%s:%s" % (self.url_scheme, self.host, self.port) + + def create_client(self) -> RestClient: + return RestClient(server_url=self.server_url, name="%s-client" % self.name).with_retry() + + def setup_account(self, client: jsonrpc.Client) -> None: + acc = client.get_account(self.account.account_address) + if not acc or self.need_funds(acc): + self.logger.info("faucet mint %s" % self.account.account_address.to_hex()) + faucet = testnet.Faucet(client) + faucet.mint(self.account.auth_key.hex(), self.initial_amount, self.initial_currency) + if not acc or self.need_rotate(acc): + self.logger.info("rotate dual attestation info for %s" % self.account.account_address.to_hex()) + self.logger.info("set base url to: %s" % self.server_url) + self.account.rotate_dual_attestation_info(client, self.server_url) + + def need_funds(self, account: jsonrpc.Account) -> bool: + for balance in account.balances: + if balance.currency == self.initial_currency and balance.amount > self.initial_amount / 2: + return False + return True + + def need_rotate(self, account: jsonrpc.Account) -> bool: + if account.role.base_url != self.server_url: + return True + if not account.role.compliance_key: + return True + if bytes.fromhex(account.role.compliance_key) != self.account.compliance_public_key_bytes: + return True + return False + + def serve(self, client: jsonrpc.Client) -> threading.Thread: + api: falcon.API = falcon_api(App(self.account, client, self.name, self.logger), self.enable_debug_api) + + def serve() -> None: + self.logger.info("serving at http://%s:%s" % (self.host, self.port)) + waitress.serve(api, host=self.host, port=self.port, clear_untrusted_proxy_headers=True, _quiet=True) + + t = threading.Thread(target=serve, daemon=True) + t.start() + return t + + def start(self, client: jsonrpc.Client) -> threading.Thread: + self.setup_account(client) + return self.serve(client) + + def __str__(self) -> str: + return json.dumps(asdict(self), indent=2) diff --git a/src/diem/testing/suites/__init__.py b/src/diem/testing/suites/__init__.py new file mode 100644 index 00000000..50fa65ee --- /dev/null +++ b/src/diem/testing/suites/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/src/diem/testing/suites/clients.py b/src/diem/testing/suites/clients.py new file mode 100644 index 00000000..0dfab6e6 --- /dev/null +++ b/src/diem/testing/suites/clients.py @@ -0,0 +1,13 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass +from diem import jsonrpc +from ..miniwallet import RestClient + + +@dataclass +class Clients: + target: RestClient + stub: RestClient + diem: jsonrpc.Client diff --git a/src/diem/testing/suites/conftest.py b/src/diem/testing/suites/conftest.py new file mode 100644 index 00000000..d98e78f4 --- /dev/null +++ b/src/diem/testing/suites/conftest.py @@ -0,0 +1,59 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + + +from ... import testnet +from ..miniwallet import RestClient, AppConfig +from .clients import Clients +from .envs import target_url, is_self_check, should_test_debug_api +import pytest + + +@pytest.fixture(scope="package") +def stub_config() -> AppConfig: + return AppConfig(name="stub-wallet", enable_debug_api=True) + + +@pytest.fixture(scope="package") +def clients(stub_config: AppConfig) -> Clients: + print("Diem JSON-RPC URL: %s" % testnet.JSON_RPC_URL) + print("Diem Testnet Faucet URL: %s" % testnet.FAUCET_URL) + print("Start stub app with config %s" % stub_config) + diem_client = testnet.create_client() + stub_config.start(diem_client) + + if is_self_check(): + conf = AppConfig(name="target-wallet", enable_debug_api=should_test_debug_api()) + print("self-checking, launch target app with config %s" % conf) + conf.start(diem_client) + target_client = conf.create_client() + else: + print("target wallet server url: %s" % target_url()) + target_client = RestClient(name="target-wallet-client", server_url=target_url()).with_retry() + + return Clients( + target=target_client, + stub=stub_config.create_client(), + diem=diem_client, + ) + + +@pytest.fixture(scope="package") +def target_client(clients: Clients) -> RestClient: + return clients.target + + +@pytest.fixture +def hrp() -> str: + return testnet.HRP + + +@pytest.fixture +def currency() -> str: + return testnet.TEST_CURRENCY_CODE + + +@pytest.fixture +def travel_rule_threshold(clients: Clients) -> int: + # todo: convert the limit base on currency + return clients.diem.get_metadata().dual_attestation_limit diff --git a/src/diem/testing/suites/envs.py b/src/diem/testing/suites/envs.py new file mode 100644 index 00000000..44eb2c85 --- /dev/null +++ b/src/diem/testing/suites/envs.py @@ -0,0 +1,21 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from os import getenv, environ + + +TARGET_URL: str = "DMW_TEST_TARGET" +TEST_DEBUG_API: str = "DMW_TEST_DEBUG_API" +SELF_CHECK: str = "DMW_SELF_CHECK" + + +def target_url() -> str: + return environ[TARGET_URL] + + +def is_self_check() -> bool: + return getenv(SELF_CHECK) is not None + + +def should_test_debug_api() -> bool: + return getenv(TEST_DEBUG_API) is not None diff --git a/src/diem/testing/suites/test_miniwallet_api.py b/src/diem/testing/suites/test_miniwallet_api.py new file mode 100644 index 00000000..78f0b486 --- /dev/null +++ b/src/diem/testing/suites/test_miniwallet_api.py @@ -0,0 +1,273 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + +from diem.testing.miniwallet import Account, Transaction, RestClient +from typing import Optional, Dict, Any, Union +from .envs import should_test_debug_api, is_self_check +from .clients import Clients +import pytest, requests, time, json + + +def test_create_account_resource_without_balance(target_client: RestClient) -> None: + account = target_client.create_account() + assert account.balances() == {} + + +def test_create_account_with_kyc_data_and_balances(target_client: RestClient, currency: str) -> None: + kyc_data = target_client.new_kyc_data() + account = target_client.create_account(kyc_data=kyc_data, balances={currency: 100}) + assert account.id + assert account.kyc_data == kyc_data + assert account.balances() == {currency: 100} + assert account.balance(currency) == 100 + + +@pytest.mark.parametrize( # pyre-ignore + "err_msg, kyc_data", + [ + ("'kyc_data' must be JSON-encoded KycDataObject", "invalid json"), + ("'kyc_data' must be JSON-encoded KycDataObject", "{}"), + ("'kyc_data' type must be 'str'", {}), + ], +) +def test_create_account_with_invalid_kyc_data( + target_client: RestClient, currency: str, err_msg: str, kyc_data: Union[str, Dict[str, Any]] +) -> None: + with pytest.raises(requests.exceptions.HTTPError, match="400 Client Error") as einfo: + target_client.create("/accounts", kyc_data=kyc_data) + if is_self_check(): + assert err_msg in einfo.value.response.text + + +@pytest.mark.parametrize( + "err_msg, balances", + [ + ("'currency' is invalid", {"invalid": 11}), + ("'currency' is invalid", {22: 11}), + ("'amount' value must be greater than or equal to zero", {"XUS": -11}), + ("'amount' type must be 'int'", {"XUS": "11"}), + ], +) +def test_create_account_with_invalid_balances( + target_client: RestClient, currency: str, err_msg: str, balances: Optional[Dict[str, int]] +) -> None: + kyc_data = target_client.new_kyc_data() + + with pytest.raises(requests.exceptions.HTTPError, match="400 Client Error") as einfo: + target_client.create("/accounts", kyc_data=kyc_data, balances=balances) + if is_self_check(): + assert err_msg in einfo.value.response.text + + +def test_create_account_payment_uri(target_client: RestClient, hrp: str) -> None: + account = target_client.create_account() + ret = account.create_payment_uri() + assert ret.account_id == account.id + intent = ret.intent(hrp) + assert intent.account_address + assert intent.subaddress + + +def test_send_payment_and_events(clients: Clients, hrp: str, currency: str) -> None: + receiver = clients.target.create_account() + payment_uri = receiver.create_payment_uri() + + amount = 1234 + sender = clients.stub.create_account(balances={currency: amount}) + assert sender.balance(currency) == amount + + index = len(sender.events()) + payment = sender.send_payment(currency, amount, payment_uri.intent(hrp).account_id) + assert payment.account_id == sender.id + assert payment.currency == currency + assert payment.amount == amount + assert payment.payee == payment_uri.intent(hrp).account_id + + sender.wait_for_balance(currency, 0) + sender.wait_for_event("updated_transaction", status=Transaction.Status.completed, start_index=index) + + receiver.wait_for_balance(currency, amount) + + new_events = [e for e in sender.events(index) if e.type != "info"] + assert len(new_events) == 4, new_events + assert new_events[0].type == "created_transaction" + assert new_events[1].type == "updated_transaction" + assert sorted(list(json.loads(new_events[1].data).keys())) == ["id", "subaddress_hex"] + assert new_events[2].type == "updated_transaction" + assert sorted(list(json.loads(new_events[2].data).keys())) == ["id", "signed_transaction"] + assert new_events[3].type == "updated_transaction" + assert sorted(list(json.loads(new_events[3].data).keys())) == ["diem_transaction_version", "id", "status"] + + +def test_receive_payment_and_events(clients: Clients, currency: str, hrp: str) -> None: + receiver = clients.stub.create_account() + payment_uri = receiver.create_payment_uri() + + index = len(receiver.events()) + amount = 1234 + sender = clients.target.create_account({currency: amount}) + payment = sender.send_payment(currency, amount, payment_uri.intent(hrp).account_id) + + receiver.wait_for_balance(currency, amount) + sender.wait_for_balance(currency, 0) + + new_events = [e for e in receiver.events(index) if e.type != "info"] + assert len(new_events) == 1 + assert new_events[0].type == "created_transaction" + txn = Transaction(**json.loads(new_events[0].data)) + assert txn.id + assert txn.currency == payment.currency + assert txn.amount == payment.amount + assert txn.diem_transaction_version + + +def test_receive_multiple_payments(clients: Clients, hrp: str, currency: str) -> None: + receiver = clients.stub.create_account() + payment_uri = receiver.create_payment_uri() + + index = len(receiver.events()) + amount = 1234 + sender1 = clients.target.create_account({currency: amount}) + sender1.send_payment(currency, amount, payment_uri.intent(hrp).account_id) + + sender2 = clients.target.create_account({currency: amount}) + sender2.send_payment(currency, amount, payment_uri.intent(hrp).account_id) + + sender1.wait_for_balance(currency, 0) + sender2.wait_for_balance(currency, 0) + receiver.wait_for_balance(currency, amount * 2) + + new_events = [e for e in receiver.events(index) if e.type != "info"] + assert len(new_events) == 2 + assert new_events[0].type == "created_transaction" + assert new_events[1].type == "created_transaction" + + +@pytest.mark.parametrize( + "invalid_payee", + [ + "invalid id", + "dm1p7ujcndcl7nudzwt8fglhx6wxnvqqqqqqqqqqqqqd8p9cq", + "tdm1p7ujcndcl7nudzwt8fglhx6wxnvqqqqqqqqqqqqqv88j4x", + ], +) +def test_send_payment_payee_is_invalid(clients: Clients, currency: str, invalid_payee: str, hrp: str) -> None: + sender = clients.stub.create_account({currency: 100}) + + index = len(sender.events()) + with pytest.raises(requests.exceptions.HTTPError, match="400 Client Error") as einfo: + sender.send_payment(currency, 1, invalid_payee) + + if is_self_check(): + assert "'payee' is invalid account identifier" in einfo.value.response.text + + assert sender.balance(currency) == 100 + assert sender.events(index) == [] + + +def test_return_client_error_if_send_payment_more_than_account_balance( + clients: Clients, currency: str, hrp: str +) -> None: + receiver = clients.target.create_account() + payment_uri = receiver.create_payment_uri() + sender = clients.stub.create_account({currency: 100}) + + index = len(sender.events()) + with pytest.raises(requests.exceptions.HTTPError, match="400 Client Error") as einfo: + sender.send_payment(currency, 101, payment_uri.intent(hrp).account_id) + if is_self_check(): + assert "account balance not enough" in einfo.value.response.text + assert sender.balance(currency) == 100 + assert sender.events(index) == [] + + +def test_send_payment_meets_travel_rule_limit( + clients: Clients, currency: str, travel_rule_threshold: int, hrp: str +) -> None: + amount = travel_rule_threshold + receiver = clients.target.create_account() + payment_uri = receiver.create_payment_uri() + sender = clients.stub.create_account({currency: amount}, kyc_data=clients.stub.new_kyc_data()) + payment = sender.send_payment(currency, amount, payee=payment_uri.intent(hrp).account_id) + + sender.wait_for_event("updated_transaction", id=payment.id, status=Transaction.Status.completed) + sender.wait_for_balance(currency, 0) + receiver.wait_for_balance(currency, travel_rule_threshold) + + +def test_account_balance_validation_should_exclude_canceled_transactions( + clients: Clients, currency: str, travel_rule_threshold: int, hrp: str +) -> None: + amount = travel_rule_threshold + receiver = clients.target.create_account() + payment_uri = receiver.create_payment_uri() + sender = clients.stub.create_account({currency: amount}, kyc_data=clients.target.new_reject_kyc_data()) + # payment should be rejected during offchain kyc data exchange + payment = sender.send_payment(currency, amount, payee=payment_uri.intent(hrp).account_id) + + sender.wait_for_event("updated_transaction", id=payment.id, status=Transaction.Status.canceled) + + sender.send_payment(currency, travel_rule_threshold - 1, payment_uri.intent(hrp).account_id) + + receiver.wait_for_balance(currency, travel_rule_threshold - 1) + sender.wait_for_balance(currency, 1) + + +@pytest.mark.parametrize( + "amount", + [ + 0, + 1, + 1_000_000_000, + 1_000_000_000_000, + ], +) +def test_internal_transfer(clients: Clients, currency: str, amount: int, hrp: str) -> None: + receiver = clients.stub.create_account() + payment_uri = receiver.create_payment_uri() + sender = clients.stub.create_account({currency: amount}) + + index = len(sender.events()) + + payment = sender.send_payment(currency, amount, payee=payment_uri.intent(hrp).account_id) + assert payment.amount == amount + assert payment.payee == payment_uri.intent(hrp).account_id + + sender.wait_for_event("updated_transaction", start_index=index, id=payment.id, status=Transaction.Status.completed) + + sender.wait_for_balance(currency, 0) + receiver.wait_for_balance(currency, amount) + + +@pytest.mark.skipif(bool(not should_test_debug_api()), reason="test debug api is not enabled") +def test_create_account_event(target_client: RestClient, currency: str) -> None: + before_timestamp = int(time.time() * 1000) + account = target_client.create_account() + after_timestamp = int(time.time() * 1000) + + events = account.events() + assert len(events) == 1 + event = events[0] + assert event.id + assert event.timestamp >= before_timestamp + assert event.timestamp <= after_timestamp + assert event.type == "created_account" + event_data = Account(**json.loads(event.data)) + assert event_data.kyc_data == account.kyc_data + assert event_data.id == account.id + + +@pytest.mark.skipif(bool(not should_test_debug_api()), reason="test debug api is not enabled") +def test_create_account_payment_uri_events(target_client: RestClient, hrp: str) -> None: + account = target_client.create_account() + index = len(account.events()) + ret = account.create_payment_uri() + assert ret + assert len(account.events(index)) == 1 + assert account.events(index)[0].type == "created_payment_uri" + + +@pytest.mark.skipif(bool(not is_self_check()), reason="self check is not enabled") +def test_openapi_spec(target_client: RestClient) -> None: + resp = target_client.send("GET", "/openapi.yaml") + assert resp.text diff --git a/src/diem/testing/suites/test_payment.py b/src/diem/testing/suites/test_payment.py new file mode 100644 index 00000000..f4d2043e --- /dev/null +++ b/src/diem/testing/suites/test_payment.py @@ -0,0 +1,164 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + + +from diem.testing.miniwallet import Event, AccountResource +from diem import offchain +from typing import List +from .clients import Clients +import pytest, json + + +@pytest.mark.parametrize("amount", [1, 999_999]) +@pytest.mark.parametrize("actor", ["sender", "receiver"]) +def test_payment_under_threshold_succeed( + actor: str, + amount: int, + currency: str, + clients: Clients, + hrp: str, +) -> None: + sender_client = clients.target if actor == "sender" else clients.stub + receiver_client = clients.stub if actor == "sender" else clients.target + + sender = sender_client.create_account({currency: amount}) + receiver = receiver_client.create_account() + sender_initial = sender.balance(currency) + receiver_initial = receiver.balance(currency) + + payment_uri = receiver.create_payment_uri() + send_payment = sender.send_payment(currency, amount, payment_uri.intent(hrp).account_id) + assert send_payment.currency == currency + assert send_payment.amount == amount + assert send_payment.payee == payment_uri.intent(hrp).account_id + + sender.wait_for_balance(currency, sender_initial - amount) + receiver.wait_for_balance(currency, receiver_initial + amount) + + +@pytest.mark.parametrize( + "sender_kyc, receiver_kyc, exchange_states, payment_result", + [ + ("valid_kyc_data", "valid_kyc_data", ["S_INIT", "R_SEND", "READY"], "completed"), + ( + "valid_kyc_data", + "soft_match_kyc_data", + ["S_INIT", "R_SEND", "S_SOFT", "R_SOFT_SEND", "READY"], + "completed", + ), + ("valid_kyc_data", "reject_kyc_data", ["S_INIT", "R_SEND", "S_ABORT"], "failed"), + ( + "valid_kyc_data", + "soft_reject_kyc_data", + ["S_INIT", "R_SEND", "S_SOFT", "R_SOFT_SEND", "S_ABORT"], + "failed", + ), + ( + "soft_match_kyc_data", + "valid_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_SEND", "READY"], + "completed", + ), + ( + "soft_match_kyc_data", + "soft_match_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_SEND", "S_SOFT", "R_SOFT_SEND", "READY"], + "completed", + ), + ( + "soft_match_kyc_data", + "reject_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_SEND", "S_ABORT"], + "failed", + ), + ( + "soft_match_kyc_data", + "soft_reject_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_SEND", "S_SOFT", "R_SOFT_SEND", "S_ABORT"], + "failed", + ), + ("reject_kyc_data", "valid_kyc_data", ["S_INIT", "R_ABORT"], "failed"), + ("reject_kyc_data", "soft_match_kyc_data", ["S_INIT", "R_ABORT"], "failed"), + ("reject_kyc_data", "reject_kyc_data", ["S_INIT", "R_ABORT"], "failed"), + ("reject_kyc_data", "soft_reject_kyc_data", ["S_INIT", "R_ABORT"], "failed"), + ( + "soft_reject_kyc_data", + "valid_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_ABORT"], + "failed", + ), + ( + "soft_reject_kyc_data", + "soft_match_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_ABORT"], + "failed", + ), + ( + "soft_reject_kyc_data", + "reject_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_ABORT"], + "failed", + ), + ( + "soft_reject_kyc_data", + "soft_reject_kyc_data", + ["S_INIT", "R_SOFT", "S_SOFT_SEND", "R_ABORT"], + "failed", + ), + ], +) +@pytest.mark.parametrize("actor", ["sender", "receiver"]) +def test_payment_meets_travel_rule_threshold( + actor: str, + sender_kyc: str, + receiver_kyc: str, + exchange_states: List[str], + payment_result: str, + currency: str, + travel_rule_threshold: int, + clients: Clients, + hrp: str, +) -> None: + amount = travel_rule_threshold + sender_client = clients.target if actor == "sender" else clients.stub + receiver_client = clients.stub if actor == "sender" else clients.target + + sender = sender_client.create_account( + {currency: amount}, kyc_data=getattr(receiver_client, "new_%s" % sender_kyc)() + ) + receiver = receiver_client.create_account(kyc_data=getattr(sender_client, "new_%s" % receiver_kyc)()) + sender_initial = sender.balance(currency) + receiver_initial = receiver.balance(currency) + + payment_uri = receiver.create_payment_uri() + send_payment = sender.send_payment(currency, amount, payment_uri.intent(hrp).account_id) + assert send_payment.currency == currency + assert send_payment.amount == amount + assert send_payment.payee == payment_uri.intent(hrp).account_id + + stub_account: AccountResource = sender if clients.stub == sender.client else receiver + + def match_exchange_states() -> None: + assert payment_command_event_states(stub_account) == exchange_states + + stub_account.wait_for(match_exchange_states) + + if payment_result == "completed": + sender.wait_for_balance(currency, sender_initial - amount) + receiver.wait_for_balance(currency, receiver_initial + amount) + else: + sender.wait_for_balance(currency, sender_initial) + receiver.wait_for_balance(currency, receiver_initial) + + +def payment_state_id(event: Event) -> str: + payment = offchain.from_dict(json.loads(event.data)["payment_object"], offchain.PaymentObject) + return offchain.payment_state.MACHINE.match_state(payment).id + + +def payment_command_event_states(account: AccountResource) -> List[str]: + return [payment_state_id(event) for event in account.events() if is_payment_command_event(event)] + + +def is_payment_command_event(e: Event) -> bool: + return e.type in ["created_payment_command", "updated_payment_command"] diff --git a/src/diem/utils.py b/src/diem/utils.py index 10f984fc..b9cce89d 100644 --- a/src/diem/utils.py +++ b/src/diem/utils.py @@ -78,6 +78,12 @@ def sub_address(addr: typing.Union[str, bytes]) -> bytes: return ret +def hex(b: typing.Optional[bytes]) -> str: + """convert an optional bytes into hex-encoded str, returns "" if bytes is None""" + + return b.hex() if b else "" + + def public_key_bytes(public_key: Ed25519PublicKey) -> bytes: """convert cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey into raw bytes""" @@ -172,3 +178,11 @@ def balance(account: jsonrpc.Account, currency: str) -> int: if b.currency == currency: return b.amount return 0 + + +def to_snake(o: typing.Any) -> str: # pyre-ignore + if isinstance(o, str): + return "".join(["_" + i.lower() if i.isupper() else i for i in o]).lstrip("_") + elif hasattr(o, "__name__"): + return to_snake(getattr(o, "__name__")) + return to_snake(type(o)) diff --git a/tests/conftest.py b/tests/conftest.py index f0893696..828f0f25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,26 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 -from diem import testnet, offchain, identifier, chain_ids, LocalAccount -from os import getenv, system +from diem import testnet, offchain, identifier, LocalAccount import pytest -@pytest.fixture(scope="session", autouse=True) -def setup_testnet(): - if getenv("dt"): - system("make docker") - print("swap testnet default values to local testnet launched by docker-compose") - testnet.JSON_RPC_URL = "http://localhost:8080/v1" - testnet.FAUCET_URL = "http://localhost:8000/mint" - testnet.CHAIN_ID = chain_ids.TESTING - yield 1 - if getenv("dts"): - system("make docker-stop") - else: - yield 0 - - @pytest.fixture def factory(): return Factory() diff --git a/tests/miniwallet/test_models.py b/tests/miniwallet/test_models.py new file mode 100644 index 00000000..7729fbe5 --- /dev/null +++ b/tests/miniwallet/test_models.py @@ -0,0 +1,67 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + + +from dataclasses import replace, asdict +from diem.testing.miniwallet import KycSample, Account, PaymentUri, Transaction, PaymentCommand +from diem import offchain + + +def test_match_kyc_data(): + ks = KycSample.gen("foo") + obj = offchain.from_json(ks.soft_match, offchain.KycDataObject) + assert ks.match_kyc_data("soft_match", obj) + assert not ks.match_kyc_data("reject", obj) + + obj = replace(obj, legal_entity_name="hello") + assert ks.match_kyc_data("soft_match", obj) + + obj = replace(obj, given_name="hello") + assert not ks.match_kyc_data("soft_match", obj) + + +def test_decode_account_kyc_data(): + assert Account(id="1").kyc_data_object() == offchain.individual_kyc_data() + + sample = KycSample.gen("foo") + account = Account(id="1", kyc_data=sample.minimum) + assert account.kyc_data_object() + assert offchain.to_json(account.kyc_data_object()) == sample.minimum + + +def test_payment_uri_intent_identifier(): + uri = PaymentUri( + id="1", + account_id="2", + payment_uri="diem://dm1p7ujcndcl7nudzwt8fglhx6wxn08kgs5tm6mz4us2vfufk", + ) + assert uri.intent("dm") + assert uri.intent("dm").sub_address.hex() == "cf64428bdeb62af2" + + +def test_transaction_balance_amount(): + txn = Transaction(id="1", account_id="2", currency="XUS", amount=1000, status=Transaction.Status.pending) + assert txn.balance_amount() == 1000 + + txn.payee = "dm1p7ujcndcl7nudzwt8fglhx6wxn08kgs5tm6mz4us2vfufk" + assert txn.balance_amount() == -1000 + + +def test_transaction_subaddress(): + txn = Transaction(id="1", account_id="2", currency="XUS", amount=1000, status=Transaction.Status.pending) + txn.subaddress_hex = "cf64428bdeb62af2" + assert txn.subaddress().hex() == "cf64428bdeb62af2" + + +def test_payment_command_to_offchain_command(factory): + offchain_cmd = factory.new_sender_payment_command() + cmd = PaymentCommand( + id="1", + account_id="2", + is_sender=offchain_cmd.is_sender(), + reference_id=offchain_cmd.reference_id(), + is_inbound=offchain_cmd.is_inbound(), + cid=offchain_cmd.id(), + payment_object=asdict(offchain_cmd.payment), + ) + assert cmd.to_offchain_command() == offchain_cmd diff --git a/tests/miniwallet/test_store.py b/tests/miniwallet/test_store.py new file mode 100644 index 00000000..d9097b57 --- /dev/null +++ b/tests/miniwallet/test_store.py @@ -0,0 +1,127 @@ +# Copyright (c) The Diem Core Contributors +# SPDX-License-Identifier: Apache-2.0 + + +from diem.testing.miniwallet.app import store, PaymentUri, Event, PaymentCommand, Account +import pytest + + +def test_create_event(): + s = store.InMemoryStore() + event = s.create_event("1", "create-abc", "test data") + assert event.id + assert event.account_id == "1" + assert event.type == "create-abc" + assert event.data == "test data" + assert event.timestamp + + assert s.find_all(Event) == [event] + + +def test_find_returns_one_matched_item(): + s = store.InMemoryStore() + s.create_event("1", "create-abc", "test data") + second = s.create_event("1", "create-hello", "test data") + s.create_event("1", "create-world", "test data") + s.create_event("1", "create-abc", "test data") + + assert s.find(Event, type="create-hello") == second + + with pytest.raises(store.NotFoundError): + s.find(Event, account_id="unknown") + with pytest.raises(ValueError): + s.find(Event, account_id="1") + + +def test_find_all(): + s = store.InMemoryStore() + assert s.find_all(Event) == [] + one = s.create_event("1", "create-abc", "test data") + two = s.create_event("1", "create-hello", "test data") + three = s.create_event("1", "create-world", "test data") + four = s.create_event("1", "create-abc", "test data") + assert s.find_all(Event) == [one, two, three, four] + + +def test_find_all_by_matching_default_none(): + s = store.InMemoryStore() + uri = s.create(PaymentUri, account_id="1", payment_uri="payment uri") + assert s.find_all(PaymentUri, currency=None, account_id="1") == [uri] + assert s.find_all(PaymentUri, currency="XUS") == [] + + +def test_find_all_by_matching_default_bool_values(): + s = store.InMemoryStore() + cmd = s.create(PaymentCommand, account_id="1", reference_id="2", cid="3", is_sender=True, payment_object={}) + assert cmd + assert s.find_all(PaymentCommand, is_inbound=False) == [cmd] + assert s.find_all(PaymentCommand, is_inbound=True) == [] + + +def test_before_create_hook(): + s = store.InMemoryStore() + + def validate(data): + raise ValueError("before create error") + + with pytest.raises(ValueError, match="before create error"): + s.create(Event, before_create=validate) + + assert s.find_all(Event) == [] + + +def test_before_update_hook(): + s = store.InMemoryStore() + event = s.create(Event, account_id="1", type="hello", data="world", timestamp=11) + + def validate(data): + raise ValueError("before update error") + + with pytest.raises(ValueError, match="before update error"): + s.update(event, before_update=validate) + + +def test_next_id(): + s = store.InMemoryStore() + for i in range(100): + assert s.next_id() == i + 1 + + +def test_create_should_create_record_event(): + s = store.InMemoryStore() + s.create(PaymentUri, account_id="1", payment_uri="payment uri") + events = s.find_all(Event) + assert len(events) == 1 + assert events[0].account_id == "1" + assert events[0].type == "created_payment_uri" + assert events[0].data == '{"account_id": "1", "payment_uri": "payment uri", "id": "1"}' + + +def test_update(): + s = store.InMemoryStore() + uri = s.create(PaymentUri, account_id="1", payment_uri="payment uri") + s.update(uri, payment_uri="abc") + assert uri.payment_uri == "abc" + assert s.find(PaymentUri, id=uri.id).payment_uri == "abc" + events = s.find_all(Event) + assert len(events) == 2 + assert events[1].account_id == "1" + assert events[1].type == "updated_payment_uri" + assert events[1].data == '{"payment_uri": "abc", "id": "1"}' + + +def test_record_create_account_event(): + s = store.InMemoryStore() + assert s.create(Account, kyc_data="kyc-data") + events = s.find_all(Event) + assert len(events) == 1 + assert events[0].account_id == "1" + assert events[0].type == "created_account" + + +def test_update_obj_not_found(): + s = store.InMemoryStore() + uri = s.create(PaymentUri, account_id="1", payment_uri="payment uri") + uri.id = "unknown" + with pytest.raises(store.NotFoundError): + s.update(uri, payment_uri="abc") diff --git a/tests/test_local_account.py b/tests/test_local_account.py index 6c765112..da8f48dc 100644 --- a/tests/test_local_account.py +++ b/tests/test_local_account.py @@ -49,3 +49,18 @@ def test_account_identifier(): assert account.account_identifier(subaddress) == identifier.encode_account( account.account_address, subaddress, account.hrp ) + + +def test_decode_account_identifier(): + account = LocalAccount() + + id1 = account.account_identifier() + address, subaddress = account.decode_account_identifier(id1) + assert address == account.account_address + assert subaddress is None + + subaddress = identifier.gen_subaddress() + id2 = account.account_identifier(subaddress) + address, subaddress = account.decode_account_identifier(id2) + assert address == account.account_address + assert subaddress == subaddress diff --git a/tests/test_offchain_command.py b/tests/test_offchain_command.py index c1787bfc..df8eb9a4 100644 --- a/tests/test_offchain_command.py +++ b/tests/test_offchain_command.py @@ -73,3 +73,9 @@ def test_new_command_raises_value_error_if_metadata_is_not_list(factory): cmd = factory.new_sender_payment_command() with pytest.raises(ValueError): cmd.new_command(metadata="hello") + + +def test_my_subaddress(factory): + cmd = factory.new_sender_payment_command() + _, subaddress = identifier.decode_account(cmd.payment.sender.address, factory.hrp()) + assert cmd.my_subaddress(factory.hrp()) == subaddress diff --git a/tests/test_utils.py b/tests/test_utils.py index bb5b1099..0c11b10f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -83,3 +83,15 @@ def test_balance(): assert utils.balance(account, "XUS") == 32 assert utils.balance(account, "XDX") == 33 assert utils.balance(account, "unknown") == 0 + + +def test_to_snake(): + assert utils.to_snake("AbcEfg") == "abc_efg" + assert utils.to_snake("ABC") == "a_b_c" + assert utils.to_snake(TypeError) == "type_error" + assert utils.to_snake(TypeError("hello")) == "type_error" + + +def test_hex(): + assert utils.hex(None) == "" + assert utils.hex(b"abcd") == "61626364"