├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── cc ├── __init__.py ├── admin.py ├── audit.py ├── forms.py ├── management │ └── commands │ │ ├── double_spend.py │ │ └── total_recieved.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150105_1859.py │ ├── 0003_auto_20150106_1041.py │ ├── 0004_txid_not_uniq.py │ ├── 0005_currency_dust.py │ ├── 0006_auto_20160519_1952.py │ ├── 0007_withdrawtransaction_state.py │ ├── 0008_withdrawtransaction_state.py │ ├── 0009_WT_ixid_index.py │ ├── 0010_withdrawtransaction_walletconflicts.py │ ├── 0011_magicbyte_charfield.py │ └── __init__.py ├── models.py ├── settings.py ├── signals.py ├── tasks.py ├── tests │ ├── __init__.py │ ├── tests_bitcoin_regtest.py │ ├── tests_dash_regtest.py │ ├── tests_litecoin_regtest.py │ ├── tests_mock.py │ └── tests_zcash_regtest.py ├── urls.py ├── validator.py └── views.py ├── docker-compose.yml ├── setup.cfg ├── setup.py └── testproject ├── db.sqlite3 ├── manage.py ├── requirements.txt └── testproject ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /*.egg-info/ 3 | dist/ 4 | env/ 5 | *.sublime-project 6 | *.sublime-workspace 7 | testproject/db.sqlite3 8 | 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - docker 3 | 4 | language: python 5 | 6 | python: 3.6 7 | 8 | before_install: 9 | - docker -v 10 | - docker-compose -v 11 | - docker-compose build 12 | 13 | script: 14 | - docker-compose up 15 | 16 | notifications: 17 | email: 18 | on_success: change 19 | on_failure: always 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | ENV PYTHONUNBUFFERED 1 3 | RUN mkdir /code 4 | WORKDIR /code 5 | ADD testproject/requirements.txt /code/ 6 | RUN pip install -r requirements.txt 7 | ADD . /code/ 8 | RUN pip install -e . 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-2018 Ivan (@limpbrains) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-cc # 2 | [![Build Status](https://travis-ci.org/limpbrains/django-cc.svg?branch=master)](https://travis-ci.org/limpbrains/django-cc) 3 | 4 | Django-cryptocurrencies web wallet for Bitcoin and a few other cryptocurrencies. 5 | 6 | Simple pluggable application inspired by django-bitcoin. 7 | 8 | Python 3 9 | 10 | ## Features ## 11 | * Multi-currency 12 | * Celery support 13 | * Withdraw and Deposit 14 | * 3 types of balances: balance, unconfirmed, holded 15 | * Works over bitcoind json-rpc 16 | 17 | ## Quick start ## 18 | 19 | Edit Currency model 20 | ```python 21 | 22 | from cc.models import Currency 23 | 24 | currency = Currency.objects.create( 25 | label = 'Bitcoin', 26 | ticker = 'BTC', 27 | api_url = 'http://root:toor@localhost:8332' 28 | ) 29 | ``` 30 | 31 | Start Celery worker 32 | ```bash 33 | $ celery worker -A tst.cel.app 34 | ``` 35 | 36 | Get new addresses for wallets 37 | 38 | ```bash 39 | $ celery call cc.tasks.refill_addresses_queue 40 | ``` 41 | 42 | Now you can create wallets, deposit and withdraw funds 43 | 44 | ```python 45 | from cc.models import Wallet 46 | 47 | wallet = Wallet.objects.create( 48 | currency=currency 49 | ) 50 | 51 | wallet.get_address() 52 | 53 | wallet.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('0.01')) 54 | ``` 55 | 56 | After creating a withdraw transaction, you need to run 57 | 58 | ```bash 59 | $ celery call cc.tasks.process_withdraw_transactions 60 | ``` 61 | 62 | Query for new deposit transactions: 63 | ```bash 64 | $ cc.tasks.query_transactions 65 | ``` 66 | 67 | If you want to catch event from bitcoind, but these calls options in bitcoin.conf file 68 | 69 | ``` 70 | walletnotify=~/env/bin/celery call cc.tasks.query_transaction --args='["BTC", "'%s'"]' 71 | blocknotify=~/env/bin/celery call cc.tasks.query_transactions --args='["BTC"]' 72 | ``` 73 | where "BTC" - ticker (short name) of the Currency 74 | 75 | ## More details 76 | 77 | ### Limitations ### 78 | * Works with full node. For each cryptocurrency you will need to run a full node, which usually requires a lot of disk space. But by running full node you are actually helping the network, so it is a good thing. 79 | * Coins are mixed between wallets. When you withdraw funds from the wallet, you can't choose which UTXO will be used. 80 | * During the withdrawal you can't choose transaction fee. 'Django-cc' uses 'send_many' RPC call, bitcoind calculates how much you will spend on transaction fee. When withdraw is finished, spent fee will be subtracted from wallet balance. To reduce fees, change 'txconfirmtarget' in your bitcoin.conf. 81 | 82 | ### Configuring Celery tasks ### 83 | This library relies heavily on Celery for running tasks in the background. You need to add it to your Project. There are a few tasks which djano-cc should do periodically: 84 | 85 | * 'refill_addresses_queue'. It queries bitcoind for new addresses and store them in DB. Each time you create a wallet and call 'wallet.get_address()' unused address will be attached to the wallet. By default, it keeps amount for new addresses to 20. You can tune this by changing 'CC_ADDRESS_QUEUE' in your project settings. Usually running this task once in an hour is enought. 86 | * 'process_withdraw_transactions'. It queries DB for any new withdraw transactions and executes them. By running it not so often, you can batch transactions, this will help you reduce network fees. 87 | * 'query-transactions'. It queries bitcoind for new incoming transactions and updates wallets balances. Bitcoin network creates one block per approximately 10 minutes, so no need to run it more often. 88 | 89 | But it is better to run 'query-transactions' in response to new events from bitcoind. You can do this by adding these lines to bitcoin.conf 90 | ``` 91 | walletnotify=~/env/bin/celery call cc.tasks.query_transaction --args='["BTC", "'%s'"]' 92 | blocknotify=~/env/bin/celery call cc.tasks.query_transactions --args='["BTC"]' 93 | ``` 94 | Each time bitcoin will receive new tx or block, you will get an instant update on balances. 95 | 96 | If bitcoind works in another environment, you can send this events by HTTP hooks. Expose 'django-cc' views in urls.py: 97 | ```python 98 | urlpatterns = [ 99 | ... 100 | url(r'^cc/', include('cc.urls')), 101 | ] 102 | ``` 103 | Then you can send http requests to trigger actions: 104 | ```bash 105 | curl -k "https://yourhost/cc/blocknotify/?currency=BTC" 106 | curl -k "https://yourhost/cc/walletnotify/?currency=BTC&txid=$1" 107 | ``` 108 | 109 | ### API ### 110 | 111 | Withdraw to network: 112 | ```python 113 | wallet.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('0.01')) 114 | ``` 115 | 116 | Transfer from wallet to wallet: 117 | ```python 118 | wallet1.transfer(wallet1.balance, wallet2, None, 'description') 119 | ``` 120 | 121 | Get wallet history: 122 | ```python 123 | for operation in wallet.get_operations(): 124 | print(operation.created) 125 | print(operation.balance) 126 | print(operation.reason.txid) 127 | print(operation.reason.address) 128 | ``` 129 | 130 | ### Database transactions 131 | 132 | When you write applications that are working with money, it is extremely important to use Database transactions. Currenly django-cc doesn't inclues any '@transaction.atomic'. You should do this by yourself. 133 | 134 | In my code I have a higher level wrapper with @transaction.atomic and to get wallets I'm always using select for update, like 'Wallet.objects.select_for_update().get(addresses=address)' to get a lock over the Wallet. 135 | 136 | ## Supported cryptocurrencies 137 | 138 | In general django-cc should work with most Bitcoin forks. I've tested it against: Bitcoin, Litecoin, Zcash (not anonymous transactions), Dogecoin and Dash. 139 | 140 | When you are adding any other than Bitcoin `Currency`, you should define `magicbyte` and `dust` values. Use tables below to get the values. 141 | 142 | ### Magic bytes 143 | 144 | Magic bytes are used to verify withdraw addresses. They are different for each cryptocurrency. 145 | 146 | | CC | Mainnet | Testnet | 147 | | -------- | ------- | ------- | 148 | | Bitcoin | 0,5 | 111,196 | 149 | | Litecoin | 48,50 | 58 | 150 | | Zcash | 28 | 29 | 151 | | Dogecoin | 30,22 | | 152 | | Dash | 76,16 | 140 | 153 | 154 | ### Dust 155 | 156 | Minimal amount of valid transaction 157 | 158 | | CC | Dust size | 159 | | -------- | ------------ | 160 | | Bitcoin | `0.00005430` | 161 | | Litecoin | `0.00054600` | 162 | 163 | ### Settings ### 164 | 165 | CC_CONFIRMATIONS - how many confirmations incoming transaction needs to increase wallet balance. Default is 2. 166 | CC_ADDRESS_QUEUE - how many addresses generate during `refill_addresses_queue`. Default is 20. 167 | CC_ALLOW_NEGATIVE_BALANCE - minimal amount of Wallet to be able to withdraw funds from it. Default is Decimal('0.001'). 168 | CC_ACCOUNT - Bitcoind once had an account system. Now it is deprecated. Do not change this. Default is '' — empty string. 169 | CC_ALLOWED_HOSTS - list of addresses how can call `/cc/blocknotify` and `/cc/walletnotify`. Default is `['localhost', '127.0.0.1']`. 170 | 171 | ### Testing 172 | 173 | Tests are written using Regtest. To run them you need docker and docker-compose. Simply run `docker-compose up` and it will build and run all tests for you. Usually it takes about 5 min to run all the tests. 174 | -------------------------------------------------------------------------------- /cc/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | VERSION = '0.1.1' 4 | -------------------------------------------------------------------------------- /cc/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models 4 | from .forms import WalletAdminForm 5 | 6 | 7 | class WalletAdmin(admin.ModelAdmin): 8 | form = WalletAdminForm 9 | list_display = ('id', 'currency', 'balance', 'holded', 'unconfirmed', 'label', 'get_address') 10 | list_filter = ('currency',) 11 | 12 | admin.site.register(models.Wallet, WalletAdmin) 13 | 14 | 15 | class OperationAdmin(admin.ModelAdmin): 16 | list_display = ('id', 'wallet', 'balance', 'holded', 'unconfirmed', 'description') 17 | list_filter = ('wallet',) 18 | 19 | admin.site.register(models.Operation, OperationAdmin) 20 | 21 | 22 | class CurrencyAdmin(admin.ModelAdmin): 23 | list_display = ('ticker', 'label', 'last_block') 24 | 25 | admin.site.register(models.Currency, CurrencyAdmin) 26 | 27 | 28 | class TransactionAdmin(admin.ModelAdmin): 29 | list_display = ('id', 'txid', 'currency', 'processed') 30 | list_filter = ('currency', 'processed') 31 | 32 | admin.site.register(models.Transaction, TransactionAdmin) 33 | 34 | 35 | class AddressAdmin(admin.ModelAdmin): 36 | list_display = ('address', 'currency', 'created', 'active', 'label', 'wallet') 37 | list_filter = ('currency', 'active') 38 | 39 | admin.site.register(models.Address, AddressAdmin) 40 | 41 | 42 | class WithdrawTransactionAdmin(admin.ModelAdmin): 43 | list_display = ('id', 'currency', 'amount', 'address', 'wallet', 'created', 'state', 'txid', 'walletconflicts', 'fee') 44 | list_filter = ('currency', 'state') 45 | 46 | admin.site.register(models.WithdrawTransaction, WithdrawTransactionAdmin) 47 | -------------------------------------------------------------------------------- /cc/audit.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from cc.models import Wallet, Operation, Address, Currency, Transaction, WithdrawTransaction 4 | 5 | 6 | def total_recieved(ticker, listreceivedbyaddress): 7 | currency = Currency.objects.get(ticker=ticker) 8 | wallets = Wallet.objects.filter(currency=currency) 9 | 10 | data = dict(map(lambda x: [x['address'], x['amount']], listreceivedbyaddress)) 11 | 12 | result = {'mismatch': [], 'missing': []} 13 | 14 | for w in wallets: 15 | tr = w.total_received() 16 | if tr > 0: 17 | summ = D('0') 18 | for a in Address.objects.filter(wallet = w): 19 | try: 20 | summ += data[a.address] 21 | except KeyError: 22 | result['missing'].append({'address': a.address}) 23 | 24 | if (tr != summ): 25 | result['mismatch'].append({ 26 | 'wallet': w.id, 27 | 'db': tr, 28 | 'coin': summ, 29 | }) 30 | 31 | return result 32 | 33 | 34 | def double_spend(ticker, listtransactions, ccc): 35 | currency = Currency.objects.get(ticker=ticker) 36 | 37 | 38 | send = filter(lambda x: x['category'] == 'send', listtransactions) 39 | missing = [] 40 | 41 | for tx in send: 42 | wtxs = WithdrawTransaction.objects.filter(txid=tx['txid'], currency=currency) 43 | 44 | if not wtxs: 45 | ccc.stdout.write(ccc.style.ERROR('Missing %(txid)s %(address)s %(amount)s %(time)s' % tx)) 46 | missing.append(tx) 47 | 48 | 49 | 50 | import IPython; IPython.embed() 51 | return 52 | 53 | return result 54 | -------------------------------------------------------------------------------- /cc/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django import forms 4 | 5 | from .models import Wallet 6 | 7 | class WalletAdminForm(forms.ModelForm): 8 | class Meta: 9 | model = Wallet 10 | exclude = [] 11 | 12 | def clean_label(self): 13 | return self.cleaned_data['label'] or None 14 | -------------------------------------------------------------------------------- /cc/management/commands/double_spend.py: -------------------------------------------------------------------------------- 1 | import json 2 | import decimal 3 | import argparse 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | from cc.audit import double_spend 7 | 8 | 9 | class Command(BaseCommand): 10 | help = 'Looks for duplicate untacked withdraw transacions need output of "listtransactions" bitcoind command' 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument('ticker', type=str) 14 | parser.add_argument('file', type=argparse.FileType('r')) 15 | 16 | def handle(self, *args, **options): 17 | data = json.load(options['file'], parse_float=decimal.Decimal) 18 | result = double_spend(options['ticker'], data, self) 19 | 20 | if not result or (not result['mismatch'] and not result['missing']): 21 | self.stdout.write(self.style.SUCCESS('Everything is allright')) 22 | 23 | elif result['mismatch']: 24 | for m in result['mismatch']: 25 | self.stdout.write(self.style.ERROR('Wallet: "%(wallet)s" balance mismatch DB: %(db)s WALLET: %(coin)s' % m)) 26 | 27 | elif result['missing']: 28 | for m in result['missing']: 29 | self.stdout.write(self.style.ERROR('Address "%(address)s" is missing' % m)) 30 | 31 | return 32 | -------------------------------------------------------------------------------- /cc/management/commands/total_recieved.py: -------------------------------------------------------------------------------- 1 | import json 2 | import decimal 3 | import argparse 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | from cc.audit import total_recieved 7 | 8 | 9 | class Command(BaseCommand): 10 | help = 'Checks total recieved with output of "listreceivedbyaddress" bitcoind command' 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument('ticker', type=str) 14 | parser.add_argument('file', type=argparse.FileType('r')) 15 | 16 | def handle(self, *args, **options): 17 | data = json.load(options['file'], parse_float=decimal.Decimal) 18 | result = total_recieved(options['ticker'], data) 19 | 20 | if not result['mismatch'] and not result['missing']: 21 | self.stdout.write(self.style.SUCCESS('Everything is allright')) 22 | 23 | elif result['mismatch']: 24 | for m in result['mismatch']: 25 | self.stdout.write(self.style.ERROR('Wallet: "%(wallet)s" balance mismatch DB: %(db)s WALLET: %(coin)s' % m)) 26 | 27 | elif result['missing']: 28 | for m in result['missing']: 29 | self.stdout.write(self.style.ERROR('Address "%(address)s" is missing' % m)) 30 | 31 | return 32 | -------------------------------------------------------------------------------- /cc/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('contenttypes', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Address', 17 | fields=[ 18 | ('address', models.CharField(max_length=50, serialize=False, verbose_name='Address', primary_key=True)), 19 | ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Created')), 20 | ('active', models.BooleanField(default=True, verbose_name='Active')), 21 | ('label', models.CharField(default=None, max_length=50, null=True, verbose_name='Label', blank=True)), 22 | ], 23 | options={ 24 | }, 25 | bases=(models.Model,), 26 | ), 27 | migrations.CreateModel( 28 | name='Currency', 29 | fields=[ 30 | ('ticker', models.CharField(default='BTC', max_length=4, serialize=False, verbose_name='Ticker', primary_key=True)), 31 | ('label', models.CharField(default='Bitcoin', unique=True, max_length=20, verbose_name='Label')), 32 | ('magicbyte', models.CommaSeparatedIntegerField(default='0,5', max_length=10, verbose_name='Magicbytes')), 33 | ('last_block', models.PositiveIntegerField(default=0, null=True, verbose_name='Last block', blank=True)), 34 | ('api_url', models.CharField(default='http://localhost:8332', max_length=100, null=True, verbose_name='API hostname', blank=True)), 35 | ], 36 | options={ 37 | 'verbose_name_plural': 'currencies', 38 | }, 39 | bases=(models.Model,), 40 | ), 41 | migrations.CreateModel( 42 | name='Operation', 43 | fields=[ 44 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 45 | ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Created')), 46 | ('balance', models.DecimalField(default=0, verbose_name='Balance', max_digits=18, decimal_places=8)), 47 | ('holded', models.DecimalField(default=0, verbose_name='Holded', max_digits=18, decimal_places=8)), 48 | ('unconfirmed', models.DecimalField(default=0, verbose_name='Unconfirmed', max_digits=18, decimal_places=8)), 49 | ('description', models.CharField(max_length=100, null=True, verbose_name='Description', blank=True)), 50 | ('reason_object_id', models.PositiveIntegerField(null=True, blank=True)), 51 | ('reason_content_type', models.ForeignKey(blank=True, to='contenttypes.ContentType', null=True, on_delete=models.CASCADE)), 52 | ], 53 | options={ 54 | }, 55 | bases=(models.Model,), 56 | ), 57 | migrations.CreateModel( 58 | name='Transaction', 59 | fields=[ 60 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 61 | ('txid', models.CharField(unique=True, max_length=100, verbose_name='Txid')), 62 | ('processed', models.BooleanField(default=False, verbose_name='Processed')), 63 | ('currency', models.ForeignKey(to='cc.Currency', on_delete=models.CASCADE)), 64 | ], 65 | options={ 66 | }, 67 | bases=(models.Model,), 68 | ), 69 | migrations.CreateModel( 70 | name='Wallet', 71 | fields=[ 72 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 73 | ('balance', models.DecimalField(default=0, verbose_name='Balance', max_digits=18, decimal_places=8)), 74 | ('holded', models.DecimalField(default=0, verbose_name='Holded', max_digits=18, decimal_places=8)), 75 | ('unconfirmed', models.DecimalField(default=0, verbose_name='Unconfirmed', max_digits=18, decimal_places=8)), 76 | ('label', models.CharField(max_length=100, null=True, verbose_name='Label', blank=True)), 77 | ('currency', models.ForeignKey(to='cc.Currency', on_delete=models.CASCADE)), 78 | ], 79 | options={ 80 | }, 81 | bases=(models.Model,), 82 | ), 83 | migrations.CreateModel( 84 | name='WithdrawTransaction', 85 | fields=[ 86 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 87 | ('amount', models.DecimalField(verbose_name='Amount', max_digits=18, decimal_places=8)), 88 | ('address', models.CharField(max_length=50, verbose_name='Address')), 89 | ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Created')), 90 | ('txid', models.CharField(max_length=100, null=True, verbose_name='Txid', blank=True)), 91 | ('fee', models.DecimalField(null=True, verbose_name='Amount', max_digits=18, decimal_places=8, blank=True)), 92 | ('currency', models.ForeignKey(to='cc.Currency', on_delete=models.CASCADE)), 93 | ('wallet', models.ForeignKey(to='cc.Wallet', on_delete=models.CASCADE)), 94 | ], 95 | options={ 96 | }, 97 | bases=(models.Model,), 98 | ), 99 | migrations.AddField( 100 | model_name='operation', 101 | name='wallet', 102 | field=models.ForeignKey(to='cc.Wallet', on_delete=models.CASCADE), 103 | preserve_default=True, 104 | ), 105 | migrations.AddField( 106 | model_name='address', 107 | name='currency', 108 | field=models.ForeignKey(to='cc.Currency', on_delete=models.CASCADE), 109 | preserve_default=True, 110 | ), 111 | migrations.AddField( 112 | model_name='address', 113 | name='wallet', 114 | field=models.ForeignKey(related_name='addresses', blank=True, to='cc.Wallet', null=True, on_delete=models.CASCADE), 115 | preserve_default=True, 116 | ), 117 | ] 118 | -------------------------------------------------------------------------------- /cc/migrations/0002_auto_20150105_1859.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('cc', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='wallet', 16 | name='label', 17 | field=models.CharField(max_length=100, unique=True, null=True, verbose_name='Label', blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /cc/migrations/0003_auto_20150106_1041.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('cc', '0002_auto_20150105_1859'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='withdrawtransaction', 16 | name='fee', 17 | field=models.DecimalField(null=True, verbose_name='Fee', max_digits=18, decimal_places=8, blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /cc/migrations/0004_txid_not_uniq.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('cc', '0003_auto_20150106_1041'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='transaction', 16 | name='address', 17 | field=models.CharField(default='', max_length=50, verbose_name='Address'), 18 | preserve_default=False, 19 | ), 20 | migrations.AlterField( 21 | model_name='transaction', 22 | name='txid', 23 | field=models.CharField(max_length=100, verbose_name='Txid'), 24 | ), 25 | migrations.AlterUniqueTogether( 26 | name='transaction', 27 | unique_together=set([('txid', 'address')]), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /cc/migrations/0005_currency_dust.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from decimal import Decimal 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cc', '0004_txid_not_uniq'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='currency', 17 | name='dust', 18 | field=models.DecimalField(default=Decimal('0.0000543'), verbose_name='Dust', max_digits=18, decimal_places=8), 19 | preserve_default=True, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /cc/migrations/0006_auto_20160519_1952.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.6 on 2016-05-19 16:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cc', '0005_currency_dust'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='currency', 17 | name='api_url', 18 | field=models.CharField(blank=True, default='http://localhost:8332', max_length=100, null=True, verbose_name='API hostname'), 19 | ), 20 | migrations.AlterField( 21 | model_name='currency', 22 | name='label', 23 | field=models.CharField(default='Bitcoin', max_length=20, unique=True, verbose_name='Label'), 24 | ), 25 | migrations.AlterField( 26 | model_name='currency', 27 | name='magicbyte', 28 | field=models.CommaSeparatedIntegerField(default='0,5', max_length=10, verbose_name='Magicbytes'), 29 | ), 30 | migrations.AlterField( 31 | model_name='currency', 32 | name='ticker', 33 | field=models.CharField(default='BTC', max_length=4, primary_key=True, serialize=False, verbose_name='Ticker'), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /cc/migrations/0007_withdrawtransaction_state.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.6 on 2016-05-19 16:53 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cc', '0006_auto_20160519_1952'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='withdrawtransaction', 17 | name='state', 18 | field=models.CharField(choices=[('NEW', 'New'), ('ERROR', 'Error'), ('DONE', 'Done')], default='DONE', max_length=10, verbose_name='Txid'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /cc/migrations/0008_withdrawtransaction_state.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-05-22 08:05 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cc', '0007_withdrawtransaction_state'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='withdrawtransaction', 17 | name='state', 18 | field=models.CharField(choices=[('NEW', 'New'), ('ERROR', 'Error'), ('DONE', 'Done')], default='NEW', max_length=10, verbose_name='State'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /cc/migrations/0009_WT_ixid_index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-05-22 08:08 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cc', '0008_withdrawtransaction_state'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='withdrawtransaction', 17 | name='txid', 18 | field=models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Txid'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /cc/migrations/0010_withdrawtransaction_walletconflicts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-06-02 18:10 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cc', '0009_WT_ixid_index'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='withdrawtransaction', 17 | name='walletconflicts', 18 | field=models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Walletconflicts txid'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /cc/migrations/0011_magicbyte_charfield.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-02-04 16:47 3 | from __future__ import unicode_literals 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | import re 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('cc', '0010_withdrawtransaction_walletconflicts'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='currency', 19 | name='magicbyte', 20 | field=models.CharField(default='0,5', max_length=10, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\,\\d+)*\\Z', 32), code='invalid', message='Enter only digits separated by commas.')], verbose_name='Magicbytes'), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /cc/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limpbrains/django-cc/3af391ebac929c46f24e6ea6019e5edb3e410e61/cc/migrations/__init__.py -------------------------------------------------------------------------------- /cc/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from decimal import Decimal 3 | 4 | from django.contrib.contenttypes.fields import GenericForeignKey 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.core.validators import validate_comma_separated_integer_list 7 | from django.db import models 8 | from django.db.models import Sum 9 | from django.utils.timezone import now 10 | from django.utils.translation import ugettext_lazy as _ 11 | 12 | from cc import settings 13 | from cc.validator import validate 14 | 15 | class Wallet(models.Model): 16 | currency = models.ForeignKey('Currency', on_delete=models.CASCADE) 17 | balance = models.DecimalField(_('Balance'), max_digits=18, decimal_places=8, default=0) 18 | holded = models.DecimalField(_('Holded'), max_digits=18, decimal_places=8, default=0) 19 | unconfirmed = models.DecimalField(_('Unconfirmed'), max_digits=18, decimal_places=8, default=0) 20 | label = models.CharField(_('Label'), max_length=100, blank=True, null=True, unique=True) 21 | 22 | def __str__(self): 23 | return u'{0} {1} "{2}"'.format(self.balance, self.currency.ticker, self.label or '') 24 | 25 | def get_address(self): 26 | active = Address.objects.filter(wallet=self, active=True, currency=self.currency)[:1] 27 | if active: 28 | return active[0] 29 | 30 | unused = Address.objects.filter(wallet=None, active=True, currency=self.currency)[:1] 31 | if unused: 32 | free = unused[0] 33 | free.wallet = self 34 | free.save() 35 | return free 36 | 37 | old = Address.objects.filter(wallet=self, active=False, currency=self.currency)[:1] 38 | if old: 39 | return old[0] 40 | 41 | def withdraw(self, amount, description="", reason=None): 42 | if amount < 0: 43 | raise ValueError('Invalid amount') 44 | 45 | if self.balance - amount < -settings.CC_ALLOW_NEGATIVE_BALANCE: 46 | raise ValueError('No money') 47 | 48 | Operation.objects.create( 49 | wallet=self, 50 | balance=-amount, 51 | description=description, 52 | reason=reason 53 | ) 54 | self.balance -= amount 55 | self.save() 56 | 57 | def transfer(self, amount, deposite_wallet, reason=None, description=""): 58 | if amount < 0: 59 | raise ValueError('Invalid amount') 60 | 61 | if self.balance - amount < -settings.CC_ALLOW_NEGATIVE_BALANCE: 62 | raise ValueError('No money') 63 | 64 | Operation.objects.create( 65 | wallet=self, 66 | balance=-amount, 67 | description=description, 68 | reason=reason 69 | ) 70 | Operation.objects.create( 71 | wallet=deposite_wallet, 72 | balance=+amount, 73 | description=description, 74 | reason=reason 75 | ) 76 | self.balance -= amount 77 | self.save() 78 | deposite_wallet.balance += amount 79 | deposite_wallet.save() 80 | 81 | def withdraw_to_address(self, address, amount, description=""): 82 | if not validate(address, self.currency.magicbyte): 83 | raise ValueError('Invalid address') 84 | 85 | if amount < 0: 86 | raise ValueError('Invalid amount') 87 | 88 | if self.balance - amount < -settings.CC_ALLOW_NEGATIVE_BALANCE: 89 | raise ValueError('No money') 90 | 91 | tx = WithdrawTransaction.objects.create( 92 | currency=self.currency, 93 | amount=amount, 94 | address=address, 95 | wallet=self, 96 | ) 97 | op = Operation.objects.create( 98 | wallet=self, 99 | balance=-amount, 100 | holded=amount, 101 | description=description, 102 | reason=tx 103 | ) 104 | self.balance -= amount 105 | self.holded += amount 106 | self.save() 107 | 108 | return { 109 | 'tx': tx, 110 | 'op': op, 111 | } 112 | 113 | def total_received(self): 114 | return Operation.objects.filter(wallet=self, balance__gt=0).aggregate(balance=Sum('balance'))['balance'] or Decimal('0') 115 | 116 | def recalc_balance(self, save=False): 117 | recalc = Operation.objects.filter(wallet=self).aggregate(balance=Sum('balance'), 118 | holded=Sum('holded'), 119 | unconfirmed=Sum('unconfirmed')) 120 | 121 | for k, v in recalc.items(): 122 | if v is None: 123 | recalc[k] = Decimal('0') 124 | 125 | if save: 126 | self.balance = recalc['balance'] 127 | self.holded = recalc['holded'] 128 | self.unconfirmed = recalc['unconfirmed'] 129 | self.save() 130 | 131 | return recalc 132 | 133 | def get_operations(self): 134 | return Operation.objects.filter(wallet=self).order_by('-created') 135 | 136 | def get_unpaid_dust_summary(self): 137 | if not self.currency.dust: 138 | return {} 139 | 140 | txs = WithdrawTransaction.objects.filter(wallet=self, txid=None, amount__lt=self.currency.dust) 141 | if len(txs) == 0: 142 | return {} 143 | 144 | from collections import defaultdict 145 | tx_hash = defaultdict(lambda : Decimal('0')) 146 | for tx in txs: 147 | tx_hash[tx.address] += tx.amount 148 | 149 | return dict(tx_hash) 150 | 151 | 152 | class Operation(models.Model): 153 | wallet = models.ForeignKey(Wallet, on_delete=models.CASCADE) 154 | created = models.DateTimeField(_('Created'), default=now) 155 | balance = models.DecimalField(_('Balance'), max_digits=18, decimal_places=8, default=0) 156 | holded = models.DecimalField(_('Holded'), max_digits=18, decimal_places=8, default=0) 157 | unconfirmed = models.DecimalField(_('Unconfirmed'), max_digits=18, decimal_places=8, default=0) 158 | description = models.CharField(_('Description'), max_length=100, blank=True, null=True) 159 | reason_content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) 160 | reason_object_id = models.PositiveIntegerField(null=True, blank=True) 161 | reason = GenericForeignKey('reason_content_type', 'reason_object_id') 162 | 163 | 164 | class Address(models.Model): 165 | address = models.CharField(_('Address'), max_length=50, primary_key=True) 166 | currency = models.ForeignKey('Currency', on_delete=models.CASCADE) 167 | created = models.DateTimeField(_('Created'), default=now) 168 | active = models.BooleanField(_('Active'), default=True) 169 | label = models.CharField(_('Label'), max_length=50, blank=True, null=True, default=None) 170 | wallet = models.ForeignKey(Wallet, blank=True, null=True, related_name="addresses", on_delete=models.CASCADE) 171 | 172 | def __str__(self): 173 | return u'{0}, {1}'.format(self.address, self.currency.ticker) 174 | 175 | 176 | class Currency(models.Model): 177 | ticker = models.CharField(_('Ticker'), max_length=4, default='BTC', primary_key=True) 178 | label = models.CharField(_('Label'), max_length=20, default='Bitcoin', unique=True) 179 | magicbyte = models.CharField(_('Magicbytes'), max_length=10, default='0,5', validators=[validate_comma_separated_integer_list]) 180 | last_block = models.PositiveIntegerField(_('Last block'), blank=True, null=True, default=0) 181 | api_url = models.CharField(_('API hostname'), default='http://localhost:8332', max_length=100, blank=True, null=True) 182 | dust = models.DecimalField(_('Dust'), max_digits=18, decimal_places=8, default=Decimal('0.0000543')) 183 | 184 | class Meta: 185 | verbose_name_plural = _('currencies') 186 | 187 | def __str__(self): 188 | return self.label 189 | 190 | 191 | class Transaction(models.Model): 192 | txid = models.CharField(_('Txid'), max_length=100) 193 | address = models.CharField(_('Address'), max_length=50) 194 | currency = models.ForeignKey('Currency', on_delete=models.CASCADE) 195 | processed = models.BooleanField(_('Processed'), default=False) 196 | 197 | class Meta: 198 | unique_together = (('txid', 'address'),) 199 | 200 | 201 | class WithdrawTransaction(models.Model): 202 | NEW = 'NEW' 203 | ERROR = 'ERROR' 204 | DONE = 'DONE' 205 | WTX_STATES = ( 206 | ('NEW', 'New'), 207 | ('ERROR', 'Error'), 208 | ('DONE', 'Done'), 209 | ) 210 | currency = models.ForeignKey('Currency', on_delete=models.CASCADE) 211 | amount = models.DecimalField(_('Amount'), max_digits=18, decimal_places=8) 212 | address = models.CharField(_('Address'), max_length=50) 213 | wallet = models.ForeignKey(Wallet, on_delete=models.CASCADE) 214 | created = models.DateTimeField(_('Created'), default=now) 215 | txid = models.CharField(_('Txid'), max_length=100, blank=True, null=True, db_index=True) 216 | walletconflicts = models.CharField(_('Walletconflicts txid'), max_length=100, blank=True, null=True, db_index=True) 217 | state = models.CharField(_('State'), max_length=10, choices=WTX_STATES, default=NEW) 218 | fee = models.DecimalField(_('Fee'), max_digits=18, decimal_places=8, null=True, blank=True) 219 | -------------------------------------------------------------------------------- /cc/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from decimal import Decimal 3 | 4 | CC_CONFIRMATIONS = getattr(settings, 'CC_CONFIRMATIONS', 2) 5 | CC_ADDRESS_QUEUE = getattr(settings, 'CC_ADDRESS_QUEUE', 20) 6 | CC_ALLOW_NEGATIVE_BALANCE = getattr(settings, 'CC_ALLOW_NEGATIVE_BALANCE', Decimal('0.001')) 7 | CC_ACCOUNT = getattr(settings, 'CC_ACCOUNT', '') 8 | CC_ALLOWED_HOSTS = getattr(settings, 'CC_ALLOWED_HOSTS', ['localhost', '127.0.0.1']) 9 | -------------------------------------------------------------------------------- /cc/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | 4 | post_deposite = django.dispatch.Signal(providing_args=["instance"]) 5 | -------------------------------------------------------------------------------- /cc/tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from socket import error as socket_error 3 | from decimal import Decimal 4 | from collections import defaultdict 5 | from http.client import CannotSendRequest 6 | 7 | from celery import shared_task 8 | from celery.utils.log import get_task_logger 9 | from bitcoinrpc.authproxy import AuthServiceProxy 10 | 11 | from django.db import transaction 12 | 13 | from .models import (Wallet, Currency, Transaction, Address, 14 | WithdrawTransaction, Operation) 15 | from . import settings 16 | from .signals import post_deposite 17 | 18 | logger = get_task_logger(__name__) 19 | 20 | 21 | @shared_task(throws=(socket_error,)) 22 | @transaction.atomic 23 | def query_transactions(ticker=None): 24 | if not ticker: 25 | for c in Currency.objects.all(): 26 | query_transactions.delay(c.ticker) 27 | return 28 | 29 | currency = Currency.objects.select_for_update().get(ticker=ticker) 30 | coin = AuthServiceProxy(currency.api_url) 31 | current_block = coin.getblockcount() 32 | 33 | block_hash = coin.getblockhash(currency.last_block) 34 | transactions = coin.listsinceblock(block_hash)['transactions'] 35 | 36 | for tx in transactions: 37 | if tx['category'] not in ('receive', 'generate', 'immature'): 38 | continue 39 | 40 | process_deposite_transaction(tx, ticker) 41 | 42 | currency.last_block = current_block 43 | currency.save() 44 | 45 | for tx in Transaction.objects.filter(processed=False, currency=currency): 46 | query_transaction(ticker, tx.txid) 47 | 48 | 49 | @transaction.atomic 50 | def process_deposite_transaction(txdict, ticker): 51 | if txdict['category'] not in ('receive', 'generate', 'immature'): 52 | return 53 | 54 | try: 55 | address = Address.objects.select_for_update().get(address=txdict['address']) 56 | except Address.DoesNotExist: 57 | return 58 | 59 | currency = Currency.objects.get(ticker=ticker) 60 | 61 | try: 62 | wallet = Wallet.objects.select_for_update().get(addresses=address) 63 | except Wallet.DoesNotExist: 64 | wallet, created = Wallet.objects.select_for_update().get_or_create( 65 | currency=currency, 66 | label='_unknown_wallet' 67 | ) 68 | address.wallet = wallet 69 | address.save() 70 | 71 | tx, created = Transaction.objects.select_for_update().get_or_create(txid=txdict['txid'], address=txdict['address'], currency=currency) 72 | 73 | if tx.processed: 74 | return 75 | 76 | if created: 77 | if txdict['confirmations'] >= settings.CC_CONFIRMATIONS and txdict['category'] != 'immature': 78 | Operation.objects.create( 79 | wallet=wallet, 80 | balance=txdict['amount'], 81 | description='Deposite', 82 | reason=tx 83 | ) 84 | wallet.balance += txdict['amount'] 85 | wallet.save() 86 | tx.processed = True 87 | else: 88 | Operation.objects.create( 89 | wallet=wallet, 90 | unconfirmed=txdict['amount'], 91 | description='Unconfirmed', 92 | reason=tx 93 | ) 94 | wallet.unconfirmed += txdict['amount'] 95 | wallet.save() 96 | 97 | else: 98 | if txdict['confirmations'] >= settings.CC_CONFIRMATIONS and txdict['category'] != 'immature': 99 | Operation.objects.create( 100 | wallet=wallet, 101 | unconfirmed=-txdict['amount'], 102 | balance=txdict['amount'], 103 | description='Confirmed', 104 | reason=tx 105 | ) 106 | wallet.unconfirmed -= txdict['amount'] 107 | wallet.balance += txdict['amount'] 108 | wallet.save() 109 | tx.processed = True 110 | 111 | post_deposite.send(sender=process_deposite_transaction, instance=wallet) 112 | tx.save() 113 | 114 | 115 | @shared_task(throws=(socket_error,)) 116 | @transaction.atomic 117 | def query_transaction(ticker, txid): 118 | currency = Currency.objects.select_for_update().get(ticker=ticker) 119 | coin = AuthServiceProxy(currency.api_url) 120 | for txdict in normalise_txifno(coin.gettransaction(txid)): 121 | process_deposite_transaction(txdict, ticker) 122 | 123 | 124 | def normalise_txifno(data): 125 | arr = [] 126 | for t in data['details']: 127 | t['confirmations'] = data['confirmations'] 128 | t['txid'] = data['txid'] 129 | t['timereceived'] = data['timereceived'] 130 | t['time'] = data['time'] 131 | arr.append(t) 132 | return arr 133 | 134 | 135 | @shared_task() 136 | def refill_addresses_queue(): 137 | for currency in Currency.objects.all(): 138 | coin = AuthServiceProxy(currency.api_url) 139 | count = Address.objects.filter(currency=currency, active=True, wallet=None).count() 140 | 141 | if count < settings.CC_ADDRESS_QUEUE: 142 | for i in range(count, settings.CC_ADDRESS_QUEUE): 143 | try: 144 | Address.objects.create(address=coin.getnewaddress(settings.CC_ACCOUNT), currency=currency) 145 | except (socket_error, CannotSendRequest) : 146 | pass 147 | 148 | 149 | @shared_task() 150 | def process_withdraw_transactions(ticker=None): 151 | if not ticker: 152 | for c in Currency.objects.all(): 153 | process_withdraw_transactions.delay(c.ticker) 154 | return 155 | 156 | with transaction.atomic(): 157 | currency = Currency.objects.select_for_update().get(ticker=ticker) 158 | coin = AuthServiceProxy(currency.api_url) 159 | 160 | # this will fail if bitcoin offline 161 | coin.getbalance() 162 | 163 | wtxs = WithdrawTransaction.objects.select_for_update() \ 164 | .select_related('wallet') \ 165 | .filter(currency=currency, state=WithdrawTransaction.NEW, txid=None) \ 166 | .order_by('wallet') 167 | 168 | transaction_hash = {} 169 | for tx in wtxs: 170 | if tx.address in transaction_hash: 171 | transaction_hash[tx.address] += tx.amount 172 | else: 173 | transaction_hash[tx.address] = tx.amount 174 | 175 | if currency.dust > Decimal('0'): 176 | for address, amount in list(transaction_hash.items()): 177 | if amount < currency.dust: 178 | wtxs = wtxs.exclude(currency=currency, address=address) 179 | del transaction_hash[address] 180 | 181 | if not transaction_hash: 182 | return 183 | 184 | wtxs_ids = list(wtxs.values_list('id', flat=True)) 185 | wtxs.update(state=WithdrawTransaction.ERROR) 186 | 187 | # this will fail if bitcoin offline 188 | coin.getbalance() 189 | 190 | txid = coin.sendmany(settings.CC_ACCOUNT, transaction_hash) 191 | 192 | if not txid: 193 | raise AssertionError('txid is empty') 194 | 195 | fee = coin.gettransaction(txid).get('fee', 0) * -1 196 | 197 | with transaction.atomic(): 198 | currency = Currency.objects.select_for_update().get(ticker=ticker) 199 | wtxs = WithdrawTransaction.objects.select_for_update().filter(id__in=wtxs_ids) 200 | if not fee: 201 | fee_per_tx = 0 202 | else: 203 | fee_per_tx = (fee / len(wtxs)) 204 | 205 | fee_hash = defaultdict(lambda : {'fee': Decimal('0'), 'amount': Decimal('0')}) 206 | 207 | for tx in wtxs: 208 | fee_hash[tx.wallet]['fee'] += fee_per_tx 209 | fee_hash[tx.wallet]['amount'] += tx.amount 210 | 211 | for (wallet, data) in fee_hash.items(): 212 | Operation.objects.create( 213 | wallet=wallet, 214 | holded=-data['amount'], 215 | balance=-data['fee'], 216 | description='Network fee', 217 | reason=tx 218 | ) 219 | 220 | wallet = Wallet.objects.get(id=tx.wallet.id) 221 | wallet.balance -= data['fee'] 222 | wallet.holded -= data['amount'] 223 | wallet.save() 224 | 225 | wtxs.update(txid=txid, fee=fee_per_tx, state=WithdrawTransaction.DONE) 226 | -------------------------------------------------------------------------------- /cc/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limpbrains/django-cc/3af391ebac929c46f24e6ea6019e5edb3e410e61/cc/tests/__init__.py -------------------------------------------------------------------------------- /cc/tests/tests_bitcoin_regtest.py: -------------------------------------------------------------------------------- 1 | import string 2 | import random 3 | from time import sleep 4 | from decimal import Decimal 5 | from mock import patch, MagicMock 6 | from bitcoinrpc.authproxy import AuthServiceProxy, JSONRPCException 7 | from django.test import TransactionTestCase 8 | 9 | from cc.models import Wallet, Address, Currency, Operation, Transaction, WithdrawTransaction 10 | from cc import tasks 11 | from cc import settings 12 | 13 | 14 | settings.CC_CONFIRMATIONS = 2 15 | settings.CC_ACCOUNT = '' 16 | URL = 'http://root:toor@bitcoind:43782/' 17 | 18 | 19 | class WalletAddressGet(TransactionTestCase): 20 | @classmethod 21 | def setUpClass(cls): 22 | super().setUpClass() 23 | cls.coin = AuthServiceProxy(URL) 24 | starting = True 25 | while starting: 26 | try: 27 | cls.coin.generate(101) 28 | except JSONRPCException as e: 29 | if e.code != -28: 30 | raise 31 | else: 32 | starting = False 33 | sleep(1) 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | cls.coin.stop() 38 | super().tearDownClass() 39 | 40 | def setUp(self): 41 | self.currency = Currency.objects.create(label='Bitcoin regtest', ticker='tbtc', api_url=URL, magicbyte='111,196') 42 | tasks.refill_addresses_queue() 43 | 44 | def test_address_refill(self): 45 | wallet = Wallet.objects.create(currency=self.currency) 46 | address = wallet.get_address() 47 | self.assertTrue(address) 48 | 49 | def test_deposit(self): 50 | wallet_before = Wallet.objects.create(currency=self.currency) 51 | address = wallet_before.get_address().address 52 | self.coin.sendtoaddress(address, Decimal('1')) 53 | self.coin.generate(1) 54 | 55 | tasks.query_transactions('tbtc') 56 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 57 | self.assertEqual(wallet_after1.balance, Decimal('0')) 58 | self.assertEqual(wallet_after1.holded, Decimal('0')) 59 | self.assertEqual(wallet_after1.unconfirmed, Decimal('1')) 60 | self.coin.generate(1) 61 | 62 | tasks.query_transactions('tbtc') 63 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 64 | self.assertEqual(wallet_after2.balance, Decimal('1')) 65 | self.assertEqual(wallet_after2.holded, Decimal('0')) 66 | self.assertEqual(wallet_after2.unconfirmed, Decimal('0')) 67 | 68 | def test_withdraw(self): 69 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('1.0')) 70 | wallet_before.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('0.1')) 71 | wallet_before.withdraw_to_address('mkYAsS9QLYo5mXVjuvxKkZUhQJxiMLX5Xk', Decimal('0.1')) 72 | wallet_before.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('0.1')) 73 | 74 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 75 | self.assertEqual(wallet_after1.balance, Decimal('0.7')) 76 | self.assertEqual(wallet_after1.holded, Decimal('0.3')) 77 | tasks.process_withdraw_transactions('tbtc') 78 | self.coin.generate(2) 79 | 80 | wtx = WithdrawTransaction.objects.last() 81 | tx = self.coin.gettransaction(wtx.txid) 82 | 83 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 84 | self.assertEqual(wallet_after2.balance, Decimal('0.7') + tx['fee']) 85 | self.assertEqual(wallet_after2.holded, Decimal('0')) 86 | 87 | def test_withdraw_error(self): 88 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('21000000')) 89 | wallet_before.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('21000000')) 90 | 91 | try: 92 | tasks.process_withdraw_transactions('tbtc') 93 | except JSONRPCException: 94 | pass 95 | 96 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 97 | self.assertEqual(wallet_after1.balance, Decimal('0')) 98 | self.assertEqual(wallet_after1.holded, Decimal('21000000')) 99 | 100 | wtx = WithdrawTransaction.objects.last() 101 | 102 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 103 | self.assertEqual(wtx.state, wtx.ERROR) 104 | -------------------------------------------------------------------------------- /cc/tests/tests_dash_regtest.py: -------------------------------------------------------------------------------- 1 | import string 2 | import random 3 | from time import sleep 4 | from decimal import Decimal 5 | from mock import patch, MagicMock 6 | from bitcoinrpc.authproxy import AuthServiceProxy, JSONRPCException 7 | from django.test import TransactionTestCase 8 | 9 | from cc.models import Wallet, Address, Currency, Operation, Transaction, WithdrawTransaction 10 | from cc import tasks 11 | from cc import settings 12 | 13 | 14 | settings.CC_CONFIRMATIONS = 2 15 | settings.CC_ACCOUNT = '' 16 | URL = 'http://root:toor@dashd:19998/' 17 | 18 | 19 | class WalletAddressGet(TransactionTestCase): 20 | @classmethod 21 | def setUpClass(cls): 22 | super().setUpClass() 23 | cls.coin = AuthServiceProxy(URL) 24 | starting = True 25 | while starting: 26 | try: 27 | cls.coin.generate(101) 28 | except JSONRPCException as e: 29 | if e.code != -28: 30 | raise 31 | else: 32 | starting = False 33 | sleep(1) 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | cls.coin.stop() 38 | super().tearDownClass() 39 | 40 | def setUp(self): 41 | self.currency = Currency.objects.create(label='Dash regtest', ticker='tdsh', api_url=URL, magicbyte='140') 42 | tasks.refill_addresses_queue() 43 | 44 | def test_address_refill(self): 45 | wallet = Wallet.objects.create(currency=self.currency) 46 | address = wallet.get_address() 47 | self.assertTrue(address) 48 | 49 | def test_deposit(self): 50 | wallet_before = Wallet.objects.create(currency=self.currency) 51 | address = wallet_before.get_address().address 52 | self.coin.sendtoaddress(address, Decimal('1')) 53 | self.coin.generate(1) 54 | 55 | tasks.query_transactions('tdsh') 56 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 57 | self.assertEqual(wallet_after1.balance, Decimal('0')) 58 | self.assertEqual(wallet_after1.holded, Decimal('0')) 59 | self.assertEqual(wallet_after1.unconfirmed, Decimal('1')) 60 | self.coin.generate(1) 61 | 62 | tasks.query_transactions('tdsh') 63 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 64 | self.assertEqual(wallet_after2.balance, Decimal('1')) 65 | self.assertEqual(wallet_after2.holded, Decimal('0')) 66 | self.assertEqual(wallet_after2.unconfirmed, Decimal('0')) 67 | 68 | def test_withdraw(self): 69 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('1.0')) 70 | wallet_before.withdraw_to_address('yT58gFY67LNSb1wG9TYsvMx4W4wt93cej9', Decimal('0.1')) 71 | wallet_before.withdraw_to_address('ye4AYzjG8DuWzk1YTKG3cCUTumwC4Z2JQD', Decimal('0.1')) 72 | wallet_before.withdraw_to_address('yT58gFY67LNSb1wG9TYsvMx4W4wt93cej9', Decimal('0.1')) 73 | 74 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 75 | self.assertEqual(wallet_after1.balance, Decimal('0.7')) 76 | self.assertEqual(wallet_after1.holded, Decimal('0.3')) 77 | tasks.process_withdraw_transactions('tdsh') 78 | self.coin.generate(2) 79 | 80 | wtx = WithdrawTransaction.objects.last() 81 | tx = self.coin.gettransaction(wtx.txid) 82 | 83 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 84 | self.assertEqual(wallet_after2.balance, Decimal('0.7') + tx['fee']) 85 | self.assertEqual(wallet_after2.holded, Decimal('0')) 86 | 87 | def test_withdraw_error(self): 88 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('21000000')) 89 | wallet_before.withdraw_to_address('yT58gFY67LNSb1wG9TYsvMx4W4wt93cej9', Decimal('21000000')) 90 | 91 | try: 92 | tasks.process_withdraw_transactions('tdsh') 93 | except JSONRPCException: 94 | pass 95 | 96 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 97 | self.assertEqual(wallet_after1.balance, Decimal('0')) 98 | self.assertEqual(wallet_after1.holded, Decimal('21000000')) 99 | 100 | wtx = WithdrawTransaction.objects.last() 101 | 102 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 103 | self.assertEqual(wtx.state, wtx.ERROR) 104 | -------------------------------------------------------------------------------- /cc/tests/tests_litecoin_regtest.py: -------------------------------------------------------------------------------- 1 | import string 2 | import random 3 | from time import sleep 4 | from decimal import Decimal 5 | from mock import patch, MagicMock 6 | from bitcoinrpc.authproxy import AuthServiceProxy, JSONRPCException 7 | from django.test import TransactionTestCase 8 | 9 | from cc.models import Wallet, Address, Currency, Operation, Transaction, WithdrawTransaction 10 | from cc import tasks 11 | from cc import settings 12 | 13 | 14 | settings.CC_CONFIRMATIONS = 2 15 | settings.CC_ACCOUNT = '' 16 | URL = 'http://root:toor@litecoind:19335/' 17 | 18 | 19 | class WalletAddressGet(TransactionTestCase): 20 | @classmethod 21 | def setUpClass(cls): 22 | super().setUpClass() 23 | cls.coin = AuthServiceProxy(URL) 24 | starting = True 25 | while starting: 26 | try: 27 | cls.coin.generate(101) 28 | except JSONRPCException as e: 29 | if e.code != -28: 30 | raise 31 | else: 32 | starting = False 33 | sleep(1) 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | cls.coin.stop() 38 | super().tearDownClass() 39 | 40 | def setUp(self): 41 | self.currency = Currency.objects.create(label='Litecoin regtest', ticker='tbtc', api_url=URL, magicbyte='58') 42 | tasks.refill_addresses_queue() 43 | 44 | def test_address_refill(self): 45 | wallet = Wallet.objects.create(currency=self.currency) 46 | address = wallet.get_address() 47 | self.assertTrue(address) 48 | 49 | def test_deposit(self): 50 | wallet_before = Wallet.objects.create(currency=self.currency) 51 | address = wallet_before.get_address().address 52 | self.coin.sendtoaddress(address, Decimal('1')) 53 | self.coin.generate(1) 54 | 55 | tasks.query_transactions('tbtc') 56 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 57 | self.assertEqual(wallet_after1.balance, Decimal('0')) 58 | self.assertEqual(wallet_after1.holded, Decimal('0')) 59 | self.assertEqual(wallet_after1.unconfirmed, Decimal('1')) 60 | self.coin.generate(1) 61 | 62 | tasks.query_transactions('tbtc') 63 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 64 | self.assertEqual(wallet_after2.balance, Decimal('1')) 65 | self.assertEqual(wallet_after2.holded, Decimal('0')) 66 | self.assertEqual(wallet_after2.unconfirmed, Decimal('0')) 67 | 68 | def test_withdraw(self): 69 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('1.0')) 70 | wallet_before.withdraw_to_address('QfMAfiiLioTdkWmSG9v6VjDot9LH1d1nJo', Decimal('0.1')) 71 | wallet_before.withdraw_to_address('QfiFeWcRV5txjDXS5wzzj1t8dD2hRXR78t', Decimal('0.1')) 72 | wallet_before.withdraw_to_address('QfMAfiiLioTdkWmSG9v6VjDot9LH1d1nJo', Decimal('0.1')) 73 | 74 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 75 | self.assertEqual(wallet_after1.balance, Decimal('0.7')) 76 | self.assertEqual(wallet_after1.holded, Decimal('0.3')) 77 | tasks.process_withdraw_transactions('tbtc') 78 | self.coin.generate(2) 79 | 80 | wtx = WithdrawTransaction.objects.last() 81 | tx = self.coin.gettransaction(wtx.txid) 82 | 83 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 84 | self.assertEqual(wallet_after2.balance, Decimal('0.7') + tx['fee']) 85 | self.assertEqual(wallet_after2.holded, Decimal('0')) 86 | 87 | def test_withdraw_error(self): 88 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('21000000')) 89 | wallet_before.withdraw_to_address('QfMAfiiLioTdkWmSG9v6VjDot9LH1d1nJo', Decimal('21000000')) 90 | 91 | try: 92 | tasks.process_withdraw_transactions('tbtc') 93 | except JSONRPCException: 94 | pass 95 | 96 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 97 | self.assertEqual(wallet_after1.balance, Decimal('0')) 98 | self.assertEqual(wallet_after1.holded, Decimal('21000000')) 99 | 100 | wtx = WithdrawTransaction.objects.last() 101 | 102 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 103 | self.assertEqual(wtx.state, wtx.ERROR) 104 | -------------------------------------------------------------------------------- /cc/tests/tests_mock.py: -------------------------------------------------------------------------------- 1 | import string 2 | import random 3 | from decimal import Decimal 4 | from mock import patch, MagicMock 5 | 6 | from django.test import TransactionTestCase 7 | 8 | from cc.models import Wallet, Address, Currency, Operation, Transaction, WithdrawTransaction 9 | from cc import tasks 10 | from cc import settings 11 | 12 | 13 | settings.CC_CONFIRMATIONS = 2 14 | settings.CC_ACCOUNT = '' 15 | 16 | 17 | class DepositeTransaction(TransactionTestCase): 18 | def setUp(self): 19 | self.txdict = { 20 | 'category': 'receive', 21 | 'account': '', 22 | 'blockhash': '000000008468ce0ec51c64aa98cf99b317274c5287e770c9eddaa83fa33222ef', 23 | 'blockindex': 1, 24 | 'walletconflicts': [], 25 | 'time': 1409150845, 26 | 'txid': '63fadb05b2f6b0c83925d402c6cf27bc841acaa8c89a335914f77f75b22ef5dc', 27 | 'blocktime': 1409154118, 28 | 'amount': Decimal('5.00000000'), 29 | 'confirmations': 87, 30 | 'timereceived': 1409150845, 31 | 'address': 'mmxv3wYKozehzp3GZSUiKvRCWSJecWNSrd' 32 | } 33 | self.currency = Currency.objects.create(label='Bitcoin', ticker='btc') 34 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test') 35 | self.address = Address.objects.create(address=self.txdict['address'], wallet=self.wallet, currency=self.currency) 36 | tasks.process_deposite_transaction(self.txdict, 'btc') 37 | 38 | def test_balance(self): 39 | wallet = Wallet.objects.get(id=self.wallet.id) 40 | self.assertEqual(wallet.balance, self.txdict['amount']) 41 | 42 | def test_tx(self): 43 | tx = Transaction.objects.get(txid=self.txdict['txid']) 44 | self.assertTrue(tx.processed) 45 | 46 | 47 | class UnconfirmedTransaction(TransactionTestCase): 48 | def setUp(self): 49 | self.txdict = { 50 | 'category': 'receive', 51 | 'account': '', 52 | 'blockhash': '000000008468ce0ec51c64aa98cf99b317274c5287e770c9eddaa83fa33222ef', 53 | 'blockindex': 1, 54 | 'walletconflicts': [], 55 | 'time': 1409150845, 56 | 'txid': '63fadb05b2f6b0c83925d402c6cf27bc841acaa8c89a335914f77f75b22ef5dc', 57 | 'blocktime': 1409154118, 58 | 'amount': Decimal('5.00000000'), 59 | 'confirmations': 1, 60 | 'timereceived': 1409150845, 61 | 'address': 'mmxv3wYKozehzp3GZSUiKvRCWSJecWNSrd' 62 | } 63 | self.currency = Currency.objects.create(label='Bitcoin', ticker='btc') 64 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test') 65 | self.address = Address.objects.create(address=self.txdict['address'], wallet=self.wallet, currency=self.currency) 66 | tasks.process_deposite_transaction(self.txdict, 'btc') 67 | 68 | def test_balance(self): 69 | wallet = Wallet.objects.get(id=self.wallet.id) 70 | self.assertEqual(wallet.unconfirmed, self.txdict['amount']) 71 | self.assertEqual(wallet.balance, Decimal('0')) 72 | 73 | def test_tx(self): 74 | tx = Transaction.objects.get(txid=self.txdict['txid']) 75 | self.assertFalse(tx.processed) 76 | 77 | 78 | class ImmatureTransaction(TransactionTestCase): 79 | def setUp(self): 80 | self.txdict = { 81 | 'category': 'immature', 82 | 'account': '', 83 | 'blockhash': '000000008468ce0ec51c64aa98cf99b317274c5287e770c9eddaa83fa33222ef', 84 | 'blockindex': 1, 85 | 'walletconflicts': [], 86 | 'time': 1422794361, 87 | 'txid': '63fadb05b2f6b0c83925d402c6cf27bc841acaa8c89a335914f77f75b22ef5dc', 88 | 'blocktime': 1409154118, 89 | 'amount': Decimal('1.00000000'), 90 | 'confirmations': 1, 91 | 'timereceived': 1422794579, 92 | 'address': '12aJBBXWcWPxfQbXL4PQ3qtx978wwLL2g9' 93 | } 94 | self.currency = Currency.objects.create(label='Bitcoin', ticker='btc') 95 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test') 96 | self.address = Address.objects.create(address=self.txdict['address'], wallet=self.wallet, currency=self.currency) 97 | tasks.process_deposite_transaction(self.txdict, 'btc') 98 | 99 | def test_balance(self): 100 | wallet = Wallet.objects.get(id=self.wallet.id) 101 | self.assertEqual(wallet.unconfirmed, self.txdict['amount']) 102 | self.assertEqual(wallet.balance, Decimal('0')) 103 | 104 | def test_tx(self): 105 | tx = Transaction.objects.get(txid=self.txdict['txid']) 106 | self.assertFalse(tx.processed) 107 | 108 | 109 | class ConfirmTransaction(TransactionTestCase): 110 | def setUp(self): 111 | self.txdict = { 112 | 'category': 'receive', 113 | 'account': '', 114 | 'blockhash': '000000008468ce0ec51c64aa98cf99b317274c5287e770c9eddaa83fa33222ef', 115 | 'blockindex': 1, 116 | 'walletconflicts': [], 117 | 'time': 1409150845, 118 | 'txid': '63fadb05b2f6b0c83925d402c6cf27bc841acaa8c89a335914f77f75b22ef5dc', 119 | 'blocktime': 1409154118, 120 | 'amount': Decimal('5.00000000'), 121 | 'confirmations': 3, 122 | 'timereceived': 1409150845, 123 | 'address': 'mmxv3wYKozehzp3GZSUiKvRCWSJecWNSrd' 124 | } 125 | self.currency = Currency.objects.create(label='Bitcoin', ticker='btc') 126 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test', unconfirmed=Decimal('5.0')) 127 | self.address = Address.objects.create(address=self.txdict['address'], wallet=self.wallet, currency=self.currency) 128 | self.tx = Transaction.objects.create(txid=self.txdict['txid'], address=self.txdict['address'], currency=self.currency) 129 | tasks.process_deposite_transaction(self.txdict, 'btc') 130 | 131 | def test_balance(self): 132 | wallet = Wallet.objects.get(id=self.wallet.id) 133 | self.assertEqual(wallet.balance, Decimal('5.0')) 134 | self.assertEqual(wallet.unconfirmed, Decimal('0')) 135 | 136 | def test_tx(self): 137 | tx = Transaction.objects.get(id=self.tx.id) 138 | self.assertTrue(tx.processed) 139 | 140 | 141 | class WalletAddress(TransactionTestCase): 142 | def setUp(self): 143 | self.btc = Currency.objects.create(label='Bitcoin', ticker='btc') 144 | self.ltc = Currency.objects.create(label='Litecoin', ticker='ltc', magicbyte='48') 145 | self.wallet = Wallet.objects.create(currency=self.btc, label='Test') 146 | 147 | def test_no_addresses(self): 148 | self.assertIsNone(self.wallet.get_address()) 149 | 150 | def test_active_address(self): 151 | active = Address.objects.create(address='1111111111111111111114oLvT2', wallet=self.wallet, currency=self.btc, active=True) 152 | Address.objects.create(address='1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i', wallet=self.wallet, currency=self.btc, active=False) 153 | self.assertEqual(self.wallet.get_address(), active) 154 | 155 | def test_unused_address(self): 156 | unused = Address.objects.create(address='1Eym7pyJcaambv8FG4ZoU8A4xsiL9us2zz', wallet=self.wallet, currency=self.btc, active=False) 157 | Address.objects.create(address='LRNYxwQsHpm2A1VhawrJQti3nUkPN7vtq3', currency=self.ltc, active=True) 158 | self.assertEqual(self.wallet.get_address(), unused) 159 | 160 | 161 | class WalletWithdraw(TransactionTestCase): 162 | def setUp(self): 163 | self.btc = Currency.objects.create(label='Bitcoin', ticker='btc') 164 | self.wallet = Wallet.objects.create(currency=self.btc, label='Test', balance=Decimal('1.0')) 165 | self.amount = Decimal('1.0') 166 | self.address = '1111111111111111111114oLvT2' 167 | self.desc = 'some desc' 168 | 169 | def test_operation(self): 170 | self.wallet.withdraw_to_address(self.address, self.amount, self.desc) 171 | op = Operation.objects.all()[0] 172 | 173 | self.assertEqual(op.wallet, self.wallet) 174 | self.assertEqual(op.holded, self.amount) 175 | self.assertEqual(op.balance, -self.amount) 176 | self.assertEqual(op.description, self.desc) 177 | self.assertIsNotNone(op.reason) 178 | 179 | def test_wallet(self): 180 | self.wallet.withdraw_to_address(self.address, self.amount, self.desc) 181 | wallet = Wallet.objects.get(id=self.wallet.id) 182 | self.assertEqual(wallet.balance, Decimal('0')) 183 | self.assertEqual(wallet.holded, self.amount) 184 | 185 | def test_no_money(self): 186 | with self.assertRaises(ValueError): 187 | self.wallet.withdraw_to_address(self.address, Decimal('100')) 188 | 189 | def test_wrong_address(self): 190 | with self.assertRaises(ValueError): 191 | self.wallet.withdraw_to_address('mz4ZbfKfU4SQWRDagkfX2TLAotpimAAVFE', Decimal('100')) 192 | 193 | 194 | class TaskWithdraw(TransactionTestCase): 195 | def setUp(self): 196 | self.currency = Currency.objects.create(label='Testnet', ticker='tst', magicbyte='111,196') 197 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test', balance=Decimal('1.0')) 198 | self.txid = 'ea12fb225a0665e6ca35ab3fd7a514c36d1d5028d99340931d745dab62c13f8a' 199 | 200 | self.mock = MagicMock(name='asp') 201 | self.mock.return_value = self.mock 202 | self.mock.sendmany.return_value = self.txid 203 | self.mock.gettransaction.return_value = { 204 | 'fee': Decimal('-0.00010000'), 205 | 'timereceived': 1410086093, 206 | 'hex': '010000000181d61f31536c155f43149bfa1a1ed0cd45c504f82e27f8411f14e6b37f926c62000000006a47304402206ac1b06a2ab1ad05c7874728188c73d99de7b8185bda0bce28e06762ea265ec0022050e614fe7aa4d34b75e6465685f12e3589a34763afa383414aaf4d2d62171ba20121020e871b50e2e1d46cac2f2b7611f1fcc97d41e9ced660682191c6ea391c7189e5ffffffff04e0247c38000000001976a91462403a00c6e4906d7624818eae2dc7572f0f592588ac002d3101000000001976a914a17b7337c17ab511686649515f7861944035846588ac80969800000000001976a91437138adaa16d895739a61d10d33fd0b898db552288ac80969800000000001976a914a621b489961d24092eba3838d3142173a8f9d8c488ac00000000', 207 | 'txid': 'ea12fb225a0665e6ca35ab3fd7a514c36d1d5028d99340931d745dab62c13f8a', 208 | 'amount': Decimal('-0.40000000'), 209 | 'walletconflicts': [], 210 | 'details': [{'category': 'send', 211 | 'account': '', 212 | 'fee': Decimal('-0.00010000'), 213 | 'amount': Decimal('-0.20000000'), 214 | 'address': 'mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB'}, 215 | {'category': 'send', 216 | 'account': '', 217 | 'fee': Decimal('-0.00010000'), 218 | 'amount': Decimal('-0.10000000'), 219 | 'address': 'mkYAsS9QLYo5mXVjuvxKkZUhQJxiMLX5Xk'}, 220 | {'category': 'send', 221 | 'account': '', 222 | 'fee': Decimal('-0.00010000'), 223 | 'amount': Decimal('-0.10000000'), 224 | 'address': 'mvfNqn5AoVWrsJGuKrdPuoQhYs71CR9uFA'}], 225 | 'confirmations': 0, 226 | 'time': 1410086093 227 | } 228 | 229 | 230 | def test_process_withdraw_transactions(self): 231 | self.wallet.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('0.1')) 232 | self.wallet.withdraw_to_address('mkYAsS9QLYo5mXVjuvxKkZUhQJxiMLX5Xk', Decimal('0.1')) 233 | self.wallet.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('0.1')) 234 | self.wallet.withdraw_to_address('mvfNqn5AoVWrsJGuKrdPuoQhYs71CR9uFA', Decimal('0.1')) 235 | 236 | with patch('cc.tasks.AuthServiceProxy', self.mock): 237 | tasks.process_withdraw_transactions(ticker=self.currency.ticker) 238 | 239 | self.mock.gettransaction.assert_called_once_with(self.txid) 240 | self.mock.sendmany.assert_called_once_with('', { 241 | 'mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB': Decimal('0.2'), 242 | 'mkYAsS9QLYo5mXVjuvxKkZUhQJxiMLX5Xk': Decimal('0.1'), 243 | 'mvfNqn5AoVWrsJGuKrdPuoQhYs71CR9uFA': Decimal('0.1') 244 | }) 245 | 246 | wallet = Wallet.objects.get(id=self.wallet.id) 247 | self.assertEqual(wallet.holded, Decimal('0')) 248 | self.assertEqual(wallet.balance, Decimal('0.5999')) 249 | 250 | fee_operation = Operation.objects.get(wallet=wallet, description='Network fee') 251 | self.assertEqual(fee_operation.balance, Decimal('-0.0001')) 252 | self.assertEqual(fee_operation.holded, Decimal('-0.4')) 253 | 254 | 255 | class TaskRefillAddressQueue(TransactionTestCase): 256 | def setUp(self): 257 | self.currency = Currency.objects.create(label='Testnet', ticker='tst', magicbyte='111,196') 258 | self.wallet = Wallet.objects.create(currency=self.currency) 259 | 260 | self.mock = MagicMock(name='asp') 261 | self.mock.return_value = self.mock 262 | self.mock.getnewaddress.side_effect = lambda: ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20)) 263 | 264 | def refill_addresses_queue(self): 265 | self.assertEqual(len(Address.objects.all()), 0) 266 | 267 | with patch('cc.tasks.AuthServiceProxy', self.mock): 268 | tasks.refill_addresses_queue() 269 | 270 | self.assertEqual(len(Address.objects.all()), settings.CC_ADDRESS_QUEUE) 271 | 272 | 273 | class WalletTransfer(TransactionTestCase): 274 | def setUp(self): 275 | self.currency = Currency.objects.create(label='Testnet', ticker='tst', magicbyte='111,196') 276 | self.wallet1 = Wallet.objects.create(currency=self.currency, balance=1) 277 | self.wallet2 = Wallet.objects.create(currency=self.currency, balance=0) 278 | 279 | def test_transfer(self): 280 | self.wallet1.transfer(Decimal('1'), self.wallet2) 281 | 282 | self.assertEqual(self.wallet1.balance, 0) 283 | self.assertEqual(self.wallet2.balance, 1) 284 | 285 | o1 = Operation.objects.get(wallet=self.wallet1) 286 | o2 = Operation.objects.get(wallet=self.wallet2) 287 | 288 | self.assertEqual(o1.balance, -1) 289 | self.assertEqual(o2.balance, 1) 290 | 291 | 292 | class QueryTransaction(TransactionTestCase): 293 | def setUp(self): 294 | self.txdict = { 295 | "amount" : Decimal('3'), 296 | "confirmations" : 54271, 297 | "blockhash" : "00000000000000017adae0a02c36e7a6379991aad2ce35c2ba1b540aff01d7b8", 298 | "blockindex" : 744, 299 | "blocktime" : 1391222639, 300 | "txid" : "01c17411ff6a4278ada87c28dad74b9d1e79c799743fd2d63dac945645123ab3", 301 | "walletconflicts" : [ 302 | ], 303 | "time" : 1391222405, 304 | "timereceived" : 1391222405, 305 | "details" : [ 306 | { 307 | "account" : "somerandomstring14aqqwd", 308 | "address" : "16ahqjUA7VJMuBpKjR3zX48xnTgPMM47cr", 309 | "category" : "receive", 310 | "amount" : Decimal('1') 311 | }, 312 | { 313 | "account" : "somerandomstring14aqqwd", 314 | "address" : "1FLrCWUJw5SG7uDHzkrRLih55PxMC763eu", 315 | "category" : "receive", 316 | "amount" : Decimal('2') 317 | } 318 | ] 319 | } 320 | self.currency = Currency.objects.create(label='Bitcoin', ticker='BTC', magicbyte='0,5') 321 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test') 322 | self.address1 = Address.objects.create(address=self.txdict['details'][0]['address'], wallet=self.wallet, currency=self.currency) 323 | self.address2 = Address.objects.create(address=self.txdict['details'][1]['address'], wallet=self.wallet, currency=self.currency) 324 | 325 | self.mock = MagicMock(name='asp') 326 | self.mock.return_value = self.mock 327 | self.mock.gettransaction.return_value = self.txdict 328 | 329 | def test_balance(self): 330 | with patch('cc.tasks.AuthServiceProxy', self.mock): 331 | tasks.query_transaction('BTC', self.txdict['txid']) 332 | 333 | wallet = Wallet.objects.get(id=self.wallet.id) 334 | 335 | self.assertEqual(wallet.balance, Decimal('3')) 336 | 337 | 338 | class Dust(TransactionTestCase): 339 | def setUp(self): 340 | self.currency = Currency.objects.create(label='Testnet', ticker='tst', magicbyte='111,196', dust=Decimal('0.00005430')) 341 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test', balance=Decimal('2.0')) 342 | self.txid = 'ea12fb225a0665e6ca35ab3fd7a514c36d1d5028d99340931d745dab62c13f8a' 343 | 344 | self.mock = MagicMock(name='asp') 345 | self.mock.return_value = self.mock 346 | self.mock.sendmany.return_value = self.txid 347 | self.mock.gettransaction.return_value = { 348 | 'fee': Decimal('-0.00010000'), 349 | 'timereceived': 1410086093, 350 | 'hex': '010000000181d61f31536c155f43149bfa1a1ed0cd45c504f82e27f8411f14e6b37f926c62000000006a47304402206ac1b06a2ab1ad05c7874728188c73d99de7b8185bda0bce28e06762ea265ec0022050e614fe7aa4d34b75e6465685f12e3589a34763afa383414aaf4d2d62171ba20121020e871b50e2e1d46cac2f2b7611f1fcc97d41e9ced660682191c6ea391c7189e5ffffffff04e0247c38000000001976a91462403a00c6e4906d7624818eae2dc7572f0f592588ac002d3101000000001976a914a17b7337c17ab511686649515f7861944035846588ac80969800000000001976a91437138adaa16d895739a61d10d33fd0b898db552288ac80969800000000001976a914a621b489961d24092eba3838d3142173a8f9d8c488ac00000000', 351 | 'txid': 'ea12fb225a0665e6ca35ab3fd7a514c36d1d5028d99340931d745dab62c13f8a', 352 | 'amount': Decimal('-10000000'), 353 | 'walletconflicts': [], 354 | 'details': [{'category': 'send', 355 | 'account': '', 356 | 'fee': Decimal('-0.00010000'), 357 | 'amount': Decimal('-1.0000000'), 358 | 'address': 'mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB'}] 359 | } 360 | 361 | def test_process_withdraw_transactions(self): 362 | self.wallet.withdraw_to_address('mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB', Decimal('1')) 363 | self.wallet.withdraw_to_address('mvfNqn5AoVWrsJGuKrdPuoQhYs71CR9uFA', Decimal('0.00000001')) 364 | 365 | with patch('cc.tasks.AuthServiceProxy', self.mock): 366 | tasks.process_withdraw_transactions(ticker=self.currency.ticker) 367 | 368 | self.mock.sendmany.assert_called_once_with('', { 369 | 'mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB': Decimal('1') 370 | }) 371 | 372 | wallet = Wallet.objects.get(id=self.wallet.id) 373 | self.assertEqual(wallet.balance, Decimal('0.99989999')) 374 | 375 | wt1 = WithdrawTransaction.objects.get(address='mvEnyQ9b9iTA11QMHAwSVtHUrtD4CTfiDB') 376 | wt2 = WithdrawTransaction.objects.get(address='mvfNqn5AoVWrsJGuKrdPuoQhYs71CR9uFA') 377 | self.assertEqual(wt1.txid, self.txid) 378 | self.assertIsNone(wt2.txid) 379 | 380 | 381 | class MultiplySendAndRecieve(TransactionTestCase): 382 | def setUp(self): 383 | self.currency = Currency.objects.create(label='Dogecoin', ticker='DOGE', magicbyte='30,22', dust=Decimal('0.00005430')) 384 | self.wallet = Wallet.objects.create(currency=self.currency, label='Test', balance=Decimal('0')) 385 | address1 = Address.objects.create(address='DAxYL8VtrREDXojb7BtPVc3kehehGobN9u', wallet=self.wallet, currency=self.currency) 386 | address2 = Address.objects.create(address='DFDwMVrNG6oqLzyRWmJh32qsmH49nseY8i', wallet=self.wallet, currency=self.currency) 387 | address3 = Address.objects.create(address='DEQMUMT8bG6RKP1tjjRRhT2NMbRkzs2TN4', wallet=self.wallet, currency=self.currency) 388 | 389 | self.mock = MagicMock(name='asp') 390 | self.mock.return_value = self.mock 391 | self.mock.getblockcount.return_value = 2535930 392 | self.mock.getblockhash.return_value = '1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691' 393 | self.mock.listsinceblock.return_value = { 394 | "transactions": [{ 395 | "account" : "", 396 | "address" : "D6ija2Wvw4TWCg9a6jvwLQ1gqZzirwLHYC", 397 | "category" : "send", 398 | "amount" : Decimal('-277.96340734'), 399 | "vout" : 0, 400 | "fee" : -1.00000000, 401 | "confirmations" : 173, 402 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 403 | "blockindex" : 12, 404 | "blocktime" : 1546085077, 405 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 406 | "walletconflicts" : [], 407 | "time" : 1546085018, 408 | "timereceived" : 1546085018 409 | }, 410 | { 411 | "account" : "", 412 | "address" : "DFcNpsPqXHufBbLfNfCEA6N2Vv5cP41z6r", 413 | "category" : "send", 414 | "amount" : Decimal('-79.16240137'), 415 | "vout" : 1, 416 | "fee" : -1.00000000, 417 | "confirmations" : 173, 418 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 419 | "blockindex" : 12, 420 | "blocktime" : 1546085077, 421 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 422 | "walletconflicts" : [], 423 | "time" : 1546085018, 424 | "timereceived" : 1546085018 425 | }, 426 | { 427 | "account" : "", 428 | "address" : "DHvgASzm2RPqStJxUANCM6ZDsFdTyRfjwb", 429 | "category" : "send", 430 | "amount" : Decimal('-39.58120069'), 431 | "vout" : 3, 432 | "fee" : -1.00000000, 433 | "confirmations" : 173, 434 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 435 | "blockindex" : 12, 436 | "blocktime" : 1546085077, 437 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 438 | "walletconflicts" : [], 439 | "time" : 1546085018, 440 | "timereceived" : 1546085018 441 | }, 442 | { 443 | "account" : "", 444 | "address" : "D6Cm1X9fKG2eYYiqCHXc1bWCk6RpVCXS3n", 445 | "category" : "send", 446 | "amount" : Decimal('-118.74360206'), 447 | "vout" : 4, 448 | "fee" : -1.00000000, 449 | "confirmations" : 173, 450 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 451 | "blockindex" : 12, 452 | "blocktime" : 1546085077, 453 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 454 | "walletconflicts" : [], 455 | "time" : 1546085018, 456 | "timereceived" : 1546085018 457 | }, 458 | { 459 | "account" : "", 460 | "address" : "DAxYL8VtrREDXojb7BtPVc3kehehGobN9u", 461 | "category" : "send", 462 | "amount" : Decimal('-79.16240137'), 463 | "vout" : 5, 464 | "fee" : -1.00000000, 465 | "confirmations" : 173, 466 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 467 | "blockindex" : 12, 468 | "blocktime" : 1546085077, 469 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 470 | "walletconflicts" : [], 471 | "time" : 1546085018, 472 | "timereceived" : 1546085018 473 | }, 474 | { 475 | "account" : "", 476 | "address" : "D9iXHXUMKni2ZeneMXQFfTvumL3DP1UNMc", 477 | "category" : "send", 478 | "amount" : Decimal('-198.80100597'), 479 | "vout" : 6, 480 | "fee" : -1.00000000, 481 | "confirmations" : 173, 482 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 483 | "blockindex" : 12, 484 | "blocktime" : 1546085077, 485 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 486 | "walletconflicts" : [], 487 | "time" : 1546085018, 488 | "timereceived" : 1546085018 489 | }, 490 | { 491 | "account" : "", 492 | "address" : "DFDwMVrNG6oqLzyRWmJh32qsmH49nseY8i", 493 | "category" : "send", 494 | "amount" : Decimal('-198.80100597'), 495 | "vout" : 7, 496 | "fee" : -1.00000000, 497 | "confirmations" : 173, 498 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 499 | "blockindex" : 12, 500 | "blocktime" : 1546085077, 501 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 502 | "walletconflicts" : [], 503 | "time" : 1546085018, 504 | "timereceived" : 1546085018 505 | }, 506 | { 507 | "account" : "", 508 | "address" : "DEQMUMT8bG6RKP1tjjRRhT2NMbRkzs2TN4", 509 | "category" : "send", 510 | "amount" : Decimal('-298.20150896'), 511 | "vout" : 8, 512 | "fee" : -1.00000000, 513 | "confirmations" : 173, 514 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 515 | "blockindex" : 12, 516 | "blocktime" : 1546085077, 517 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 518 | "walletconflicts" : [], 519 | "time" : 1546085018, 520 | "timereceived" : 1546085018 521 | }, 522 | { 523 | "account" : "", 524 | "address" : "DRWV6punNdNNMetJRegrkKRHA2eiuvBf3D", 525 | "category" : "send", 526 | "amount" : Decimal('-99.40050298'), 527 | "vout" : 9, 528 | "fee" : -1.00000000, 529 | "confirmations" : 173, 530 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 531 | "blockindex" : 12, 532 | "blocktime" : 1546085077, 533 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 534 | "walletconflicts" : [], 535 | "time" : 1546085018, 536 | "timereceived" : 1546085018 537 | }, 538 | { 539 | "account" : "", 540 | "address" : "DAxYL8VtrREDXojb7BtPVc3kehehGobN9u", 541 | "category" : "receive", 542 | "amount" : Decimal('79.16240137'), 543 | "vout" : 5, 544 | "confirmations" : 173, 545 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 546 | "blockindex" : 12, 547 | "blocktime" : 1546085077, 548 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 549 | "walletconflicts" : [], 550 | "time" : 1546085018, 551 | "timereceived" : 1546085018 552 | }, 553 | { 554 | "account" : "", 555 | "address" : "DFDwMVrNG6oqLzyRWmJh32qsmH49nseY8i", 556 | "category" : "receive", 557 | "amount" : Decimal('198.80100597'), 558 | "vout" : 7, 559 | "confirmations" : 173, 560 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 561 | "blockindex" : 12, 562 | "blocktime" : 1546085077, 563 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 564 | "walletconflicts" : [], 565 | "time" : 1546085018, 566 | "timereceived" : 1546085018 567 | }, 568 | { 569 | "account" : "", 570 | "address" : "DEQMUMT8bG6RKP1tjjRRhT2NMbRkzs2TN4", 571 | "category" : "receive", 572 | "amount" : Decimal('298.20150896'), 573 | "vout" : 8, 574 | "confirmations" : 173, 575 | "blockhash" : "7a114c079063e7a17e9282aa0d719e99fc0b178c4dc2e004f7be2277327513f6", 576 | "blockindex" : 12, 577 | "blocktime" : 1546085077, 578 | "txid" : "238cf78c93383c0bd42b10e331a2804fc34b968db0142dd27565ebf47b79638d", 579 | "walletconflicts" : [], 580 | "time" : 1546085018, 581 | "timereceived" : 1546085018 582 | }] 583 | } 584 | 585 | def test_balance(self): 586 | with patch('cc.tasks.AuthServiceProxy', self.mock): 587 | tasks.query_transactions('DOGE') 588 | 589 | wallet = Wallet.objects.get(id=self.wallet.id) 590 | 591 | self.assertEqual(wallet.balance, Decimal('576.1649163')) 592 | -------------------------------------------------------------------------------- /cc/tests/tests_zcash_regtest.py: -------------------------------------------------------------------------------- 1 | import string 2 | import random 3 | from time import sleep 4 | from decimal import Decimal 5 | from mock import patch, MagicMock 6 | from bitcoinrpc.authproxy import AuthServiceProxy, JSONRPCException 7 | from django.test import TransactionTestCase 8 | 9 | from cc.models import Wallet, Address, Currency, Operation, Transaction, WithdrawTransaction 10 | from cc import tasks 11 | from cc import settings 12 | 13 | 14 | settings.CC_CONFIRMATIONS = 2 15 | settings.CC_ACCOUNT = '' 16 | URL = 'http://root:toor@zcashd:18232/' 17 | 18 | 19 | class WalletAddressGet(TransactionTestCase): 20 | @classmethod 21 | def setUpClass(cls): 22 | super().setUpClass() 23 | cls.coin = AuthServiceProxy(URL) 24 | starting = True 25 | while starting: 26 | try: 27 | cls.coin.generate(101) 28 | except JSONRPCException as e: 29 | if e.code != -28: 30 | raise 31 | else: 32 | starting = False 33 | sleep(1) 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | cls.coin.stop() 38 | super().tearDownClass() 39 | 40 | def setUp(self): 41 | self.currency = Currency.objects.create(label='Zcash regtest', ticker='tzec', api_url=URL, magicbyte='29') 42 | tasks.refill_addresses_queue() 43 | 44 | def wait_for_operation(self, operation): 45 | waiting = True 46 | while waiting: 47 | waiting = self.coin.z_getoperationstatus([operation])[0]['status'] != 'success' 48 | sleep(1) 49 | 50 | def test_address_refill(self): 51 | wallet = Wallet.objects.create(currency=self.currency) 52 | address = wallet.get_address() 53 | self.assertTrue(address) 54 | 55 | def test_deposit(self): 56 | wallet_before = Wallet.objects.create(currency=self.currency) 57 | address = wallet_before.get_address().address 58 | self.coin.sendtoaddress(address, Decimal('1')) 59 | self.coin.generate(1) 60 | 61 | tasks.query_transactions('tzec') 62 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 63 | self.assertEqual(wallet_after1.balance, Decimal('0')) 64 | self.assertEqual(wallet_after1.holded, Decimal('0')) 65 | self.assertEqual(wallet_after1.unconfirmed, Decimal('1')) 66 | self.coin.generate(1) 67 | 68 | tasks.query_transactions('tzec') 69 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 70 | self.assertEqual(wallet_after2.balance, Decimal('1')) 71 | self.assertEqual(wallet_after2.holded, Decimal('0')) 72 | self.assertEqual(wallet_after2.unconfirmed, Decimal('0')) 73 | 74 | def test_withdraw(self): 75 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('1.0')) 76 | wallet_before.withdraw_to_address('tmGa4dwQrtw6ctnijug586PwXt9h8JExguc', Decimal('0.1')) 77 | wallet_before.withdraw_to_address('tmEHsxHvPS5inmcDYPDocFPmynGCz5PLYf3', Decimal('0.1')) 78 | wallet_before.withdraw_to_address('tmGa4dwQrtw6ctnijug586PwXt9h8JExguc', Decimal('0.1')) 79 | 80 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 81 | self.assertEqual(wallet_after1.balance, Decimal('0.7')) 82 | self.assertEqual(wallet_after1.holded, Decimal('0.3')) 83 | tasks.process_withdraw_transactions('tzec') 84 | self.coin.generate(2) 85 | 86 | wtx = WithdrawTransaction.objects.last() 87 | tx = self.coin.gettransaction(wtx.txid) 88 | 89 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 90 | self.assertEqual(wallet_after2.balance, Decimal('0.7') + tx['fee']) 91 | self.assertEqual(wallet_after2.holded, Decimal('0')) 92 | 93 | def test_withdraw_error(self): 94 | wallet_before = Wallet.objects.create(currency=self.currency, balance=Decimal('21000000')) 95 | wallet_before.withdraw_to_address('tmGa4dwQrtw6ctnijug586PwXt9h8JExguc', Decimal('21000000')) 96 | 97 | try: 98 | tasks.process_withdraw_transactions('tzec') 99 | except JSONRPCException: 100 | pass 101 | 102 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 103 | self.assertEqual(wallet_after1.balance, Decimal('0')) 104 | self.assertEqual(wallet_after1.holded, Decimal('21000000')) 105 | 106 | wtx = WithdrawTransaction.objects.last() 107 | 108 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 109 | self.assertEqual(wtx.state, wtx.ERROR) 110 | 111 | def test_deposit_from_z(self): 112 | zaddress = self.coin.z_getnewaddress('sprout') 113 | unspent = [u for u in self.coin.listunspent() if u['amount'] > 6][0] 114 | amount = unspent['amount'] 115 | operation1 = self.coin.z_sendmany(unspent['address'], [{'address': zaddress, 'amount': amount}], 1, 0) 116 | self.wait_for_operation(operation1) 117 | self.coin.generate(6) 118 | 119 | wallet_before = Wallet.objects.create(currency=self.currency) 120 | address = wallet_before.get_address().address 121 | operation2 = self.coin.z_sendmany(zaddress, [{'address': address, "amount": amount}], 1, 0) 122 | self.wait_for_operation(operation2) 123 | self.coin.generate(1) 124 | 125 | tasks.query_transactions('tzec') 126 | wallet_after1 = Wallet.objects.get(id=wallet_before.id) 127 | self.assertEqual(wallet_after1.balance, Decimal('0')) 128 | self.assertEqual(wallet_after1.holded, Decimal('0')) 129 | self.assertGreaterEqual(wallet_after1.unconfirmed, amount) # FIXME should be assertEqual 130 | self.coin.generate(1) 131 | 132 | tasks.query_transactions('tzec') 133 | wallet_after2 = Wallet.objects.get(id=wallet_before.id) 134 | self.assertEqual(wallet_after2.balance, amount) 135 | self.assertEqual(wallet_after2.holded, Decimal('0')) 136 | self.assertGreaterEqual(wallet_after2.unconfirmed, Decimal('0')) # FIXME should be assertEqual 137 | -------------------------------------------------------------------------------- /cc/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | import cc.views as views 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^blocknotify/$', views.blocknotify, name='cc-blocknotify'), 8 | url(r'^walletnotify/$', views.walletnotify, name='cc-walletnotify'), 9 | ] 10 | -------------------------------------------------------------------------------- /cc/validator.py: -------------------------------------------------------------------------------- 1 | from pycoin.encoding.b58 import a2b_hashed_base58 2 | from pycoin.encoding.exceptions import EncodingError 3 | 4 | 5 | def validate(address, magic_bytes): 6 | try: 7 | return a2b_hashed_base58(address)[:1] in bytes(map(int, magic_bytes.split(','))) 8 | except EncodingError: 9 | return False 10 | -------------------------------------------------------------------------------- /cc/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.http import HttpResponse, HttpResponseForbidden, HttpResponseBadRequest 4 | from django.http.request import validate_host, split_domain_port 5 | 6 | from . import settings 7 | from .models import Currency 8 | from .tasks import query_transactions, query_transaction 9 | 10 | 11 | def cc_validate_host(func): 12 | def validate(request): 13 | domain, port = split_domain_port(request.META['HTTP_HOST']) 14 | if not validate_host(domain, settings.CC_ALLOWED_HOSTS): 15 | return HttpResponseForbidden('forbiden') 16 | 17 | return func(request) 18 | 19 | return validate 20 | 21 | 22 | def vaidate_currency(func): 23 | def validate(request): 24 | ticker = request.GET.get('currency') 25 | if not ticker: 26 | return HttpResponseBadRequest('Currency is missing') 27 | 28 | try: 29 | currency = Currency.objects.get(ticker=ticker) 30 | except Currency.DoesNotExist: 31 | return HttpResponseBadRequest('Wrong currency ticker') 32 | 33 | return func(request) 34 | 35 | return validate 36 | 37 | 38 | def vaidate_txid(func): 39 | def validate(request): 40 | if not request.GET.get('txid'): 41 | return HttpResponseBadRequest('Txid is missing') 42 | 43 | return func(request) 44 | 45 | return validate 46 | 47 | 48 | @cc_validate_host 49 | @vaidate_currency 50 | def blocknotify(request): 51 | query_transactions.delay(ticker=request.GET['currency']) 52 | return HttpResponse('success') 53 | 54 | 55 | @cc_validate_host 56 | @vaidate_currency 57 | @vaidate_txid 58 | def walletnotify(request): 59 | query_transaction.delay(request.GET['currency'], request.GET['txid']) 60 | return HttpResponse('success') 61 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | testproject: 5 | build: . 6 | command: python3 testproject/manage.py test cc 7 | # command: python3 testproject/manage.py runserver 0.0.0.0:8000 8 | volumes: 9 | - .:/code 10 | ports: 11 | - "8000:8000" 12 | depends_on: 13 | - dashd 14 | - bitcoind 15 | - litecoind 16 | - zcashd 17 | 18 | bitcoind: 19 | image: btcpayserver/bitcoin:0.17.0 20 | environment: 21 | BITCOIN_NETWORK: regtest 22 | BITCOIN_EXTRA_ARGS: |- 23 | rpcuser=root 24 | rpcpassword=toor 25 | rpcport=43782 26 | ports: 27 | - "43782:43782" 28 | 29 | dashd: 30 | image: btcpayserver/dash:0.13.0 31 | environment: 32 | BITCOIN_EXTRA_ARGS: | 33 | regtest=1 34 | rpcuser=root 35 | rpcpassword=toor 36 | rpcport=19998 37 | ports: 38 | - "19998:19998" 39 | 40 | litecoind: 41 | image: nicolasdorier/docker-litecoin:0.16.3 42 | environment: 43 | BITCOIN_EXTRA_ARGS: |- 44 | regtest=1 45 | rpcuser=root 46 | rpcpassword=toor 47 | rpcport=19335 48 | ports: 49 | - "19335:19335" 50 | 51 | zcashd: 52 | image: messari/zcash-core:2.0.3 53 | command: -printtoconsole -regtest=1 -rpcuser=root -rpcpassword=toor -server -rest -rpcallowip=0.0.0.0/0 54 | ports: 55 | - "18232:18232" 56 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from os.path import join, dirname 3 | 4 | 5 | setup( 6 | author='Ivan Vershigora', 7 | author_email='ivan.vershigora@gmail.com', 8 | classifiers=[ 9 | 'Development Status :: 5 - Production/Stable', 10 | 'Framework :: Django', 11 | 'Intended Audience :: Developers', 12 | 'License :: OSI Approved :: MIT License', 13 | 'Programming Language :: Python :: 3', 14 | 'Programming Language :: Python :: 3.4', 15 | 'Programming Language :: Python :: 3.5', 16 | 'Programming Language :: Python :: 3.6', 17 | 'Topic :: Software Development :: Build Tools', 18 | ], 19 | description='Django wallet for Bitcoin and other cryptocurrencies', 20 | long_description_content_type="text/markdown", 21 | download_url = 'https://github.com/limpbrains/django-cc/tarball/0.2.3', 22 | install_requires=[ 23 | 'celery>=3', 24 | 'Django>=1.7', 25 | 'mock', 26 | 'pycoin>=0.90', 27 | 'python-bitcoinrpc>=1.0', 28 | ], 29 | keywords='bitcoin django wallet cryptocurrency litecoin zcash dogecoin dash', 30 | license='MIT License', 31 | long_description=open(join(dirname(__file__), 'README.md')).read(), 32 | name='django-cc', 33 | packages=find_packages(), 34 | url='https://github.com/limpbrains/django-cc', 35 | version='0.2.3', 36 | ) 37 | -------------------------------------------------------------------------------- /testproject/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limpbrains/django-cc/3af391ebac929c46f24e6ea6019e5edb3e410e61/testproject/db.sqlite3 -------------------------------------------------------------------------------- /testproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | sys.path.append('../') 6 | 7 | if __name__ == '__main__': 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | -------------------------------------------------------------------------------- /testproject/requirements.txt: -------------------------------------------------------------------------------- 1 | celery>=3 2 | Django>=1.7 3 | mock 4 | pycoin>=0.90 5 | python-bitcoinrpc>=1.0 6 | ipython 7 | -------------------------------------------------------------------------------- /testproject/testproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limpbrains/django-cc/3af391ebac929c46f24e6ea6019e5edb3e410e61/testproject/testproject/__init__.py -------------------------------------------------------------------------------- /testproject/testproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'c+9yz_f#!#n3&etl&9rw#62or$^djf_-6d85in3zkyp$um*3r9' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'cc', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | ROOT_URLCONF = 'testproject.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = 'testproject.wsgi.application' 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 76 | 77 | DATABASES = { 78 | 'default': { 79 | 'ENGINE': 'django.db.backends.sqlite3', 80 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 120 | 121 | STATIC_URL = '/static/' 122 | -------------------------------------------------------------------------------- /testproject/testproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | from django.conf.urls import include, url 4 | 5 | 6 | urlpatterns = [ 7 | path('admin/', admin.site.urls), 8 | url(r'^cc/', include('cc.urls')), 9 | ] 10 | -------------------------------------------------------------------------------- /testproject/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------