Compare commits

..

66 Commits

Author SHA1 Message Date
nolash
efa23797f3 Bump aux version 2021-07-08 11:48:50 +02:00
nolash
2c0f65ed73 Remove hardcoded value 2021-07-07 17:42:47 +02:00
nolash
3f6fbc1c61 Noop calc for non-demurrage token in aux 2021-07-07 14:17:50 +02:00
nolash
d26af206cb Add correct extras selector for contract migration cic-eth include 2021-07-07 13:52:19 +02:00
nolash
36085f218f Add necessary services requirements 2021-07-07 11:59:51 +02:00
nolash
d1d680c9aa Add aux-all to tasker in local cluster 2021-07-07 10:32:01 +02:00
nolash
abd4024322 Bump version 2021-07-07 09:59:20 +02:00
nolash
644b9ec4ec Merge remote-tracking branch 'origin/master' into lash/demurrage-task 2021-07-07 09:58:57 +02:00
Louis Holbrook
5f6c57647f Merge branch 'lash/explicit-token-type' into 'master'
Add explicit token type toggle in contract migration

See merge request grassrootseconomics/cic-internal-integration!216
2021-07-07 07:56:45 +00:00
Louis Holbrook
ed029a936c Add explicit token type toggle in contract migration 2021-07-07 07:56:45 +00:00
nolash
bc52e46620 Merge remote-tracking branch 'origin/master' into lash/demurrage-task 2021-07-07 09:47:09 +02:00
Louis Holbrook
c559bb2fee Merge branch 'lash/free-api' into 'master'
Minimal dependencies in cic-eth api setup

Closes #75

See merge request grassrootseconomics/cic-internal-integration!210
2021-07-07 07:45:21 +00:00
Louis Holbrook
9b79034ed3 Minimal dependencies in cic-eth api setup 2021-07-07 07:45:21 +00:00
nolash
ba729a0a54 Properly fix mock path in cic-eth test 2021-07-07 09:32:52 +02:00
nolash
cb4927edc9 Correct mocks path in cic tests, faulty contract-migration build 2021-07-07 09:24:40 +02:00
b7d5c6799f Merge branch 'bvander/data-seeding-updates' into 'master'
data-seeding image updates and tweak meta import

See merge request grassrootseconomics/cic-internal-integration!212
2021-07-05 17:14:47 +00:00
eef8bb2cf7 data-seeding image updates and tweak meta import 2021-07-05 17:14:46 +00:00
cf96fee430 Merge branch 'bvander/contract-migration-caching' into 'master'
refactor contract-migration and add buildkit caching

See merge request grassrootseconomics/cic-internal-integration!211
2021-07-05 17:13:27 +00:00
9740963431 refactor contract-migration and add buildkit caching 2021-07-05 17:13:26 +00:00
nolash
185458e320 Merge remote-tracking branch 'origin/master' into lash/demurrage-task 2021-07-05 13:05:02 +02:00
Louis Holbrook
a3c4932488 Merge branch 'lash/new-sarafu-token' into 'master'
Add flavors of demurrage token to dev deployment

See merge request grassrootseconomics/cic-internal-integration!181
2021-07-05 10:58:08 +00:00
Louis Holbrook
aa667951be Add flavors of demurrage token to dev deployment 2021-07-05 10:58:08 +00:00
nolash
d9214ddd62 Bump cic-eth dep to future build 2021-07-05 11:59:52 +02:00
nolash
b5d6d80d8e Add demurrage token task api 2021-07-05 11:58:07 +02:00
nolash
2f09ac9110 Change aux module to unpopulated base module name 2021-07-05 11:28:27 +02:00
nolash
bc8851ad06 Merge branch 'lash/new-sarafu-token' into lash/demurrage-task 2021-07-05 10:41:35 +02:00
nolash
46840040a0 Remove dev dockerfile 2021-07-05 10:41:19 +02:00
nolash
4e18060589 Remove dev dockerfile 2021-07-05 10:40:32 +02:00
nolash
08a77f9818 Move cic-eth token fixture to package, add demurrage token calc task 2021-07-05 10:29:33 +02:00
Louis Holbrook
c2459cfd65 Merge branch 'lash/traffic' into 'master'
data-seeding: Rehabilitate traffic scripts

See merge request grassrootseconomics/cic-internal-integration!213
2021-07-04 17:19:21 +00:00
Louis Holbrook
e7102ff02d data-seeding: Rehabilitate traffic scripts 2021-07-04 17:19:21 +00:00
nolash
5fab939270 Upgrade erc20 demurrage token deploy, explicit multi mode flag 2021-07-04 13:53:03 +02:00
nolash
51109db487 Merge branch 'lash/new-sarafu-token' into lash/demurrage-task 2021-07-04 13:26:22 +02:00
nolash
f44e2c8b45 Move registry initialization before aux 2021-07-04 13:21:07 +02:00
a942c785f6 Merge branch 'philip/improving-import-scripts-logging' into 'master'
Improves import scripts logging

Closes #74

See merge request grassrootseconomics/cic-internal-integration!207
2021-07-03 16:55:51 +00:00
70704b09ec Improves import scripts logging 2021-07-03 16:55:51 +00:00
nolash
40a7eec6ad Add aux requirement for demurrage task 2021-07-02 21:32:48 +02:00
nolash
6a75ce37c4 Add demurrage token amount adjust task 2021-07-02 21:25:47 +02:00
nolash
9b2f2ab0b1 Reduce profanity 2021-07-01 11:05:31 +02:00
nolash
98a38b7117 Upgrade cic-base for contract-migration 2021-06-30 22:51:23 +02:00
nolash
a2ca61355d Merge remote-tracking branch 'origin/master' into lash/new-sarafu-token 2021-06-30 22:01:07 +02:00
nolash
b0638f2262 Rename env var for token symbol in contract migration 2021-06-30 21:16:06 +02:00
Louis Holbrook
a075c55957 Merge branch 'lash/allowance' into 'master'
Add allowance check and transferFrom task

See merge request grassrootseconomics/cic-internal-integration!203
2021-06-30 18:15:40 +00:00
Louis Holbrook
6464f651ec Add allowance check and transferFrom task 2021-06-30 18:15:40 +00:00
Louis Holbrook
5145282946 Merge branch 'lash/faucet-verif' into 'master'
Consider faucet amount in verify balance

See merge request grassrootseconomics/cic-internal-integration!206
2021-06-30 18:14:53 +00:00
nolash
1e87f2ed31 Consider faucet amount in verify balance 2021-06-30 17:22:26 +02:00
nolash
ea4c68f311 Merge remote-tracking branch 'origin/master' into lash/new-sarafu-token 2021-06-30 16:47:36 +02:00
Louis Holbrook
c852f41d76 Merge branch 'lash/move-to-chainlib-eth' into 'master'
Move to chainlib-eth dependency

See merge request grassrootseconomics/cic-internal-integration!199
2021-06-30 14:44:18 +00:00
Louis Holbrook
f8e68cff96 Move to chainlib-eth dependency 2021-06-30 14:44:17 +00:00
7027d77836 Merge branch 'philip/refactor-integration-tests' into 'master'
Philip/refactor integration tests

See merge request grassrootseconomics/cic-internal-integration!182
2021-06-30 14:27:56 +00:00
d356f8167d Philip/refactor integration tests 2021-06-30 14:27:56 +00:00
753d21fe95 Merge branch 'bvander/data-seeding-build-improvements' into 'master'
uses the new base image

See merge request grassrootseconomics/cic-internal-integration!204
2021-06-29 17:17:18 +00:00
3b6e031746 Merge branch 'philip/db-session-management' into 'master'
Philip/db session management

Closes cic-ussd#53

See merge request grassrootseconomics/cic-internal-integration!193
2021-06-29 10:49:25 +00:00
b1d5d45eef Philip/db session management 2021-06-29 10:49:25 +00:00
53317cb912 Merge branch 'philip/notify-queue-setting' into 'master'
Enable api level setting of queue value.

See merge request grassrootseconomics/cic-internal-integration!184
2021-06-29 10:21:43 +00:00
18382a1f35 Merge branch 'bvander/integration-notes' into 'master'
integration updates: meta imports

See merge request grassrootseconomics/cic-internal-integration!201
2021-06-28 22:40:54 +00:00
29e91fafab integration updates: meta imports 2021-06-28 22:40:54 +00:00
5b20a9a24a Merge branch 'bvander/better-data-seeding-container' into 'master'
add key store and vars

See merge request grassrootseconomics/cic-internal-integration!202
2021-06-28 22:40:04 +00:00
a252195bdc uses the new base image 2021-06-28 10:56:15 -07:00
Spencer Ofwiti
f1be3b633c Merge branch 'lash/downgrade-chainsyncer' into 'master'
Downgrade chainsyncer

See merge request grassrootseconomics/cic-internal-integration!200
2021-06-28 15:36:56 +00:00
0bf2c35fcd add key store and vars 2021-06-26 09:50:03 -07:00
eb2f71aee0 Enable api level setting of queue value. 2021-06-14 10:38:23 +03:00
nolash
ba0dc9371e Add colors to run script for contract migrations 2021-06-10 07:16:59 +02:00
nolash
03ac6633a2 Merge branch 'master' into lash/new-sarafu-token 2021-06-09 15:00:02 +02:00
nolash
4dc8dff369 Remove foo 2021-06-05 17:13:14 +02:00
nolash
a74e69aeb3 Introduce dev dockerfile for contract migration enabling faster iteration in dev 2021-06-05 17:06:37 +02:00
139 changed files with 3203 additions and 2308 deletions

View File

@@ -16,6 +16,7 @@ import cic_base.config
import cic_base.log
import cic_base.argparse
import cic_base.rpc
from cic_base.eth.syncer import chain_interface
from cic_eth_registry import CICRegistry
from cic_eth_registry.error import UnknownContractError
from chainlib.chain import ChainSpec
@@ -28,10 +29,8 @@ from hexathon import (
strip_0x,
)
from chainsyncer.backend.sql import SQLBackend
from chainsyncer.driver import (
HeadSyncer,
HistorySyncer,
)
from chainsyncer.driver.head import HeadSyncer
from chainsyncer.driver.history import HistorySyncer
from chainsyncer.db.models.base import SessionBase
# local imports
@@ -113,10 +112,10 @@ def main():
logg.info('resuming sync session {}'.format(syncer_backend))
for syncer_backend in syncer_backends:
syncers.append(HistorySyncer(syncer_backend))
syncers.append(HistorySyncer(syncer_backend, chain_interface))
syncer_backend = SQLBackend.live(chain_spec, block_offset+1)
syncers.append(HeadSyncer(syncer_backend))
syncers.append(HeadSyncer(syncer_backend, chain_interface))
trusted_addresses_src = config.get('CIC_TRUST_ADDRESS')
if trusted_addresses_src == None:

View File

@@ -1,12 +1,13 @@
cic-base~=0.1.2b10
cic-base==0.1.3a3+build.984b5cff
alembic==1.4.2
confini~=0.3.6rc3
uwsgi==2.0.19.1
moolb~=0.1.0
cic-eth-registry~=0.5.5a4
cic-eth-registry~=0.5.6a1
SQLAlchemy==1.3.20
semver==2.13.0
psycopg2==2.8.6
celery==4.4.7
redis==3.5.3
chainsyncer[sql]~=0.0.2a4
chainsyncer[sql]~=0.0.3a3
erc20-faucet~=0.2.2a1

View File

@@ -2,6 +2,7 @@
import os
import argparse
import logging
import re
import alembic
from alembic.config import Config as AlembicConfig
@@ -23,6 +24,8 @@ argparser = argparse.ArgumentParser()
argparser.add_argument('-c', type=str, default=config_dir, help='config file')
argparser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration')
argparser.add_argument('--migrations-dir', dest='migrations_dir', default=migrationsdir, type=str, help='path to alembic migrations directory')
argparser.add_argument('--reset', action='store_true', help='downgrade before upgrading')
argparser.add_argument('-f', action='store_true', help='force action')
argparser.add_argument('-v', action='store_true', help='be verbose')
argparser.add_argument('-vv', action='store_true', help='be more verbose')
args = argparser.parse_args()
@@ -53,4 +56,10 @@ ac = AlembicConfig(os.path.join(migrations_dir, 'alembic.ini'))
ac.set_main_option('sqlalchemy.url', dsn)
ac.set_main_option('script_location', migrations_dir)
if args.reset:
if not args.f:
if not re.match(r'[yY][eE]?[sS]?', input('EEK! this will DELETE the existing db. are you sure??')):
logg.error('user chickened out on requested reset, bailing')
sys.exit(1)
alembic.command.downgrade(ac, 'base')
alembic.command.upgrade(ac, 'head')

View File

@@ -6,6 +6,5 @@ sqlparse==0.4.1
pytest-celery==0.0.0a1
eth_tester==0.5.0b3
py-evm==0.3.0a20
web3==5.12.2
cic-eth-registry~=0.5.5a3
cic-base[full]==0.1.2b8
cic_base[full]==0.1.3a3+build.984b5cff
sarafu-faucet~=0.0.4a1

View File

@@ -0,0 +1 @@
include *requirements.txt

View File

@@ -0,0 +1,53 @@
# standard imports
import logging
# external imports
import celery
from erc20_demurrage_token.demurrage import DemurrageCalculator
from chainlib.connection import RPCConnection
from chainlib.chain import ChainSpec
from chainlib.eth.constant import ZERO_ADDRESS
from cic_eth_registry import CICRegistry
logg = logging.getLogger(__name__)
celery_app = celery.current_app
class NoopCalculator:
def amount_since(self, amount, timestamp):
logg.debug('noopcalculator amount {} timestamp {}'.format(amount, timestamp))
return amount
class DemurrageCalculationTask(celery.Task):
demurrage_token_calcs = {}
@classmethod
def register_token(cls, rpc, chain_spec, token_symbol, sender_address=ZERO_ADDRESS):
registry = CICRegistry(chain_spec, rpc)
token_address = registry.by_name(token_symbol, sender_address=sender_address)
try:
c = DemurrageCalculator.from_contract(rpc, chain_spec, token_address, sender_address=sender_address)
logg.info('found demurrage calculator for ERC20 {} @ {}'.format(token_symbol, token_address))
except:
logg.warning('Token {} at address {} does not appear to be a demurrage contract. Calls to balance adjust for this token will always return the same amount'.format(token_symbol, token_address))
c = NoopCalculator()
cls.demurrage_token_calcs[token_symbol] = c
@celery_app.task(bind=True, base=DemurrageCalculationTask)
def get_adjusted_balance(self, token_symbol, amount, timestamp):
c = self.demurrage_token_calcs[token_symbol]
return c.amount_since(amount, timestamp)
def aux_setup(rpc, config, sender_address=ZERO_ADDRESS):
chain_spec_str = config.get('CIC_CHAIN_SPEC')
chain_spec = ChainSpec.from_chain_str(chain_spec_str)
token_symbol = config.get('CIC_DEFAULT_TOKEN_SYMBOL')
DemurrageCalculationTask.register_token(rpc, chain_spec, token_symbol, sender_address=sender_address)

View File

@@ -0,0 +1,30 @@
# standard imports
import logging
# external imports
import celery
from cic_eth.api.base import ApiBase
app = celery.current_app
logg = logging.getLogger(__name__)
class Api(ApiBase):
def get_adjusted_balance(self, token_symbol, balance, timestamp):
s = celery.signature(
'cic_eth_aux.erc20_demurrage_token.get_adjusted_balance',
[
token_symbol,
balance,
timestamp,
],
queue=None,
)
if self.callback_param != None:
s.link(self.callback_success)
s.link.on_error(self.callback_error)
t = s.apply_async(queue=self.queue)
return t

View File

@@ -0,0 +1,5 @@
celery==4.4.7
erc20-demurrage-token~=0.0.2a3
cic-eth-registry~=0.5.6a1
chainlib~=0.0.5a1
cic_eth~=0.12.0a2

View File

@@ -0,0 +1,30 @@
[metadata]
name = cic-eth-aux-erc20-demurrage-token
version = 0.0.2a4
description = cic-eth tasks supporting erc20 demurrage token
author = Louis Holbrook
author_email = dev@holbrook.no
url = https://gitlab.com/ccicnet/erc20-demurrage-token
keywords =
ethereum
blockchain
cryptocurrency
erc20
classifiers =
Programming Language :: Python :: 3
Operating System :: OS Independent
Development Status :: 3 - Alpha
Environment :: No Input/Output (Daemon)
Intended Audience :: Developers
License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Topic :: Internet
#Topic :: Blockchain :: EVM
license = GPL3
licence_files =
LICENSE
[options]
include_package_data = True
python_requires = >= 3.6
packages =
cic_eth_aux.erc20_demurrage_token

View File

@@ -0,0 +1,25 @@
from setuptools import setup
requirements = []
f = open('requirements.txt', 'r')
while True:
l = f.readline()
if l == '':
break
requirements.append(l.rstrip())
f.close()
test_requirements = []
f = open('test_requirements.txt', 'r')
while True:
l = f.readline()
if l == '':
break
test_requirements.append(l.rstrip())
f.close()
setup(
install_requires=requirements,
tests_require=test_requirements,
)

View File

@@ -0,0 +1,12 @@
pytest==6.0.1
pytest-celery==0.0.0a1
pytest-mock==3.3.1
pytest-cov==2.10.1
eth-tester==0.5.0b3
py-evm==0.3.0a20
SQLAlchemy==1.3.20
cic-eth~=0.12.0a1
liveness~=0.0.1a7
eth-accounts-index==0.0.12a1
eth-contract-registry==0.5.6a1
eth-address-index==0.1.2a1

View File

