├── jobcoin
├── __init__.py
├── cli.py
├── util.py
├── api.py
└── jobcoin.py
├── tests
├── __init__.py
├── test_cli.py
├── test_jobcoin.py
├── test_util.py
└── test_api.py
├── requirements_test.txt
├── requirements.txt
├── imgs
├── Jobcoin_Mixer.png
├── Mixer_example.png
├── Mixer_example_error.png
├── Mixer_example_error_2.png
└── Mixer_example_error_3.png
├── .env_sample
├── Pipfile
├── Makefile
├── tox.ini
├── setup.py
├── .gitignore
└── README.md
/jobcoin/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
--------------------------------------------------------------------------------
/requirements_test.txt:
--------------------------------------------------------------------------------
1 | pytest==3.5.0
2 | nose==1.3.7
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | click==6.7
2 | python-dotenv==0.16.0
3 | requests==2.20.0
4 | tox==3.23.0
--------------------------------------------------------------------------------
/imgs/Jobcoin_Mixer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cypherpunk-symposium/coin-mixer-py/HEAD/imgs/Jobcoin_Mixer.png
--------------------------------------------------------------------------------
/imgs/Mixer_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cypherpunk-symposium/coin-mixer-py/HEAD/imgs/Mixer_example.png
--------------------------------------------------------------------------------
/imgs/Mixer_example_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cypherpunk-symposium/coin-mixer-py/HEAD/imgs/Mixer_example_error.png
--------------------------------------------------------------------------------
/imgs/Mixer_example_error_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cypherpunk-symposium/coin-mixer-py/HEAD/imgs/Mixer_example_error_2.png
--------------------------------------------------------------------------------
/imgs/Mixer_example_error_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cypherpunk-symposium/coin-mixer-py/HEAD/imgs/Mixer_example_error_3.png
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import pytest
3 | import nose.tools as nt
4 | from jobcoin import jobcoin
5 |
6 |
7 |
--------------------------------------------------------------------------------
/tests/test_jobcoin.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import pytest
3 | import nose.tools as nt
4 | from jobcoin import jobcoin
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.env_sample:
--------------------------------------------------------------------------------
1 | API_ADDRESS_URL = ''
2 | API_TRANSACTIONS_URL = ''
3 | SIGNIFICANT_DIGITS = 17
4 | HOUSE_ADDRESS = 'Jobcoin-House'
5 | FEE_PERCENTAGE = 10
6 | MAX_WITHDRAW_VALUE = 1
7 |
--------------------------------------------------------------------------------
/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | url = "https://pypi.org/simple"
3 | verify_ssl = true
4 | name = "pypi"
5 |
6 | [packages]
7 | click = "==6.7"
8 | requests = "==2.20.0"
9 | python-dotenv = "==0.16.0"
10 | pytest = "==3.5.0"
11 | nose = "==1.3.7"
12 |
13 |
14 | [dev-packages]
15 |
16 | [requires]
17 | python_version = "3.9"
18 |
--------------------------------------------------------------------------------
/tests/test_util.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import pytest
3 | import nose.tools as nt
4 | from pathlib import Path
5 | from jobcoin import util
6 |
7 |
8 | def test_combine_url_success():
9 | url = 'http://test.com/'
10 | parameter = 'test.html'
11 | result = util.combine_url(url, parameter)
12 | nt.assert_true(result=='http://test.com/test.html')
13 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: clean test install lint
2 |
3 | clean:
4 | @find . -iname '*.py[co]' -delete
5 | @find . -iname '__pycache__' -delete
6 | @rm -rf '.pytest_cache'
7 | @rm -rf dist/
8 | @rm -rf build/
9 | @rm -rf *.egg-info
10 | @rm -rf Pipfile.lock
11 | @rm -rf .tox
12 |
13 | test:
14 | tox
15 |
16 | install:
17 | python3 setup.py install
18 |
19 | lint:
20 | tox -e lint
21 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | minversion = 2.4
3 | envlist = formatting, py36, py37, py38, py39, pypy, benchmark
4 | skip_missing_interpreters = true
5 |
6 | [testenv]
7 | setenv =
8 | COVERAGE_FILE = {toxinidir}/.coverage.{envname}
9 | deps =
10 | -r {toxinidir}/requirements_test.txt
11 | commands =
12 | pytest
13 |
14 | [testenv:lint]
15 | skip_install = true
16 | deps = flake8
17 | commands = flake8 jobcoin/
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 |
3 | setup(
4 | name='jobcoin',
5 | version='0.0.1',
6 | packages=find_packages(include=['jobcoin', 'jobcoin.*']),
7 | author='Mia Stein',
8 | install_requires=[
9 | 'click',
10 | 'requests',
11 | 'python-dotenv'
12 | ],
13 | entry_points={
14 | 'console_scripts': ['jobcoin=jobcoin.cli:main']
15 | },
16 | )
17 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import pytest
3 | import requests
4 | import nose.tools as nt
5 | from jobcoin import api
6 |
7 |
8 | @pytest.fixture
9 | def response_get_balance(address):
10 | return requests.get('http://jobcoin.gemini.com/aide-sports/api/addresses/{}'.format(address))
11 |
12 |
13 | def test_get_balance_200():
14 | response = response_get_balance('Alice')
15 | nt.assert_true(response.ok)
16 |
17 |
18 | def test_get_balance_response_not_none():
19 | response = response_get_balance('Alice')
20 | nt.assert_is_not_none(response)
21 |
22 |
23 | def test_get_balance_response_empty_address():
24 | response = response_get_balance('')
25 | nt.assert_true(response.status_code==404)
26 |
--------------------------------------------------------------------------------
/jobcoin/cli.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | cli.py
4 |
5 | CLI entry point for Jobcoin app.
6 | """
7 |
8 | import sys
9 | import click
10 |
11 | import jobcoin.util as util
12 | from jobcoin.jobcoin import Jobcoin
13 |
14 |
15 | @click.command()
16 | def main(args=None):
17 |
18 | client = Jobcoin()
19 |
20 | print('✨ Welcome to the Jobcoin mixer! ✨\n')
21 |
22 | while True:
23 | addresses = click.prompt(
24 | 'Please enter a comma-separated list of new, unused Jobcoin '
25 | 'addresses where your mixed Jobcoins will be sent.',
26 | prompt_suffix='\n[blank to quit] > ',
27 | default='',
28 | show_default=False)
29 |
30 | # Validate list of addresses
31 | personal_addresses = util.get_addresses_list(addresses)
32 | client.validate_addresses(personal_addresses)
33 |
34 | # Generate a deposit address
35 | deposit_address = util.generate_deposit_address()
36 | click.echo(
37 | '\n✅ You may now send Jobcoins to address {deposit_address}. '
38 | 'They will be mixed and sent to your destination addresses.'
39 | .format(deposit_address=deposit_address))
40 |
41 | # Get source address
42 | source_address = click.prompt(
43 | '\nPlease enter the (source) address. ',
44 | prompt_suffix='\n[source address] > ',
45 | default='',
46 | show_default=False)
47 |
48 | # Get number of coins to be deposited
49 | deposit = click.prompt(
50 | '\nPlease enter the value to be transferred.',
51 | prompt_suffix='\n[deposit value] > ',
52 | default='',
53 | show_default=False)
54 |
55 | # Run mix algorithm
56 | print('\n✅ Starting Jobcoin Mixer algorithm')
57 | client.mix_algorithm(deposit,
58 | source_address,
59 | deposit_address,
60 | personal_addresses)
61 |
62 |
63 | if __name__ == '__main__':
64 | sys.exit(main())
65 |
--------------------------------------------------------------------------------
/jobcoin/util.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | util.py
4 |
5 | Implements util methods for other modules.
6 | """
7 |
8 | import os
9 | import sys
10 | import uuid
11 |
12 | from pathlib import Path
13 | from dotenv import load_dotenv
14 |
15 |
16 | def get_config(key, env_path=None):
17 | """Given a key, get the value from the env file.
18 |
19 | Arguments:
20 | key (str)
21 | """
22 | env_path = env_path or Path('.') / '.env'
23 | load_dotenv(dotenv_path=env_path)
24 | value = os.getenv(key)
25 |
26 | if not value:
27 | print('📛 Please set {} in .env'.format(key))
28 | sys.exit(0)
29 | return value
30 |
31 |
32 | def combine_url(url, parameter):
33 | """Ensures that a URL is well-composed.
34 |
35 | Arguments:
36 | url (str)
37 | parameter (str)
38 | """
39 | url = url.strip('/')
40 | return '{}/{}'.format(url, parameter)
41 |
42 |
43 | def round_float(num):
44 | """Ensures that a string preserves desired precision when converted to float.
45 | Arguments:
46 | num (str)
47 | Returns:
48 | num (float)
49 | """
50 | try:
51 | return round(float(num), int(get_config('SIGNIFICANT_DIGITS')))
52 | except ValueError:
53 | print('📛 Value needs to be a number.')
54 | sys.exit(0)
55 |
56 |
57 | def generate_deposit_address():
58 | """Creates a disposible hexadecimal address.
59 |
60 | Returns:
61 | hex address (str)
62 | """
63 | return uuid.uuid4().hex
64 |
65 |
66 | def get_addresses_list(addresses):
67 | """Format a string of addresses.
68 |
69 | Arguments:
70 | addresses (str)
71 | Returns:
72 | addresses (list)
73 | """
74 | if not addresses:
75 | sys.exit(0)
76 | addresses = addresses.split()
77 | return [_.strip(',') for _ in addresses]
78 |
79 |
80 | def is_close(a, b=0.0, rel_tol=1e-09, abs_tol=0.0):
81 | """Deals with float comparison.
82 |
83 | Arguments:
84 | a, b (floats)
85 | Returns:
86 | closeness between a and b (float)
87 | """
88 | return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)
89 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
--------------------------------------------------------------------------------
/jobcoin/api.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | api.py
4 |
5 | This module implements a client for Jobcoin API RESTful endpoints.
6 | All requests calls are handled in this module.
7 | """
8 |
9 | import sys
10 | import requests
11 |
12 | import jobcoin.util as util
13 |
14 |
15 | def _get_request(url):
16 | """Sends a GET request to the given URL.
17 |
18 | Parameters:
19 | url (str)
20 | Returns:
21 | response (str)
22 | """
23 | try:
24 | return requests.get(url)
25 | except requests.exceptions.RequestException as e:
26 | print('📛 GET request error: {}:'.format(e))
27 | sys.exit(0)
28 |
29 |
30 | def _post_request(url, payload):
31 | """Sends a POST request to the given URL and payload.
32 |
33 | Parameters:
34 | url (str)
35 | payload (dict)
36 | Returns:
37 | response (str)
38 | """
39 | try:
40 | return requests.post(url, data=payload)
41 | except requests.exceptions.RequestException as e:
42 | print('📛 POST request error: {}:'.format(e))
43 | sys.exit(0)
44 |
45 |
46 | def get_balance(address):
47 | """Gets the balance from a given address.
48 |
49 | Parameters:
50 | address (str)
51 | Returns:
52 | balance (float): unused address returns a balance of 0
53 | """
54 |
55 | url = util.combine_url(util.get_config('API_ADDRESS_URL'), address)
56 | response = _get_request(url)
57 |
58 | if response.status_code == 200:
59 | data = response.json()
60 |
61 | try:
62 | return util.round_float(data['balance'])
63 | except KeyError as e:
64 | print('📛 Could not get balance from {}: '.format(address),
65 | 'Response is not well-formatted: {}'.format(e))
66 |
67 | else:
68 | print('📛 Could not get balance from {}:'.format(address),
69 | 'status code {}'.format(response.status_code))
70 | sys.exit(0)
71 |
72 |
73 | def get_transactions():
74 | """Gets a list of all the transations in Jobcoin.
75 |
76 | Returns:
77 | transactions (list of dicts)
78 | """
79 | # NOTE: The list of transactions might become very large with time.
80 | # if no check is done in the server, an enhancement would add this check,
81 | # sort by timestamp, or truncate after a certain size limit.
82 |
83 | url = util.get_config('API_TRANSACTIONS_URL')
84 | response = _get_request(url)
85 |
86 | if response.status_code == 200:
87 | return response.json()
88 |
89 | else:
90 | print('📛 Could not get transaction list: ',
91 | 'status code {status}'.format(status=response.status_code))
92 | sys.exit(0)
93 |
94 |
95 | def post_transaction(from_address, to_address, amount):
96 | """Issue a transaction between two given address and a given amount.
97 |
98 | Input Arguments:
99 | from_address (str)
100 | to_address (str)
101 | amount (str)
102 | Returns:
103 | status (str)
104 | """
105 |
106 | url = util.get_config('API_TRANSACTIONS_URL')
107 | payload = {
108 | 'fromAddress': from_address,
109 | 'toAddress': to_address,
110 | 'amount': amount
111 | }
112 | response = _post_request(url, payload)
113 |
114 | if response.status_code == 422:
115 | print('📛 Could not post transaction: Insufficient funds!')
116 | sys.exit(0)
117 | elif response.status_code == 400:
118 | print('📛 Could not post transaction: Amount must be over 0!')
119 | sys.exit(0)
120 | elif response.status_code != 200:
121 | print('📛 Could not post transaction: ',
122 | 'status code {}'.format(response.status_code))
123 | sys.exit(0)
124 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## jobcoin: a cryptocurrency coin mixer
2 |
3 |
4 |
5 |
6 |
82 |
83 |
90 |
91 |
98 |
99 |
106 |
107 |