@@ -0,0 +1,88 @@
# external imports
import celery
from chainlib.eth.pytest.fixtures_chain import *
from chainlib.eth.pytest.fixtures_ethtester import *
from cic_eth_registry.pytest.fixtures_contracts import *
from cic_eth_registry.pytest.fixtures_tokens import *
from erc20_demurrage_token.unittest.base import TestTokenDeploy
from erc20_demurrage_token.token import DemurrageToken
from eth_token_index.index import TokenUniqueSymbolIndex
from eth_address_declarator.declarator import AddressDeclarator
# cic-eth imports
from cic_eth.pytest.fixtures_celery import *
from cic_eth.pytest.fixtures_token import *
from cic_eth.pytest.fixtures_config import *
@pytest.fixture(scope='function')
def demurrage_token(
default_chain_spec,
eth_rpc,
token_registry,
contract_roles,
eth_signer,
):
d = TestTokenDeploy(eth_rpc, token_symbol='BAR', token_name='Bar Token')
nonce_oracle = RPCNonceOracle(contract_roles['CONTRACT_DEPLOYER'], conn=eth_rpc)
c = DemurrageToken(default_chain_spec, signer=eth_signer, nonce_oracle=nonce_oracle)
token_address = d.deploy(eth_rpc, contract_roles['CONTRACT_DEPLOYER'], c, 'SingleNocap')
logg.debug('demurrage token contract "BAR" deployed to {}'.format(token_address))
return token_address
@pytest.fixture(scope='function')
def demurrage_token_symbol(
default_chain_spec,
eth_rpc,
demurrage_token,
contract_roles,
):
c = DemurrageToken(default_chain_spec)
o = c.symbol(demurrage_token, sender_address=contract_roles['CONTRACT_DEPLOYER'])
r = eth_rpc.do(o)
return c.parse_symbol(r)
@pytest.fixture(scope='function')
def demurrage_token_declaration(
foo_token_declaration,
):
return foo_token_declaration
@pytest.fixture(scope='function')
def register_demurrage_token(
default_chain_spec,
token_registry,
eth_rpc,
eth_signer,
register_lookups,
contract_roles,
demurrage_token_declaration,
demurrage_token,
address_declarator,
):
nonce_oracle = RPCNonceOracle(contract_roles['CONTRACT_DEPLOYER'], eth_rpc)
c = TokenUniqueSymbolIndex(default_chain_spec, signer=eth_signer, nonce_oracle=nonce_oracle)
(tx_hash_hex, o) = c.register(token_registry, contract_roles['CONTRACT_DEPLOYER'], demurrage_token)
eth_rpc.do(o)
o = receipt(tx_hash_hex)
r = eth_rpc.do(o)
assert r['status'] == 1
nonce_oracle = RPCNonceOracle(contract_roles['TRUSTED_DECLARATOR'], eth_rpc)
c = AddressDeclarator(default_chain_spec, signer=eth_signer, nonce_oracle=nonce_oracle)
(tx_hash_hex, o) = c.add_declaration(address_declarator, contract_roles['TRUSTED_DECLARATOR'], demurrage_token, demurrage_token_declaration)
eth_rpc.do(o)
o = receipt(tx_hash_hex)
r = eth_rpc.do(o)
assert r['status'] == 1
return token_registry

View File

@@ -0,0 +1,69 @@
# standard imports
import logging
import copy
import datetime
# external imports
import celery
# cic-eth imports
from cic_eth_aux.erc20_demurrage_token import (
DemurrageCalculationTask,
aux_setup,
)
from cic_eth_aux.erc20_demurrage_token.api import Api as AuxApi
logg = logging.getLogger()
def test_demurrage_calulate_task(
default_chain_spec,
eth_rpc,
cic_registry,
celery_session_worker,
register_demurrage_token,
demurrage_token_symbol,
contract_roles,
load_config,
):
config = copy.copy(load_config)
config.add(str(default_chain_spec), 'CIC_CHAIN_SPEC', exists_ok=True)
config.add(demurrage_token_symbol, 'CIC_DEFAULT_TOKEN_SYMBOL', exists_ok=True)
aux_setup(eth_rpc, load_config, sender_address=contract_roles['CONTRACT_DEPLOYER'])
since = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
s = celery.signature(
'cic_eth_aux.erc20_demurrage_token.get_adjusted_balance',
[
demurrage_token_symbol,
1000,
since.timestamp(),
],
queue=None,
)
t = s.apply_async()
r = t.get_leaf()
assert t.successful()
assert r == 980
def test_demurrage_calculate_api(
default_chain_spec,
eth_rpc,
cic_registry,
celery_session_worker,
register_demurrage_token,
demurrage_token_symbol,
contract_roles,
load_config,
):
api = AuxApi(str(default_chain_spec), queue=None)
since = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
t = api.get_adjusted_balance(demurrage_token_symbol, 1000, since.timestamp())
r = t.get_leaf()
assert t.successful()
assert r == 980

View File

@@ -3,31 +3,29 @@
APP_NAME: cic-eth
DOCKERFILE_PATH: $APP_NAME/docker/Dockerfile
.cic_eth_changes_target:
.cic_eth_mr_changes_target:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
#changes:
#- $CONTEXT/$APP_NAME/**/*
changes:
- $CONTEXT/$APP_NAME/**/*
when: always
build-mr-cic-eth:
extends:
- .cic_eth_variables
- .cic_eth_changes_target
- .cic_eth_mr_changes_target
- .py_build_target_test
test-mr-cic-eth:
extends:
- .cic_eth_variables
- .cic_eth_changes_target
- .cic_eth_mr_changes_target
stage: test
image: $CI_REGISTRY_IMAGE/$APP_NAME-test:latest
image: $IMAGE_TAG_BASE
script:
- cd apps/$APP_NAME/
- pytest -x --cov=cic_eth --cov-fail-under=90 --cov-report term-missing tests
needs: ["build-mr-cic-eth"]
build-push-cic-eth:
extends:
- .py_build_push
- .cic_eth_variables

2
apps/cic-eth/MANIFEST.in Normal file
View File

@@ -0,0 +1,2 @@
include *requirements.txt config/test/*

View File

@@ -0,0 +1,5 @@
SQLAlchemy==1.3.20
cic-eth-registry~=0.5.6a1
hexathon~=0.0.1a7
chainqueue~=0.0.2b5
eth-erc20==0.0.10a2

View File

@@ -5,4 +5,3 @@
"""
from .api_task import Api
from .api_admin import AdminApi

View File

@@ -562,13 +562,13 @@ class AdminApi:
tx['source_token_symbol'] = source_token.symbol
o = erc20_c.balance_of(tx['source_token'], tx['sender'], sender_address=self.call_address)
r = self.rpc.do(o)
tx['sender_token_balance'] = erc20_c.parse_balance_of(r)
tx['sender_token_balance'] = erc20_c.parse_balance(r)
if destination_token != None:
tx['destination_token_symbol'] = destination_token.symbol
o = erc20_c.balance_of(tx['destination_token'], tx['recipient'], sender_address=self.call_address)
r = self.rpc.do(o)
tx['recipient_token_balance'] = erc20_c.parse_balance_of(r)
tx['recipient_token_balance'] = erc20_c.parse_balance(r)
#tx['recipient_token_balance'] = destination_token.function('balanceOf')(tx['recipient']).call()
# TODO: this can mean either not subitted or culled, need to check other txs with same nonce to determine which

View File

@@ -8,59 +8,19 @@ import logging
# external imports
import celery
from cic_eth_registry import CICRegistry
from chainlib.chain import ChainSpec
# local imports
from cic_eth.db.enum import LockEnum
from cic_eth.api.base import ApiBase
from cic_eth.enum import LockEnum
app = celery.current_app
logg = logging.getLogger(__name__)
class Api:
"""Creates task chains to perform well-known CIC operations.
Each method that sends tasks returns details about the root task. The root task uuid can be provided in the callback, to enable to caller to correlate the result with individual calls. It can also be used to independently poll the completion of a task chain.
:param callback_param: Static value to pass to callback
:type callback_param: str
:param callback_task: Callback task that executes callback_param call. (Must be included by the celery worker)
:type callback_task: string
:param queue: Name of worker queue to submit tasks to
:type queue: str
"""
def __init__(self, chain_str, queue='cic-eth', callback_param=None, callback_task='cic_eth.callbacks.noop.noop', callback_queue=None):
self.chain_str = chain_str
self.chain_spec = ChainSpec.from_chain_str(chain_str)
self.callback_param = callback_param
self.callback_task = callback_task
self.queue = queue
logg.debug('api using queue {}'.format(self.queue))
self.callback_success = None
self.callback_error = None
if callback_queue == None:
callback_queue=self.queue
if callback_param != None:
self.callback_success = celery.signature(
callback_task,
[
callback_param,
0,
],
queue=callback_queue,
)
self.callback_error = celery.signature(
callback_task,
[
callback_param,
1,
],
queue=callback_queue,
)
class Api(ApiBase):
def default_token(self):
s_token = celery.signature(
@@ -204,6 +164,82 @@ class Api:
# return t
def transfer_from(self, from_address, to_address, value, token_symbol, spender_address):
"""Executes a chain of celery tasks that performs a transfer of ERC20 tokens by one address on behalf of another address to a third party.
:param from_address: Ethereum address of sender
:type from_address: str, 0x-hex
:param to_address: Ethereum address of recipient
:type to_address: str, 0x-hex
:param value: Estimated return from conversion
:type value: int
:param token_symbol: ERC20 token symbol of token to send
:type token_symbol: str
:param spender_address: Ethereum address of recipient
:type spender_address: str, 0x-hex
:returns: uuid of root task
:rtype: celery.Task
"""
s_check = celery.signature(
'cic_eth.admin.ctrl.check_lock',
[
[token_symbol],
self.chain_spec.asdict(),
LockEnum.QUEUE,
from_address,
],
queue=self.queue,
)
s_nonce = celery.signature(
'cic_eth.eth.nonce.reserve_nonce',
[
self.chain_spec.asdict(),
from_address,
],
queue=self.queue,
)
s_tokens = celery.signature(
'cic_eth.eth.erc20.resolve_tokens_by_symbol',
[
self.chain_spec.asdict(),
],
queue=self.queue,
)
s_allow = celery.signature(
'cic_eth.eth.erc20.check_allowance',
[
from_address,
value,
self.chain_spec.asdict(),
spender_address,
],
queue=self.queue,
)
s_transfer = celery.signature(
'cic_eth.eth.erc20.transfer_from',
[
from_address,
to_address,
value,
self.chain_spec.asdict(),
spender_address,
],
queue=self.queue,
)
s_tokens.link(s_allow)
s_nonce.link(s_tokens)
s_check.link(s_nonce)
if self.callback_param != None:
s_transfer.link(self.callback_success)
s_allow.link(s_transfer).on_error(self.callback_error)
else:
s_allow.link(s_transfer)
t = s_check.apply_async(queue=self.queue)
return t
def transfer(self, from_address, to_address, value, token_symbol):
"""Executes a chain of celery tasks that performs a transfer of ERC20 tokens from one address to another.

View File

@@ -0,0 +1,52 @@
# standard imports
import logging
# external imports
import celery
from chainlib.chain import ChainSpec
logg = logging.getLogger(__name__)
class ApiBase:
"""Creates task chains to perform well-known CIC operations.
Each method that sends tasks returns details about the root task. The root task uuid can be provided in the callback, to enable to caller to correlate the result with individual calls. It can also be used to independently poll the completion of a task chain.
:param callback_param: Static value to pass to callback
:type callback_param: str
:param callback_task: Callback task that executes callback_param call. (Must be included by the celery worker)
:type callback_task: string
:param queue: Name of worker queue to submit tasks to
:type queue: str
"""
def __init__(self, chain_str, queue='cic-eth', callback_param=None, callback_task='cic_eth.callbacks.noop.noop', callback_queue=None):
self.chain_str = chain_str
self.chain_spec = ChainSpec.from_chain_str(chain_str)
self.callback_param = callback_param
self.callback_task = callback_task
self.queue = queue
logg.debug('api using queue {}'.format(self.queue))
self.callback_success = None
self.callback_error = None
if callback_queue == None:
callback_queue=self.queue
if callback_param != None:
self.callback_success = celery.signature(
callback_task,
[
callback_param,
0,
],
queue=callback_queue,
)
self.callback_error = celery.signature(
callback_task,
[
callback_param,
1,
],
queue=callback_queue,
)

View File

@@ -1,158 +1 @@
# standard imports
import enum
@enum.unique
class StatusBits(enum.IntEnum):
"""Individual bit flags that are combined to define the state and legacy of a queued transaction
"""
QUEUED = 0x01 # transaction should be sent to network
IN_NETWORK = 0x08 # transaction is in network
DEFERRED = 0x10 # an attempt to send the transaction to network has failed
GAS_ISSUES = 0x20 # transaction is pending sender account gas funding
LOCAL_ERROR = 0x100 # errors that originate internally from the component
NODE_ERROR = 0x200 # errors originating in the node (invalid RLP input...)
NETWORK_ERROR = 0x400 # errors that originate from the network (REVERT)
UNKNOWN_ERROR = 0x800 # unclassified errors (the should not occur)
FINAL = 0x1000 # transaction processing has completed
OBSOLETE = 0x2000 # transaction has been replaced by a different transaction with higher fee
MANUAL = 0x8000 # transaction processing has been manually overridden
@enum.unique
class StatusEnum(enum.IntEnum):
"""
- Inactive, not finalized. (<0)
* PENDING: The initial state of a newly added transaction record. No action has been performed on this transaction yet.
* SENDFAIL: The transaction was not received by the node.
* RETRY: The transaction is queued for a new send attempt after previously failing.
* READYSEND: The transaction is queued for its first send attempt
* OBSOLETED: A new transaction with the same nonce and higher gas has been sent to network.
* WAITFORGAS: The transaction is on hold pending gas funding.
- Active state: (==0)
* SENT: The transaction has been sent to the mempool.
- Inactive, finalized. (>0)
* FUBAR: Unknown error occurred and transaction is abandoned. Manual intervention needed.
* CANCELLED: The transaction was sent, but was not mined and has disappered from the mempool. This usually follows a transaction being obsoleted.
* OVERRIDDEN: Transaction has been manually overriden.
* REJECTED: The transaction was rejected by the node.
* REVERTED: The transaction was mined, but exception occurred during EVM execution. (Block number will be set)
* SUCCESS: THe transaction was successfully mined. (Block number will be set)
"""
PENDING = 0
SENDFAIL = StatusBits.DEFERRED | StatusBits.LOCAL_ERROR
RETRY = StatusBits.QUEUED | StatusBits.DEFERRED
READYSEND = StatusBits.QUEUED
OBSOLETED = StatusBits.OBSOLETE | StatusBits.IN_NETWORK
WAITFORGAS = StatusBits.GAS_ISSUES
SENT = StatusBits.IN_NETWORK
FUBAR = StatusBits.FINAL | StatusBits.UNKNOWN_ERROR
CANCELLED = StatusBits.IN_NETWORK | StatusBits.FINAL | StatusBits.OBSOLETE
OVERRIDDEN = StatusBits.FINAL | StatusBits.OBSOLETE | StatusBits.MANUAL
REJECTED = StatusBits.NODE_ERROR | StatusBits.FINAL
REVERTED = StatusBits.IN_NETWORK | StatusBits.FINAL | StatusBits.NETWORK_ERROR
SUCCESS = StatusBits.IN_NETWORK | StatusBits.FINAL
@enum.unique
class LockEnum(enum.IntEnum):
"""
STICKY: When set, reset is not possible
CREATE: Disable creation of accounts
SEND: Disable sending to network
QUEUE: Disable queueing new or modified transactions
"""
STICKY=1
INIT=2
CREATE=4
SEND=8
QUEUE=16
QUERY=32
ALL=int(0xfffffffffffffffe)
def status_str(v, bits_only=False):
"""Render a human-readable string describing the status
If the bit field exactly matches a StatusEnum value, the StatusEnum label will be returned.
If a StatusEnum cannot be matched, the string will be postfixed with "*", unless explicitly instructed to return bit field labels only.
:param v: Status bit field
:type v: number
:param bits_only: Only render individual bit labels.
:type bits_only: bool
:returns: Status string
:rtype: str
"""
s = ''
if not bits_only:
try:
s = StatusEnum(v).name
return s
except ValueError:
pass
if v == 0:
return 'NONE'
for i in range(16):
b = (1 << i)
if (b & 0xffff) & v:
n = StatusBits(b).name
if len(s) > 0:
s += ','
s += n
if not bits_only:
s += '*'
return s
def all_errors():
"""Bit mask of all error states
:returns: Error flags
:rtype: number
"""
return StatusBits.LOCAL_ERROR | StatusBits.NODE_ERROR | StatusBits.NETWORK_ERROR | StatusBits.UNKNOWN_ERROR
def is_error_status(v):
"""Check if value is an error state
:param v: Status bit field
:type v: number
:returns: True if error
:rtype: bool
"""
return bool(v & all_errors())
def dead():
"""Bit mask defining whether a transaction is still likely to be processed on the network.
:returns: Bit mask
:rtype: number
"""
return StatusBits.FINAL | StatusBits.OBSOLETE
def is_alive(v):
"""Check if transaction is still likely to be processed on the network.
The contingency of "likely" refers to the case a transaction has been obsoleted after sent to the network, but the network still confirms the obsoleted transaction. The return value of this method will not change as a result of this, BUT the state itself will (as the FINAL bit will be set).
:returns:
"""
return bool(v & dead() == 0)
from cic_eth.enum import *

View File

@@ -0,0 +1,158 @@
# standard imports
import enum
@enum.unique
class StatusBits(enum.IntEnum):
"""Individual bit flags that are combined to define the state and legacy of a queued transaction
"""
QUEUED = 0x01 # transaction should be sent to network
IN_NETWORK = 0x08 # transaction is in network
DEFERRED = 0x10 # an attempt to send the transaction to network has failed
GAS_ISSUES = 0x20 # transaction is pending sender account gas funding
LOCAL_ERROR = 0x100 # errors that originate internally from the component
NODE_ERROR = 0x200 # errors originating in the node (invalid RLP input...)
NETWORK_ERROR = 0x400 # errors that originate from the network (REVERT)
UNKNOWN_ERROR = 0x800 # unclassified errors (the should not occur)
FINAL = 0x1000 # transaction processing has completed
OBSOLETE = 0x2000 # transaction has been replaced by a different transaction with higher fee
MANUAL = 0x8000 # transaction processing has been manually overridden
@enum.unique
class StatusEnum(enum.IntEnum):
"""
- Inactive, not finalized. (<0)
* PENDING: The initial state of a newly added transaction record. No action has been performed on this transaction yet.
* SENDFAIL: The transaction was not received by the node.
* RETRY: The transaction is queued for a new send attempt after previously failing.
* READYSEND: The transaction is queued for its first send attempt
* OBSOLETED: A new transaction with the same nonce and higher gas has been sent to network.
* WAITFORGAS: The transaction is on hold pending gas funding.
- Active state: (==0)
* SENT: The transaction has been sent to the mempool.
- Inactive, finalized. (>0)
* FUBAR: Unknown error occurred and transaction is abandoned. Manual intervention needed.
* CANCELLED: The transaction was sent, but was not mined and has disappered from the mempool. This usually follows a transaction being obsoleted.
* OVERRIDDEN: Transaction has been manually overriden.
* REJECTED: The transaction was rejected by the node.
* REVERTED: The transaction was mined, but exception occurred during EVM execution. (Block number will be set)
* SUCCESS: THe transaction was successfully mined. (Block number will be set)
"""
PENDING = 0
SENDFAIL = StatusBits.DEFERRED | StatusBits.LOCAL_ERROR
RETRY = StatusBits.QUEUED | StatusBits.DEFERRED
READYSEND = StatusBits.QUEUED
OBSOLETED = StatusBits.OBSOLETE | StatusBits.IN_NETWORK
WAITFORGAS = StatusBits.GAS_ISSUES
SENT = StatusBits.IN_NETWORK
FUBAR = StatusBits.FINAL | StatusBits.UNKNOWN_ERROR
CANCELLED = StatusBits.IN_NETWORK | StatusBits.FINAL | StatusBits.OBSOLETE
OVERRIDDEN = StatusBits.FINAL | StatusBits.OBSOLETE | StatusBits.MANUAL
REJECTED = StatusBits.NODE_ERROR | StatusBits.FINAL
REVERTED = StatusBits.IN_NETWORK | StatusBits.FINAL | StatusBits.NETWORK_ERROR
SUCCESS = StatusBits.IN_NETWORK | StatusBits.FINAL
@enum.unique
class LockEnum(enum.IntEnum):
"""
STICKY: When set, reset is not possible
CREATE: Disable creation of accounts
SEND: Disable sending to network
QUEUE: Disable queueing new or modified transactions
"""
STICKY=1
INIT=2
CREATE=4
SEND=8
QUEUE=16
QUERY=32
ALL=int(0xfffffffffffffffe)
def status_str(v, bits_only=False):
"""Render a human-readable string describing the status
If the bit field exactly matches a StatusEnum value, the StatusEnum label will be returned.
If a StatusEnum cannot be matched, the string will be postfixed with "*", unless explicitly instructed to return bit field labels only.
:param v: Status bit field
:type v: number
:param bits_only: Only render individual bit labels.
:type bits_only: bool
:returns: Status string
:rtype: str
"""
s = ''
if not bits_only:
try:
s = StatusEnum(v).name
return s
except ValueError:
pass
if v == 0:
return 'NONE'
for i in range(16):
b = (1 << i)
if (b & 0xffff) & v:
n = StatusBits(b).name
if len(s) > 0:
s += ','
s += n
if not bits_only:
s += '*'
return s
def all_errors():
"""Bit mask of all error states
:returns: Error flags
:rtype: number
"""
return StatusBits.LOCAL_ERROR | StatusBits.NODE_ERROR | StatusBits.NETWORK_ERROR | StatusBits.UNKNOWN_ERROR
def is_error_status(v):
"""Check if value is an error state
:param v: Status bit field
:type v: number
:returns: True if error
:rtype: bool
"""
return bool(v & all_errors())
def dead():
"""Bit mask defining whether a transaction is still likely to be processed on the network.
:returns: Bit mask
:rtype: number
"""
return StatusBits.FINAL | StatusBits.OBSOLETE
def is_alive(v):
"""Check if transaction is still likely to be processed on the network.
The contingency of "likely" refers to the case a transaction has been obsoleted after sent to the network, but the network still confirms the obsoleted transaction. The return value of this method will not change as a result of this, BUT the state itself will (as the FINAL bit will be set).
:returns:
"""
return bool(v & dead() == 0)

View File

@@ -80,3 +80,8 @@ class SignerError(SeppukuError):
class RoleAgencyError(SeppukuError):
"""Exception raise when a role cannot perform its function. This is a critical exception
"""
class YouAreBrokeError(Exception):
"""Exception raised when a value transfer is attempted without access to sufficient funds
"""

View File

@@ -24,6 +24,7 @@ from cic_eth.error import (
TokenCountError,
PermanentTxError,
OutOfGasError,
YouAreBrokeError,
)
from cic_eth.queue.tx import register_tx
from cic_eth.eth.gas import (
@@ -71,6 +72,117 @@ def balance(tokens, holder_address, chain_spec_dict):
return tokens
@celery_app.task(bind=True)
def check_allowance(self, tokens, holder_address, value, chain_spec_dict, spender_address):
"""Best-effort verification that the allowance for a transfer from spend is sufficient.
:raises YouAreBrokeError: If allowance is insufficient
:param tokens: Token addresses
:type tokens: list of str, 0x-hex
:param holder_address: Token holder address
:type holder_address: str, 0x-hex
:param value: Amount of token, in 'wei'
:type value: int
:param chain_str: Chain spec string representation
:type chain_str: str
:param spender_address: Address of account spending on behalf of holder
:type spender_address: str, 0x-hex
:return: Token list as passed to task
:rtype: dict
"""
logg.debug('tokens {}'.format(tokens))
if len(tokens) != 1:
raise TokenCountError
t = tokens[0]
chain_spec = ChainSpec.from_dict(chain_spec_dict)
rpc = RPCConnection.connect(chain_spec, 'default')
caller_address = ERC20Token.caller_address
c = ERC20(chain_spec)
o = c.allowance(t['address'], holder_address, spender_address, sender_address=caller_address)
r = rpc.do(o)
allowance = c.parse_allowance(r)
if allowance < value:
errstr = 'allowance {} insufficent to transfer {} {} by {} on behalf of {}'.format(allowance, value, t['symbol'], spender_address, holder_address)
logg.error(errstr)
raise YouAreBrokeError(errstr)
return tokens
@celery_app.task(bind=True, base=CriticalSQLAlchemyAndSignerTask)
def transfer_from(self, tokens, holder_address, receiver_address, value, chain_spec_dict, spender_address):
"""Transfer ERC20 tokens between addresses
First argument is a list of tokens, to enable the task to be chained to the symbol to token address resolver function. However, it accepts only one token as argument.
:param tokens: Token addresses
:type tokens: list of str, 0x-hex
:param holder_address: Token holder address
:type holder_address: str, 0x-hex
:param receiver_address: Token receiver address
:type receiver_address: str, 0x-hex
:param value: Amount of token, in 'wei'
:type value: int
:param chain_str: Chain spec string representation
:type chain_str: str
:param spender_address: Address of account spending on behalf of holder
:type spender_address: str, 0x-hex
:raises TokenCountError: Either none or more then one tokens have been passed as tokens argument
:return: Transaction hash for tranfer operation
:rtype: str, 0x-hex
"""
# we only allow one token, one transfer
logg.debug('tokens {}'.format(tokens))
if len(tokens) != 1:
raise TokenCountError
t = tokens[0]
chain_spec = ChainSpec.from_dict(chain_spec_dict)
queue = self.request.delivery_info.get('routing_key')
rpc = RPCConnection.connect(chain_spec, 'default')
rpc_signer = RPCConnection.connect(chain_spec, 'signer')
session = self.create_session()
nonce_oracle = CustodialTaskNonceOracle(holder_address, self.request.root_id, session=session)
gas_oracle = self.create_gas_oracle(rpc, MaxGasOracle.gas)
c = ERC20(chain_spec, signer=rpc_signer, gas_oracle=gas_oracle, nonce_oracle=nonce_oracle)
try:
(tx_hash_hex, tx_signed_raw_hex) = c.transfer_from(t['address'], spender_address, holder_address, receiver_address, value, tx_format=TxFormat.RLP_SIGNED)
except FileNotFoundError as e:
raise SignerError(e)
except ConnectionError as e:
raise SignerError(e)
rpc_signer.disconnect()
rpc.disconnect()
cache_task = 'cic_eth.eth.erc20.cache_transfer_from_data'
register_tx(tx_hash_hex, tx_signed_raw_hex, chain_spec, queue, cache_task=cache_task, session=session)
session.commit()
session.close()
gas_pair = gas_oracle.get_gas(tx_signed_raw_hex)
gas_budget = gas_pair[0] * gas_pair[1]
logg.debug('transfer tx {} {} {}'.format(tx_hash_hex, queue, gas_budget))
s = create_check_gas_task(
[tx_signed_raw_hex],
chain_spec,
holder_address,
gas_budget,
[tx_hash_hex],
queue,
)
s.apply_async()
return tx_hash_hex
@celery_app.task(bind=True, base=CriticalSQLAlchemyAndSignerTask)
def transfer(self, tokens, holder_address, receiver_address, value, chain_spec_dict):
"""Transfer ERC20 tokens between addresses
@@ -232,6 +344,7 @@ def resolve_tokens_by_symbol(self, token_symbols, chain_spec_dict):
logg.debug('token {}'.format(token_address))
tokens.append({
'address': token_address,
'symbol': token_symbol,
'converters': [],
})
rpc.disconnect()
@@ -279,6 +392,48 @@ def cache_transfer_data(
return (tx_hash_hex, cache_id)
@celery_app.task(base=CriticalSQLAlchemyTask)
def cache_transfer_from_data(
tx_hash_hex,
tx_signed_raw_hex,
chain_spec_dict,
):
"""Helper function for otx_cache_transfer_from
:param tx_hash_hex: Transaction hash
:type tx_hash_hex: str, 0x-hex
:param tx: Signed raw transaction
:type tx: str, 0x-hex
:returns: Transaction hash and id of cache element in storage backend, respectively
:rtype: tuple
"""
chain_spec = ChainSpec.from_dict(chain_spec_dict)
tx_signed_raw_bytes = bytes.fromhex(strip_0x(tx_signed_raw_hex))
tx = unpack(tx_signed_raw_bytes, chain_spec)
tx_data = ERC20.parse_transfer_from_request(tx['data'])
spender_address = tx_data[0]
recipient_address = tx_data[1]
token_value = tx_data[2]
session = SessionBase.create_session()
tx_cache = TxCache(
tx_hash_hex,
tx['from'],
recipient_address,
tx['to'],
tx['to'],
token_value,
token_value,
session=session,
)
session.add(tx_cache)
session.commit()
cache_id = tx_cache.id
session.close()
return (tx_hash_hex, cache_id)
@celery_app.task(base=CriticalSQLAlchemyTask)
def cache_approve_data(
tx_hash_hex,

View File

View File

@@ -37,7 +37,7 @@ def celery_includes():
'cic_eth.eth.account',
'cic_eth.callbacks.noop',
'cic_eth.callbacks.http',
'tests.mock.filter',
'cic_eth.pytest.mock.filter',
]

View File

@@ -2,18 +2,20 @@
import os
import logging
# third-party imports
# external imports
import pytest
import confini
script_dir = os.path.dirname(os.path.realpath(__file__))
root_dir = os.path.dirname(script_dir)
logg = logging.getLogger(__file__)
root_dir = os.path.dirname(os.path.dirname(script_dir))
logg = logging.getLogger(__name__)
@pytest.fixture(scope='session')
def load_config():
config_dir = os.path.join(root_dir, 'config/test')
config_dir = os.environ.get('CONFINI_DIR')
if config_dir == None:
config_dir = os.path.join(root_dir, 'config/test')
conf = confini.Config(config_dir, 'CICTEST')
conf.process()
logg.debug('config {}'.format(conf))

View File

@@ -37,7 +37,8 @@ def init_database(
database_engine,
):
rootdir = os.path.dirname(os.path.dirname(__file__))
script_dir = os.path.dirname(os.path.realpath(__file__))
rootdir = os.path.dirname(os.path.dirname(script_dir))
dbdir = os.path.join(rootdir, 'cic_eth', 'db')
migrationsdir = os.path.join(dbdir, 'migrations', load_config.get('DATABASE_ENGINE'))
if not os.path.isdir(migrationsdir):

View File

@@ -0,0 +1,19 @@
# external imports
import pytest
from eth_erc20 import ERC20
# TODO: missing dep fixture includes
@pytest.fixture(scope='function')
def foo_token_symbol(
default_chain_spec,
foo_token,
eth_rpc,
contract_roles,
):
c = ERC20(default_chain_spec)
o = c.symbol(foo_token, sender_address=contract_roles['CONTRACT_DEPLOYER'])
r = eth_rpc.do(o)
return c.parse_symbol(r)

View File

@@ -12,7 +12,7 @@ from chainlib.eth.constant import ZERO_ADDRESS
from chainlib.eth.address import is_checksum_address
# local imports
from cic_eth.api import AdminApi
from cic_eth.api.admin import AdminApi
from cic_eth.db.enum import LockEnum
logging.basicConfig(level=logging.WARNING)

View File

@@ -1,136 +0,0 @@
# standard imports
import os
import re
import logging
import argparse
import json
# third-party imports
import web3
import confini
import celery
from json.decoder import JSONDecodeError
from cic_registry.chain import ChainSpec
# local imports
from cic_eth.db import dsn_from_config
from cic_eth.db.models.base import SessionBase
from cic_eth.eth.util import unpack_signed_raw_tx
logging.basicConfig(level=logging.WARNING)
logg = logging.getLogger()
rootdir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
dbdir = os.path.join(rootdir, 'cic_eth', 'db')
migrationsdir = os.path.join(dbdir, 'migrations')
config_dir = os.path.join('/usr/local/etc/cic-eth')
argparser = argparse.ArgumentParser()
argparser.add_argument('-c', type=str, default=config_dir, help='config file')
argparser.add_argument('-i', '--chain-spec', dest='i', type=str, help='chain spec')
argparser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration')
argparser.add_argument('-q', type=str, default='cic-eth', help='queue name for worker tasks')
argparser.add_argument('-v', action='store_true', help='be verbose')
argparser.add_argument('-vv', action='store_true', help='be more verbose')
args = argparser.parse_args()
if args.vv:
logging.getLogger().setLevel(logging.DEBUG)
elif args.v:
logging.getLogger().setLevel(logging.INFO)
config = confini.Config(args.c, args.env_prefix)
config.process()
args_override = {
'CIC_CHAIN_SPEC': getattr(args, 'i'),
}
config.censor('PASSWORD', 'DATABASE')
config.censor('PASSWORD', 'SSL')
logg.debug('config:\n{}'.format(config))
dsn = dsn_from_config(config)
SessionBase.connect(dsn)
celery_app = celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY_BROKER_URL'))
queue = args.q
re_something = r'^/something/?'
chain_spec = ChainSpec.from_chain_str(config.get('CIC_CHAIN_SPEC'))
def process_something(session, env):
r = re.match(re_something, env.get('PATH_INFO'))
if not r:
return None
#if env.get('CONTENT_TYPE') != 'application/json':
# raise AttributeError('content type')
#if env.get('REQUEST_METHOD') != 'POST':
# raise AttributeError('method')
#post_data = json.load(env.get('wsgi.input'))
#return ('text/plain', 'foo'.encode('utf-8'),)
# uwsgi application
def application(env, start_response):
for k in env.keys():
logg.debug('env {} {}'.format(k, env[k]))
headers = []
content = b''
err = None
session = SessionBase.create_session()
for handler in [
process_something,
]:
try:
r = handler(session, env)
except AttributeError as e:
logg.error('handler fail attribute {}'.format(e))
err = '400 Impertinent request'
break
except JSONDecodeError as e:
logg.error('handler fail json {}'.format(e))
err = '400 Invalid data format'
break
except KeyError as e:
logg.error('handler fail key {}'.format(e))
err = '400 Invalid JSON'
break
except ValueError as e:
logg.error('handler fail value {}'.format(e))
err = '400 Invalid data'
break
except RuntimeError as e:
logg.error('task fail value {}'.format(e))
err = '500 Task failed, sorry I cannot tell you more'
break
if r != None:
(mime_type, content) = r
break
session.close()
if err != None:
headers.append(('Content-Type', 'text/plain, charset=UTF-8',))
start_response(err, headers)
session.close()
return [content]
headers.append(('Content-Length', str(len(content))),)
headers.append(('Access-Control-Allow-Origin', '*',));
if len(content) == 0:
headers.append(('Content-Type', 'text/plain, charset=UTF-8',))
start_response('404 Looked everywhere, sorry', headers)
else:
headers.append(('Content-Type', mime_type,))
start_response('200 OK', headers)
return [content]

View File

@@ -7,6 +7,8 @@ import tempfile
import re
import urllib
import websocket
import stat
import importlib
# external imports
import celery
@@ -68,6 +70,8 @@ from cic_eth.task import BaseTask
logging.basicConfig(level=logging.WARNING)
logg = logging.getLogger()
script_dir = os.path.dirname(os.path.realpath(__file__))
config_dir = os.path.join('/usr/local/etc/cic-eth')
argparser = argparse.ArgumentParser()
@@ -79,6 +83,8 @@ argparser.add_argument('--default-token-symbol', dest='default_token_symbol', ty
argparser.add_argument('--trace-queue-status', default=None, dest='trace_queue_status', action='store_true', help='set to perist all queue entry status changes to storage')
argparser.add_argument('-i', '--chain-spec', dest='i', type=str, help='chain spec')
argparser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration')
argparser.add_argument('--aux-all', action='store_true', help='include tasks from all submodules from the aux module path')
argparser.add_argument('--aux', action='append', type=str, default=[], help='add single submodule from the aux module path')
argparser.add_argument('-v', action='store_true', help='be verbose')
argparser.add_argument('-vv', action='store_true', help='be more verbose')
args = argparser.parse_args()
@@ -109,6 +115,8 @@ if len(health_modules) != 0:
health_modules = health_modules.split(',')
logg.debug('health mods {}'.format(health_modules))
# connect to database
dsn = dsn_from_config(config)
SessionBase.connect(dsn, pool_size=int(config.get('DATABASE_POOL_SIZE')), debug=config.true('DATABASE_DEBUG'))
@@ -167,6 +175,84 @@ Otx.tracing = config.true('TASKS_TRACE_QUEUE_STATUS')
# raise RuntimeError()
liveness.linux.load(health_modules, rundir=config.get('CIC_RUN_DIR'), config=config, unit='cic-eth-tasker')
rpc = RPCConnection.connect(chain_spec, 'default')
try:
registry = connect_registry(rpc, chain_spec, config.get('CIC_REGISTRY_ADDRESS'))
except UnknownContractError as e:
logg.exception('Registry contract connection failed for {}: {}'.format(config.get('CIC_REGISTRY_ADDRESS'), e))
sys.exit(1)
logg.info('connected contract registry {}'.format(config.get('CIC_REGISTRY_ADDRESS')))
trusted_addresses_src = config.get('CIC_TRUST_ADDRESS')
if trusted_addresses_src == None:
logg.critical('At least one trusted address must be declared in CIC_TRUST_ADDRESS')
sys.exit(1)
trusted_addresses = trusted_addresses_src.split(',')
for address in trusted_addresses:
logg.info('using trusted address {}'.format(address))
connect_declarator(rpc, chain_spec, trusted_addresses)
connect_token_registry(rpc, chain_spec)
# detect aux
# TODO: move to separate file
#aux_dir = os.path.join(script_dir, '..', '..', 'aux')
aux = []
if args.aux_all:
if len(args.aux) > 0:
logg.warning('--aux-all is set so --aux will have no effect')
for p in sys.path:
logg.debug('checking for aux modules in {}'.format(p))
aux_dir = os.path.join(p, 'cic_eth_aux')
try:
d = os.listdir(aux_dir)
except FileNotFoundError:
logg.debug('no aux module found in {}'.format(aux_dir))
continue
for v in d:
if v[:1] == '.':
logg.debug('dotfile, skip {}'.format(v))
continue
aux_mod_path = os.path.join(aux_dir, v)
st = os.stat(aux_mod_path)
if not stat.S_ISDIR(st.st_mode):
logg.debug('not a dir, skip {}'.format(v))
continue
aux_mod_file = os.path.join(aux_dir, v,'__init__.py')
try:
st = os.stat(aux_mod_file)
except FileNotFoundError:
logg.debug('__init__.py not found, skip {}'.format(v))
continue
aux.append(v)
logg.debug('found module {} in {}'.format(v, aux_dir))
elif len(args.aux) > 0:
for p in sys.path:
v_found = None
for v in args.aux:
aux_dir = os.path.join(p, 'cic_eth_aux')
aux_mod_file = os.path.join(aux_dir, v, '__init__.py')
try:
st = os.stat(aux_mod_file)
v_found = v
except FileNotFoundError:
logg.debug('cannot find explicity requested aux module {} in path {}'.format(v, aux_dir))
continue
if v_found == None:
logg.critical('excplicity requested aux module {} not found in any path'.format(v))
sys.exit(1)
logg.info('aux module {} found in path {}'.format(v, aux_dir))
aux.append(v)
for v in aux:
mname = 'cic_eth_aux.' + v
mod = importlib.import_module(mname)
mod.aux_setup(rpc, config)
logg.info('loaded aux module {}'.format(mname))
def main():
argv = ['worker']
if args.vv:
@@ -189,24 +275,6 @@ def main():
rpc = RPCConnection.connect(chain_spec, 'default')
try:
registry = connect_registry(rpc, chain_spec, config.get('CIC_REGISTRY_ADDRESS'))
except UnknownContractError as e:
logg.exception('Registry contract connection failed for {}: {}'.format(config.get('CIC_REGISTRY_ADDRESS'), e))
sys.exit(1)
logg.info('connected contract registry {}'.format(config.get('CIC_REGISTRY_ADDRESS')))
trusted_addresses_src = config.get('CIC_TRUST_ADDRESS')
if trusted_addresses_src == None:
logg.critical('At least one trusted address must be declared in CIC_TRUST_ADDRESS')
sys.exit(1)
trusted_addresses = trusted_addresses_src.split(',')
for address in trusted_addresses:
logg.info('using trusted address {}'.format(address))
connect_declarator(rpc, chain_spec, trusted_addresses)
connect_token_registry(rpc, chain_spec)
BaseTask.default_token_symbol = config.get('CIC_DEFAULT_TOKEN_SYMBOL')
BaseTask.default_token_address = registry.by_name(BaseTask.default_token_symbol)
default_token = ERC20Token(chain_spec, rpc, BaseTask.default_token_address)

View File

@@ -15,6 +15,7 @@ import cic_base.config
import cic_base.log
import cic_base.argparse
import cic_base.rpc
from cic_base.eth.syncer import chain_interface
from cic_eth_registry.error import UnknownContractError
from chainlib.chain import ChainSpec
from chainlib.eth.constant import ZERO_ADDRESS
@@ -26,10 +27,8 @@ from hexathon import (
strip_0x,
)
from chainsyncer.backend.sql import SQLBackend
from chainsyncer.driver import (
HeadSyncer,
HistorySyncer,
)
from chainsyncer.driver.head import HeadSyncer
from chainsyncer.driver.history import HistorySyncer
from chainsyncer.db.models.base import SessionBase
# local imports
@@ -80,6 +79,7 @@ chain_spec = ChainSpec.from_chain_str(config.get('CIC_CHAIN_SPEC'))
cic_base.rpc.setup(chain_spec, config.get('ETH_PROVIDER'))
def main():
# connect to celery
celery.Celery(broker=config.get('CELERY_BROKER_URL'), backend=config.get('CELERY_RESULT_URL'))
@@ -121,11 +121,11 @@ def main():
for syncer_backend in syncer_backends:
try:
syncers.append(HistorySyncer(syncer_backend))
syncers.append(HistorySyncer(syncer_backend, chain_interface))
logg.info('Initializing HISTORY syncer on backend {}'.format(syncer_backend))
except AttributeError:
logg.info('Initializing HEAD syncer on backend {}'.format(syncer_backend))
syncers.append(HeadSyncer(syncer_backend))
syncers.append(HeadSyncer(syncer_backend, chain_interface))
connect_registry(rpc, chain_spec, config.get('CIC_REGISTRY_ADDRESS'))

View File

@@ -12,10 +12,8 @@ import confini
import celery
# local imports
from cic_eth.api import (
Api,
AdminApi,
)
from cic_eth.api import Api
from cic_eth.api.admin import AdminApi
logging.basicConfig(level=logging.WARNING)
logg = logging.getLogger()

View File

@@ -11,7 +11,7 @@ from chainlib.chain import ChainSpec
from chainlib.eth.connection import EthHTTPConnection
# local imports
from cic_eth.api.api_admin import AdminApi
from cic_eth.api.admin import AdminApi
logging.basicConfig(level=logging.WARNING)
logg = logging.getLogger()

View File

@@ -12,7 +12,7 @@ from chainlib.chain import ChainSpec
from xdg.BaseDirectory import xdg_config_home
# local imports
from cic_eth.api import AdminApi
from cic_eth.api.admin import AdminApi
from cic_eth.db import dsn_from_config
from cic_eth.db.models.base import SessionBase

View File

@@ -19,7 +19,7 @@ from chainlib.eth.connection import EthHTTPConnection
from hexathon import add_0x
# local imports
from cic_eth.api import AdminApi
from cic_eth.api.admin import AdminApi
from cic_eth.db.enum import (
StatusEnum,
status_str,

View File

@@ -1,6 +1,5 @@
# import
import time
import requests
import logging
import uuid
@@ -76,7 +75,7 @@ class CriticalSQLAlchemyTask(CriticalTask):
class CriticalWeb3Task(CriticalTask):
autoretry_for = (
requests.exceptions.ConnectionError,
ConnectionError,
)
safe_gas_threshold_amount = 2000000000 * 60000 * 3
safe_gas_refill_amount = safe_gas_threshold_amount * 5
@@ -86,7 +85,7 @@ class CriticalSQLAlchemyAndWeb3Task(CriticalTask):
autoretry_for = (
sqlalchemy.exc.DatabaseError,
sqlalchemy.exc.TimeoutError,
requests.exceptions.ConnectionError,
ConnectionError,
sqlalchemy.exc.ResourceClosedError,
)
safe_gas_threshold_amount = 2000000000 * 60000 * 3
@@ -102,7 +101,7 @@ class CriticalSQLAlchemyAndSignerTask(CriticalTask):
class CriticalWeb3AndSignerTask(CriticalTask):
autoretry_for = (
requests.exceptions.ConnectionError,
ConnectionError,
)
safe_gas_threshold_amount = 2000000000 * 60000 * 3
safe_gas_refill_amount = safe_gas_threshold_amount * 5

View File

@@ -8,9 +8,9 @@ import semver
version = (
0,
11,
12,
0,
'beta.16',
'alpha.2',
)
version_object = semver.VersionInfo(

View File

@@ -1,6 +1,6 @@
FROM python:3.8.6-slim-buster as compile
WORKDIR /usr/src/cic-eth
WORKDIR /usr/src
RUN apt-get update && \
apt install -y gcc gnupg libpq-dev wget make g++ gnupg bash procps git
@@ -8,12 +8,21 @@ RUN apt-get update && \
#RUN python -m venv venv && . venv/bin/activate
ARG pip_extra_index_url_flag='--index https://pypi.org/simple --extra-index-url https://pip.grassrootseconomics.net:8433'
ARG EXTRA_INDEX_URL="https://pip.grassrootseconomics.net:8433"
ARG GITLAB_PYTHON_REGISTRY="https://gitlab.com/api/v4/projects/27624814/packages/pypi/simple"
RUN /usr/local/bin/python -m pip install --upgrade pip
RUN pip install semver
# TODO use a packaging style that lets us copy requirments only ie. pip-tools
COPY cic-eth-aux/ ./cic-eth-aux/
WORKDIR /usr/src/cic-eth-aux/erc20-demurrage-token
RUN pip install --extra-index-url $GITLAB_PYTHON_REGISTRY \
--extra-index-url $EXTRA_INDEX_URL .
WORKDIR /usr/src/cic-eth
COPY cic-eth/ .
RUN pip install $pip_extra_index_url_flag .
RUN pip install --extra-index-url $GITLAB_PYTHON_REGISTRY \
--extra-index-url $EXTRA_INDEX_URL .[services]
# --- TEST IMAGE ---
FROM python:3.8.6-slim-buster as test
@@ -32,8 +41,12 @@ COPY --from=compile /usr/local/lib/python3.8/site-packages/ \
# COPY --from=compile /usr/src/cic-eth/ .
# RUN . venv/bin/activate
ARG EXTRA_INDEX_URL="https://pip.grassrootseconomics.net:8433"
ARG GITLAB_PYTHON_REGISTRY="https://gitlab.com/api/v4/projects/27624814/packages/pypi/simple"
COPY cic-eth/test_requirements.txt .
RUN pip install $pip_extra_index_url_flag -r test_requirements.txt
RUN pip install --extra-index-url $GITLAB_PYTHON_REGISTRY \
--extra-index-url $EXTRA_INDEX_URL -r test_requirements.txt
COPY cic-eth .

View File

@@ -8,7 +8,7 @@ set -e
echo "!!! starting signer"
python /usr/local/bin/crypto-dev-daemon -c /usr/local/etc/crypto-dev-signer -vv 2> /tmp/signer.log &
echo "!!! starting tracker"
echo "!!! starting taskerd"
/usr/local/bin/cic-eth-taskerd $@
# thanks! https://docs.docker.com/config/containers/multi-service_container/

View File

@@ -1,25 +1,3 @@
cic-base~=0.1.2b15
celery==4.4.7
crypto-dev-signer~=0.4.14b3
confini~=0.3.6rc3
cic-eth-registry~=0.5.5a7
redis==3.5.3
alembic==1.4.2
websockets==8.1
requests~=2.24.0
eth_accounts_index~=0.0.11a12
erc20-transfer-authorization~=0.3.1a7
uWSGI==2.0.19.1
chainlib~=0.0.5a1
semver==2.13.0
websocket-client==0.57.0
moolb~=0.1.1b2
eth-address-index~=0.1.1a11
chainlib~=0.0.3rc2
hexathon~=0.0.1a7
chainsyncer[sql]==0.0.2a5
chainqueue~=0.0.2b3
sarafu-faucet~=0.0.3a3
erc20-faucet~=0.2.1a5
coincurve==15.0.0
potaahto~=0.0.1a2
pycryptodome==3.10.1

View File

@@ -2,6 +2,8 @@
import os
import argparse
import logging
import re
import sys
import alembic
from alembic.config import Config as AlembicConfig
@@ -23,6 +25,8 @@ argparser = argparse.ArgumentParser()
argparser.add_argument('-c', type=str, default=config_dir, help='config file')
argparser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration')
argparser.add_argument('--migrations-dir', dest='migrations_dir', default=migrationsdir, type=str, help='path to alembic migrations directory')
argparser.add_argument('--reset', action='store_true', help='downgrade before upgrading')
argparser.add_argument('-f', action='store_true', help='force action')
argparser.add_argument('-v', action='store_true', help='be verbose')
argparser.add_argument('-vv', action='store_true', help='be more verbose')
args = argparser.parse_args()
@@ -53,4 +57,10 @@ ac = AlembicConfig(os.path.join(migrations_dir, 'alembic.ini'))
ac.set_main_option('sqlalchemy.url', dsn)
ac.set_main_option('script_location', migrations_dir)
if args.reset:
if not args.f:
if not re.match(r'[yY][eE]?[sS]?', input('EEK! this will DELETE the existing db. are you sure??')):
logg.error('user chickened out on requested reset, bailing')
sys.exit(1)
alembic.command.downgrade(ac, 'base')
alembic.command.upgrade(ac, 'head')

View File

@@ -0,0 +1,14 @@
chainsyncer[sql]~=0.0.3a3
chainqueue~=0.0.2b5
alembic==1.4.2
confini~=0.3.6rc4
redis==3.5.3
hexathon~=0.0.1a7
pycryptodome==3.10.1
liveness~=0.0.1a7
eth-address-index~=0.1.2a1
eth-accounts-index~=0.0.12a1
cic-eth-registry~=0.5.6a1
erc20-faucet~=0.2.2a1
sarafu-faucet~=0.0.4a1
moolb~=0.1.1b2

View File

@@ -39,22 +39,25 @@ packages =
cic_eth.callbacks
cic_eth.sync
cic_eth.check
# should be concealed behind extras "test" if possible (but its not unfortunately)
cic_eth.pytest
cic_eth.pytest.mock
scripts =
./scripts/migrate.py
[options.entry_points]
console_scripts =
# daemons
cic-eth-taskerd = cic_eth.runnable.daemons.tasker:main
cic-eth-trackerd = cic_eth.runnable.daemons.tracker:main
cic-eth-dispatcherd = cic_eth.runnable.daemons.dispatcher:main
cic-eth-retrierd = cic_eth.runnable.daemons.retry:main
cic-eth-taskerd = cic_eth.runnable.daemons.tasker:main [services]
cic-eth-trackerd = cic_eth.runnable.daemons.tracker:main [services]
cic-eth-dispatcherd = cic_eth.runnable.daemons.dispatcher:main [services]
cic-eth-retrierd = cic_eth.runnable.daemons.retry:main [services]
# tools
cic-eth-create = cic_eth.runnable.create:main
cic-eth-inspect = cic_eth.runnable.view:main
cic-eth-ctl = cic_eth.runnable.ctrl:main
cic-eth-info = cic_eth.runnable.info:main
cic-eth-create = cic_eth.runnable.create:main [tools]
cic-eth-inspect = cic_eth.runnable.view:main [tools]
cic-eth-ctl = cic_eth.runnable.ctrl:main [tools]
cic-eth-info = cic_eth.runnable.info:main [tools]
# TODO: Merge this with ctl when subcmds sorted to submodules
cic-eth-tag = cic_eth.runnable.tag:main
cic-eth-resend = cic_eth.runnable.resend:main
cic-eth-transfer = cic_eth.runnable.transfer:main
cic-eth-tag = cic_eth.runnable.tag:main [tools]
cic-eth-resend = cic_eth.runnable.resend:main [tools]
cic-eth-transfer = cic_eth.runnable.transfer:main [tools]

View File

@@ -11,6 +11,41 @@ while True:
requirements.append(l.rstrip())
f.close()
admin_requirements = []
f = open('admin_requirements.txt', 'r')
while True:
l = f.readline()
if l == '':
break
admin_requirements.append(l.rstrip())
f.close()
tools_requirements = []
f = open('tools_requirements.txt', 'r')
while True:
l = f.readline()
if l == '':
break
tools_requirements.append(l.rstrip())
f.close()
services_requirements = []
f = open('services_requirements.txt', 'r')
while True:
l = f.readline()
if l == '':
break
services_requirements.append(l.rstrip())
f.close()
setup(
install_requires=requirements
)
install_requires=requirements,
extras_require = {
'tools': tools_requirements,
'admin_api': admin_requirements,
'services': services_requirements,
}
)

View File

@@ -2,5 +2,8 @@ pytest==6.0.1
pytest-celery==0.0.0a1
pytest-mock==3.3.1
pytest-cov==2.10.1
pytest-redis==2.0.0
redis==3.5.3
eth-tester==0.5.0b3
py-evm==0.3.0a20
eth-erc20~=0.0.10a2

View File

@@ -5,6 +5,7 @@ import logging
import uuid
# external imports
import pytest
from eth_erc20 import ERC20
import redis
@@ -17,11 +18,12 @@ root_dir = os.path.dirname(script_dir)
sys.path.insert(0, root_dir)
# assemble fixtures
from tests.fixtures_config import *
from tests.fixtures_database import *
from tests.fixtures_celery import *
from tests.fixtures_role import *
from tests.fixtures_contract import *
from cic_eth.pytest.fixtures_config import *
from cic_eth.pytest.fixtures_celery import *
from cic_eth.pytest.fixtures_database import *
from cic_eth.pytest.fixtures_role import *
from cic_eth.pytest.fixtures_contract import *
from cic_eth.pytest.fixtures_token import *
from chainlib.eth.pytest import *
from eth_contract_registry.pytest import *
from cic_eth_registry.pytest.fixtures_contracts import *
@@ -37,20 +39,6 @@ def api(
return Api(chain_str, queue=None, callback_param='foo')
@pytest.fixture(scope='function')
def foo_token_symbol(
default_chain_spec,
foo_token,
eth_rpc,
contract_roles,
):
c = ERC20(default_chain_spec)
o = c.symbol(foo_token, sender_address=contract_roles['CONTRACT_DEPLOYER'])
r = eth_rpc.do(o)
return c.parse_symbol(r)
@pytest.fixture(scope='function')
def default_token(
foo_token,

View File

@@ -40,7 +40,7 @@ from chainqueue.sql.query import (
)
# local imports
from cic_eth.api import AdminApi
from cic_eth.api.admin import AdminApi
from cic_eth.db.models.role import AccountRole
from cic_eth.db.enum import LockEnum
from cic_eth.error import InitializationError

View File

@@ -35,7 +35,7 @@ from eth_erc20 import ERC20
from cic_eth_registry import CICRegistry
# local imports
from cic_eth.api.api_admin import AdminApi
from cic_eth.api.admin import AdminApi
from cic_eth.eth.gas import cache_gas_data
from cic_eth.eth.erc20 import cache_transfer_data

View File

@@ -15,7 +15,7 @@ from cic_eth.db.models.nonce import (
)
# test imports
from tests.mock.filter import (
from cic_eth.pytest.mock.filter import (
block_filter,
tx_filter,
)
@@ -110,7 +110,7 @@ def test_list_tx(
logg.debug('r {}'.format(r))
# test the api
t = api.list(agent_roles['ALICE'], external_task='tests.mock.filter.filter')
t = api.list(agent_roles['ALICE'], external_task='cic_eth.pytest.mock.filter.filter')
r = t.get_leaf()
assert t.successful()

View File

@@ -1 +1 @@
from tests.fixtures_celery import *
from cic_eth.pytest.fixtures_celery import *

View File

@@ -13,6 +13,7 @@ from chainlib.eth.tx import (
# local imports
from cic_eth.queue.tx import register_tx
from cic_eth.error import YouAreBrokeError
logg = logging.getLogger()
@@ -167,3 +168,101 @@ def test_erc20_approve_task(
r = t.get_leaf()
logg.debug('result {}'.format(r))
def test_erc20_transfer_from_task(
default_chain_spec,
foo_token,
agent_roles,
custodial_roles,
eth_signer,
eth_rpc,
init_database,
celery_session_worker,
token_roles,
):
token_object = {
'address': foo_token,
}
transfer_value = 100 * (10 ** 6)
nonce_oracle = RPCNonceOracle(token_roles['FOO_TOKEN_OWNER'], conn=eth_rpc)
c = ERC20(default_chain_spec, signer=eth_signer, nonce_oracle=nonce_oracle)
(tx_hash, o) = c.approve(foo_token, token_roles['FOO_TOKEN_OWNER'], agent_roles['ALICE'], transfer_value)
r = eth_rpc.do(o)
o = receipt(tx_hash)
r = eth_rpc.do(o)
assert r['status'] == 1
s_nonce = celery.signature(
'cic_eth.eth.nonce.reserve_nonce',
[
[token_object],
default_chain_spec.asdict(),
custodial_roles['FOO_TOKEN_GIFTER'],
],
queue=None,
)
s_transfer = celery.signature(
'cic_eth.eth.erc20.transfer_from',
[
custodial_roles['FOO_TOKEN_GIFTER'],
agent_roles['BOB'],
transfer_value,
default_chain_spec.asdict(),
agent_roles['ALICE'],
],
queue=None,
)
s_nonce.link(s_transfer)
t = s_nonce.apply_async()
r = t.get_leaf()
logg.debug('result {}'.format(r))
def test_erc20_allowance_check_task(
default_chain_spec,
foo_token,
agent_roles,
custodial_roles,
eth_signer,
eth_rpc,
init_database,
celery_session_worker,
token_roles,
):
token_object = {
'address': foo_token,
'symbol': 'FOO',
}
transfer_value = 100 * (10 ** 6)
s_check = celery.signature(
'cic_eth.eth.erc20.check_allowance',
[
[token_object],
custodial_roles['FOO_TOKEN_GIFTER'],
transfer_value,
default_chain_spec.asdict(),
agent_roles['ALICE']
],
queue=None,
)
t = s_check.apply_async()
with pytest.raises(YouAreBrokeError):
t.get()
nonce_oracle = RPCNonceOracle(token_roles['FOO_TOKEN_OWNER'], conn=eth_rpc)
c = ERC20(default_chain_spec, signer=eth_signer, nonce_oracle=nonce_oracle)
(tx_hash, o) = c.approve(foo_token, token_roles['FOO_TOKEN_OWNER'], agent_roles['ALICE'], transfer_value)
r = eth_rpc.do(o)
o = receipt(tx_hash)
r = eth_rpc.do(o)
assert r['status'] == 1
t = s_check.apply_async()
t.get()
assert t.successful()

View File

@@ -0,0 +1,8 @@
crypto-dev-signer~=0.4.14b6
chainqueue~=0.0.2b5
confini~=0.3.6rc4
cic-eth-registry~=0.5.6a1
redis==3.5.3
hexathon~=0.0.1a7
pycryptodome==3.10.1
pyxdg==0.27

View File

@@ -26,7 +26,7 @@ def get_sms_queue_tasks(app, task_prefix='cic_notify.tasks.sms.'):
for q in qs[host]:
if re.match(re_q, q['name']):
host_queues.append((host, q['name'],))
task_prefix_len = len(task_prefix)
queue_tasks = []
for (host, queue) in host_queues:
@@ -35,17 +35,18 @@ def get_sms_queue_tasks(app, task_prefix='cic_notify.tasks.sms.'):
for task in tasks:
if len(task) >= task_prefix_len and task[:task_prefix_len] == task_prefix:
queue_tasks.append((queue, task,))
return queue_tasks
class Api:
# TODO: Implement callback strategy
def __init__(self, queue='cic-notify'):
def __init__(self, queue=None):
"""
:param queue: The queue on which to execute notification tasks
:type queue: str
"""
self.queue = queue
self.sms_tasks = get_sms_queue_tasks(app)
logg.debug('sms tasks {}'.format(self.sms_tasks))
@@ -61,13 +62,19 @@ class Api:
"""
signatures = []
for q in self.sms_tasks:
if not self.queue:
queue = q[0]
else:
queue = self.queue
signature = celery.signature(
q[1],
[
message,
recipient,
],
queue=q[0],
queue=queue,
)
signatures.append(signature)

View File

@@ -9,7 +9,7 @@ import semver
logg = logging.getLogger()
version = (0, 4, 0, 'alpha.5')
version = (0, 4, 0, 'alpha.7')
version_object = semver.VersionInfo(
major=version[0],

View File

@@ -1 +1 @@
cic_base[full_graph]~=0.1.2a61
cic_base[full_graph]==0.1.3a3+build.984b5cff

View File

@@ -2,4 +2,3 @@ pytest~=6.0.1
pytest-celery~=0.0.0a1
pytest-mock~=3.3.1
pysqlite3~=0.4.3

View File

@@ -0,0 +1,9 @@
# standard import
from enum import IntEnum
class AccountStatus(IntEnum):
PENDING = 1
ACTIVE = 2
LOCKED = 3
RESET = 4

View File

@@ -1,19 +1,13 @@
# standard imports
from enum import IntEnum
# third party imports
from sqlalchemy import Column, Integer, String
# local imports
from cic_ussd.db.enum import AccountStatus
from cic_ussd.db.models.base import SessionBase
from cic_ussd.encoder import check_password_hash, create_password_hash
class AccountStatus(IntEnum):
PENDING = 1
ACTIVE = 2
LOCKED = 3
RESET = 4
# third party imports
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm.session import Session
class Account(SessionBase):
@@ -30,6 +24,21 @@ class Account(SessionBase):
account_status = Column(Integer)
preferred_language = Column(String)
@staticmethod
def get_by_phone_number(phone_number: str, session: Session):
"""Retrieves an account from a phone number.
:param phone_number: The E164 format of a phone number.
:type phone_number:str
:param session:
:type session:
:return: An account object.
:rtype: Account
"""
session = SessionBase.bind_session(session=session)
account = session.query(Account).filter_by(phone_number=phone_number).first()
SessionBase.release_session(session=session)
return account
def __init__(self, blockchain_address, phone_number):
self.blockchain_address = blockchain_address
self.phone_number = phone_number

View File

@@ -6,11 +6,13 @@ import logging
import celery
import i18n
from cic_eth.api.api_task import Api
from sqlalchemy.orm.session import Session
from tinydb.table import Document
from typing import Optional
# local imports
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.models.ussd_session import UssdSession
from cic_ussd.db.models.task_tracker import TaskTracker
from cic_ussd.menu.ussd_menu import UssdMenu
@@ -22,15 +24,18 @@ from cic_ussd.validator import check_known_user, validate_response_type
logg = logging.getLogger()
def add_tasks_to_tracker(task_uuid):
"""
This function takes tasks spawned over api interfaces and records their creation time for tracking.
def add_tasks_to_tracker(session, task_uuid: str):
"""This function takes tasks spawned over api interfaces and records their creation time for tracking.
:param session:
:type session:
:param task_uuid: The uuid for an initiated task.
:type task_uuid: str
"""
session = SessionBase.bind_session(session=session)
task_record = TaskTracker(task_uuid=task_uuid)
TaskTracker.session.add(task_record)
TaskTracker.session.commit()
session.add(task_record)
session.flush()
SessionBase.release_session(session=session)
def define_response_with_content(headers: list, response: str) -> tuple:
@@ -95,6 +100,7 @@ def create_or_update_session(
service_code: str,
user_input: str,
current_menu: str,
session,
session_data: Optional[dict] = None) -> InMemoryUssdSession:
"""
Handles the creation or updating of session as necessary.
@@ -108,12 +114,15 @@ def create_or_update_session(
:type user_input: str
:param current_menu: Menu name that is currently being displayed on the ussd session
:type current_menu: str
:param session:
:type session:
:param session_data: Any additional data that was persisted during the user's interaction with the system.
:type session_data: dict.
:return: ussd session object
:rtype: InMemoryUssdSession
"""
existing_ussd_session = UssdSession.session.query(UssdSession).filter_by(
session = SessionBase.bind_session(session=session)
existing_ussd_session = session.query(UssdSession).filter_by(
external_session_id=external_session_id).first()
if existing_ussd_session:
@@ -132,20 +141,25 @@ def create_or_update_session(
current_menu=current_menu,
session_data=session_data
)
SessionBase.release_session(session=session)
return ussd_session
def get_account_status(phone_number) -> str:
def get_account_status(phone_number, session: Session) -> str:
"""Get the status of a user's account.
:param phone_number: The phone number to be checked.
:type phone_number: str
:param session:
:type session:
:return: The user account status.
:rtype: str
"""
user = Account.session.query(Account).filter_by(phone_number=phone_number).first()
status = user.get_account_status()
Account.session.add(user)
Account.session.commit()
session = SessionBase.bind_session(session=session)
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
status = account.get_account_status()
session.add(account)
session.flush()
SessionBase.release_session(session=session)
return status
@@ -165,6 +179,7 @@ def initiate_account_creation_request(chain_str: str,
external_session_id: str,
phone_number: str,
service_code: str,
session,
user_input: str) -> str:
"""This function issues a task to create a blockchain account on cic-eth. It then creates a record of the ussd
session corresponding to the creation of the account and returns a response denoting that the user's account is
@@ -177,6 +192,8 @@ def initiate_account_creation_request(chain_str: str,
:type phone_number: str
:param service_code: The service code dialed.
:type service_code: str
:param session:
:type session:
:param user_input: The input entered by the user.
:type user_input: str
:return: A response denoting that the account is being created.
@@ -190,7 +207,7 @@ def initiate_account_creation_request(chain_str: str,
creation_task_id = cic_eth_api.create_account().id
# record task initiation time
add_tasks_to_tracker(task_uuid=creation_task_id)
add_tasks_to_tracker(task_uuid=creation_task_id, session=session)
# cache account creation data
cache_account_creation_task_id(phone_number=phone_number, task_id=creation_task_id)
@@ -204,6 +221,7 @@ def initiate_account_creation_request(chain_str: str,
phone=phone_number,
service_code=service_code,
current_menu=current_menu.get('name'),
session=session,
user_input=user_input)
# define response to relay to user
@@ -268,12 +286,14 @@ def cache_account_creation_task_id(phone_number: str, task_id: str):
redis_cache.persist(name=task_id)
def process_current_menu(ussd_session: Optional[dict], user: Account, user_input: str) -> Document:
def process_current_menu(account: Account, session: Session, ussd_session: Optional[dict], user_input: str) -> Document:
"""This function checks user input and returns a corresponding ussd menu
:param ussd_session: An in db ussd session object.
:type ussd_session: UssdSession
:param user: A user object.
:type user: Account
:param account: A account object.
:type account: Account
:param session:
:type session:
:param user_input: The user's input.
:type user_input: str
:return: An in memory ussd menu object.
@@ -285,7 +305,13 @@ def process_current_menu(ussd_session: Optional[dict], user: Account, user_input
else:
# get current state
latest_input = get_latest_input(user_input=user_input)
current_menu = process_request(ussd_session=ussd_session, user_input=latest_input, user=user)
session = SessionBase.bind_session(session=session)
current_menu = process_request(
account=account,
session=session,
ussd_session=ussd_session,
user_input=latest_input)
SessionBase.release_session(session=session)
return current_menu
@@ -294,6 +320,7 @@ def process_menu_interaction_requests(chain_str: str,
phone_number: str,
queue: str,
service_code: str,
session,
user_input: str) -> str:
"""This function handles requests intended for interaction with ussd menu, it checks whether a user matching the
provided phone number exists and in the absence of which it creates an account for the user.
@@ -308,25 +335,29 @@ def process_menu_interaction_requests(chain_str: str,
:type queue: str
:param service_code: The service dialed by the user making the request.
:type service_code: str
:param session:
:type session:
:param user_input: The inputs entered by the user.
:type user_input: str
:return: A response based on the request received.
:rtype: str
"""
# check whether the user exists
if not check_known_user(phone=phone_number):
if not check_known_user(phone_number=phone_number, session=session):
response = initiate_account_creation_request(chain_str=chain_str,
external_session_id=external_session_id,
phone_number=phone_number,
service_code=service_code,
session=session,
user_input=user_input)
else:
# get user
user = Account.session.query(Account).filter_by(phone_number=phone_number).first()
# get account
session = SessionBase.bind_session(session=session)
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
# retrieve and cache user's metadata
blockchain_address = user.blockchain_address
blockchain_address = account.blockchain_address
s_query_person_metadata = celery.signature(
'cic_ussd.tasks.metadata.query_person_metadata',
[blockchain_address]
@@ -334,24 +365,25 @@ def process_menu_interaction_requests(chain_str: str,
s_query_person_metadata.apply_async(queue='cic-ussd')
# find any existing ussd session
existing_ussd_session = UssdSession.session.query(UssdSession).filter_by(
external_session_id=external_session_id).first()
existing_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
# validate user inputs
if existing_ussd_session:
current_menu = process_current_menu(
account=account,
session=session,
ussd_session=existing_ussd_session.to_json(),
user=user,
user_input=user_input
)
else:
current_menu = process_current_menu(
account=account,
session=session,
ussd_session=None,
user=user,
user_input=user_input
)
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=user.phone_number)
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=account.phone_number, session=session)
if last_ussd_session:
# create or update the ussd session as appropriate
@@ -361,6 +393,7 @@ def process_menu_interaction_requests(chain_str: str,
service_code=service_code,
user_input=user_input,
current_menu=current_menu.get('name'),
session=session,
session_data=last_ussd_session.session_data
)
else:
@@ -369,15 +402,17 @@ def process_menu_interaction_requests(chain_str: str,
phone=phone_number,
service_code=service_code,
user_input=user_input,
current_menu=current_menu.get('name')
current_menu=current_menu.get('name'),
session=session
)
# define appropriate response
response = custom_display_text(
account=account,
display_key=current_menu.get('display_key'),
menu_name=current_menu.get('name'),
session=session,
ussd_session=ussd_session.to_json(),
user=user
)
# check that the response from the processor is valid
@@ -386,21 +421,26 @@ def process_menu_interaction_requests(chain_str: str,
# persist session to db
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
SessionBase.release_session(session=session)
return response
def reset_pin(phone_number: str) -> str:
def reset_pin(phone_number: str, session: Session) -> str:
"""Reset account status from Locked to Pending.
:param phone_number: The phone number belonging to the account to be unlocked.
:type phone_number: str
:param session:
:type session:
:return: The status of the pin reset.
:rtype: str
"""
user = Account.session.query(Account).filter_by(phone_number=phone_number).first()
user.reset_account_pin()
Account.session.add(user)
Account.session.commit()
session = SessionBase.bind_session(session=session)
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
account.reset_account_pin()
session.add(account)
session.flush()
SessionBase.release_session(session=session)
response = f'Pin reset for user {phone_number} is successful!'
return response
@@ -438,11 +478,13 @@ def update_ussd_session(
return session
def save_to_in_memory_ussd_session_data(queue: str, session_data: dict, ussd_session: dict):
def save_to_in_memory_ussd_session_data(queue: str, session: Session, session_data: dict, ussd_session: dict):
"""This function is used to save information to the session data attribute of a ussd session object in the redis
cache.
:param queue: The queue on which the celery task should run.
:type queue: str
:param session:
:type session:
:param session_data: A dictionary containing data for a specific ussd session in redis that needs to be saved
temporarily.
:type session_data: dict
@@ -473,7 +515,7 @@ def save_to_in_memory_ussd_session_data(queue: str, session_data: dict, ussd_ses
service_code=in_redis_ussd_session.get('service_code'),
user_input=in_redis_ussd_session.get('user_input'),
current_menu=in_redis_ussd_session.get('state'),
session=session,
session_data=session_data
)
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)

View File

@@ -33,19 +33,5 @@ def process_phone_number(phone_number: str, region: str):
return parsed_phone_number
def get_user_by_phone_number(phone_number: str) -> Optional[Account]:
"""This function queries the database for a user based on the provided phone number.
:param phone_number: A valid phone number.
:type phone_number: str
:return: A user object matching a given phone number
:rtype: Account|None
"""
# consider adding region to user's metadata
phone_number = process_phone_number(phone_number=phone_number, region=E164Format.region)
user = Account.session.query(Account).filter_by(phone_number=phone_number).first()
return user
class Support:
phone_number = None

View File

@@ -2,25 +2,26 @@
import datetime
import logging
import json
import re
from typing import Optional
# third party imports
import celery
from sqlalchemy import desc
from cic_eth.api import Api
from sqlalchemy.orm.session import Session
from tinydb.table import Document
# local imports
from cic_ussd.account import define_account_tx_metadata, retrieve_account_statement
from cic_ussd.balance import BalanceManager, compute_operational_balance, get_cached_operational_balance
from cic_ussd.chain import Chain
from cic_ussd.db.models.account import AccountStatus, Account
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.models.ussd_session import UssdSession
from cic_ussd.error import MetadataNotFoundError, SeppukuError
from cic_ussd.db.enum import AccountStatus
from cic_ussd.error import SeppukuError
from cic_ussd.menu.ussd_menu import UssdMenu
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
from cic_ussd.phone_number import get_user_by_phone_number, Support
from cic_ussd.phone_number import Support
from cic_ussd.redis import cache_data, create_cached_data_key, get_cached_data
from cic_ussd.state_machine import UssdStateMachine
from cic_ussd.conversions import to_wei, from_wei
@@ -62,47 +63,48 @@ def retrieve_token_symbol(chain_str: str = Chain.spec.__str__()):
raise SeppukuError(f'Could not retrieve default token for: {chain_str}')
def process_pin_authorization(display_key: str, user: Account, **kwargs) -> str:
"""
This method provides translation for all ussd menu entries that follow the pin authorization pattern.
def process_pin_authorization(account: Account, display_key: str, **kwargs) -> str:
"""This method provides translation for all ussd menu entries that follow the pin authorization pattern.
:param account: The account in a running USSD session.
:type account: Account
:param display_key: The path in the translation files defining an appropriate ussd response
:type display_key: str
:param user: The user in a running USSD session.
:type user: Account
:param kwargs: Any additional information required by the text values in the internationalization files.
:type kwargs
:return: A string value corresponding the ussd menu's text value.
:rtype: str
"""
remaining_attempts = 3
if user.failed_pin_attempts > 0:
if account.failed_pin_attempts > 0:
return translation_for(
key=f'{display_key}.retry',
preferred_language=user.preferred_language,
remaining_attempts=(remaining_attempts - user.failed_pin_attempts)
preferred_language=account.preferred_language,
remaining_attempts=(remaining_attempts - account.failed_pin_attempts)
)
else:
return translation_for(
key=f'{display_key}.first',
preferred_language=user.preferred_language,
preferred_language=account.preferred_language,
**kwargs
)
def process_exit_insufficient_balance(display_key: str, user: Account, ussd_session: dict):
def process_exit_insufficient_balance(account: Account, display_key: str, session: Session, ussd_session: dict):
"""This function processes the exit menu letting users their account balance is insufficient to perform a specific
transaction.
:param account: The account requesting access to the ussd menu.
:type account: Account
:param display_key: The path in the translation files defining an appropriate ussd response
:type display_key: str
:param user: The user requesting access to the ussd menu.
:type user: Account
:param session:
:type session:
:param ussd_session: A JSON serialized in-memory ussd session object
:type ussd_session: dict
:return: Corresponding translation text response
:rtype: str
"""
# get account balance
operational_balance = get_cached_operational_balance(blockchain_address=user.blockchain_address)
operational_balance = get_cached_operational_balance(blockchain_address=account.blockchain_address)
# compile response data
user_input = ussd_session.get('user_input').split('*')[-1]
@@ -112,13 +114,13 @@ def process_exit_insufficient_balance(display_key: str, user: Account, ussd_sess
token_symbol = retrieve_token_symbol()
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
tx_recipient_information = define_account_tx_metadata(user=recipient)
return translation_for(
key=display_key,
preferred_language=user.preferred_language,
preferred_language=account.preferred_language,
amount=from_wei(transaction_amount),
token_symbol=token_symbol,
recipient_information=tx_recipient_information,
@@ -126,12 +128,14 @@ def process_exit_insufficient_balance(display_key: str, user: Account, ussd_sess
)
def process_exit_successful_transaction(display_key: str, user: Account, ussd_session: dict):
def process_exit_successful_transaction(account: Account, display_key: str, session: Session, ussd_session: dict):
"""This function processes the exit menu after a successful initiation for a transfer of tokens.
:param account: The account requesting access to the ussd menu.
:type account: Account
:param display_key: The path in the translation files defining an appropriate ussd response
:type display_key: str
:param user: The user requesting access to the ussd menu.
:type user: Account
:param session:
:type session:
:param ussd_session: A JSON serialized in-memory ussd session object
:type ussd_session: dict
:return: Corresponding translation text response
@@ -140,13 +144,13 @@ def process_exit_successful_transaction(display_key: str, user: Account, ussd_se
transaction_amount = to_wei(int(ussd_session.get('session_data').get('transaction_amount')))
token_symbol = retrieve_token_symbol()
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
tx_recipient_information = define_account_tx_metadata(user=recipient)
tx_sender_information = define_account_tx_metadata(user=user)
tx_sender_information = define_account_tx_metadata(user=account)
return translation_for(
key=display_key,
preferred_language=user.preferred_language,
preferred_language=account.preferred_language,
transaction_amount=from_wei(transaction_amount),
token_symbol=token_symbol,
recipient_information=tx_recipient_information,
@@ -154,13 +158,15 @@ def process_exit_successful_transaction(display_key: str, user: Account, ussd_se
)
def process_transaction_pin_authorization(user: Account, display_key: str, ussd_session: dict):
def process_transaction_pin_authorization(account: Account, display_key: str, session: Session, ussd_session: dict):
"""This function processes pin authorization where making a transaction is concerned. It constructs a
pre-transaction response menu that shows the details of the transaction.
:param user: The user requesting access to the ussd menu.
:type user: Account
:param account: The account requesting access to the ussd menu.
:type account: Account
:param display_key: The path in the translation files defining an appropriate ussd response
:type display_key: str
:param session:
:type session:
:param ussd_session: The USSD session determining what user data needs to be extracted and added to the menu's
text values.
:type ussd_session: UssdSession
@@ -169,16 +175,16 @@ def process_transaction_pin_authorization(user: Account, display_key: str, ussd_
"""
# compile response data
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
tx_recipient_information = define_account_tx_metadata(user=recipient)
tx_sender_information = define_account_tx_metadata(user=user)
tx_sender_information = define_account_tx_metadata(user=account)
token_symbol = retrieve_token_symbol()
user_input = ussd_session.get('session_data').get('transaction_amount')
transaction_amount = to_wei(value=int(user_input))
logg.debug('Requires integration to determine user tokens.')
return process_pin_authorization(
user=user,
account=account,
display_key=display_key,
recipient_information=tx_recipient_information,
transaction_amount=from_wei(transaction_amount),
@@ -187,14 +193,12 @@ def process_transaction_pin_authorization(user: Account, display_key: str, ussd_
)
def process_account_balances(user: Account, display_key: str, ussd_session: dict):
def process_account_balances(user: Account, display_key: str):
"""
:param user:
:type user:
:param display_key:
:type display_key:
:param ussd_session:
:type ussd_session:
:return:
:rtype:
"""
@@ -290,19 +294,18 @@ def process_display_user_metadata(user: Account, display_key: str):
preferred_language=user.preferred_language,
full_name=absent,
gender=absent,
age=absent,
location=absent,
products=absent
)
def process_account_statement(user: Account, display_key: str, ussd_session: dict):
def process_account_statement(user: Account, display_key: str):
"""
:param user:
:type user:
:param display_key:
:type display_key:
:param ussd_session:
:type ussd_session:
:return:
:rtype:
"""
@@ -404,23 +407,26 @@ def process_start_menu(display_key: str, user: Account):
)
def retrieve_most_recent_ussd_session(phone_number: str) -> UssdSession:
def retrieve_most_recent_ussd_session(phone_number: str, session: Session) -> UssdSession:
# get last ussd session based on user phone number
last_ussd_session = UssdSession.session\
.query(UssdSession)\
session = SessionBase.bind_session(session=session)
last_ussd_session = session.query(UssdSession)\
.filter_by(msisdn=phone_number)\
.order_by(desc(UssdSession.created))\
.first()
SessionBase.release_session(session=session)
return last_ussd_session
def process_request(user_input: str, user: Account, ussd_session: Optional[dict] = None) -> Document:
def process_request(account: Account, session, user_input: str, ussd_session: Optional[dict] = None) -> Document:
"""This function assesses a request based on the user from the request comes, the session_id and the user's
input. It determines whether the request translates to a return to an existing session by checking whether the
provided session id exists in the database or whether the creation of a new ussd session object is warranted.
It then returns the appropriate ussd menu text values.
:param user: The user requesting access to the ussd menu.
:type user: Account
:param account: The account requesting access to the ussd menu.
:type account: Account
:param session:
:type session:
:param user_input: The value a user enters in the ussd menu.
:type user_input: str
:param ussd_session: A JSON serialized in-memory ussd session object
@@ -433,11 +439,15 @@ def process_request(user_input: str, user: Account, ussd_session: Optional[dict]
if user_input == "0":
return UssdMenu.parent_menu(menu_name=ussd_session.get('state'))
else:
successive_state = next_state(ussd_session=ussd_session, user=user, user_input=user_input)
successive_state = next_state(
account=account,
session=session,
ussd_session=ussd_session,
user_input=user_input)
return UssdMenu.find_by_name(name=successive_state)
else:
if user.has_valid_pin():
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=user.phone_number)
if account.has_valid_pin():
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=account.phone_number, session=session)
if last_ussd_session:
# get last state
@@ -456,28 +466,30 @@ def process_request(user_input: str, user: Account, ussd_session: Optional[dict]
else:
return UssdMenu.find_by_name(name=last_state)
else:
if user.failed_pin_attempts >= 3 and user.get_account_status() == AccountStatus.LOCKED.name:
if account.failed_pin_attempts >= 3 and account.get_account_status() == AccountStatus.LOCKED.name:
return UssdMenu.find_by_name(name='exit_pin_blocked')
elif user.preferred_language is None:
elif account.preferred_language is None:
return UssdMenu.find_by_name(name='initial_language_selection')
else:
return UssdMenu.find_by_name(name='initial_pin_entry')
def next_state(ussd_session: dict, user: Account, user_input: str) -> str:
def next_state(account: Account, session, ussd_session: dict, user_input: str) -> str:
"""This function navigates the state machine based on the ussd session object and user inputs it receives.
It checks the user input and provides the successive state in the state machine. It then updates the session's
state attribute with the new state.
:param account: The account requesting access to the ussd menu.
:type account: Account
:param session:
:type session:
:param ussd_session: A JSON serialized in-memory ussd session object
:type ussd_session: dict
:param user: The user requesting access to the ussd menu.
:type user: Account
:param user_input: The value a user enters in the ussd menu.
:type user_input: str
:return: A string value corresponding the successive give a specific state in the state machine.
"""
state_machine = UssdStateMachine(ussd_session=ussd_session)
state_machine.scan_data((user_input, ussd_session, user))
state_machine.scan_data((user_input, ussd_session, account, session))
new_state = state_machine.state
return new_state
@@ -492,42 +504,63 @@ def process_exit_invalid_menu_option(display_key: str, preferred_language: str):
def custom_display_text(
account: Account,
display_key: str,
menu_name: str,
ussd_session: dict,
user: Account) -> str:
session: Session,
ussd_session: dict) -> str:
"""This function extracts the appropriate session data based on the current menu name. It then inserts them as
keywords in the i18n function.
:param account: The account in a running USSD session.
:type account: Account
:param display_key: The path in the translation files defining an appropriate ussd response
:type display_key: str
:param menu_name: The name by which a specific menu can be identified.
:type menu_name: str
:param user: The user in a running USSD session.
:type user: Account
:param session:
:type session:
:param ussd_session: A JSON serialized in-memory ussd session object
:type ussd_session: dict
:return: A string value corresponding the ussd menu's text value.
:rtype: str
"""
if menu_name == 'transaction_pin_authorization':
return process_transaction_pin_authorization(display_key=display_key, user=user, ussd_session=ussd_session)
return process_transaction_pin_authorization(
account=account,
display_key=display_key,
session=session,
ussd_session=ussd_session)
elif menu_name == 'exit_insufficient_balance':
return process_exit_insufficient_balance(display_key=display_key, user=user, ussd_session=ussd_session)
return process_exit_insufficient_balance(
account=account,
display_key=display_key,
session=session,
ussd_session=ussd_session)
elif menu_name == 'exit_successful_transaction':
return process_exit_successful_transaction(display_key=display_key, user=user, ussd_session=ussd_session)
return process_exit_successful_transaction(
account=account,
display_key=display_key,
session=session,
ussd_session=ussd_session)
elif menu_name == 'start':
return process_start_menu(display_key=display_key, user=user)
return process_start_menu(display_key=display_key, user=account)
elif 'pin_authorization' in menu_name:
return process_pin_authorization(display_key=display_key, user=user)
return process_pin_authorization(
account=account,
display_key=display_key,
session=session)
elif 'enter_current_pin' in menu_name:
return process_pin_authorization(display_key=display_key, user=user)
return process_pin_authorization(
account=account,
display_key=display_key,
session=session)
elif menu_name == 'account_balances':
return process_account_balances(display_key=display_key, user=user, ussd_session=ussd_session)
return process_account_balances(display_key=display_key, user=account)
elif 'transaction_set' in menu_name:
return process_account_statement(display_key=display_key, user=user, ussd_session=ussd_session)
return process_account_statement(display_key=display_key, user=account)
elif menu_name == 'display_user_metadata':
return process_display_user_metadata(display_key=display_key, user=user)
return process_display_user_metadata(display_key=display_key, user=account)
elif menu_name == 'exit_invalid_menu_option':
return process_exit_invalid_menu_option(display_key=display_key, preferred_language=user.preferred_language)
return process_exit_invalid_menu_option(display_key=display_key, preferred_language=account.preferred_language)
else:
return translation_for(key=display_key, preferred_language=user.preferred_language)
return translation_for(key=display_key, preferred_language=account.preferred_language)

View File

@@ -8,9 +8,12 @@ from urllib.parse import urlparse, parse_qs
# third-party imports
from sqlalchemy import desc
from sqlalchemy.orm.session import Session
# local imports
from cic_ussd.db.models.account import AccountStatus, Account
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.enum import AccountStatus
from cic_ussd.operations import get_account_status, reset_pin
from cic_ussd.validator import check_known_user
@@ -72,24 +75,26 @@ def get_account_creation_callback_request_data(env: dict) -> tuple:
return status, task_id, result
def process_pin_reset_requests(env: dict, phone_number: str):
def process_pin_reset_requests(env: dict, phone_number: str, session: Session):
"""This function processes requests that are responsible for the pin reset functionality. It processes GET and PUT
requests responsible for returning an account's status and
:param env: A dictionary of values representing data sent on the api.
:type env: dict
:param phone_number: The phone of the user whose pin is being reset.
:type phone_number: str
:param session:
:type session:
:return: A response denoting the result of the request to reset the user's pin.
:rtype: str
"""
if not check_known_user(phone=phone_number):
if not check_known_user(phone_number=phone_number, session=session):
return f'No user matching {phone_number} was found.', '404 Not Found'
if get_request_method(env) == 'PUT':
return reset_pin(phone_number=phone_number), '200 OK'
return reset_pin(phone_number=phone_number, session=session), '200 OK'
if get_request_method(env) == 'GET':
status = get_account_status(phone_number=phone_number)
status = get_account_status(phone_number=phone_number, session=session)
response = {
'status': f'{status}'
}
@@ -97,16 +102,18 @@ def process_pin_reset_requests(env: dict, phone_number: str):
return response, '200 OK'
def process_locked_accounts_requests(env: dict) -> tuple:
def process_locked_accounts_requests(env: dict, session: Session) -> tuple:
"""This function authenticates staff requests and returns a serialized JSON formatted list of blockchain addresses
of accounts for which the PIN has been locked due to too many failed attempts.
:param env: A dictionary of values representing data sent on the api.
:type env: dict
:param session:
:type session:
:return: A tuple containing a serialized list of blockchain addresses for locked accounts and corresponding message
for the response.
:rtype: tuple
"""
logg.debug('Authentication requires integration with cic-auth')
session = SessionBase.bind_session(session=session)
response = ''
if get_request_method(env) == 'GET':
@@ -123,12 +130,14 @@ def process_locked_accounts_requests(env: dict) -> tuple:
else:
limit = r[1]
locked_accounts = Account.session.query(Account.blockchain_address).filter(
locked_accounts = session.query(Account.blockchain_address).filter(
Account.account_status == AccountStatus.LOCKED.value,
Account.failed_pin_attempts >= 3).order_by(desc(Account.updated)).offset(offset).limit(limit).all()
# convert lists to scalar blockchain addresses
locked_accounts = [blockchain_address for (blockchain_address, ) in locked_accounts]
SessionBase.release_session(session=session)
response = json.dumps(locked_accounts)
return response, '200 OK'
return response, '405 Play by the rules'

View File

@@ -36,11 +36,8 @@ logg.debug('config loaded from {}:\n{}'.format(args.c, config))
# set up db
data_source_name = dsn_from_config(config)
SessionBase.connect(data_source_name, pool_size=int(config.get('DATABASE_POOL_SIZE')), debug=config.true('DATABASE_DEBUG'))
# create session for the life time of http request
SessionBase.session = SessionBase.create_session()
# handle requests from CICADA
def application(env, start_response):
"""Loads python code for application to be accessible over web server
:param env: Object containing server and request information
@@ -55,19 +52,24 @@ def application(env, start_response):
errors_headers = [('Content-Type', 'text/plain'), ('Content-Length', '0')]
headers = [('Content-Type', 'text/plain')]
# create session for the life time of http request
session = SessionBase.create_session()
if get_request_endpoint(env) == '/pin':
phone_number = get_query_parameters(env=env, query_name='phoneNumber')
phone_number = quote_plus(phone_number)
response, message = process_pin_reset_requests(env=env, phone_number=phone_number)
response, message = process_pin_reset_requests(env=env, phone_number=phone_number, session=session)
response_bytes, headers = define_response_with_content(headers=errors_headers, response=response)
SessionBase.session.close()
session.commit()
session.close()
start_response(message, headers)
return [response_bytes]
# handle requests for locked accounts
response, message = process_locked_accounts_requests(env=env)
response, message = process_locked_accounts_requests(env=env, session=session)
response_bytes, headers = define_response_with_content(headers=headers, response=response)
start_response(message, headers)
SessionBase.session.close()
session.commit()
session.close()
return [response_bytes]

View File

@@ -127,6 +127,8 @@ def main():
argv.append('--loglevel=INFO')
argv.append('-Q')
argv.append(args.q)
argv.append('-n')
argv.append(args.q)
current_app.worker_main(argv)

View File

@@ -55,8 +55,6 @@ data_source_name = dsn_from_config(config)
SessionBase.connect(data_source_name,
pool_size=int(config.get('DATABASE_POOL_SIZE')),
debug=config.true('DATABASE_DEBUG'))
# create session for the life time of http request
SessionBase.session = SessionBase.create_session()
# set up translations
i18n.load_path.append(config.get('APP_LOCALE_PATH'))
@@ -143,6 +141,9 @@ def application(env, start_response):
errors_headers = [('Content-Type', 'text/plain'), ('Content-Length', '0')]
headers = [('Content-Type', 'text/plain')]
# create session for the life time of http request
session = SessionBase.create_session()
if get_request_method(env=env) == 'POST' and get_request_endpoint(env=env) == '/':
if env.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
@@ -206,17 +207,20 @@ def application(env, start_response):
phone_number=phone_number,
queue=args.q,
service_code=service_code,
session=session,
user_input=user_input)
response_bytes, headers = define_response_with_content(headers=headers, response=response)
start_response('200 OK,', headers)
SessionBase.session.close()
session.commit()
session.close()
return [response_bytes]
else:
logg.error('invalid query {}'.format(env))
for r in env:
logg.debug('{}: {}'.format(r, env))
session.close()
start_response('405 Play by the rules', errors_headers)
return []

View File

@@ -3,6 +3,7 @@ import logging
from typing import Tuple
# third-party imports
from sqlalchemy.orm.session import Session
# local imports
from cic_ussd.db.models.account import Account
@@ -10,11 +11,11 @@ from cic_ussd.db.models.account import Account
logg = logging.getLogger(__file__)
def process_mini_statement_request(state_machine_data: Tuple[str, dict, Account]):
def process_mini_statement_request(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function compiles a brief statement of a user's last three inbound and outbound transactions and send the
same as a message on their selected avenue for notification.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
logg.debug('This section requires integration with cic-eth. (The last 6 transactions would be sent as an sms.)')

View File

@@ -16,7 +16,7 @@ def menu_one_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A user input's match with '1'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '1'
@@ -27,7 +27,7 @@ def menu_two_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A user input's match with '2'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '2'
@@ -38,7 +38,7 @@ def menu_three_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A user input's match with '3'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '3'
@@ -50,7 +50,7 @@ def menu_four_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A user input's match with '4'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '4'
@@ -62,7 +62,7 @@ def menu_five_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A user input's match with '5'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '5'
@@ -74,7 +74,7 @@ def menu_six_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A user input's match with '6'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '6'
@@ -86,7 +86,7 @@ def menu_zero_zero_selected(state_machine_data: Tuple[str, dict, Account]) -> bo
:return: A user input's match with '00'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '00'
@@ -98,5 +98,5 @@ def menu_ninety_nine_selected(state_machine_data: Tuple[str, dict, Account]) ->
:return: A user input's match with '99'
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user_input == '99'

View File

@@ -9,11 +9,13 @@ import re
from typing import Tuple
# third party imports
import bcrypt
from sqlalchemy.orm.session import Session
# local imports
from cic_ussd.db.models.account import AccountStatus, Account
from cic_ussd.encoder import PasswordEncoder, create_password_hash, check_password_hash
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.enum import AccountStatus
from cic_ussd.encoder import create_password_hash, check_password_hash
from cic_ussd.operations import persist_session_to_db_task, create_or_update_session
from cic_ussd.redis import InMemoryStore
@@ -21,7 +23,7 @@ from cic_ussd.redis import InMemoryStore
logg = logging.getLogger(__file__)
def is_valid_pin(state_machine_data: Tuple[str, dict, Account]) -> bool:
def is_valid_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks a pin's validity by ensuring it has a length of for characters and the characters are
numeric.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
@@ -29,7 +31,7 @@ def is_valid_pin(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A pin's validity
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
pin_is_valid = False
matcher = r'^\d{4}$'
if re.match(matcher, user_input):
@@ -37,34 +39,34 @@ def is_valid_pin(state_machine_data: Tuple[str, dict, Account]) -> bool:
return pin_is_valid
def is_authorized_pin(state_machine_data: Tuple[str, dict, Account]) -> bool:
def is_authorized_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks whether the user input confirming a specific pin matches the initial pin entered.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
:return: A match between two pin values.
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user.verify_password(password=user_input)
def is_locked_account(state_machine_data: Tuple[str, dict, Account]) -> bool:
def is_locked_account(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks whether a user's account is locked due to too many failed attempts.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
:return: A match between two pin values.
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user.get_account_status() == AccountStatus.LOCKED.name
def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, Account]):
def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function hashes a pin and stores it in session data.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
# define redis cache entry point
cache = InMemoryStore.cache
@@ -93,54 +95,56 @@ def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, Accoun
service_code=in_redis_ussd_session.get('service_code'),
user_input=user_input,
current_menu=in_redis_ussd_session.get('state'),
session=session,
session_data=session_data
)
persist_session_to_db_task(external_session_id=external_session_id, queue='cic-ussd')
def pins_match(state_machine_data: Tuple[str, dict, Account]) -> bool:
def pins_match(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks whether the user input confirming a specific pin matches the initial pin entered.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
:return: A match between two pin values.
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
initial_pin = ussd_session.get('session_data').get('initial_pin')
logg.debug(f'USSD SESSION: {ussd_session}')
return check_password_hash(user_input, initial_pin)
def complete_pin_change(state_machine_data: Tuple[str, dict, Account]):
def complete_pin_change(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function persists the user's pin to the database
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
session = SessionBase.bind_session(session=session)
password_hash = ussd_session.get('session_data').get('initial_pin')
user.password_hash = password_hash
Account.session.add(user)
Account.session.commit()
session.add(user)
session.flush()
SessionBase.release_session(session=session)
def is_blocked_pin(state_machine_data: Tuple[str, dict, Account]) -> bool:
def is_blocked_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks whether the user input confirming a specific pin matches the initial pin entered.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
:return: A match between two pin values.
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
return user.get_account_status() == AccountStatus.LOCKED.name
def is_valid_new_pin(state_machine_data: Tuple[str, dict, Account]) -> bool:
def is_valid_new_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks whether the user's new pin is a valid pin and that it isn't the same as the old one.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
:return: A match between two pin values.
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
is_old_pin = user.verify_password(password=user_input)
return is_valid_pin(state_machine_data=state_machine_data) and not is_old_pin

View File

@@ -9,15 +9,15 @@ logg = logging.getLogger()
def send_terms_to_user_if_required(state_machine_data: Tuple[str, dict, Account]):
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
logg.debug('Requires integration to cic-notify.')
def process_mini_statement_request(state_machine_data: Tuple[str, dict, Account]):
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
logg.debug('Requires integration to cic-notify.')
def upsell_unregistered_recipient(state_machine_data: Tuple[str, dict, Account]):
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
logg.debug('Requires integration to cic-notify.')

View File

@@ -5,13 +5,16 @@ from typing import Tuple
# third party imports
import celery
from sqlalchemy.orm.session import Session
# local imports
from cic_ussd.balance import BalanceManager, compute_operational_balance
from cic_ussd.balance import compute_operational_balance
from cic_ussd.chain import Chain
from cic_ussd.db.models.account import AccountStatus, Account
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.enum import AccountStatus
from cic_ussd.operations import save_to_in_memory_ussd_session_data
from cic_ussd.phone_number import get_user_by_phone_number, process_phone_number, E164Format
from cic_ussd.phone_number import process_phone_number, E164Format
from cic_ussd.processor import retrieve_token_symbol
from cic_ussd.redis import create_cached_data_key, get_cached_data
from cic_ussd.transactions import OutgoingTransactionProcessor
@@ -20,7 +23,7 @@ from cic_ussd.transactions import OutgoingTransactionProcessor
logg = logging.getLogger(__file__)
def is_valid_recipient(state_machine_data: Tuple[str, dict, Account]) -> bool:
def is_valid_recipient(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks that a user exists, is not the initiator of the transaction, has an active account status
and is authorized to perform standard transactions.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
@@ -28,15 +31,17 @@ def is_valid_recipient(state_machine_data: Tuple[str, dict, Account]) -> bool:
:return: A user's validity
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
phone_number = process_phone_number(user_input, E164Format.region)
recipient = get_user_by_phone_number(phone_number=user_input)
session = SessionBase.bind_session(session=session)
recipient = Account.get_by_phone_number(phone_number=phone_number, session=session)
SessionBase.release_session(session=session)
is_not_initiator = phone_number != user.phone_number
has_active_account_status = user.get_account_status() == AccountStatus.ACTIVE.name
return is_not_initiator and has_active_account_status and recipient is not None
def is_valid_transaction_amount(state_machine_data: Tuple[str, dict, Account]) -> bool:
def is_valid_transaction_amount(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks that the transaction amount provided is valid as per the criteria for the transaction
being attempted.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
@@ -44,14 +49,14 @@ def is_valid_transaction_amount(state_machine_data: Tuple[str, dict, Account]) -
:return: A transaction amount's validity
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
try:
return int(user_input) > 0
except ValueError:
return False
def has_sufficient_balance(state_machine_data: Tuple[str, dict, Account]) -> bool:
def has_sufficient_balance(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
"""This function checks that the transaction amount provided is valid as per the criteria for the transaction
being attempted.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
@@ -59,10 +64,7 @@ def has_sufficient_balance(state_machine_data: Tuple[str, dict, Account]) -> boo
:return: An account balance's validity
:rtype: bool
"""
user_input, ussd_session, user = state_machine_data
balance_manager = BalanceManager(address=user.blockchain_address,
chain_str=Chain.spec.__str__(),
token_symbol='SRF')
user_input, ussd_session, user, session = state_machine_data
# get cached balance
key = create_cached_data_key(
identifier=bytes.fromhex(user.blockchain_address[2:]),
@@ -74,30 +76,37 @@ def has_sufficient_balance(state_machine_data: Tuple[str, dict, Account]) -> boo
return int(user_input) <= operational_balance
def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, Account]):
def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function saves the phone number corresponding the intended recipients blockchain account.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
session_data = ussd_session.get('session_data') or {}
session_data['recipient_phone_number'] = user_input
recipient_phone_number = process_phone_number(phone_number=user_input, region=E164Format.region)
session_data['recipient_phone_number'] = recipient_phone_number
save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
save_to_in_memory_ussd_session_data(
queue='cic-ussd',
session=session,
session_data=session_data,
ussd_session=ussd_session)
def retrieve_recipient_metadata(state_machine_data: Tuple[str, dict, Account]):
def retrieve_recipient_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
"""
:param state_machine_data:
:type state_machine_data:
:return:
:rtype:
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
recipient = get_user_by_phone_number(phone_number=user_input)
recipient_phone_number = process_phone_number(phone_number=user_input, region=E164Format.region)
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
blockchain_address = recipient.blockchain_address
# retrieve and cache account's metadata
s_query_person_metadata = celery.signature(
'cic_ussd.tasks.metadata.query_person_metadata',
@@ -106,32 +115,36 @@ def retrieve_recipient_metadata(state_machine_data: Tuple[str, dict, Account]):
s_query_person_metadata.apply_async(queue='cic-ussd')
def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict, Account]):
def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function saves the phone number corresponding the intended recipients blockchain account.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
session_data = ussd_session.get('session_data') or {}
session_data['transaction_amount'] = user_input
save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
save_to_in_memory_ussd_session_data(
queue='cic-ussd',
session=session,
session_data=session_data,
ussd_session=ussd_session)
def process_transaction_request(state_machine_data: Tuple[str, dict, Account]):
def process_transaction_request(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function saves the phone number corresponding the intended recipients blockchain account.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
# retrieve token symbol
chain_str = Chain.spec.__str__()
# get user from phone number
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
to_address = recipient.blockchain_address
from_address = user.blockchain_address
amount = int(ussd_session.get('session_data').get('transaction_amount'))

View File

@@ -5,12 +5,14 @@ from typing import Tuple
# third-party imports
import celery
from cic_types.models.person import Person, generate_metadata_pointer
from cic_types.models.person import generate_metadata_pointer
from cic_types.models.person import generate_vcard_from_contact_data, manage_identity_data
from sqlalchemy.orm.session import Session
# local imports
from cic_ussd.chain import Chain
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
from cic_ussd.error import MetadataNotFoundError
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
from cic_ussd.operations import save_to_in_memory_ussd_session_data
@@ -19,15 +21,17 @@ from cic_ussd.redis import get_cached_data
logg = logging.getLogger(__file__)
def change_preferred_language_to_en(state_machine_data: Tuple[str, dict, Account]):
def change_preferred_language_to_en(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function changes the user's preferred language to english.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
session = SessionBase.bind_session(session=session)
user.preferred_language = 'en'
Account.session.add(user)
Account.session.commit()
session.add(user)
session.flush()
SessionBase.release_session(session=session)
preferences_data = {
'preferred_language': 'en'
@@ -40,15 +44,17 @@ def change_preferred_language_to_en(state_machine_data: Tuple[str, dict, Account
s.apply_async(queue='cic-ussd')
def change_preferred_language_to_sw(state_machine_data: Tuple[str, dict, Account]):
def change_preferred_language_to_sw(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function changes the user's preferred language to swahili.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
"""
user_input, ussd_session, user = state_machine_data
user.preferred_language = 'sw'
Account.session.add(user)
Account.session.commit()
user_input, ussd_session, account, session = state_machine_data
session = SessionBase.bind_session(session=session)
account.preferred_language = 'sw'
session.add(account)
session.flush()
SessionBase.release_session(session=session)
preferences_data = {
'preferred_language': 'sw'
@@ -56,20 +62,22 @@ def change_preferred_language_to_sw(state_machine_data: Tuple[str, dict, Account
s = celery.signature(
'cic_ussd.tasks.metadata.add_preferences_metadata',
[user.blockchain_address, preferences_data]
[account.blockchain_address, preferences_data]
)
s.apply_async(queue='cic-ussd')
def update_account_status_to_active(state_machine_data: Tuple[str, dict, Account]):
def update_account_status_to_active(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function sets user's account to active.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
"""
user_input, ussd_session, user = state_machine_data
user.activate_account()
Account.session.add(user)
Account.session.commit()
user_input, ussd_session, account, session = state_machine_data
session = SessionBase.bind_session(session=session)
account.activate_account()
session.add(account)
session.flush()
SessionBase.release_session(session=session)
def process_gender_user_input(user: Account, user_input: str):
@@ -81,6 +89,7 @@ def process_gender_user_input(user: Account, user_input: str):
:return:
:rtype:
"""
gender = ""
if user.preferred_language == 'en':
if user_input == '1':
gender = 'Male'
@@ -98,13 +107,13 @@ def process_gender_user_input(user: Account, user_input: str):
return gender
def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict, Account]):
def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function saves first name data to the ussd session in the redis cache.
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
session = SessionBase.bind_session(session=session)
# get current menu
current_state = ussd_session.get('state')
@@ -137,7 +146,11 @@ def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict,
session_data = {
key: user_input
}
save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
save_to_in_memory_ussd_session_data(
queue='cic-ussd',
session=session,
session_data=session_data,
ussd_session=ussd_session)
def format_user_metadata(metadata: dict, user: Account):
@@ -197,12 +210,12 @@ def format_user_metadata(metadata: dict, user: Account):
}
def save_complete_user_metadata(state_machine_data: Tuple[str, dict, Account]):
def save_complete_user_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
"""This function persists elements of the user metadata stored in session data
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
# get session data
metadata = ussd_session.get('session_data')
@@ -218,8 +231,8 @@ def save_complete_user_metadata(state_machine_data: Tuple[str, dict, Account]):
s_create_person_metadata.apply_async(queue='cic-ussd')
def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, Account]):
user_input, ussd_session, user = state_machine_data
def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, Account, Session]):
user_input, ussd_session, user, session = state_machine_data
blockchain_address = user.blockchain_address
key = generate_metadata_pointer(
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
@@ -269,8 +282,8 @@ def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, Account]):
s_edit_person_metadata.apply_async(queue='cic-ussd')
def get_user_metadata(state_machine_data: Tuple[str, dict, Account]):
user_input, ussd_session, user = state_machine_data
def get_user_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
user_input, ussd_session, user, session = state_machine_data
blockchain_address = user.blockchain_address
s_get_user_metadata = celery.signature(
'cic_ussd.tasks.metadata.query_person_metadata',

View File

@@ -19,7 +19,7 @@ def has_cached_user_metadata(state_machine_data: Tuple[str, dict, Account]):
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
# check for user metadata in cache
key = generate_metadata_pointer(
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
@@ -34,7 +34,7 @@ def is_valid_name(state_machine_data: Tuple[str, dict, Account]):
:param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
name_matcher = "^[a-zA-Z]+$"
valid_name = re.match(name_matcher, user_input)
if valid_name:
@@ -50,7 +50,7 @@ def is_valid_gender_selection(state_machine_data: Tuple[str, dict, Account]):
:return:
:rtype:
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
selection_matcher = "^[1-2]$"
if re.match(selection_matcher, user_input):
return True
@@ -65,6 +65,6 @@ def is_valid_date(state_machine_data: Tuple[str, dict, Account]):
:return:
:rtype:
"""
user_input, ussd_session, user = state_machine_data
user_input, ussd_session, user, session = state_machine_data
# For MVP this value is defaulting to year
return len(user_input) == 4 and int(user_input) >= 1900

View File

@@ -9,6 +9,7 @@ from confini import Config
# local imports
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
logg = logging.getLogger(__file__)
@@ -45,29 +46,21 @@ def check_request_content_length(config: Config, env: dict):
config.get('APP_MAX_BODY_LENGTH'))
def check_known_user(phone: str):
"""
This method attempts to ascertain whether the user already exists and is known to the system.
def check_known_user(phone_number: str, session):
"""This method attempts to ascertain whether the user already exists and is known to the system.
It sends a get request to the platform application and attempts to retrieve the user's data which it persists in
memory.
:param phone: A valid phone number
:type phone: str
:param phone_number: A valid phone number
:type phone_number: str
:param session:
:type session:
:return: Is known phone number
:rtype: boolean
"""
user = Account.session.query(Account).filter_by(phone_number=phone).first()
return user is not None
def check_phone_number(number: str):
"""
Checks whether phone number is present
:param number: A valid phone number
:type number: str
:return: Phone number presence
:rtype: boolean
"""
return number is not None
session = SessionBase.bind_session(session=session)
account = session.query(Account).filter_by(phone_number=phone_number).first()
SessionBase.release_session(session=session)
return account is not None
def check_request_method(env: dict):

View File

@@ -1,4 +1,4 @@
cic_base[full_graph]~=0.1.2b21
cic-eth~=0.11.0b16
cic-notify~=0.4.0a5
cic_base[full_graph]==0.1.3a3+build.984b5cff
cic-eth~=0.12.0a1
cic-notify~=0.4.0a7
cic-types~=0.1.0a11

View File

@@ -8,4 +8,4 @@ pytest-mock==3.3.1
pytest-ordering==0.6
pytest-redis==2.0.0
requests-mock==1.8.0
tavern==1.14.2
tavern==1.14.2

View File

@@ -124,46 +124,6 @@ def second_profile_management_session_id() -> str:
return session_id()
@pytest.fixture(scope='session')
def first_account_change_given_name() -> str:
return fake.first_name()
@pytest.fixture(scope='session')
def second_account_change_given_name() -> str:
return fake.first_name()
@pytest.fixture(scope='session')
def first_account_change_family_name() -> str:
return fake.last_name()
@pytest.fixture(scope='session')
def second_account_change_family_name() -> str:
return fake.last_name()
@pytest.fixture(scope='session')
def first_account_change_location() -> str:
return fake.city()
@pytest.fixture(scope='session')
def second_account_change_location() -> str:
return fake.city()
@pytest.fixture(scope='session')
def first_account_change_product() -> str:
return fake.color_name()
@pytest.fixture(scope='session')
def second_account_change_product() -> str:
return fake.color_name()
@pytest.fixture(scope='session')
def first_profile_management_session_id_1() -> str:
return session_id()

View File

@@ -0,0 +1,25 @@
# INTEGRATION TESTING
This folder contains integration tests.
## OVERVIEW
There are four files defining the integration tests.
* **test_account_creation**: Tests account sign up process.
* **test_transactions**: Tests transactions between two accounts.
* **test_profile_management**: Tests that account metadata can be edited.
* **test_account_management**: Tests that account management functionalities are intact.
## REQUIREMENTS
In order to run the transaction tests, please ensure that the faucet amount is set to a non-zero value, ideally `50000000`
which is the value set in the config file `.config/test/integration.ini`.
This implies setting the `DEV_FAUCET_AMOUNT` to a non-zero value before bringing up the contract-migration image:
```shell
export DEV_FAUCET_AMOUNT=50000000
RUN_MASK=1 docker-compose up contract-migration
RUN_MASK=2 docker-compose up contract-migration
```

View File

@@ -214,12 +214,13 @@ stages:
status_code:
- 200
headers:
Content-Length: '28'
Content-Length: '51'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Enter first name\n0. Back"
expected_response: "CON Balance {gift_value} {token_symbol}\n1. Send\n2. My Account\n3. Help"
delay_before: 10
- name: Pin number confirmation [{second_account_pin_number} - second account]
request:
@@ -232,227 +233,6 @@ stages:
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '37'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Weka jina lako la kwanza\n0. Nyuma"
- name: Enter first name [first_account_given_name - first account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{first_metadata_entry_session_id}"
phoneNumber: "{first_account_phone_number}"
text: "1*{first_account_pin_number}*{first_account_pin_number}*{first_account_given_name}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '29'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Enter family name\n0. Back"
- name: Enter first name [second_account_given_name - second account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{second_metadata_entry_session_id}"
phoneNumber: "{second_account_phone_number}"
text: "2*{second_account_pin_number}*{second_account_pin_number}*{second_account_given_name}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '37'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Weka jina lako la mwisho\n0. Nyuma"
- name: Enter last name [first_account_family_name - first account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{first_metadata_entry_session_id}"
phoneNumber: "{first_account_phone_number}"
text: "1*{first_account_pin_number}*{first_account_pin_number}*{first_account_given_name}*{first_account_family_name}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '51'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Enter gender\n1. Male\n2. Female\n3. Other\n0. Back"
- name: Enter last name [second_account_family_name - second account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{second_metadata_entry_session_id}"
phoneNumber: "{second_account_phone_number}"
text: "2*{second_account_pin_number}*{second_account_pin_number}*{second_account_given_name}*{second_account_family_name}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '64'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Weka jinsia yako\n1. Mwanaume\n2. Mwanamke\n3. Nyngine\n0. Nyuma"
- name: Select gender [Male - first account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{first_metadata_entry_session_id}"
phoneNumber: "{first_account_phone_number}"
text: "1*{first_account_pin_number}*{first_account_pin_number}*{first_account_given_name}*{first_account_family_name}*1"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '31'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Enter your location\n0. Back"
- name: Select gender [Female - second account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{second_metadata_entry_session_id}"
phoneNumber: "{second_account_phone_number}"
text: "2*{second_account_pin_number}*{second_account_pin_number}*{second_account_given_name}*{second_account_family_name}*2"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '27'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Weka eneo lako\n0. Nyuma"
- name: Enter location [first_account_location - first account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{first_metadata_entry_session_id}"
phoneNumber: "{first_account_phone_number}"
text: "1*{first_account_pin_number}*{first_account_pin_number}*{first_account_given_name}*{first_account_family_name}*1*{first_account_location}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '55'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Please enter a product or service you offer\n0. Back"
- name: Enter location [second_account_location - second account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{second_metadata_entry_session_id}"
phoneNumber: "{second_account_phone_number}"
text: "2*{second_account_pin_number}*{second_account_pin_number}*{second_account_given_name}*{second_account_family_name}*2*{second_account_location}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '42'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Weka bidhaa ama huduma unauza\n0. Nyuma"
- name: Enter product [first_account_product - first account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{first_metadata_entry_session_id}"
phoneNumber: "{first_account_phone_number}"
text: "1*{first_account_pin_number}*{first_account_pin_number}*{first_account_given_name}*{first_account_family_name}*1*{first_account_location}*{first_account_product}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200
headers:
Content-Length: '51'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Balance {gift_value} {token_symbol}\n1. Send\n2. My Account\n3. Help"
delay_before: 10
- name: Enter product [second_account_product - second account]
request:
url: "{server_url}"
data:
serviceCode: "*483*46#"
sessionId: "{second_metadata_entry_session_id}"
phoneNumber: "{second_account_phone_number}"
text: "2*{second_account_pin_number}*{second_account_pin_number}*{second_account_given_name}*{second_account_family_name}*2*{second_account_location}*{second_account_product}"
headers:
content-type: "application/x-www-form-urlencoded"
method: POST
response:
status_code:
- 200

View File

@@ -31,7 +31,6 @@ stages:
status_code:
- 200
headers:
Content-Length: '51'
Content-Type: "text/plain"
verify_response_with:
function: ext.validator:validate_response

View File

@@ -170,7 +170,7 @@ stages:
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON {second_account_given_name} {second_account_family_name} {second_account_phone_number} will receive 17.00 {token_symbol} from {first_account_given_name} {first_account_family_name} {first_account_phone_number}.\nPlease enter your PIN to confirm.\n0. Back"
expected_response: "CON {second_account_phone_number} will receive 17.00 {token_symbol} from {first_account_phone_number}.\nPlease enter your PIN to confirm.\n0. Back"
- name: Enter transcation amount [second account]
request:
@@ -191,7 +191,7 @@ stages:
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON {first_account_given_name} {first_account_family_name} {first_account_phone_number} atapokea 25.00 {token_symbol} kutoka kwa {second_account_given_name} {second_account_family_name} {second_account_phone_number}.\nTafadhali weka nambari yako ya siri kudhibitisha.\n0. Nyuma"
expected_response: "CON {first_account_phone_number} atapokea 25.00 {token_symbol} kutoka kwa {second_account_phone_number}.\nTafadhali weka nambari yako ya siri kudhibitisha.\n0. Nyuma"
- name: Pin to authorize transaction [first account]
request:
@@ -212,7 +212,7 @@ stages:
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Your request has been sent. {second_account_given_name} {second_account_family_name} {second_account_phone_number} will receive 17.00 {token_symbol} from {first_account_given_name} {first_account_family_name} {first_account_phone_number}.\n00. Back\n99. Exit"
expected_response: "CON Your request has been sent. {second_account_phone_number} will receive 17.00 {token_symbol} from {first_account_phone_number}.\n00. Back\n99. Exit"
- name: Pin to authorize transaction [second account]
request:
@@ -233,7 +233,7 @@ stages:
verify_response_with:
function: ext.validator:validate_response
extra_kwargs:
expected_response: "CON Ombi lako limetumwa. {first_account_given_name} {first_account_family_name} {first_account_phone_number} atapokea 25.00 {token_symbol} kutoka kwa {second_account_given_name} {second_account_family_name} {second_account_phone_number}.\n00. Nyuma\n99. Ondoka"
expected_response: "CON Ombi lako limetumwa. {first_account_phone_number} atapokea 25.00 {token_symbol} kutoka kwa {second_account_phone_number}.\n00. Nyuma\n99. Ondoka"
- name: Verify balance changes [first account]
delay_before: 10

View File

@@ -12,7 +12,7 @@
{
"trigger": "scan_data",
"source": "enter_date_of_birth",
"dest": "enter_gender",
"dest": "enter_location",
"conditions": "cic_ussd.state_machine.logic.validator.is_valid_date",
"after": "cic_ussd.state_machine.logic.user.save_metadata_attribute_to_session_data",
"unless": "cic_ussd.state_machine.logic.validator.has_cached_user_metadata"

View File

@@ -2,7 +2,7 @@
{
"trigger": "scan_data",
"source": "enter_gender",
"dest": "enter_location",
"dest": "enter_date_of_birth",
"after": "cic_ussd.state_machine.logic.user.save_metadata_attribute_to_session_data",
"conditions": "cic_ussd.state_machine.logic.validator.is_valid_gender_selection",
"unless": "cic_ussd.state_machine.logic.validator.has_cached_user_metadata"

View File

@@ -15,7 +15,7 @@
{
"trigger": "scan_data",
"source": "enter_family_name",
"dest": "enter_date_of_birth",
"dest": "enter_gender",
"after": "cic_ussd.state_machine.logic.user.save_metadata_attribute_to_session_data",
"unless": "cic_ussd.state_machine.logic.validator.has_cached_user_metadata"
},

View File

@@ -55,7 +55,7 @@ en:
CON My profile
1. Edit name
2. Edit gender
3. Edit Age
3. Edit age
4. Edit location
5. Edit products
6. View my profile

View File

@@ -55,7 +55,7 @@ sw:
CON Wasifu wangu
1. Weka jina
2. Weka jinsia
3 Weka umri
3. Weka umri
4. Weka eneo
5. Weka bidhaa
6. Angalia wasifu wako

View File

@@ -1,7 +1,7 @@
.contract_migration_variables:
variables:
APP_NAME: contract-migration
DOCKERFILE_PATH: $APP_NAME/docker/Dockerfile
DOCKERFILE_PATH: $APP_NAME/docker/Dockerfile_ci
.contract_migration_changes_target:
rules:

View File

@@ -1,28 +1,22 @@
# syntax = docker/dockerfile:1.2
FROM python:3.8.6-slim-buster as compile-image
FROM registry.gitlab.com/grassrootseconomics/cic-base-images:python-3.8.6-dev-55da5f4e
RUN apt-get update
RUN apt-get install -y --no-install-recommends git gcc g++ libpq-dev gawk jq telnet wget openssl iputils-ping gnupg socat bash procps make python2 cargo
WORKDIR /root
RUN touch /etc/apt/sources.list.d/ethereum.list
RUN echo 'deb http://ppa.launchpad.net/ethereum/ethereum/ubuntu bionic main' > /etc/apt/sources.list.d/ethereum.list
RUN echo 'deb-src http://ppa.launchpad.net/ethereum/ethereum/ubuntu bionic main' >> /etc/apt/sources.list.d/ethereum.list
RUN cat etc/apt/sources.list.d/ethereum.list
RUN cat /etc/apt/sources.list.d/ethereum.list
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 2A518C819BE37D2C2031944D1C52189C923F6CA9
RUN apt-get update
RUN apt-get install solc
RUN pip install --upgrade pip
#RUN apt-get install solc
WORKDIR /root
RUN mkdir -vp /usr/local/etc/cic
COPY contract-migration/nvm.sh .
ENV CONFINI_DIR /usr/local/etc/cic/
RUN mkdir -vp $CONFINI_DIR
ARG cic_config_commit=35c69ba75f00c8147150acf325565d5391cf25bf
ARG cic_config_commit=0abe0867f18077907c7023bf0ef5e466a3984dd8
ARG cic_config_url=https://gitlab.com/grassrootseconomics/cic-config.git/
RUN echo Install confini schema files && \
git clone --depth 1 $cic_config_url cic-config && \
@@ -31,70 +25,18 @@ RUN echo Install confini schema files && \
git checkout $cic_config_commit && \
cp -v *.ini $CONFINI_DIR
# Install nvm with node and npm
# https://stackoverflow.com/questions/25899912/how-to-install-nvm-in-docker
ENV NVM_DIR /root/.nvm
ENV NODE_VERSION 15.3.0
ENV BANCOR_NODE_VERSION 10.16.0
RUN wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash \
&& . $NVM_DIR/nvm.sh \
&& nvm install $NODE_VERSION \
&& nvm alias default $NODE_VERSION \
&& nvm use $NODE_VERSION
# && chown -R root:root "$NVM_DIR/versions/node/v$NODE_VERSION"
ENV NODE_PATH $NVM_DIR/versions/node//v$NODE_VERSION/lib/node_modules
ENV PATH $NVM_DIR/versions/node//v$NODE_VERSION/bin:$PATH
#RUN useradd --create-home grassroots
# WORKDIR /home/grassroots
# USER grassroots
COPY contract-migration/requirements.txt .
ARG pip_extra_args=""
ARG pip_index_url=https://pypi.org/simple
ARG pip_extra_index_url=https://pip.grassrootseconomics.net:8433
RUN pip install --index-url https://pypi.org/simple \
--extra-index-url $pip_extra_index_url -r requirements.txt
ARG EXTRA_INDEX_URL="https://pip.grassrootseconomics.net:8433"
ARG GITLAB_PYTHON_REGISTRY="https://gitlab.com/api/v4/projects/27624814/packages/pypi/simple"
ARG pip_trusted_host=pypi.org
RUN --mount=type=cache,mode=0755,target=/root/.cache/pip \
pip install --index-url https://pypi.org/simple \
--force-reinstall \
--trusted-host $pip_trusted_host \
--extra-index-url $GITLAB_PYTHON_REGISTRY --extra-index-url $EXTRA_INDEX_URL \
-r requirements.txt
# -------------- begin runtime container ----------------
FROM python:3.8.6-slim-buster as runtime-image
RUN apt-get update
RUN apt-get install -y --no-install-recommends gnupg libpq-dev
RUN apt-get install -y jq bash iputils-ping socat
COPY --from=compile-image /usr/local/bin/ /usr/local/bin/
COPY --from=compile-image /usr/local/etc/cic/ /usr/local/etc/cic/
COPY --from=compile-image /usr/local/lib/python3.8/site-packages/ \
/usr/local/lib/python3.8/site-packages/
ENV EXTRA_INDEX_URL https://pip.grassrootseconomics.net:8433
# RUN useradd -u 1001 --create-home grassroots
# RUN adduser grassroots sudo && \
# echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# WORKDIR /home/grassroots
COPY contract-migration/testdata/pgp testdata/pgp
COPY contract-migration/sarafu_declaration.json sarafu_declaration.json
COPY contract-migration/keystore keystore
COPY contract-migration/envlist .
# A shared output dir for environment configs
RUN mkdir -p /tmp/cic/config
# RUN chown grassroots:grassroots /tmp/cic/config
RUN chmod a+rwx /tmp/cic/config
COPY contract-migration/*.sh ./
# RUN chown grassroots:grassroots -R .
RUN chmod gu+x *.sh
# we copied these from the root build container.
# this is dumb though...I guess the compile image should have the same user
# RUN chown grassroots:grassroots -R /usr/local/lib/python3.8/site-packages/
# USER grassroots
ENTRYPOINT [ ]
COPY contract-migration/ .
RUN chmod +x *.sh

View File

@@ -0,0 +1,42 @@
# syntax = docker/dockerfile:1.2
FROM registry.gitlab.com/grassrootseconomics/cic-base-images:python-3.8.6-dev-55da5f4e
WORKDIR /root
# solc install which we needed for bancor. Leaving as an artfact of HOW to do it.
#RUN touch /etc/apt/sources.list.d/ethereum.list
#RUN echo 'deb http://ppa.launchpad.net/ethereum/ethereum/ubuntu bionic main' > /etc/apt/sources.list.d/ethereum.list
#RUN echo 'deb-src http://ppa.launchpad.net/ethereum/ethereum/ubuntu bionic main' >> /etc/apt/sources.list.d/ethereum.list
#RUN cat /etc/apt/sources.list.d/ethereum.list
#RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 2A518C819BE37D2C2031944D1C52189C923F6CA9
#RUN apt-get install solc
RUN mkdir -vp /usr/local/etc/cic
ENV CONFINI_DIR /usr/local/etc/cic/
RUN mkdir -vp $CONFINI_DIR
ARG cic_config_commit=0abe0867f18077907c7023bf0ef5e466a3984dd8
ARG cic_config_url=https://gitlab.com/grassrootseconomics/cic-config.git/
RUN echo Install confini schema files && \
git clone --depth 1 $cic_config_url cic-config && \
cd cic-config && \
git fetch --depth 1 origin $cic_config_commit && \
git checkout $cic_config_commit && \
cp -v *.ini $CONFINI_DIR
COPY contract-migration/requirements.txt .
ARG pip_index_url=https://pypi.org/simple
ARG EXTRA_INDEX_URL="https://pip.grassrootseconomics.net:8433"
ARG GITLAB_PYTHON_REGISTRY="https://gitlab.com/api/v4/projects/27624814/packages/pypi/simple"
ARG pip_trusted_host=pypi.org
RUN pip install --index-url https://pypi.org/simple \
--force-reinstall \
--trusted-host $pip_trusted_host \
--extra-index-url $GITLAB_PYTHON_REGISTRY --extra-index-url $EXTRA_INDEX_URL \
-r requirements.txt
COPY contract-migration/ .
RUN chmod +x *.sh

View File

@@ -0,0 +1,3 @@
#eth-contract-registry==0.5.5a3
#erc20-demurrage-token==0.0.2a3
#eth-address-index==0.1.1a12

View File

@@ -1,4 +1,5 @@
cic-base[full_graph]==0.1.2b15
sarafu-faucet==0.0.3a3
sarafu-token==0.0.1a8
cic-eth==0.11.0b16
cic_base[full]==0.1.3a4+build.ce68c833
sarafu-faucet~=0.0.4a1
cic-eth[tools]==0.12.0a2
eth-erc20~=0.0.10a3
erc20-demurrage-token==0.0.2a3

View File

@@ -2,8 +2,28 @@
set -a
CIC_CHAIN_SPEC=${CIC_CHAIN_SPEC:-evm:bloxberg:8995}
default_token=giftable_erc20_token
CIC_DEFAULT_TOKEN_SYMBOL=${CIC_DEFAULT_TOKEN_SYMBOL:-GFT}
TOKEN_SYMBOL=${CIC_DEFAULT_TOKEN_SYMBOL}
TOKEN_NAME=${TOKEN_NAME:-$TOKEN_SYMBOL}
TOKEN_TYPE=${TOKEN_TYPE:-$default_token}
if [ $TOKEN_TYPE == 'default' ]; then
>&2 echo resolving "default" token to $default_token
TOKEN_TYPE=$default_token
fi
cat <<EOF
external token settings:
token_type: $TOKEN_TYPE
token_symbol: $TOKEN_SYMBOL
token_name: $TOKEN_NAME
token_decimals: $TOKEN_DECIMALS
token_demurrage: $TOKEN_DEMURRAGE_LEVEL
token_redistribution_period: $TOKEN_REDISTRIBUTION_PERIOD
token_supply_limit: $TOKEN_SUPPLY_LIMIT
EOF
CIC_CHAIN_SPEC=${CIC_CHAIN_SPEC:-evm:bloxberg:8995}
TOKEN_SYMBOL=${TOKEN_SYMBOL:-GFT}
DEV_ETH_ACCOUNT_RESERVE_MINTER=${DEV_ETH_ACCOUNT_RESERVE_MINTER:-$DEV_ETH_ACCOUNT_CONTRACT_DEPLOYER}
DEV_ETH_ACCOUNT_ACCOUNTS_INDEX_WRITER=${DEV_ETH_ACCOUNT_RESERVE_MINTER:-$DEV_ETH_ACCOUNT_CONTRACT_DEPLOYER}
DEV_RESERVE_AMOUNT=${DEV_ETH_RESERVE_AMOUNT:-""10000000000000000000000000000000000}
@@ -19,11 +39,6 @@ if [ ! -z $DEV_ETH_GAS_PRICE ]; then
>&2 echo using static gas price $DEV_ETH_GAS_PRICE
fi
if [[ $CIC_DEFAULT_TOKEN_SYMBOL != 'GFT' && $CIC_DEFAULT_TOKEN_SYMBOL != 'SRF' ]]; then
>&2 echo CIC_DEFAULT_TOKEN_SYMBOL must be one of [GFT,SRF], but was $CIC_DEFAULT_TOKEN_SYMBOL
exit 1
fi
echo "environment:"
printenv
echo \n
@@ -60,17 +75,23 @@ if [[ -n "${ETH_PROVIDER}" ]]; then
./wait-for-it.sh "${ETH_PROVIDER_HOST}:${ETH_PROVIDER_PORT}"
fi
if [ $CIC_DEFAULT_TOKEN_SYMBOL == 'GFT' ]; then
>&2 echo "deploying 'giftable token'"
DEV_RESERVE_ADDRESS=`giftable-token-deploy $gas_price_arg -p $ETH_PROVIDER -y $DEV_ETH_KEYSTORE_FILE -i $CIC_CHAIN_SPEC -vv -w --name "Giftable Token" --symbol "GFT" --decimals 6 -vv`
if [ $TOKEN_TYPE == $default_token ]; then
>&2 echo deploying default token $TOKEN_TYPE
DEV_RESERVE_ADDRESS=`giftable-token-deploy $gas_price_arg -p $ETH_PROVIDER -y $DEV_ETH_KEYSTORE_FILE -i $CIC_CHAIN_SPEC -vv -ww --name $TOKEN_NAME --symbol $TOKEN_SYMBOL --decimals 6 -vv`
elif [ $TOKEN_TYPE == 'erc20_demurrage_token' ]; then
>&2 echo deploying token $TOKEN_TYPE
if [ -z $TOKEN_SINK_ADDRESS ]; then
if [ ! -z $TOKEN_REDISTRIBUTION_PERIOD ]; then
>&2 echo -e "\033[;93mtoken sink address not set, so redistribution will be BURNED\033[;39m"
fi
fi
DEV_RESERVE_ADDRESS=`erc20-demurrage-token-deploy $gas_price_arg -p $ETH_PROVIDER -y $DEV_ETH_KEYSTORE_FILE -i $CIC_CHAIN_SPEC -vv -ww`
else
>&2 echo "deploying 'sarafu' token'"
DEV_RESERVE_ADDRESS=`sarafu-token-deploy $gas_price_arg -p $ETH_PROVIDER -y $DEV_ETH_KEYSTORE_FILE -i $CIC_CHAIN_SPEC -vv -w --name "Sarafu" --decimals 6 -vv SRF $DEV_SARAFU_DEMURRAGE_LEVEL`
>&2 echo unknown token type $TOKEN_TYPE
exit 1
fi
giftable-token-gift $gas_price_arg -p $ETH_PROVIDER -y $DEV_ETH_KEYSTORE_FILE -i $CIC_CHAIN_SPEC -vv -w -a $DEV_RESERVE_ADDRESS $DEV_RESERVE_AMOUNT
#BANCOR_REGISTRY_ADDRESS=`cic-bancor-deploy $gas_price_arg --bancor-dir /usr/local/share/cic/bancor -z $DEV_ETH_RESERVE_ADDRESS -p $ETH_PROVIDER -o $DEV_ETH_ACCOUNT_CONTRACT_DEPLOYER`
>&2 echo "deploy account index contract"
DEV_ACCOUNT_INDEX_ADDRESS=`eth-accounts-index-deploy $gas_price_arg -i $CIC_CHAIN_SPEC -p $ETH_PROVIDER -y $DEV_ETH_KEYSTORE_FILE -vv -w`
>&2 echo "add deployer address as account index writer"

Some files were not shown because too many files have changed in this diff Show More