├── docs
├── .gitignore
├── requirements.txt
├── _static
│ └── .gitignore
├── _templates
│ └── .gitignore
├── includes
│ ├── finding-home-directory.rst
│ ├── add-user-to-docker-group.rst
│ └── cron-examples.rst
├── scheduling.rst
├── README.md
├── Makefile
├── conf.py
├── make.bat
├── faq.rst
├── installation.rst
├── index.rst
├── persistent-storage.rst
├── tagged-balance.rst
├── getting-started.rst
└── xpub-withdraw.rst
├── var
├── cache
│ └── .gitignore
├── logs
│ └── .gitignore
└── storage
│ └── .gitignore
├── resources
├── xpub_derive
│ ├── requirements.txt
│ ├── README.md
│ └── main.py
└── images
│ ├── logo.png
│ ├── logo-small.png
│ ├── logo-white.png
│ ├── dca-illustration.png
│ └── github-logo-colored.png
├── tools
└── php-cs-fixer
│ └── composer.json
├── .dockerignore
├── .gitignore
├── docker
├── docker-entrypoint.sh
├── php-development.ini
└── php-production.ini
├── .readthedocs.yml
├── sonar-project.properties
├── .github
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── run-tests-on-change.yml
│ └── build-and-publish-to-docker.yml
├── src
├── Exception
│ ├── Bl3pClientException.php
│ ├── BuyTimeoutException.php
│ ├── BinanceClientException.php
│ ├── BitvavoClientException.php
│ ├── KrakenClientException.php
│ ├── NoExchangeAvailableException.php
│ ├── UnableToGetRandomQuoteException.php
│ ├── NoMasterPublicKeyAvailableException.php
│ ├── NoRecipientAddressAvailableException.php
│ ├── CouldNotGetExternalDerivationException.php
│ ├── NoDerivationComponentAvailableException.php
│ └── PendingBuyOrderException.php
├── Validator
│ ├── ValidationException.php
│ ├── BitcoinAddressValidatorException.php
│ ├── ValidationInterface.php
│ └── BitcoinAddressValidator.php
├── Bitcoin.php
├── Client
│ ├── BinanceClientInterface.php
│ ├── BitvavoClientInterface.php
│ ├── KrakenClientInterface.php
│ ├── Bl3pClientInterface.php
│ ├── VerboseHttpClientDecorator.php
│ ├── BitvavoClient.php
│ └── KrakenClient.php
├── Command
│ ├── MachineReadableOutputCommandInterface.php
│ ├── VersionCommand.php
│ ├── BalanceCommand.php
│ └── VerifyXPubCommand.php
├── Service
│ ├── BalanceServiceInterface.php
│ ├── WithdrawServiceInterface.php
│ ├── BalanceService.php
│ ├── Kraken
│ │ ├── KrakenBalanceService.php
│ │ └── KrakenWithdrawService.php
│ ├── Bitvavo
│ │ ├── BitvavoBalanceService.php
│ │ └── BitvavoWithdrawService.php
│ ├── Bl3p
│ │ ├── Bl3pBalanceService.php
│ │ └── Bl3pWithdrawService.php
│ ├── Binance
│ │ ├── BinanceBalanceService.php
│ │ └── BinanceWithdrawService.php
│ ├── BuyServiceInterface.php
│ ├── MockExchange
│ │ ├── MockExchangeWithdrawService.php
│ │ └── MockExchangeBuyService.php
│ └── BuyService.php
├── Provider
│ ├── WithdrawAddressProviderInterface.php
│ ├── SimpleWithdrawAddressProvider.php
│ └── XpubWithdrawAddressProvider.php
├── Component
│ ├── AddressFromMasterPublicKeyComponentInterface.php
│ ├── ExternalAddressFromMasterPublicKeyComponent.php
│ └── AddressFromMasterPublicKeyComponent.php
├── Model
│ ├── Quote.php
│ ├── CompletedWithdraw.php
│ ├── NotificationEmailConfiguration.php
│ ├── NotificationEmailTemplateInformation.php
│ ├── RemoteReleaseInformation.php
│ └── CompletedBuyOrder.php
├── Repository
│ ├── TaggedIntegerRepositoryInterface.php
│ └── JsonFileTaggedIntegerRepository.php
├── Event
│ ├── BuySuccessEvent.php
│ └── WithdrawSuccessEvent.php
├── Factory
│ └── DeriveFromMasterPublicKeyComponentFactory.php
└── EventListener
│ ├── ResetTaggedBalanceListener.php
│ ├── Notifications
│ ├── AbstractSendTelegramListener.php
│ ├── SendEmailOnBuyListener.php
│ ├── SendEmailOnWithdrawListener.php
│ ├── SendTelegramOnWithdrawListener.php
│ └── SendTelegramOnBuyListener.php
│ ├── IncreaseTaggedBalanceListener.php
│ ├── WriteOrderToCsvListener.php
│ └── XPubAddressUsedListener.php
├── tests
├── Service
│ ├── Bl3p
│ │ └── BuyServiceTestException.php
│ ├── Bitvavo
│ │ ├── BuyServiceTestException.php
│ │ └── BitvavoBalanceServiceTest.php
│ ├── Kraken
│ │ └── KrakenBalanceServiceTest.php
│ ├── BalanceServiceTest.php
│ └── Binance
│ │ └── BinanceBalanceServiceTest.php
├── bootstrap.php
├── Model
│ ├── QuoteTest.php
│ ├── CompletedWithdrawTest.php
│ ├── NotificationEmailConfigurationTest.php
│ ├── NotificationEmailTemplateInformationTest.php
│ ├── RemoteReleaseInformationTest.php
│ └── CompletedBuyOrderTest.php
├── Exception
│ └── PendingBuyOrderExceptionTest.php
├── Event
│ ├── BuySuccessEventTest.php
│ └── WithdrawSuccessEventTest.php
├── EventListener
│ ├── Notifications
│ │ ├── AbstractSendTelegramListenerTest.php
│ │ ├── TesterOfAbstractSendEmailListener.php
│ │ └── SendEmailOnWithdrawListenerTest.php
│ ├── ResetTaggedBalanceListenerTest.php
│ └── IncreaseTaggedBalanceListenerTest.php
├── Provider
│ ├── SimpleWithdrawAddressProviderTest.php
│ └── XpubWithdrawAddressProviderTest.php
├── Validator
│ └── BitcoinAddressValidatorTest.php
├── Component
│ ├── AddressFromMasterPublicKeyComponentTest.php
│ └── ExternalAddressFromMasterPublicKeyComponentTest.php
├── Command
│ ├── BalanceCommandTest.php
│ └── VersionCommandTest.php
└── Factory
│ └── DeriveFromMasterPublicKeyComponentFactoryTest.php
├── phpunit.xml.dist
├── rector.php
├── docker-compose.yml
├── LICENSE
├── .php-cs-fixer.php
├── composer.json
├── bin
└── bitcoin-dca
├── README.md
└── Dockerfile
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _build
2 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx_rtd_theme
2 |
--------------------------------------------------------------------------------
/var/cache/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/var/logs/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/docs/_static/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/docs/_templates/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/var/storage/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/resources/xpub_derive/requirements.txt:
--------------------------------------------------------------------------------
1 | btclib==2020.5.11
2 | fire==0.3.1
3 | termcolor==1.1.0
4 |
--------------------------------------------------------------------------------
/resources/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jorijn/bitcoin-dca/HEAD/resources/images/logo.png
--------------------------------------------------------------------------------
/resources/images/logo-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jorijn/bitcoin-dca/HEAD/resources/images/logo-small.png
--------------------------------------------------------------------------------
/resources/images/logo-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jorijn/bitcoin-dca/HEAD/resources/images/logo-white.png
--------------------------------------------------------------------------------
/tools/php-cs-fixer/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "require": {
3 | "friendsofphp/php-cs-fixer": "^3.0"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/resources/images/dca-illustration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jorijn/bitcoin-dca/HEAD/resources/images/dca-illustration.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | vendor
2 | docs
3 | var/cache/*
4 | .env
5 | .env.dist
6 | .php_cs
7 | resources/xpub_derive/venv
8 | Dockerfile
9 |
--------------------------------------------------------------------------------
/docs/includes/finding-home-directory.rst:
--------------------------------------------------------------------------------
1 | .. note::
2 | You can find out where your home directory is using ``$ echo $HOME``.
3 |
--------------------------------------------------------------------------------
/resources/images/github-logo-colored.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jorijn/bitcoin-dca/HEAD/resources/images/github-logo-colored.png
--------------------------------------------------------------------------------
/resources/xpub_derive/README.md:
--------------------------------------------------------------------------------
1 | # Development
2 |
3 | Run:
4 |
5 | ```bash
6 | $ python3 -m pip install -r requirements.txt
7 | ```
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | vendor
3 | .php-cs-fixer.cache
4 | .vscode
5 | resources/xpub_derive/.idea
6 | resources/xpub_derive/venv
7 | .phpunit.result.cache
8 |
--------------------------------------------------------------------------------
/docker/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | if [ $1 == "composer" ]; then
5 | exec $@
6 | exit $?
7 | fi
8 |
9 | exec /app/bin/bitcoin-dca "$@"
10 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | sphinx:
3 | configuration: docs/conf.py
4 | formats: all
5 | python:
6 | version: 3.7
7 | install:
8 | - requirements: docs/requirements.txt
9 |
--------------------------------------------------------------------------------
/docs/includes/add-user-to-docker-group.rst:
--------------------------------------------------------------------------------
1 | .. note::
2 | Add your unprivileged user to the correct group to execute Docker commands without root: ``$ sudo usermod -aG docker ${USER}``. You might need to log out & log back in for this to take effect.
3 |
--------------------------------------------------------------------------------
/docs/scheduling.rst:
--------------------------------------------------------------------------------
1 | .. _scheduling:
2 |
3 | Scheduling buy & withdrawing
4 | ============================
5 |
6 | .. note::
7 | This guide is meant for people on Linux. You can use it on your VPS or Raspberry Pi.
8 |
9 | .. include:: ./includes/cron-examples.rst
10 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Development for documentation
2 |
3 | Version: Python 3.7 (`brew install python@3.7` & `brew link --overwrite python@3.7`)
4 |
5 | Install Sphinx Autobuild: `pip3 install sphinx-autobuild sphinx_rtd_theme`
6 |
7 | Run: `sphinx-autobuild . _build/html --port 10000`
8 |
9 | Open: http://127.0.0.1:10000
10 |
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | sonar.projectKey=Jorijn_bitcoin-dca
2 | sonar.organization=jorijns
3 | sonar.php.coverage.reportPaths=tests_coverage.xml
4 | sonar.php.tests.reportPath=tests_log.xml
5 | sonar.coverage.exclusions=**/*Test.php,*.py,**/*.py
6 | sonar.php.exclusions=**/vendor/**,*.py,**/*.py,**/*.email.html.php
7 | sonar.html.exclusions=**/*.email.html.php
8 | sonar.python.version=3
9 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "composer"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 |
8 | - package-ecosystem: "docker"
9 | directory: "/"
10 | schedule:
11 | interval: "daily"
12 |
13 | - package-ecosystem: "github-actions"
14 | directory: "/"
15 | schedule:
16 | interval: "daily"
17 |
--------------------------------------------------------------------------------
/src/Exception/Bl3pClientException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class Bl3pClientException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/BuyTimeoutException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class BuyTimeoutException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Validator/ValidationException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Validator;
15 |
16 | class ValidationException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/BinanceClientException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class BinanceClientException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/BitvavoClientException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class BitvavoClientException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/KrakenClientException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class KrakenClientException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/tests/Service/Bl3p/BuyServiceTestException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Service;
15 |
16 | class BuyServiceTestException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Bitcoin.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca;
15 |
16 | final class Bitcoin
17 | {
18 | public const SATOSHIS = '100000000';
19 | public const DECIMALS = 8;
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exception/NoExchangeAvailableException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class NoExchangeAvailableException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/tests/Service/Bitvavo/BuyServiceTestException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Service;
15 |
16 | class BuyServiceTestException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/UnableToGetRandomQuoteException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class UnableToGetRandomQuoteException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Validator/BitcoinAddressValidatorException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Validator;
15 |
16 | class BitcoinAddressValidatorException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/NoMasterPublicKeyAvailableException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class NoMasterPublicKeyAvailableException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/NoRecipientAddressAvailableException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class NoRecipientAddressAvailableException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/CouldNotGetExternalDerivationException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class CouldNotGetExternalDerivationException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exception/NoDerivationComponentAvailableException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class NoDerivationComponentAvailableException extends \RuntimeException
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Client/BinanceClientInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Client;
15 |
16 | interface BinanceClientInterface
17 | {
18 | public function request(string $method, string $url, array $options = []): array;
19 | }
20 |
--------------------------------------------------------------------------------
/src/Command/MachineReadableOutputCommandInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Command;
15 |
16 | interface MachineReadableOutputCommandInterface
17 | {
18 | public function isDisplayingMachineReadableOutput(): bool;
19 | }
20 |
--------------------------------------------------------------------------------
/src/Validator/ValidationInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Validator;
15 |
16 | interface ValidationInterface
17 | {
18 | /**
19 | * @param mixed $input
20 | */
21 | public function validate($input): void;
22 | }
23 |
--------------------------------------------------------------------------------
/src/Client/BitvavoClientInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Client;
15 |
16 | interface BitvavoClientInterface
17 | {
18 | public function apiCall(string $path, string $method = 'GET', array $parameters = [], array $body = []): array;
19 | }
20 |
--------------------------------------------------------------------------------
/src/Service/BalanceServiceInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service;
15 |
16 | interface BalanceServiceInterface
17 | {
18 | public function supportsExchange(string $exchange): bool;
19 |
20 | public function getBalances(): array;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Provider/WithdrawAddressProviderInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Provider;
15 |
16 | interface WithdrawAddressProviderInterface
17 | {
18 | /**
19 | * Method should return a Bitcoin address for withdrawal.
20 | */
21 | public function provide(): string;
22 | }
23 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ./src
6 |
7 |
8 |
9 |
10 | ./tests
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/Client/KrakenClientInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Client;
15 |
16 | interface KrakenClientInterface
17 | {
18 | public function queryPublic(string $method, array $arguments = []): array;
19 |
20 | public function queryPrivate(string $method, array $arguments = []): array;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Component/AddressFromMasterPublicKeyComponentInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Component;
15 |
16 | interface AddressFromMasterPublicKeyComponentInterface
17 | {
18 | public function derive(string $masterPublicKey, $path = '0/0'): string;
19 |
20 | public function supported(): bool;
21 | }
22 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | use Rector\Config\RectorConfig;
15 | use Rector\Set\ValueObject\LevelSetList;
16 |
17 | return static function (RectorConfig $rectorConfig): void {
18 | $rectorConfig->paths([
19 | __DIR__.'/src',
20 | __DIR__.'/tests',
21 | ]);
22 |
23 | $rectorConfig->sets([
24 | LevelSetList::UP_TO_PHP_82,
25 | ]);
26 | };
27 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/src/Model/Quote.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Model;
15 |
16 | class Quote
17 | {
18 | public function __construct(protected string $quote, protected string $author)
19 | {
20 | }
21 |
22 | public function getQuote(): string
23 | {
24 | return $this->quote;
25 | }
26 |
27 | public function getAuthor(): string
28 | {
29 | return $this->author;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/src/Repository/TaggedIntegerRepositoryInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Repository;
15 |
16 | interface TaggedIntegerRepositoryInterface
17 | {
18 | public function increase(string $tag, int $value = 1): void;
19 |
20 | public function decrease(string $tag, int $value = 1): void;
21 |
22 | public function set(string $tag, int $value): void;
23 |
24 | public function get(string $tag): int;
25 | }
26 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | ################################################################################################
2 | # This file is for development purposes and shouldn't be used to run Bitcoin DCA
3 | # in a production-alike setting for actual purchasing or withdrawing.
4 | #
5 | # If you are looking how to run Bitcoin DCA, please see:
6 | # https://bitcoin-dca.readthedocs.io/en/latest/getting-started.html
7 | ################################################################################################
8 |
9 | services:
10 | app:
11 | build:
12 | context: .
13 | target: development_build
14 | volumes:
15 | - .:/app:cached
16 | - ./vendor:/app/vendor:delegated
17 | env_file:
18 | - .env
19 |
--------------------------------------------------------------------------------
/src/Exception/PendingBuyOrderException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Exception;
15 |
16 | class PendingBuyOrderException extends \Exception
17 | {
18 | public function __construct(protected string $orderId)
19 | {
20 | parent::__construct(self::class.' is supposed to be handled, something went wrong here.');
21 | }
22 |
23 | public function getOrderId(): string
24 | {
25 | return $this->orderId;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Client/Bl3pClientInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Client;
15 |
16 | interface Bl3pClientInterface
17 | {
18 | /**
19 | * To make a call to BL3P API.
20 | *
21 | * @param string $path path to call
22 | * @param array $parameters parameters to add to the call
23 | *
24 | * @return array result of call
25 | *
26 | * @throws \Exception
27 | */
28 | public function apiCall($path, $parameters = []): array;
29 | }
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Platform (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Version [e.g. 22]
29 |
30 | **Additional context**
31 | Add any other context about the problem here.
32 |
--------------------------------------------------------------------------------
/src/Service/WithdrawServiceInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
17 |
18 | interface WithdrawServiceInterface
19 | {
20 | public function withdraw(int $balanceToWithdraw, string $addressToWithdrawTo): CompletedWithdraw;
21 |
22 | public function getAvailableBalance(): int;
23 |
24 | public function getWithdrawFeeInSatoshis(): int;
25 |
26 | public function supportsExchange(string $exchange): bool;
27 | }
28 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | import sphinx_rtd_theme
2 |
3 | project = 'Bitcoin DCA'
4 | copyright = '2021, Jorijn Schrijvershof'
5 | author = 'Jorijn Schrijvershof'
6 | extensions = []
7 | templates_path = ['_templates']
8 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
9 | html_theme = 'sphinx_rtd_theme'
10 | html_static_path = ['_static']
11 | pygments_style = 'sphinx'
12 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
13 | html_theme_options = {
14 | 'navigation_depth': 4,
15 | }
16 | master_doc = 'index'
17 | html_logo = '../resources/images/logo-white.png'
18 |
19 | # I use a privacy focussed service https://usefathom.com/ to track how the documentation
20 | # is being used. This allows me to improve its contents.
21 | html_js_files = [('https://krill.jorijn.com/script.js', {'data-site': 'MXGDAIWO'})]
22 |
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | use Symfony\Component\Dotenv\Dotenv;
15 |
16 | require dirname(__DIR__).'/vendor/autoload.php';
17 |
18 | if (method_exists(Dotenv::class, 'bootEnv') && file_exists(dirname(__DIR__).'/.env')) {
19 | (new Dotenv())->usePutenv()->bootEnv(dirname(__DIR__).'/.env');
20 | }
21 |
22 | // set the default location for the external derivation tool
23 | if (false === getenv('XPUB_PYTHON_CLI') && file_exists('/app/resources/xpub_derive/main.py')) {
24 | putenv('XPUB_PYTHON_CLI=/usr/bin/python3 /app/resources/xpub_derive/main.py');
25 | }
26 |
--------------------------------------------------------------------------------
/src/Provider/SimpleWithdrawAddressProvider.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Provider;
15 |
16 | use Jorijn\Bitcoin\Dca\Validator\ValidationInterface;
17 |
18 | class SimpleWithdrawAddressProvider implements WithdrawAddressProviderInterface
19 | {
20 | public function __construct(protected ValidationInterface $validation, protected ?string $configuredAddress)
21 | {
22 | }
23 |
24 | public function provide(): string
25 | {
26 | $this->validation->validate($this->configuredAddress);
27 |
28 | return $this->configuredAddress;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Model/CompletedWithdraw.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Model;
15 |
16 | class CompletedWithdraw
17 | {
18 | public function __construct(protected string $recipientAddress, protected int $netAmount, protected string $id)
19 | {
20 | }
21 |
22 | public function getRecipientAddress(): string
23 | {
24 | return $this->recipientAddress;
25 | }
26 |
27 | public function getNetAmount(): int
28 | {
29 | return $this->netAmount;
30 | }
31 |
32 | public function getId(): string
33 | {
34 | return $this->id;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/resources/xpub_derive/main.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import fire
4 |
5 | from btclib import bip32, slip32
6 |
7 |
8 | def derive(mpub: str, start: int, length: int):
9 | """
10 | Will generate list of derived addresses, starting at index for .
11 | :param mpub: master extended public key
12 | :param start: starting index to generate address list
13 | :param length: how many addresses to generate
14 | :return: a json list of derived addresses
15 | """
16 |
17 | address_list = {}
18 | for index in range(start, start + length):
19 | xpub = bip32.derive(xkey=mpub, path=f"./0/{index}")
20 | address = slip32.address_from_xpub(xpub).decode("ascii")
21 | address_list[f"0/{index}"] = address
22 |
23 | return json.dumps(address_list)
24 |
25 |
26 | if __name__ == "__main__":
27 | fire.Fire({
28 | "derive": derive
29 | })
30 |
--------------------------------------------------------------------------------
/src/Model/NotificationEmailConfiguration.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Model;
15 |
16 | class NotificationEmailConfiguration
17 | {
18 | public function __construct(protected string $to, protected string $from, protected string $subjectPrefix)
19 | {
20 | }
21 |
22 | public function getTo(): string
23 | {
24 | return $this->to;
25 | }
26 |
27 | public function getFrom(): string
28 | {
29 | return $this->from;
30 | }
31 |
32 | public function getSubjectPrefix(): string
33 | {
34 | return $this->subjectPrefix;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Event/BuySuccessEvent.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Event;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder;
17 | use Symfony\Contracts\EventDispatcher\Event;
18 |
19 | class BuySuccessEvent extends Event
20 | {
21 | public function __construct(protected CompletedBuyOrder $completedBuyOrder, protected ?string $tag = null)
22 | {
23 | }
24 |
25 | public function getTag(): ?string
26 | {
27 | return $this->tag;
28 | }
29 |
30 | public function getBuyOrder(): CompletedBuyOrder
31 | {
32 | return $this->completedBuyOrder;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/src/Event/WithdrawSuccessEvent.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Event;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
17 | use Symfony\Contracts\EventDispatcher\Event;
18 |
19 | class WithdrawSuccessEvent extends Event
20 | {
21 | public function __construct(protected CompletedWithdraw $completedWithdraw, protected ?string $tag = null)
22 | {
23 | }
24 |
25 | public function getTag(): ?string
26 | {
27 | return $this->tag;
28 | }
29 |
30 | public function getCompletedWithdraw(): CompletedWithdraw
31 | {
32 | return $this->completedWithdraw;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Service/BalanceService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service;
15 |
16 | use Jorijn\Bitcoin\Dca\Exception\NoExchangeAvailableException;
17 |
18 | class BalanceService
19 | {
20 | public function __construct(protected iterable $registeredServices, protected string $configuredExchange)
21 | {
22 | }
23 |
24 | public function getBalances(): array
25 | {
26 | foreach ($this->registeredServices as $registeredService) {
27 | if ($registeredService->supportsExchange($this->configuredExchange)) {
28 | return $registeredService->getBalances();
29 | }
30 | }
31 |
32 | throw new NoExchangeAvailableException('no exchange was available to provide balances');
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/tests/Model/QuoteTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Model;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\Quote;
17 | use PHPUnit\Framework\TestCase;
18 |
19 | /**
20 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Model\Quote
21 | *
22 | * @internal
23 | */
24 | final class QuoteTest extends TestCase
25 | {
26 | /**
27 | * @covers ::__construct
28 | * @covers ::getAuthor
29 | * @covers ::getQuote
30 | */
31 | public function testGetters(): void
32 | {
33 | $dto = new Quote(
34 | $quote = 'q'.random_int(1000, 2000),
35 | $author = 'a'.random_int(1000, 2000)
36 | );
37 |
38 | static::assertSame($quote, $dto->getQuote());
39 | static::assertSame($author, $dto->getAuthor());
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tests/Exception/PendingBuyOrderExceptionTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Exception;
15 |
16 | use Jorijn\Bitcoin\Dca\Exception\PendingBuyOrderException;
17 | use PHPUnit\Framework\TestCase;
18 |
19 | /**
20 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Exception\PendingBuyOrderException
21 | *
22 | * @internal
23 | */
24 | final class PendingBuyOrderExceptionTest extends TestCase
25 | {
26 | /**
27 | * @covers ::__construct
28 | * @covers ::getOrderId
29 | */
30 | public function testGetOrderId(): void
31 | {
32 | $orderId = 'oid'.random_int(1000, 2000);
33 | $pendingBuyOrderException = new PendingBuyOrderException($orderId);
34 | static::assertSame($orderId, $pendingBuyOrderException->getOrderId());
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jorijn Schrijvershof
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 |
--------------------------------------------------------------------------------
/src/Model/NotificationEmailTemplateInformation.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Model;
15 |
16 | class NotificationEmailTemplateInformation
17 | {
18 | public function __construct(
19 | protected string $exchange,
20 | protected string $logoLocation,
21 | protected string $iconLocation,
22 | protected string $quotesLocation
23 | ) {
24 | }
25 |
26 | public function getExchange(): string
27 | {
28 | return $this->exchange;
29 | }
30 |
31 | public function getLogoLocation(): string
32 | {
33 | return $this->logoLocation;
34 | }
35 |
36 | public function getIconLocation(): string
37 | {
38 | return $this->iconLocation;
39 | }
40 |
41 | public function getQuotesLocation(): string
42 | {
43 | return $this->quotesLocation;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Factory/DeriveFromMasterPublicKeyComponentFactory.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Factory;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponentInterface;
17 | use Jorijn\Bitcoin\Dca\Exception\NoDerivationComponentAvailableException;
18 |
19 | class DeriveFromMasterPublicKeyComponentFactory
20 | {
21 | public function __construct(protected iterable $availableComponents)
22 | {
23 | }
24 |
25 | public function createDerivationComponent(): AddressFromMasterPublicKeyComponentInterface
26 | {
27 | foreach ($this->availableComponents as $availableComponent) {
28 | if ($availableComponent->supported()) {
29 | return $availableComponent;
30 | }
31 | }
32 |
33 | throw new NoDerivationComponentAvailableException('no derivation component is available');
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Model/RemoteReleaseInformation.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Model;
15 |
16 | class RemoteReleaseInformation
17 | {
18 | public function __construct(protected array $releaseInformation, protected string $localVersion, protected string $remoteVersion)
19 | {
20 | }
21 |
22 | public function getReleaseInformation(): array
23 | {
24 | return $this->releaseInformation;
25 | }
26 |
27 | public function getLocalVersion(): string
28 | {
29 | return $this->localVersion;
30 | }
31 |
32 | public function getRemoteVersion(): string
33 | {
34 | return $this->remoteVersion;
35 | }
36 |
37 | public function isOutdated(): bool
38 | {
39 | return version_compare(
40 | $this->getLocalVersion(),
41 | $this->getRemoteVersion(),
42 | '<'
43 | );
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.php-cs-fixer.php:
--------------------------------------------------------------------------------
1 |
9 |
10 | This source file is subject to the MIT license that is bundled
11 | with this source code in the file LICENSE.
12 | EOF;
13 |
14 | $finder = PhpCsFixer\Finder::create()
15 | ->in(__DIR__)
16 | ->exclude('vendor')
17 | ->exclude('var');
18 |
19 | return (new PhpCsFixer\Config())
20 | ->setRiskyAllowed(true)
21 | ->setRules(
22 | [
23 | '@Symfony' => true,
24 | '@Symfony:risky' => true,
25 | '@PHP81Migration' => true,
26 | '@PHP80Migration:risky' => true,
27 | '@PhpCsFixer' => true,
28 | '@PhpCsFixer:risky' => true,
29 | 'general_phpdoc_annotation_remove' => ['annotations' => ['expectedDeprecation']],
30 | 'header_comment' => ['header' => $header],
31 | 'array_syntax' => ['syntax' => 'short'],
32 | 'method_argument_space' => [
33 | 'on_multiline' => 'ensure_fully_multiline'
34 | ],
35 | ]
36 | )
37 | ->setFinder($finder);
38 |
--------------------------------------------------------------------------------
/src/EventListener/ResetTaggedBalanceListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent;
17 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
18 | use Psr\Log\LoggerInterface;
19 |
20 | class ResetTaggedBalanceListener
21 | {
22 | public function __construct(
23 | protected TaggedIntegerRepositoryInterface $taggedIntegerRepository,
24 | protected LoggerInterface $logger
25 | ) {
26 | }
27 |
28 | public function onWithdrawSucces(WithdrawSuccessEvent $withdrawSuccessEvent): void
29 | {
30 | $tag = $withdrawSuccessEvent->getTag();
31 |
32 | if (!$tag) {
33 | return;
34 | }
35 |
36 | $this->taggedIntegerRepository->set($tag, 0);
37 |
38 | $this->logger->info('reset tagged balance for tag {tag}', ['tag' => $tag]);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Service/Kraken/KrakenBalanceService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Kraken;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\KrakenClientInterface;
17 | use Jorijn\Bitcoin\Dca\Service\BalanceServiceInterface;
18 |
19 | class KrakenBalanceService implements BalanceServiceInterface
20 | {
21 | public function __construct(protected KrakenClientInterface $krakenClient)
22 | {
23 | }
24 |
25 | public function supportsExchange(string $exchange): bool
26 | {
27 | return 'kraken' === $exchange;
28 | }
29 |
30 | public function getBalances(): array
31 | {
32 | $response = $this->krakenClient->queryPrivate('Balance');
33 | $rows = [];
34 |
35 | foreach ($response as $symbol => $available) {
36 | $rows[] = [$symbol, $available.' '.$symbol, $available.' '.$symbol];
37 | }
38 |
39 | return $rows;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Validator/BitcoinAddressValidator.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Validator;
15 |
16 | use BitWasp\Bitcoin\Address\AddressCreator;
17 |
18 | class BitcoinAddressValidator implements ValidationInterface
19 | {
20 | public function __construct(protected AddressCreator $addressCreator)
21 | {
22 | }
23 |
24 | public function validate($input): void
25 | {
26 | if (empty($input)) {
27 | throw new BitcoinAddressValidatorException('Configured address cannot be empty');
28 | }
29 |
30 | try {
31 | $this->addressCreator->fromString($input);
32 | } catch (\Throwable $exception) {
33 | throw new BitcoinAddressValidatorException(
34 | 'Configured address failed validation',
35 | $exception->getCode(),
36 | $exception
37 | );
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/Event/BuySuccessEventTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Event;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\BuySuccessEvent;
17 | use Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder;
18 | use PHPUnit\Framework\TestCase;
19 |
20 | /**
21 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Event\BuySuccessEvent
22 | *
23 | * @internal
24 | */
25 | final class BuySuccessEventTest extends TestCase
26 | {
27 | /**
28 | * @covers ::__construct
29 | * @covers ::getBuyOrder
30 | * @covers ::getTag
31 | */
32 | public function testGetters(): void
33 | {
34 | $completedBuyOrder = new CompletedBuyOrder();
35 | $tag = 'tag'.random_int(1000, 2000);
36 |
37 | $buySuccessEvent = new BuySuccessEvent($completedBuyOrder, $tag);
38 |
39 | static::assertSame($completedBuyOrder, $buySuccessEvent->getBuyOrder());
40 | static::assertSame($tag, $buySuccessEvent->getTag());
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/docs/faq.rst:
--------------------------------------------------------------------------------
1 | .. _faq:
2 |
3 | Frequently Asked Questions
4 | ==========================
5 |
6 | .. toctree::
7 | :caption: Table of Contents
8 | :maxdepth: 2
9 |
10 | faq
11 |
12 | I already have MyNode / Umbrel running, can I use this tool too?
13 | ----------------------------------------------------------------
14 |
15 | Yes! MyNode and Umbrel are both based on Linux and have Docker pre-installed. You can use all features of Bitcoin DCA.
16 |
17 | Things you should keep in mind: The default user doesn't have permission to run Docker by default. MyNode uses user ``admin`` and Umbrel uses ``umbrel``.
18 |
19 | .. include:: ./includes/add-user-to-docker-group.rst
20 |
21 | See :ref:`getting-started` for more information.
22 |
23 | How do I make sure the time is set up correctly?
24 | ------------------------------------------------
25 |
26 | You can check the current system time with this command:
27 |
28 | .. code-block:: bash
29 |
30 | $ date
31 | Fri May 28 08:46:37 CEST 2021
32 |
33 | In some cases, it is possible that the timezone is configured incorrectly. On MyNode, Umbrel or on any other Debian-based system you can update this as following:
34 |
35 | .. code-block:: bash
36 |
37 | $ sudo dpkg-reconfigure tzdata
38 |
--------------------------------------------------------------------------------
/src/Service/Bitvavo/BitvavoBalanceService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Bitvavo;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\BitvavoClientInterface;
17 | use Jorijn\Bitcoin\Dca\Service\BalanceServiceInterface;
18 |
19 | class BitvavoBalanceService implements BalanceServiceInterface
20 | {
21 | public function __construct(protected BitvavoClientInterface $bitvavoClient)
22 | {
23 | }
24 |
25 | public function supportsExchange(string $exchange): bool
26 | {
27 | return 'bitvavo' === $exchange;
28 | }
29 |
30 | public function getBalances(): array
31 | {
32 | $response = $this->bitvavoClient->apiCall('balance');
33 | $rows = [];
34 |
35 | foreach ($response as ['symbol' => $symbol, 'available' => $available, 'inOrder' => $inOrder]) {
36 | $rows[] = [$symbol, $available.' '.$symbol, bcsub((string) $available, (string) $inOrder, 8).' '.$symbol];
37 | }
38 |
39 | return $rows;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Service/Bl3p/Bl3pBalanceService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Bl3p;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\Bl3pClientInterface;
17 | use Jorijn\Bitcoin\Dca\Service\BalanceServiceInterface;
18 |
19 | class Bl3pBalanceService implements BalanceServiceInterface
20 | {
21 | public function __construct(protected Bl3pClientInterface $bl3pClient)
22 | {
23 | }
24 |
25 | public function supportsExchange(string $exchange): bool
26 | {
27 | return 'bl3p' === $exchange;
28 | }
29 |
30 | public function getBalances(): array
31 | {
32 | $response = $this->bl3pClient->apiCall('GENMKT/money/info');
33 | $rows = [];
34 |
35 | foreach ($response['data']['wallets'] ?? [] as $currency => $wallet) {
36 | if (0 === (int) $wallet['balance']['value_int']) {
37 | continue;
38 | }
39 |
40 | $rows[] = [$currency, $wallet['balance']['display'], $wallet['available']['display']];
41 | }
42 |
43 | return $rows;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/EventListener/Notifications/AbstractSendTelegramListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Psr\EventDispatcher\EventDispatcherInterface;
17 | use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransport;
18 |
19 | abstract class AbstractSendTelegramListener
20 | {
21 | public function __construct(
22 | protected TelegramTransport $telegramTransport,
23 | protected EventDispatcherInterface $eventDispatcher,
24 | protected string $exchange,
25 | protected bool $isEnabled
26 | ) {
27 | }
28 |
29 | public function getTransport(): TelegramTransport
30 | {
31 | return $this->telegramTransport;
32 | }
33 |
34 | public function getDispatcher(): EventDispatcherInterface
35 | {
36 | return $this->eventDispatcher;
37 | }
38 |
39 | public function getExchange(): string
40 | {
41 | return $this->exchange;
42 | }
43 |
44 | public function isEnabled(): bool
45 | {
46 | return $this->isEnabled;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/tests/Model/CompletedWithdrawTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Model;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
17 | use PHPUnit\Framework\TestCase;
18 |
19 | /**
20 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Model\CompletedWithdraw
21 | *
22 | * @internal
23 | */
24 | final class CompletedWithdrawTest extends TestCase
25 | {
26 | /**
27 | * @covers ::__construct
28 | * @covers ::getId
29 | * @covers ::getNetAmount
30 | * @covers ::getRecipientAddress
31 | */
32 | public function testGetters(): void
33 | {
34 | $id = 'id'.random_int(5, 10);
35 | $recipientAddress = 'ra'.random_int(5, 10);
36 | $netAmount = random_int(5, 10);
37 |
38 | $completedWithdraw = new CompletedWithdraw($recipientAddress, $netAmount, $id);
39 |
40 | static::assertSame($id, $completedWithdraw->getId());
41 | static::assertSame($recipientAddress, $completedWithdraw->getRecipientAddress());
42 | static::assertSame($netAmount, $completedWithdraw->getNetAmount());
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/EventListener/IncreaseTaggedBalanceListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\BuySuccessEvent;
17 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
18 | use Psr\Log\LoggerInterface;
19 |
20 | class IncreaseTaggedBalanceListener
21 | {
22 | public function __construct(
23 | protected TaggedIntegerRepositoryInterface $taggedIntegerRepository,
24 | protected LoggerInterface $logger
25 | ) {
26 | }
27 |
28 | public function onBalanceIncrease(BuySuccessEvent $buySuccessEvent): void
29 | {
30 | if (!$tag = $buySuccessEvent->getTag()) {
31 | return;
32 | }
33 |
34 | $completedBuyOrder = $buySuccessEvent->getBuyOrder();
35 | $netAmount = $completedBuyOrder->getAmountInSatoshis() - $completedBuyOrder->getFeesInSatoshis();
36 |
37 | $this->taggedIntegerRepository->increase($tag, $netAmount);
38 |
39 | $this->logger->info('increased balance for tag {tag} with {balance} satoshis', [
40 | 'tag' => $tag,
41 | 'balance' => $netAmount,
42 | ]);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/tests/Event/WithdrawSuccessEventTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Event;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent;
17 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
18 | use PHPUnit\Framework\TestCase;
19 |
20 | /**
21 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent
22 | *
23 | * @internal
24 | */
25 | final class WithdrawSuccessEventTest extends TestCase
26 | {
27 | /**
28 | * @covers ::__construct
29 | * @covers ::getCompletedWithdraw
30 | * @covers ::getTag
31 | */
32 | public function testGetters(): void
33 | {
34 | $address = 'a'.random_int(1000, 2000);
35 | $amount = random_int(1000, 2000);
36 | $id = (string) random_int(1000, 2000);
37 | $tag = 'tag'.random_int(1000, 2000);
38 |
39 | $completedWithdraw = new CompletedWithdraw($address, $amount, $id);
40 | $withdrawSuccessEvent = new WithdrawSuccessEvent($completedWithdraw, $tag);
41 |
42 | static::assertSame($completedWithdraw, $withdrawSuccessEvent->getCompletedWithdraw());
43 | static::assertSame($tag, $withdrawSuccessEvent->getTag());
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/Model/NotificationEmailConfigurationTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Model;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\NotificationEmailConfiguration;
17 | use PHPUnit\Framework\TestCase;
18 |
19 | /**
20 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Model\NotificationEmailConfiguration
21 | *
22 | * @internal
23 | */
24 | final class NotificationEmailConfigurationTest extends TestCase
25 | {
26 | /**
27 | * @covers ::__construct
28 | * @covers ::getFrom
29 | * @covers ::getSubjectPrefix
30 | * @covers ::getTo
31 | */
32 | public function testGetters(): void
33 | {
34 | $notificationEmailConfiguration = new NotificationEmailConfiguration(
35 | $to = 'to'.random_int(1000, 2000),
36 | $from = 'from'.random_int(1000, 2000),
37 | $subjectPrefix = 'sp'.random_int(1000, 2000)
38 | );
39 |
40 | static::assertSame($to, $notificationEmailConfiguration->getTo());
41 | static::assertSame($from, $notificationEmailConfiguration->getFrom());
42 | static::assertSame($subjectPrefix, $notificationEmailConfiguration->getSubjectPrefix());
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/docs/installation.rst:
--------------------------------------------------------------------------------
1 | .. _installation:
2 |
3 | Installation
4 | ============
5 |
6 | Requirements
7 | ------------
8 | * You need to have an account on a supported Exchange;
9 | * You need to have Docker installed: https://docs.docker.com/get-docker/;
10 | * You need to have an API key active on a supported Exchange. It needs **read**, **trade** and **withdraw** permission.
11 |
12 | .. include:: ./includes/add-user-to-docker-group.rst
13 |
14 | Using Docker Hub (easiest)
15 | --------------------------
16 |
17 | Installing
18 | ^^^^^^^^^^
19 | Use these commands to download this tool from Docker Hub:
20 |
21 | .. code-block:: bash
22 |
23 | $ docker pull ghcr.io/jorijn/bitcoin-dca:latest
24 |
25 | Upgrading
26 | ^^^^^^^^^
27 | Using these commands you can download the newest version from Docker Hub:
28 |
29 | .. code-block:: bash
30 |
31 | $ docker image rm ghcr.io/jorijn/bitcoin-dca
32 | $ docker pull ghcr.io/jorijn/bitcoin-dca:latest
33 |
34 | Build your own (more control)
35 | -----------------------------
36 | If you desire more control, pull this project from `GitHub `_ and build it yourself. To do this, execute these commands:
37 |
38 | .. code-block:: bash
39 |
40 | cd ~
41 | git clone https://github.com/Jorijn/bitcoin-dca.git
42 | cd bitcoin-dca
43 | docker build . -t ghcr.io/jorijn/bitcoin-dca:latest
44 |
45 | When an upgrade is available, run ``git pull`` to fetch the latest changes and build the docker container again.
46 |
47 | Next: :ref:`Configuration `
48 |
--------------------------------------------------------------------------------
/src/Provider/XpubWithdrawAddressProvider.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Provider;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponentInterface;
17 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
18 | use Jorijn\Bitcoin\Dca\Validator\ValidationInterface;
19 |
20 | class XpubWithdrawAddressProvider implements WithdrawAddressProviderInterface
21 | {
22 | public function __construct(
23 | protected ValidationInterface $validation,
24 | protected AddressFromMasterPublicKeyComponentInterface $addressFromMasterPublicKeyComponent,
25 | protected TaggedIntegerRepositoryInterface $taggedIntegerRepository,
26 | protected ?string $configuredXPub
27 | ) {
28 | }
29 |
30 | public function provide(): string
31 | {
32 | $activeIndex = $this->taggedIntegerRepository->get($this->configuredXPub);
33 | $activeDerivationPath = sprintf('0/%d', $activeIndex);
34 | $derivedAddress = $this->addressFromMasterPublicKeyComponent->derive(
35 | $this->configuredXPub,
36 | $activeDerivationPath
37 | );
38 |
39 | $this->validation->validate($derivedAddress);
40 |
41 | return $derivedAddress;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Command/VersionCommand.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Command;
15 |
16 | use Symfony\Component\Console\Command\Command;
17 | use Symfony\Component\Console\Input\InputInterface;
18 | use Symfony\Component\Console\Output\OutputInterface;
19 | use Symfony\Component\Console\Style\SymfonyStyle;
20 |
21 | class VersionCommand extends Command
22 | {
23 | public function __construct(protected string $versionFile)
24 | {
25 | parent::__construct(null);
26 | }
27 |
28 | public function configure(): void
29 | {
30 | $this
31 | ->setDescription('Show the current version / build information of Bitcoin DCA')
32 | ;
33 | }
34 |
35 | public function execute(InputInterface $input, OutputInterface $output): int
36 | {
37 | $symfonyStyle = new SymfonyStyle($input, $output);
38 |
39 | if (!file_exists($this->versionFile) || !is_readable($this->versionFile)) {
40 | $versionInfo = ['version' => 'no version file present, probably a development build'];
41 | } else {
42 | $versionInfo = json_decode(file_get_contents($this->versionFile), true, 512, JSON_THROW_ON_ERROR);
43 | }
44 |
45 | $symfonyStyle->horizontalTable([array_keys($versionInfo)], [array_values($versionInfo)]);
46 |
47 | return Command::SUCCESS;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/Model/NotificationEmailTemplateInformationTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Model;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\NotificationEmailTemplateInformation;
17 | use PHPUnit\Framework\TestCase;
18 |
19 | /**
20 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Model\NotificationEmailTemplateInformation
21 | *
22 | * @internal
23 | */
24 | final class NotificationEmailTemplateInformationTest extends TestCase
25 | {
26 | /**
27 | * @covers ::__construct
28 | * @covers ::getExchange
29 | * @covers ::getIconLocation
30 | * @covers ::getLogoLocation
31 | * @covers ::getQuotesLocation
32 | */
33 | public function testGetters(): void
34 | {
35 | $notificationEmailTemplateInformation = new NotificationEmailTemplateInformation(
36 | $exchange = 'e'.random_int(1000, 2000),
37 | $logoLocation = 'l'.random_int(1000, 2000),
38 | $iconLocation = 'i'.random_int(1000, 2000),
39 | $quotesLocation = 'q'.random_int(1000, 2000)
40 | );
41 |
42 | static::assertSame($exchange, $notificationEmailTemplateInformation->getExchange());
43 | static::assertSame($logoLocation, $notificationEmailTemplateInformation->getLogoLocation());
44 | static::assertSame($iconLocation, $notificationEmailTemplateInformation->getIconLocation());
45 | static::assertSame($quotesLocation, $notificationEmailTemplateInformation->getQuotesLocation());
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Command/BalanceCommand.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Command;
15 |
16 | use Jorijn\Bitcoin\Dca\Service\BalanceService;
17 | use Symfony\Component\Console\Command\Command;
18 | use Symfony\Component\Console\Helper\Table;
19 | use Symfony\Component\Console\Input\InputInterface;
20 | use Symfony\Component\Console\Output\OutputInterface;
21 | use Symfony\Component\Console\Style\SymfonyStyle;
22 |
23 | class BalanceCommand extends Command
24 | {
25 | public function __construct(protected BalanceService $balanceService)
26 | {
27 | parent::__construct(null);
28 | }
29 |
30 | public function configure(): void
31 | {
32 | $this
33 | ->setDescription('Gets the balance from the exchange and tests the API key')
34 | ;
35 | }
36 |
37 | public function execute(InputInterface $input, OutputInterface $output): int
38 | {
39 | $symfonyStyle = new SymfonyStyle($input, $output);
40 |
41 | try {
42 | $rows = $this->balanceService->getBalances();
43 |
44 | $table = new Table($output);
45 | $table->setHeaders(['Currency', 'Balance', 'Available']);
46 | $table->setRows($rows);
47 | $table->render();
48 |
49 | $symfonyStyle->success('Success!');
50 | } catch (\Throwable $exception) {
51 | $symfonyStyle->error($exception->getMessage());
52 |
53 | return 1;
54 | }
55 |
56 | return 0;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/tests/EventListener/Notifications/AbstractSendTelegramListenerTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Jorijn\Bitcoin\Dca\EventListener\Notifications\AbstractSendTelegramListener;
17 | use PHPUnit\Framework\TestCase;
18 | use Symfony\Component\EventDispatcher\EventDispatcher;
19 | use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransport;
20 |
21 | /**
22 | * @internal
23 | *
24 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\EventListener\Notifications\AbstractSendTelegramListener
25 | */
26 | final class AbstractSendTelegramListenerTest extends TestCase
27 | {
28 | /**
29 | * @covers ::__construct
30 | * @covers ::getDispatcher
31 | * @covers ::getExchange
32 | * @covers ::getTransport
33 | * @covers ::isEnabled
34 | */
35 | public function testGetterAndSetters(): void
36 | {
37 | $listener = $this->getMockForAbstractClass(AbstractSendTelegramListener::class, [
38 | $telegramTransport = new TelegramTransport(''),
39 | $eventDispatcher = new EventDispatcher(),
40 | $exchange = 'e'.random_int(1000, 2000),
41 | $enabled = (bool) random_int(0, 1),
42 | ]);
43 |
44 | static::assertSame($telegramTransport, $listener->getTransport());
45 | static::assertSame($eventDispatcher, $listener->getDispatcher());
46 | static::assertSame($exchange, $listener->getExchange());
47 | static::assertSame($enabled, $listener->isEnabled());
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/Model/RemoteReleaseInformationTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Model;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\RemoteReleaseInformation;
17 | use PHPUnit\Framework\TestCase;
18 |
19 | /**
20 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Model\RemoteReleaseInformation
21 | *
22 | * @internal
23 | */
24 | final class RemoteReleaseInformationTest extends TestCase
25 | {
26 | private const V_1_0_0 = 'v1.0.0';
27 | private const V_1_0_1 = 'v1.0.1';
28 |
29 | /**
30 | * @covers ::__construct
31 | * @covers ::getLocalVersion
32 | * @covers ::getReleaseInformation
33 | * @covers ::getRemoteVersion
34 | * @covers ::isOutdated
35 | */
36 | public function testGetters(): void
37 | {
38 | $releaseInformation = ['r' => random_int(1000, 2000)];
39 |
40 | $outdated = new RemoteReleaseInformation($releaseInformation, self::V_1_0_0, self::V_1_0_1);
41 | static::assertSame($releaseInformation, $outdated->getReleaseInformation());
42 | static::assertSame(self::V_1_0_0, $outdated->getLocalVersion());
43 | static::assertSame(self::V_1_0_1, $outdated->getRemoteVersion());
44 | static::assertTrue($outdated->isOutdated());
45 |
46 | $sameVersion = new RemoteReleaseInformation($releaseInformation, self::V_1_0_0, self::V_1_0_0);
47 | $newerVersion = new RemoteReleaseInformation($releaseInformation, self::V_1_0_1, self::V_1_0_0);
48 |
49 | static::assertFalse($sameVersion->isOutdated());
50 | static::assertFalse($newerVersion->isOutdated());
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/EventListener/Notifications/SendEmailOnBuyListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\BuySuccessEvent;
17 |
18 | class SendEmailOnBuyListener extends AbstractSendEmailListener
19 | {
20 | final public const NOTIFICATION_SUBJECT_LINE = 'You bought %s sats on %s';
21 |
22 | public function onBuy(BuySuccessEvent $buySuccessEvent): void
23 | {
24 | if (!$this->isEnabled) {
25 | return;
26 | }
27 |
28 | $templateVariables = array_merge(
29 | [
30 | 'buyOrder' => $buySuccessEvent->getBuyOrder(),
31 | 'tag' => $buySuccessEvent->getTag(),
32 | ],
33 | $this->getTemplateVariables()
34 | );
35 |
36 | $html = $this->renderTemplate($this->templateLocation, $templateVariables);
37 |
38 | $email = $this->createEmail()
39 | ->subject(
40 | sprintf(
41 | '[%s] %s',
42 | $this->notificationEmailConfiguration->getSubjectPrefix(),
43 | sprintf(
44 | self::NOTIFICATION_SUBJECT_LINE,
45 | number_format($buySuccessEvent->getBuyOrder()->getAmountInSatoshis()),
46 | ucfirst($this->notificationEmailTemplateInformation->getExchange())
47 | )
48 | )
49 | )
50 | ->html($html)
51 | ->text($this->htmlConverter->convert($html))
52 | ;
53 |
54 | $this->mailer->send($email);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Service/Bl3p/Bl3pWithdrawService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Bl3p;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\Bl3pClientInterface;
17 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
18 | use Jorijn\Bitcoin\Dca\Service\WithdrawServiceInterface;
19 | use Psr\Log\LoggerInterface;
20 |
21 | class Bl3pWithdrawService implements WithdrawServiceInterface
22 | {
23 | final public const BL3P = 'bl3p';
24 |
25 | public function __construct(protected Bl3pClientInterface $bl3pClient, protected LoggerInterface $logger)
26 | {
27 | }
28 |
29 | public function withdraw(int $balanceToWithdraw, string $addressToWithdrawTo): CompletedWithdraw
30 | {
31 | $netAmountToWithdraw = $balanceToWithdraw - $this->getWithdrawFeeInSatoshis();
32 | $response = $this->bl3pClient->apiCall('GENMKT/money/withdraw', [
33 | 'currency' => 'BTC',
34 | 'address' => $addressToWithdrawTo,
35 | 'amount_int' => $netAmountToWithdraw,
36 | ]);
37 |
38 | return new CompletedWithdraw($addressToWithdrawTo, $netAmountToWithdraw, $response['data']['id']);
39 | }
40 |
41 | public function getAvailableBalance(): int
42 | {
43 | $response = $this->bl3pClient->apiCall('GENMKT/money/info');
44 |
45 | return (int) ($response['data']['wallets']['BTC']['available']['value_int'] ?? 0);
46 | }
47 |
48 | public function getWithdrawFeeInSatoshis(): int
49 | {
50 | return 5000;
51 | }
52 |
53 | public function supportsExchange(string $exchange): bool
54 | {
55 | return self::BL3P === $exchange;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. image:: ../resources/images/dca-illustration.png
2 |
3 | Welcome to Bitcoin DCA's documentation!
4 | ====================================
5 |
6 | .. toctree::
7 | :maxdepth: 1
8 |
9 | installation
10 | getting-started
11 | configuration
12 | scheduling
13 | persistent-storage
14 | xpub-withdraw
15 | tagged-balance
16 | getting-notified
17 | faq
18 |
19 | About this software
20 | -------------------
21 | This self-hosted DCA (Dollar Cost Averaging) tool is built with flexibility in mind, allowing you to specify your schedule for buying and withdrawing.
22 |
23 | A few examples of possible scenario's:
24 |
25 | * Buy each week, never withdraw;
26 | * Buy monthly and withdraw at the same time to reduce exchange risk;
27 | * Buy each week but withdraw only at the end of the month to save on withdrawal fees.
28 |
29 | Supported Exchanges
30 | -------------------
31 |
32 | .. list-table::
33 | :header-rows: 1
34 |
35 | * - Exchange
36 | - URL
37 | - XPUB withdraw supported
38 | - Currencies
39 | * - BL3P
40 | - https://bl3p.eu/
41 | - Yes
42 | - EUR
43 | * - Bitvavo
44 | - https://bitvavo.com/
45 | - No *
46 | - EUR
47 | * - Kraken
48 | - https://kraken.com/
49 | - No
50 | - USD EUR CAD JPY GBP CHF AUD
51 | * - Binance
52 | - https://binance.com/
53 | - Yes
54 | - USDT BUSD EUR USDC USDT GBP AUD TRY BRL DAI TUSD RUB UAH PAX BIDR NGN IDRT VAI
55 |
56 | .. note::
57 | Due to regulatory changes in The Netherlands, Bitvavo currently requires you to provide proof of address ownership, thus (temporarily) disabling Bitcoin-DCA's XPUB feature.
58 |
59 | Telegram / Support
60 | ------------------
61 | You can visit the Bitcoin DCA Support channel on Telegram: https://t.me/bitcoindca
62 |
63 | Contributing
64 | ------------
65 | Contributions are highly welcome! Feel free to submit issues and pull requests on https://github.com/jorijn/bitcoin-dca.
66 |
67 |
--------------------------------------------------------------------------------
/src/EventListener/Notifications/SendEmailOnWithdrawListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent;
17 |
18 | class SendEmailOnWithdrawListener extends AbstractSendEmailListener
19 | {
20 | final public const NOTIFICATION_SUBJECT_LINE = 'You withdrew %s satoshis from %s';
21 |
22 | public function onWithdraw(WithdrawSuccessEvent $withdrawSuccessEvent): void
23 | {
24 | if (!$this->isEnabled) {
25 | return;
26 | }
27 |
28 | $templateVariables = array_merge(
29 | [
30 | 'completedWithdraw' => $withdrawSuccessEvent->getCompletedWithdraw(),
31 | 'tag' => $withdrawSuccessEvent->getTag(),
32 | ],
33 | $this->getTemplateVariables()
34 | );
35 |
36 | $html = $this->renderTemplate($this->templateLocation, $templateVariables);
37 |
38 | $email = $this->createEmail()
39 | ->subject(
40 | sprintf(
41 | '[%s] %s',
42 | $this->notificationEmailConfiguration->getSubjectPrefix(),
43 | sprintf(
44 | self::NOTIFICATION_SUBJECT_LINE,
45 | number_format($withdrawSuccessEvent->getCompletedWithdraw()->getNetAmount()),
46 | ucfirst($this->notificationEmailTemplateInformation->getExchange())
47 | )
48 | )
49 | )
50 | ->html($html)
51 | ->text($this->htmlConverter->convert($html))
52 | ;
53 |
54 | $this->mailer->send($email);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/EventListener/Notifications/SendTelegramOnWithdrawListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent;
17 | use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions;
18 | use Symfony\Component\Notifier\Message\ChatMessage;
19 |
20 | class SendTelegramOnWithdrawListener extends AbstractSendTelegramListener
21 | {
22 | public function onWithdraw(WithdrawSuccessEvent $withdrawSuccessEvent): void
23 | {
24 | if (!$this->isEnabled) {
25 | return;
26 | }
27 |
28 | $completedWithdraw = $withdrawSuccessEvent->getCompletedWithdraw();
29 | $formattedSats = number_format($completedWithdraw->getNetAmount());
30 |
31 | $htmlMessage = <<💰 Bitcoin-DCA just withdrew {$formattedSats} sat.
33 |
34 | Transaction overview:
35 |
36 | Address: {$completedWithdraw->getRecipientAddress()}
37 | ID: {$completedWithdraw->getId()}
38 | TLGRM;
39 |
40 | if ($withdrawSuccessEvent->getTag()) {
41 | $htmlMessage .= PHP_EOL.'Tag: '.htmlspecialchars($withdrawSuccessEvent->getTag()).'';
42 | }
43 |
44 | $chatMessage = new ChatMessage(
45 | $htmlMessage,
46 | new TelegramOptions(
47 | [
48 | 'parse_mode' => TelegramOptions::PARSE_MODE_HTML,
49 | 'disable_web_page_preview' => true,
50 | ]
51 | )
52 | );
53 |
54 | $this->telegramTransport->send($chatMessage);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Service/Binance/BinanceBalanceService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Binance;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\BinanceClientInterface;
17 | use Jorijn\Bitcoin\Dca\Service\BalanceServiceInterface;
18 |
19 | class BinanceBalanceService implements BalanceServiceInterface
20 | {
21 | public function __construct(protected BinanceClientInterface $binanceClient)
22 | {
23 | }
24 |
25 | public function supportsExchange(string $exchange): bool
26 | {
27 | return 'binance' === $exchange;
28 | }
29 |
30 | public function getBalances(): array
31 | {
32 | $response = $this->binanceClient->request('GET', 'api/v3/account', [
33 | 'extra' => ['security_type' => 'USER_DATA'],
34 | ]);
35 |
36 | return array_filter(
37 | array_reduce($response['balances'], static function (array $balances, array $asset): array {
38 | $decimals = \strlen(explode('.', (string) $asset['free'])[1]);
39 |
40 | // binance holds a gazillion altcoins, no interest in showing hundreds if their balance
41 | // is zero.
42 | if (bccomp((string) $asset['free'], '0', $decimals) <= 0) {
43 | $balances[$asset['asset']] = false;
44 |
45 | return $balances;
46 | }
47 |
48 | $balances[$asset['asset']] = [
49 | $asset['asset'],
50 | bcadd((string) $asset['free'], (string) $asset['locked'], $decimals),
51 | $asset['free'],
52 | ];
53 |
54 | return $balances;
55 | }, [])
56 | );
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/docs/persistent-storage.rst:
--------------------------------------------------------------------------------
1 | .. _persistent-storage:
2 |
3 | Setting up persistent storage for Bitcoin DCA
4 | ==========================================
5 |
6 | What do I need persistent storage for?
7 | --------------------------------------
8 | * :ref:`tagged-balance`
9 | * :ref:`xpub`
10 |
11 | Since it's plain buying and withdrawing, Bitcoin DCA doesn't need to remember any state for regular operations. However, when it comes to XPUB's and tagging, you do. In the case of tagging it has to remember how much balance each tag has and for XPUB's it needs to save the active index for address derivation. Since it's starting at 0, not saving the state would cause Bitcoin DCA to always return the same, first, address.
12 |
13 | Currently, the internal applications stores the data at the ``/app/var/storage`` path but since this is an internal Docker container path, you will need to `mount `_ a new location to this path to have the storage be persistent.
14 |
15 | Picking a location
16 | ------------------
17 | Lets create a new directory somewhere in your home directory. For this example, we'll assume your username is ``bob`` and your home directory is found located at ``/home/bob``.
18 |
19 | .. include:: ./includes/finding-home-directory.rst
20 |
21 | We'll be creating a new directory here where the files will be stored:
22 |
23 | .. code-block:: bash
24 |
25 | $ mkdir -p /home/bob/applications/bitcoin-dca
26 |
27 | Running with a mounted volume
28 | -----------------------------
29 |
30 | Now, when running this tool you need to mount the new storage directory onto the container using argument ``-v /home/bob/applications/bitcoin-dca:/app/var/storage``. A typical command could look like this:
31 |
32 | .. code-block:: bash
33 | :caption: Running withdraw with a persistent storage directory
34 |
35 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca -v /home/bob/applications/bitcoin-dca:/app/var/storage ghcr.io/jorijn/bitcoin-dca:latest withdraw --all
36 |
--------------------------------------------------------------------------------
/src/Service/BuyServiceInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service;
15 |
16 | use Jorijn\Bitcoin\Dca\Exception\PendingBuyOrderException;
17 | use Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder;
18 |
19 | interface BuyServiceInterface
20 | {
21 | /**
22 | * Should return true or false depending on if this service will support provided exchange name.
23 | */
24 | public function supportsExchange(string $exchange): bool;
25 |
26 | /**
27 | * Method should buy $amount of $baseCurrency in BTC. Should only return a CompletedBuyOrder object when the
28 | * (market) order was filled. Should throw PendingBuyOrderException if it is not filled yet.
29 | *
30 | * @param int $amount the amount that should be bought
31 | *
32 | * @throws PendingBuyOrderException
33 | */
34 | public function initiateBuy(int $amount): CompletedBuyOrder;
35 |
36 | /**
37 | * Method should check if the given $orderId is filled already. Should only return a CompletedBuyOrder object when
38 | * the (market) order was filled. Should throw PendingBuyOrderException if it is not filled yet.
39 | *
40 | * @param string $orderId the order id of the order that is being checked
41 | *
42 | * @throws PendingBuyOrderException
43 | */
44 | public function checkIfOrderIsFilled(string $orderId): CompletedBuyOrder;
45 |
46 | /**
47 | * Method should cancel the order corresponding with this order id. Method will be called if the order was not
48 | * filled within set timeout.
49 | *
50 | * @param string $orderId the order id of the order that is being cancelled
51 | */
52 | public function cancelBuyOrder(string $orderId): void;
53 | }
54 |
--------------------------------------------------------------------------------
/src/Service/MockExchange/MockExchangeWithdrawService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\MockExchange;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
17 | use Jorijn\Bitcoin\Dca\Service\WithdrawServiceInterface;
18 |
19 | /**
20 | * @codeCoverageIgnore This file is solely used for testing
21 | */
22 | class MockExchangeWithdrawService implements WithdrawServiceInterface
23 | {
24 | protected int $availableBalance;
25 | protected int $withdrawFeeInSatoshis;
26 |
27 | public function __construct(protected bool $isEnabled)
28 | {
29 | $this->setAvailableBalance(random_int(100000, 500000));
30 | $this->setWithdrawFeeInSatoshis(random_int(30000, 50000));
31 | }
32 |
33 | public function withdraw(int $balanceToWithdraw, string $addressToWithdrawTo): CompletedWithdraw
34 | {
35 | return new CompletedWithdraw($addressToWithdrawTo, $balanceToWithdraw, sprintf('MOCK_%s', time()));
36 | }
37 |
38 | public function getAvailableBalance(): int
39 | {
40 | return $this->availableBalance;
41 | }
42 |
43 | public function getWithdrawFeeInSatoshis(): int
44 | {
45 | return $this->withdrawFeeInSatoshis;
46 | }
47 |
48 | public function supportsExchange(string $exchange): bool
49 | {
50 | return $this->isEnabled;
51 | }
52 |
53 | public function setAvailableBalance(int $availableBalance): self
54 | {
55 | $this->availableBalance = $availableBalance;
56 |
57 | return $this;
58 | }
59 |
60 | public function setWithdrawFeeInSatoshis(int $withdrawFeeInSatoshis): self
61 | {
62 | $this->withdrawFeeInSatoshis = $withdrawFeeInSatoshis;
63 |
64 | return $this;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/docs/includes/cron-examples.rst:
--------------------------------------------------------------------------------
1 | The ``buy`` and ``withdraw`` command both allow skipping the confirmation questions with the ``--yes`` option. By leveraging the system's cron daemon on Linux, you can create flexible setups. Use the command ``crontab -e`` to edit periodic tasks for your user:
2 |
3 | Since it's best to use absolute paths in crontabs, we'll be using ``$(command -v docker)`` to have it automatically determined for you.
4 |
5 | .. code-block:: bash
6 | :caption: Finding out where Docker is located
7 |
8 | $ command -v docker
9 | -> /usr/bin/docker
10 |
11 | Example: Buying €50.00 of Bitcoin and withdrawing every monday. Buy at 3am and withdraw at 3:30am.
12 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
13 |
14 | .. code-block:: bash
15 |
16 | 0 3 * * mon $(command -v docker) run --rm --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest buy 50 --yes --no-ansi
17 | 30 3 * * mon $(command -v docker) run --rm --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest withdraw --all --yes --no-ansi
18 |
19 | Example: Buying €50.00 of Bitcoin every week on monday, withdrawing everything on the 1st of every month.
20 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
21 |
22 | .. code-block:: bash
23 |
24 | 0 3 * * mon $(command -v docker) run --rm --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest buy 50 --yes --no-ansi
25 | 0 0 1 * * $(command -v docker) run --rm --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest withdraw --all --yes --no-ansi
26 |
27 | .. note::
28 | You can use the great tool at https://crontab.guru/ to try more combinations.
29 |
30 | Tips
31 | ----
32 | * You can create and run this tool with different configuration files, e.g. different withdrawal addresses for your spouse, children or other saving purposes.
33 | * Go nuts on security, use a different API keys for buying and withdrawal. You can even lock your BL3P account to only allow a single Bitcoin address for withdrawal through the API.
34 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jorijn/bitcoin-dca",
3 | "description": "Tool for automatically buying and withdrawing Bitcoin",
4 | "type": "project",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Jorijn Schrijvershof",
9 | "email": "jorijn@jorijn.com"
10 | }
11 | ],
12 | "require": {
13 | "php": "^8.2",
14 | "symfony/console": "^6.0",
15 | "symfony/dotenv": "^6.0",
16 | "symfony/dependency-injection": "^6.0",
17 | "symfony/yaml": "^6.0",
18 | "symfony/config": "^6.0",
19 | "ext-curl": "*",
20 | "ext-json": "*",
21 | "ext-iconv": "*",
22 | "ext-bcmath": "*",
23 | "protonlabs/bitcoin": "1.0.x-dev",
24 | "symfony/event-dispatcher": "^6.0",
25 | "monolog/monolog": "^2.0",
26 | "symfony/http-client": "^6.0",
27 | "symfony/serializer": "^6.0",
28 | "symfony/property-access": "^6.0",
29 | "symfony/mailer": "^6.0",
30 | "league/html-to-markdown": "^5.0",
31 | "symfony/notifier": "^6.0",
32 | "symfony/telegram-notifier": "^6.0",
33 | "symfony/proxy-manager-bridge": "^6.0",
34 | "symfony/amazon-mailer": "^6.0",
35 | "symfony/google-mailer": "^6.0",
36 | "symfony/mailchimp-mailer": "^6.0",
37 | "symfony/mailgun-mailer": "^6.0",
38 | "symfony/mailjet-mailer": "^6.0",
39 | "symfony/postmark-mailer": "^6.0",
40 | "symfony/sendgrid-mailer": "^6.0",
41 | "symfony/sendinblue-mailer": "^6.0"
42 | },
43 | "autoload": {
44 | "psr-4": {
45 | "Jorijn\\Bitcoin\\Dca\\": "src/"
46 | }
47 | },
48 | "autoload-dev": {
49 | "psr-4": {
50 | "Tests\\Jorijn\\Bitcoin\\Dca\\": "tests/"
51 | }
52 | },
53 | "scripts": {
54 | "test": "vendor/bin/phpunit --testdox",
55 | "php-cs-fixer": "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix"
56 | },
57 | "require-dev": {
58 | "phpunit/phpunit": "^10.0",
59 | "rector/rector": "^0.15.24"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Repository/JsonFileTaggedIntegerRepository.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Repository;
15 |
16 | use JsonException;
17 |
18 | class JsonFileTaggedIntegerRepository implements TaggedIntegerRepositoryInterface
19 | {
20 | public function __construct(protected string $fileLocation)
21 | {
22 | }
23 |
24 | public function increase(string $tag, int $value = 1): void
25 | {
26 | $data = $this->read();
27 |
28 | $data[$tag] ??= 0;
29 | $data[$tag] += $value;
30 |
31 | $this->write($data);
32 | }
33 |
34 | public function decrease(string $tag, int $value = 1): void
35 | {
36 | $data = $this->read();
37 |
38 | $data[$tag] ??= 0;
39 | $data[$tag] -= $value;
40 |
41 | $this->write($data);
42 | }
43 |
44 | public function set(string $tag, int $value): void
45 | {
46 | $data = $this->read();
47 |
48 | $data[$tag] = $value;
49 |
50 | $this->write($data);
51 | }
52 |
53 | public function get(string $tag): int
54 | {
55 | $data = $this->read();
56 |
57 | return $data[$tag] ?? 0;
58 | }
59 |
60 | /**
61 | * @param array $data
62 | *
63 | * @throws \JsonException
64 | */
65 | protected function write($data = []): void
66 | {
67 | file_put_contents($this->fileLocation, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, 512));
68 | }
69 |
70 | protected function read(): array
71 | {
72 | if (file_exists($this->fileLocation)) {
73 | try {
74 | return json_decode(file_get_contents($this->fileLocation), true, 512, JSON_THROW_ON_ERROR);
75 | } catch (JsonException) {
76 | return [];
77 | }
78 | }
79 |
80 | return [];
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/docs/tagged-balance.rst:
--------------------------------------------------------------------------------
1 | .. _tagged-balance:
2 |
3 | Using tags to keep track of balance
4 | ===================================
5 |
6 | .. note::
7 | You need persistent storage to keep track of tagged balances. See :ref:`persistent-storage`
8 |
9 | What is tagging and what can I use it for?
10 | ------------------------------------------
11 |
12 | Tagging is a multi tenant solution for DCA. It enables you to categorize each buy and maintain a balance for each category created. For example, you could use it to save some Bitcoin for your children. It's as easy as supplying.
13 |
14 | Example: Bobby
15 | -------
16 |
17 | Lets say I have a kid called Bobby, I'm a true believer Bitcoin will someday rule the world and I would like to save some Bitcoin for him separately from my own account. I would then buy Bitcoin the regular way, except now I would provide a new argument: ``-t bobby``.
18 |
19 | .. code-block:: bash
20 | :caption: Buying € 10,- of Bitcoin for Bobby
21 |
22 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest buy 10 -t bobby
23 |
24 | Then, when time comes to withdraw, you can withdraw his funds to a wallet of his own:
25 |
26 | .. code-block:: bash
27 | :caption: Withdrawing all Bitcoin specifically for Bobby
28 |
29 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca-bobby ghcr.io/jorijn/bitcoin-dca:latest withdraw --all -t bobby
30 |
31 | .. note::
32 | Docker can't handle both ``-e`` and ``--file-file``, it's best to create a seperate configuration file containing another xpub or withdrawal address.
33 |
34 | Of course, other examples are possible, e.g. tagging balance for buying a house or a car.
35 |
36 | Technical note, tagging works like this:
37 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
38 |
39 | * Buying 10.000 with tag ``mike``, Mike's balance is now 10.000, total balance 10.000;
40 | * Buying 10.000 with tag ``bobby``, Bobby's balance is now 10.000, total balance 20.000;
41 | * Buying 15.000 with tag ``mike``, Mike's balance is now 25.000, total balance 35.000;
42 | * Withdrawing all for tag ``mike``, initiating a withdrawal for 25.000 leaving the balance for Mike at 0 and Bobby 10.000.
43 |
--------------------------------------------------------------------------------
/src/EventListener/Notifications/SendTelegramOnBuyListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\BuySuccessEvent;
17 | use Symfony\Component\Notifier\Bridge\Telegram\TelegramOptions;
18 | use Symfony\Component\Notifier\Message\ChatMessage;
19 |
20 | class SendTelegramOnBuyListener extends AbstractSendTelegramListener
21 | {
22 | public function onBuy(BuySuccessEvent $buySuccessEvent): void
23 | {
24 | if (!$this->isEnabled) {
25 | return;
26 | }
27 |
28 | $formattedSats = number_format($buySuccessEvent->getBuyOrder()->getAmountInSatoshis());
29 | $exchange = ucfirst($this->getExchange());
30 |
31 | $htmlMessage = <<💰 Bitcoin-DCA just bought {$formattedSats} sats at {$exchange}.
33 |
34 | Transaction overview:
35 |
36 | Purchased: {$buySuccessEvent->getBuyOrder()->getDisplayAmountBought()}
37 | Spent: {$buySuccessEvent->getBuyOrder()->getDisplayAmountSpent()}
38 | Fee: {$buySuccessEvent->getBuyOrder()->getDisplayFeesSpent()}
39 | Price: {$buySuccessEvent->getBuyOrder()->getDisplayAveragePrice()}
40 | TLGRM;
41 |
42 | if ($buySuccessEvent->getTag()) {
43 | $htmlMessage .= PHP_EOL.'Tag: '.htmlspecialchars($buySuccessEvent->getTag()).'';
44 | }
45 |
46 | $chatMessage = new ChatMessage(
47 | $htmlMessage,
48 | new TelegramOptions(
49 | [
50 | 'parse_mode' => TelegramOptions::PARSE_MODE_HTML,
51 | 'disable_web_page_preview' => true,
52 | ]
53 | )
54 | );
55 |
56 | $this->telegramTransport->send($chatMessage);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests-on-change.yml:
--------------------------------------------------------------------------------
1 | name: Run the Bitcoin-DCA automated tests every time some code changes
2 |
3 | on:
4 | push:
5 |
6 | jobs:
7 | run-tests:
8 | runs-on: ubuntu-latest
9 | permissions:
10 | contents: read
11 |
12 | steps:
13 | - name: Checkout repository
14 | uses: actions/checkout@v4
15 |
16 | - name: Set up Docker Buildx
17 | uses: docker/setup-buildx-action@v3
18 |
19 | - name: Build the Docker image and execute the testsuite
20 | uses: docker/build-push-action@v5
21 | with:
22 | context: .
23 | push: false
24 | target: testing_stage
25 | load: true
26 | tags: jorijn/bitcoin-dca:ci
27 | cache-from: type=registry,ref=jorijn/bitcoin-dca:latest
28 |
29 | - name: Extract the test logging artifacts from the container that was built
30 | if: success() || failure()
31 | run: |
32 | docker run --rm --entrypoint= -v ${GITHUB_WORKSPACE}:/app/ jorijn/bitcoin-dca:ci sh -c "cp /tmp/tests_*.xml /app/"
33 | sed -i "s/\/app\//\/github\/workspace\//g" tests_coverage.xml tests_log.xml
34 |
35 | - name: Upload logging artifacts
36 | uses: actions/upload-artifact@v4
37 | if: success() || failure()
38 | with:
39 | name: test_coverage_and_logging
40 | path: |
41 | tests_coverage.xml
42 | tests_log.xml
43 |
44 | - name: Publish Test Report
45 | uses: mikepenz/action-junit-report@v4
46 | if: success() || failure()
47 | with:
48 | report_paths: 'tests_*.xml'
49 |
50 | # sonarcloud:
51 | # runs-on: ubuntu-latest
52 | # needs: run-tests
53 | # steps:
54 | # - uses: actions/checkout@v4
55 | # with:
56 | # fetch-depth: 0
57 | # - name: Download artifact
58 | # uses: actions/download-artifact@v4
59 | # with:
60 | # name: test_coverage_and_logging
61 | # - name: SonarCloud Scan
62 | # uses: sonarsource/sonarcloud-github-action@v2.1.1
63 | # env:
64 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65 | # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
66 |
--------------------------------------------------------------------------------
/tests/Service/Kraken/KrakenBalanceServiceTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Service\Kraken;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\KrakenClientInterface;
17 | use Jorijn\Bitcoin\Dca\Service\Kraken\KrakenBalanceService;
18 | use PHPUnit\Framework\TestCase;
19 |
20 | /**
21 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Service\Kraken\KrakenBalanceService
22 | *
23 | * @covers ::__construct
24 | *
25 | * @internal
26 | */
27 | final class KrakenBalanceServiceTest extends TestCase
28 | {
29 | protected \Jorijn\Bitcoin\Dca\Client\KrakenClientInterface|\PHPUnit\Framework\MockObject\MockObject $client;
30 |
31 | protected KrakenBalanceService $balanceService;
32 |
33 | protected function setUp(): void
34 | {
35 | parent::setUp();
36 |
37 | $this->client = $this->createMock(KrakenClientInterface::class);
38 | $this->balanceService = new KrakenBalanceService($this->client);
39 | }
40 |
41 | /**
42 | * @covers ::supportsExchange
43 | */
44 | public function testSupportsExchange(): void
45 | {
46 | static::assertTrue($this->balanceService->supportsExchange('kraken'));
47 | static::assertFalse($this->balanceService->supportsExchange('something_else'));
48 | }
49 |
50 | /**
51 | * @covers ::getBalances
52 | */
53 | public function testGetBalances(): void
54 | {
55 | $this->client
56 | ->expects(static::once())
57 | ->method('queryPrivate')
58 | ->with('Balance')
59 | ->willReturn(
60 | [
61 | 'BTC' => '3',
62 | 'EUR' => '2',
63 | ]
64 | )
65 | ;
66 |
67 | $result = $this->balanceService->getBalances();
68 |
69 | static::assertSame([
70 | ['BTC', '3 BTC', '3 BTC'],
71 | ['EUR', '2 EUR', '2 EUR'],
72 | ], $result);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-publish-to-docker.yml:
--------------------------------------------------------------------------------
1 | name: Create and publish the Bitcoin-DCA Docker image
2 |
3 | on:
4 | release:
5 | types: [published]
6 | workflow_dispatch:
7 |
8 | env:
9 | REGISTRY: ghcr.io
10 | IMAGE_NAME: ${{ github.repository }}
11 |
12 | jobs:
13 | build-and-push-image:
14 | runs-on: ubuntu-latest
15 | permissions:
16 | contents: read
17 | packages: write
18 |
19 | steps:
20 | - name: Checkout repository
21 | uses: actions/checkout@v4
22 |
23 | -
24 | name: Set up QEMU
25 | uses: docker/setup-qemu-action@v3
26 |
27 | -
28 | name: Set up Docker Buildx
29 | uses: docker/setup-buildx-action@v3
30 |
31 | - name: Log in to the GitHub Container registry
32 | uses: docker/login-action@v3
33 | with:
34 | registry: ${{ env.REGISTRY }}
35 | username: ${{ github.actor }}
36 | password: ${{ secrets.GITHUB_TOKEN }}
37 |
38 | - name: Log in to the Docker Hub Container registry
39 | uses: docker/login-action@v3
40 | with:
41 | username: ${{ secrets.DOCKERHUB_USERNAME }}
42 | password: ${{ secrets.DOCKERHUB_PASSWORD }}
43 |
44 | - name: Extract metadata (tags, labels) for Docker
45 | id: meta
46 | uses: docker/metadata-action@v5
47 | with:
48 | images: docker.io/${{ env.IMAGE_NAME }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
49 |
50 | -
51 | name: Extract build metadata to version.json
52 | run: |
53 | cat > "${GITHUB_WORKSPACE}/version.json" <
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Service\Bitvavo;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\BitvavoClientInterface;
17 | use Jorijn\Bitcoin\Dca\Service\Bitvavo\BitvavoBalanceService;
18 | use PHPUnit\Framework\TestCase;
19 |
20 | /**
21 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Service\Bitvavo\BitvavoBalanceService
22 | *
23 | * @covers ::__construct
24 | *
25 | * @internal
26 | */
27 | final class BitvavoBalanceServiceTest extends TestCase
28 | {
29 | private \Jorijn\Bitcoin\Dca\Client\BitvavoClientInterface|\PHPUnit\Framework\MockObject\MockObject $client;
30 | private BitvavoBalanceService $service;
31 |
32 | protected function setUp(): void
33 | {
34 | parent::setUp();
35 |
36 | $this->client = $this->createMock(BitvavoClientInterface::class);
37 | $this->service = new BitvavoBalanceService($this->client);
38 | }
39 |
40 | /**
41 | * @covers ::supportsExchange
42 | */
43 | public function testSupportsExchange(): void
44 | {
45 | static::assertTrue($this->service->supportsExchange('bitvavo'));
46 | static::assertFalse($this->service->supportsExchange('bitvivo'));
47 | }
48 |
49 | /**
50 | * @covers ::getBalances
51 | */
52 | public function testShowsBalance(): void
53 | {
54 | $this->client
55 | ->expects(static::once())
56 | ->method('apiCall')
57 | ->with('balance')
58 | ->willReturn($this->getStubResponse())
59 | ;
60 |
61 | $result = $this->service->getBalances();
62 |
63 | static::assertSame([
64 | ['BTC', '3 BTC', '1.00000000 BTC'],
65 | ['EUR', '2 EUR', '2.00000000 EUR'],
66 | ], $result);
67 | }
68 |
69 | protected function getStubResponse(): array
70 | {
71 | return [
72 | ['symbol' => 'BTC', 'available' => '3', 'inOrder' => '2'],
73 | ['symbol' => 'EUR', 'available' => '2', 'inOrder' => '0'],
74 | ];
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/EventListener/WriteOrderToCsvListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\BuySuccessEvent;
17 | use Psr\Log\LoggerAwareTrait;
18 | use Psr\Log\LoggerInterface;
19 | use Symfony\Component\Serializer\Encoder\CsvEncoder;
20 | use Symfony\Component\Serializer\SerializerAwareTrait;
21 | use Symfony\Component\Serializer\SerializerInterface;
22 |
23 | class WriteOrderToCsvListener
24 | {
25 | use LoggerAwareTrait;
26 | use SerializerAwareTrait;
27 |
28 | public function __construct(
29 | SerializerInterface $serializer,
30 | LoggerInterface $logger,
31 | protected ?string $csvLocation
32 | ) {
33 | $this->setSerializer($serializer);
34 | $this->setLogger($logger);
35 | }
36 |
37 | public function onSuccessfulBuy(BuySuccessEvent $buySuccessEvent): void
38 | {
39 | if (null === $this->csvLocation) {
40 | return;
41 | }
42 |
43 | try {
44 | $addHeaders = !file_exists($this->csvLocation) || 0 === filesize($this->csvLocation);
45 | $csvData = $this->serializer->serialize(
46 | $buySuccessEvent->getBuyOrder(),
47 | 'csv',
48 | [CsvEncoder::NO_HEADERS_KEY => !$addHeaders]
49 | );
50 |
51 | $resource = fopen($this->csvLocation, 'a');
52 | fwrite($resource, $csvData);
53 | fclose($resource);
54 |
55 | $this->logger->info(
56 | 'wrote order information to file',
57 | [
58 | 'file' => $this->csvLocation,
59 | 'add_headers' => $addHeaders,
60 | ]
61 | );
62 | } catch (\Throwable $exception) {
63 | $this->logger->error(
64 | 'unable to write order to file',
65 | [
66 | 'reason' => $exception->getMessage(),
67 | 'exception' => $exception,
68 | ]
69 | );
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/tests/Provider/SimpleWithdrawAddressProviderTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Provider;
15 |
16 | use Jorijn\Bitcoin\Dca\Provider\SimpleWithdrawAddressProvider;
17 | use Jorijn\Bitcoin\Dca\Validator\ValidationException;
18 | use Jorijn\Bitcoin\Dca\Validator\ValidationInterface;
19 | use PHPUnit\Framework\TestCase;
20 |
21 | /**
22 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Provider\SimpleWithdrawAddressProvider
23 | *
24 | * @covers ::__construct
25 | *
26 | * @internal
27 | */
28 | final class SimpleWithdrawAddressProviderTest extends TestCase
29 | {
30 | private \PHPUnit\Framework\MockObject\MockObject|\Jorijn\Bitcoin\Dca\Validator\ValidationInterface $validation;
31 |
32 | private SimpleWithdrawAddressProvider $provider;
33 | private string $configuredAddress;
34 |
35 | protected function setUp(): void
36 | {
37 | parent::setUp();
38 |
39 | $this->configuredAddress = 'ca'.random_int(1000, 2000);
40 | $this->validation = $this->createMock(ValidationInterface::class);
41 | $this->provider = new SimpleWithdrawAddressProvider($this->validation, $this->configuredAddress);
42 | }
43 |
44 | /**
45 | * @covers ::provide
46 | */
47 | public function testExpectExceptionWhenValidationFails(): void
48 | {
49 | $validationException = new ValidationException('error'.random_int(1000, 2000));
50 |
51 | $this->validation
52 | ->expects(static::once())
53 | ->method('validate')
54 | ->with($this->configuredAddress)
55 | ->willThrowException($validationException)
56 | ;
57 |
58 | $this->expectExceptionObject($validationException);
59 |
60 | $this->provider->provide();
61 | }
62 |
63 | /**
64 | * @covers ::provide
65 | */
66 | public function testExpectAddressToBeReturnedWhenValid(): void
67 | {
68 | $this->validation
69 | ->expects(static::once())
70 | ->method('validate')
71 | ->with($this->configuredAddress)
72 | ;
73 |
74 | static::assertSame($this->configuredAddress, $this->provider->provide());
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/Client/VerboseHttpClientDecorator.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Client;
15 |
16 | use Psr\Log\LoggerAwareTrait;
17 | use Psr\Log\LoggerInterface;
18 | use Symfony\Component\HttpClient\DecoratorTrait;
19 | use Symfony\Contracts\HttpClient\HttpClientInterface;
20 | use Symfony\Contracts\HttpClient\ResponseInterface;
21 |
22 | class VerboseHttpClientDecorator implements HttpClientInterface
23 | {
24 | use DecoratorTrait;
25 | use LoggerAwareTrait;
26 |
27 | public function __construct(
28 | protected HttpClientInterface $httpClient,
29 | LoggerInterface $logger,
30 | protected bool $enabled = false
31 | ) {
32 | $this->setLogger($logger);
33 | }
34 |
35 | public function request(string $method, string $url, array $options = []): ResponseInterface
36 | {
37 | if (!$this->enabled) {
38 | return $this->httpClient->request($method, $url, $options);
39 | }
40 |
41 | $this->logger->debug(
42 | '[API call] about to make a request',
43 | [
44 | 'method' => $method,
45 | 'url' => $url,
46 | 'options' => $options,
47 | ]
48 | );
49 |
50 | try {
51 | $response = $this->httpClient->request($method, $url, $options);
52 | } catch (\Throwable $exception) {
53 | $this->logger->debug(
54 | '[API call] exception was raised',
55 | [
56 | 'reason' => $exception->getMessage() ?: $exception::class,
57 | 'exception' => $exception,
58 | ]
59 | );
60 |
61 | throw $exception;
62 | }
63 |
64 | $this->logger->debug(
65 | '[API call] received a response from API remote party',
66 | [
67 | 'method' => $method,
68 | 'url' => $url,
69 | 'options' => $options,
70 | 'response' => $response->getContent(false),
71 | 'http_code' => $response->getStatusCode(),
72 | 'headers' => $response->getHeaders(false),
73 | 'info' => $response->getInfo(),
74 | ]
75 | );
76 |
77 | return $response;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Component/ExternalAddressFromMasterPublicKeyComponent.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Component;
15 |
16 | use Psr\Log\LoggerInterface;
17 |
18 | class ExternalAddressFromMasterPublicKeyComponent implements AddressFromMasterPublicKeyComponentInterface
19 | {
20 | protected array $addressCache = [];
21 |
22 | public function __construct(protected LoggerInterface $logger, protected string $externalToolLocation)
23 | {
24 | }
25 |
26 | public function derive(string $masterPublicKey, $path = '0/0'): string
27 | {
28 | if (empty($masterPublicKey)) {
29 | throw new \InvalidArgumentException('Master Public Key cannot be empty');
30 | }
31 |
32 | // if item is present in the cache, return it from there
33 | if (isset($this->addressCache[$masterPublicKey][$path])) {
34 | return $this->addressCache[$masterPublicKey][$path];
35 | }
36 |
37 | [$namespace, $index] = explode('/', (string) $path);
38 | if ('0' !== $namespace) {
39 | throw new \InvalidArgumentException('no change addresses supported');
40 | }
41 |
42 | $rangeLow = ($index - 25) < 0 ? 0 : $index - 25;
43 | $command = sprintf(
44 | '%s derive %s %s %s 2>&1',
45 | $this->externalToolLocation,
46 | escapeshellarg($masterPublicKey),
47 | escapeshellarg((string) $rangeLow),
48 | escapeshellarg((string) ($index + 25))
49 | );
50 |
51 | $strResult = shell_exec($command);
52 |
53 | try {
54 | // decode the result and add it to the cache in the same go.
55 | $result = $this->addressCache[$masterPublicKey] = json_decode($strResult, true, 512, JSON_THROW_ON_ERROR);
56 | } catch (\Throwable $exception) {
57 | $this->logger->error(
58 | 'failed to decode from external derivation tool: '.($exception->getMessage() ?: $exception::class),
59 | [
60 | 'exception' => $exception,
61 | 'result' => $strResult,
62 | ]
63 | );
64 |
65 | throw $exception;
66 | }
67 |
68 | return $result[$path];
69 | }
70 |
71 | public function supported(): bool
72 | {
73 | return true;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/tests/Validator/BitcoinAddressValidatorTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Validator;
15 |
16 | use BitWasp\Bitcoin\Address\AddressCreator;
17 | use Jorijn\Bitcoin\Dca\Validator\BitcoinAddressValidator;
18 | use Jorijn\Bitcoin\Dca\Validator\BitcoinAddressValidatorException;
19 | use PHPUnit\Framework\TestCase;
20 |
21 | /**
22 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Validator\BitcoinAddressValidator
23 | *
24 | * @covers ::__construct
25 | *
26 | * @internal
27 | */
28 | final class BitcoinAddressValidatorTest extends TestCase
29 | {
30 | private \BitWasp\Bitcoin\Address\AddressCreator|\PHPUnit\Framework\MockObject\MockObject $addressCreator;
31 |
32 | private BitcoinAddressValidator $validator;
33 |
34 | protected function setUp(): void
35 | {
36 | parent::setUp();
37 |
38 | $this->addressCreator = $this->createMock(AddressCreator::class);
39 | $this->validator = new BitcoinAddressValidator($this->addressCreator);
40 | }
41 |
42 | /**
43 | * @covers ::validate
44 | */
45 | public function testExpectFailureOnEmptyInput(): void
46 | {
47 | $this->expectException(BitcoinAddressValidatorException::class);
48 | $this->validator->validate('');
49 | }
50 |
51 | /**
52 | * @covers ::validate
53 | */
54 | public function testExpectFailureOnAddressCreatorFailure(): void
55 | {
56 | $address = 'address'.random_int(1000, 2000);
57 | $addressCreatorException = new \Exception('error'.random_int(1000, 2000));
58 |
59 | $this->addressCreator
60 | ->expects(static::once())
61 | ->method('fromString')
62 | ->with($address)
63 | ->willThrowException($addressCreatorException)
64 | ;
65 |
66 | $this->expectException(BitcoinAddressValidatorException::class);
67 | $this->validator->validate($address);
68 | }
69 |
70 | /**
71 | * @covers ::validate
72 | */
73 | public function testExpectTrueWhenAddressIsValid(): void
74 | {
75 | $address = 'address'.random_int(1000, 2000);
76 |
77 | $this->addressCreator
78 | ->expects(static::once())
79 | ->method('fromString')
80 | ->with($address)
81 | ;
82 |
83 | $this->validator->validate($address);
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Client/BitvavoClient.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Client;
15 |
16 | use Jorijn\Bitcoin\Dca\Exception\BitvavoClientException;
17 | use Psr\Log\LoggerInterface;
18 | use Symfony\Contracts\HttpClient\HttpClientInterface;
19 |
20 | class BitvavoClient implements BitvavoClientInterface
21 | {
22 | public function __construct(
23 | protected HttpClientInterface $httpClient,
24 | protected LoggerInterface $logger,
25 | protected ?string $apiKey,
26 | protected ?string $apiSecret,
27 | protected string $accessWindow = '10000'
28 | ) {
29 | }
30 |
31 | public function apiCall(
32 | string $path,
33 | string $method = 'GET',
34 | array $parameters = [],
35 | array $body = [],
36 | string $now = null
37 | ): array {
38 | if (null === $now) {
39 | $time = explode(' ', microtime());
40 | $now = $time[1].substr($time[0], 2, 3);
41 | }
42 |
43 | $query = http_build_query($parameters, '', '&');
44 | $endpointParams = $path.(empty($parameters) ? null : '?'.$query);
45 | $hashString = $now.$method.'/v2/'.$endpointParams;
46 |
47 | if (!empty($body)) {
48 | $hashString .= json_encode($body, JSON_THROW_ON_ERROR);
49 | }
50 |
51 | $headers = [
52 | 'Bitvavo-Access-Key' => $this->apiKey,
53 | 'Bitvavo-Access-Signature' => hash_hmac('sha256', $hashString, $this->apiSecret),
54 | 'Bitvavo-Access-Timestamp' => $now,
55 | 'Bitvavo-Access-Window' => $this->accessWindow,
56 | 'User-Agent' => 'Mozilla/4.0 (compatible; Bitvavo PHP client; Jorijn/BitcoinDca; '.PHP_OS.'; PHP/'.PHP_VERSION.')',
57 | 'Content-Type' => 'application/json',
58 | ];
59 |
60 | $serverResponse = $this->httpClient->request(
61 | $method,
62 | $path,
63 | [
64 | 'headers' => $headers,
65 | 'query' => $parameters,
66 | ] + (empty($body) ? [] : ['json' => $body])
67 | );
68 |
69 | $responseData = $serverResponse->toArray(false);
70 |
71 | if (isset($responseData['errorCode'])) {
72 | throw new BitvavoClientException($responseData['error'], $responseData['errorCode']);
73 | }
74 |
75 | return $responseData;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/tests/Service/BalanceServiceTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Service;
15 |
16 | use Jorijn\Bitcoin\Dca\Exception\NoExchangeAvailableException;
17 | use Jorijn\Bitcoin\Dca\Service\BalanceService;
18 | use Jorijn\Bitcoin\Dca\Service\BalanceServiceInterface;
19 | use PHPUnit\Framework\TestCase;
20 |
21 | /**
22 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Service\BalanceService
23 | *
24 | * @internal
25 | */
26 | final class BalanceServiceTest extends TestCase
27 | {
28 | private const SUPPORTS_EXCHANGE = 'supportsExchange';
29 | private const GET_BALANCES = 'getBalances';
30 |
31 | /**
32 | * @covers ::__construct
33 | * @covers ::getBalances
34 | */
35 | public function testGetBalances(): void
36 | {
37 | $balances = [random_int(1000, 2000)];
38 | $exchange = 'configuredExchange'.random_int(1000, 2000);
39 |
40 | $unsupportedExchange = $this->createMock(BalanceServiceInterface::class);
41 | $supportedExchange = $this->createMock(BalanceServiceInterface::class);
42 |
43 | $unsupportedExchange->method(self::SUPPORTS_EXCHANGE)->with($exchange)->willReturn(false);
44 | $supportedExchange->method(self::SUPPORTS_EXCHANGE)->with($exchange)->willReturn(true);
45 |
46 | $unsupportedExchange->expects(static::never())->method(self::GET_BALANCES);
47 | $supportedExchange->expects(static::once())->method(self::GET_BALANCES)->willReturn($balances);
48 |
49 | $balanceService = new BalanceService([$unsupportedExchange, $supportedExchange], $exchange);
50 | static::assertSame($balances, $balanceService->getBalances());
51 | }
52 |
53 | /**
54 | * @covers ::__construct
55 | * @covers ::getBalances
56 | */
57 | public function testGetNoServicesAvailable(): void
58 | {
59 | $exchange = 'configuredExchange'.random_int(1000, 2000);
60 |
61 | $unsupportedExchange = $this->createMock(BalanceServiceInterface::class);
62 | $unsupportedExchange->method(self::SUPPORTS_EXCHANGE)->with($exchange)->willReturn(false);
63 | $unsupportedExchange->expects(static::never())->method(self::GET_BALANCES);
64 |
65 | $this->expectException(NoExchangeAvailableException::class);
66 |
67 | $balanceService = new BalanceService([$unsupportedExchange], $exchange);
68 | $balanceService->getBalances();
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Service/Bitvavo/BitvavoWithdrawService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Bitvavo;
15 |
16 | use Jorijn\Bitcoin\Dca\Bitcoin;
17 | use Jorijn\Bitcoin\Dca\Client\BitvavoClientInterface;
18 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
19 | use Jorijn\Bitcoin\Dca\Service\WithdrawServiceInterface;
20 | use Psr\Log\LoggerInterface;
21 |
22 | class BitvavoWithdrawService implements WithdrawServiceInterface
23 | {
24 | final public const SYMBOL = 'symbol';
25 |
26 | public function __construct(protected BitvavoClientInterface $bitvavoClient, protected LoggerInterface $logger)
27 | {
28 | }
29 |
30 | public function withdraw(int $balanceToWithdraw, string $addressToWithdrawTo): CompletedWithdraw
31 | {
32 | $netAmountToWithdraw = $balanceToWithdraw - $this->getWithdrawFeeInSatoshis();
33 |
34 | $this->bitvavoClient->apiCall('withdrawal', 'POST', [], [
35 | self::SYMBOL => 'BTC',
36 | 'address' => $addressToWithdrawTo,
37 | 'amount' => bcdiv((string) $netAmountToWithdraw, Bitcoin::SATOSHIS, Bitcoin::DECIMALS),
38 | 'addWithdrawalFee' => true,
39 | ]);
40 |
41 | // bitvavo doesn't support any ID for withdrawal, using timestamp instead
42 | return new CompletedWithdraw($addressToWithdrawTo, $netAmountToWithdraw, (string) time());
43 | }
44 |
45 | public function getAvailableBalance(): int
46 | {
47 | $response = $this->bitvavoClient->apiCall('balance', 'GET', [self::SYMBOL => 'BTC']);
48 | if (!isset($response[0])) {
49 | return 0;
50 | }
51 | if ('BTC' !== $response[0][self::SYMBOL]) {
52 | return 0;
53 | }
54 |
55 | $available = (int) bcmul((string) $response[0]['available'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
56 | $inOrder = (int) bcmul((string) $response[0]['inOrder'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
57 |
58 | return $available - $inOrder;
59 | }
60 |
61 | public function getWithdrawFeeInSatoshis(): int
62 | {
63 | $response = $this->bitvavoClient->apiCall('assets', 'GET', [self::SYMBOL => 'BTC']);
64 |
65 | return (int) bcmul((string) $response['withdrawalFee'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
66 | }
67 |
68 | public function supportsExchange(string $exchange): bool
69 | {
70 | return 'bitvavo' === $exchange;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/tests/Component/AddressFromMasterPublicKeyComponentTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Component;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponent;
17 | use Jorijn\Bitcoin\Dca\Exception\NoMasterPublicKeyAvailableException;
18 | use PHPUnit\Framework\TestCase;
19 |
20 | /**
21 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponent
22 | *
23 | * @internal
24 | */
25 | final class AddressFromMasterPublicKeyComponentTest extends TestCase
26 | {
27 | use MasterPublicKeyScenarioTrait;
28 |
29 | protected function setUp(): void
30 | {
31 | parent::setUp();
32 |
33 | if (\PHP_INT_SIZE !== 8) {
34 | static::markTestSkipped('unsupported on non 64 bits systems');
35 | }
36 | }
37 |
38 | /**
39 | * @dataProvider providerOfScenarios
40 | *
41 | * @covers ::derive
42 | */
43 | public function testDerive(string $xpub, array $expectedAddressList): void
44 | {
45 | $addressFromMasterPublicKeyComponent = new AddressFromMasterPublicKeyComponent();
46 | foreach ($expectedAddressList as $index => $expectedAddress) {
47 | static::assertSame(
48 | $expectedAddress,
49 | $addressFromMasterPublicKeyComponent->derive($xpub, '0/'.$index)
50 | );
51 | }
52 | }
53 |
54 | /**
55 | * @covers ::derive
56 | */
57 | public function testDeriveWithEmptyXpubKey(): void
58 | {
59 | $addressFromMasterPublicKeyComponent = new AddressFromMasterPublicKeyComponent();
60 | $this->expectException(\InvalidArgumentException::class);
61 | $addressFromMasterPublicKeyComponent->derive('');
62 | }
63 |
64 | /**
65 | * @covers ::derive
66 | */
67 | public function testDeriveWithUnsupportedKey(): void
68 | {
69 | $addressFromMasterPublicKeyComponent = new AddressFromMasterPublicKeyComponent();
70 | $this->expectException(NoMasterPublicKeyAvailableException::class);
71 | $addressFromMasterPublicKeyComponent->derive('(╯°□°)╯︵ ┻━┻');
72 | }
73 |
74 | /**
75 | * @covers ::supported
76 | */
77 | public function testSupported(): void
78 | {
79 | $addressFromMasterPublicKeyComponent = new AddressFromMasterPublicKeyComponent();
80 | static::assertSame(\PHP_INT_SIZE === 8, $addressFromMasterPublicKeyComponent->supported());
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/Service/Kraken/KrakenWithdrawService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Kraken;
15 |
16 | use Jorijn\Bitcoin\Dca\Bitcoin;
17 | use Jorijn\Bitcoin\Dca\Client\KrakenClientInterface;
18 | use Jorijn\Bitcoin\Dca\Exception\KrakenClientException;
19 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
20 | use Jorijn\Bitcoin\Dca\Service\WithdrawServiceInterface;
21 | use Psr\Log\LoggerInterface;
22 |
23 | class KrakenWithdrawService implements WithdrawServiceInterface
24 | {
25 | final public const ASSET_NAME = 'XXBT';
26 |
27 | public function __construct(
28 | protected KrakenClientInterface $krakenClient,
29 | protected LoggerInterface $logger,
30 | protected ?string $withdrawKey
31 | ) {
32 | }
33 |
34 | public function withdraw(int $balanceToWithdraw, string $addressToWithdrawTo): CompletedWithdraw
35 | {
36 | $response = $this->krakenClient->queryPrivate('Withdraw', [
37 | 'asset' => self::ASSET_NAME,
38 | 'key' => $this->withdrawKey,
39 | 'amount' => bcdiv((string) $balanceToWithdraw, Bitcoin::SATOSHIS, Bitcoin::DECIMALS),
40 | ]);
41 |
42 | return new CompletedWithdraw($addressToWithdrawTo, $balanceToWithdraw, $response['refid']);
43 | }
44 |
45 | public function getAvailableBalance(): int
46 | {
47 | try {
48 | $response = $this->krakenClient->queryPrivate('Balance');
49 |
50 | foreach ($response as $symbol => $available) {
51 | if (self::ASSET_NAME === $symbol) {
52 | return (int) bcmul((string) $available, Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
53 | }
54 | }
55 | } catch (KrakenClientException) {
56 | return 0;
57 | }
58 |
59 | return 0;
60 | }
61 |
62 | public function getWithdrawFeeInSatoshis(): int
63 | {
64 | $response = $this->krakenClient->queryPrivate(
65 | 'WithdrawInfo',
66 | [
67 | 'asset' => self::ASSET_NAME,
68 | 'key' => $this->withdrawKey,
69 | 'amount' => bcdiv((string) $this->getAvailableBalance(), Bitcoin::SATOSHIS, Bitcoin::DECIMALS),
70 | ]
71 | );
72 |
73 | return (int) bcmul((string) $response['fee'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
74 | }
75 |
76 | public function supportsExchange(string $exchange): bool
77 | {
78 | return 'kraken' === $exchange;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/EventListener/XPubAddressUsedListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\EventListener;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponentInterface;
17 | use Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent;
18 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
19 | use Psr\Log\LoggerInterface;
20 |
21 | class XPubAddressUsedListener
22 | {
23 | public function __construct(
24 | protected TaggedIntegerRepositoryInterface $taggedIntegerRepository,
25 | protected AddressFromMasterPublicKeyComponentInterface $addressFromMasterPublicKeyComponent,
26 | protected LoggerInterface $logger,
27 | protected ?string $configuredXPub
28 | ) {
29 | }
30 |
31 | public function onWithdrawAddressUsed(WithdrawSuccessEvent $withdrawSuccessEvent): void
32 | {
33 | if (null === $this->configuredXPub) {
34 | return;
35 | }
36 |
37 | try {
38 | $activeIndex = $this->taggedIntegerRepository->get($this->configuredXPub);
39 | $activeDerivationPath = sprintf('0/%d', $activeIndex);
40 | $derivedAddress = $this->addressFromMasterPublicKeyComponent->derive(
41 | $this->configuredXPub,
42 | $activeDerivationPath
43 | );
44 | $completedWithdraw = $withdrawSuccessEvent->getCompletedWithdraw();
45 |
46 | // validate that given address matches the one derived from the xpub
47 | if ($derivedAddress !== $completedWithdraw->getRecipientAddress()) {
48 | return;
49 | }
50 |
51 | $this->logger->info('found successful withdraw for configured xpub, increasing index', [
52 | 'xpub' => $this->configuredXPub,
53 | 'used_index' => $activeIndex,
54 | 'used_address' => $derivedAddress,
55 | 'derivation_path' => $activeDerivationPath,
56 | ]);
57 |
58 | // we have a match, increase the index in the database so a new address is returned next time
59 | $this->taggedIntegerRepository->increase($this->configuredXPub);
60 | } catch (\Throwable $exception) {
61 | $this->logger->error('failed to determine / increase xpub index', [
62 | 'xpub' => $this->configuredXPub,
63 | 'reason' => $exception->getMessage() ?: $exception::class,
64 | ]);
65 |
66 | throw $exception;
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/EventListener/ResetTaggedBalanceListenerTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\EventListener;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent;
17 | use Jorijn\Bitcoin\Dca\EventListener\ResetTaggedBalanceListener;
18 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
19 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
20 | use PHPUnit\Framework\TestCase;
21 | use Psr\Log\LoggerInterface;
22 |
23 | /**
24 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\EventListener\ResetTaggedBalanceListener
25 | *
26 | * @covers ::__construct
27 | *
28 | * @internal
29 | */
30 | final class ResetTaggedBalanceListenerTest extends TestCase
31 | {
32 | private \PHPUnit\Framework\MockObject\MockObject|\Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface $repository;
33 |
34 | private \Psr\Log\LoggerInterface|\PHPUnit\Framework\MockObject\MockObject $logger;
35 | private ResetTaggedBalanceListener $listener;
36 |
37 | protected function setUp(): void
38 | {
39 | parent::setUp();
40 |
41 | $this->repository = $this->createMock(TaggedIntegerRepositoryInterface::class);
42 | $this->logger = $this->createMock(LoggerInterface::class);
43 | $this->listener = new ResetTaggedBalanceListener($this->repository, $this->logger);
44 | }
45 |
46 | /**
47 | * @covers ::onWithdrawSucces
48 | */
49 | public function testListenerDoesNotActWithoutTag(): void
50 | {
51 | $withdrawSuccessEvent = new WithdrawSuccessEvent($this->createMock(CompletedWithdraw::class));
52 |
53 | $this->repository
54 | ->expects(static::never())
55 | ->method('set')
56 | ;
57 |
58 | $this->listener->onWithdrawSucces($withdrawSuccessEvent);
59 | }
60 |
61 | /**
62 | * @covers ::onWithdrawSucces
63 | *
64 | * @throws \Exception
65 | */
66 | public function testListenerResetsBalanceForTag(): void
67 | {
68 | $tag = 'tag'.random_int(1000, 2000);
69 | $withdrawSuccessEvent = new WithdrawSuccessEvent($this->createMock(CompletedWithdraw::class), $tag);
70 |
71 | $this->repository
72 | ->expects(static::once())
73 | ->method('set')
74 | ->with($tag, 0)
75 | ;
76 |
77 | $this->logger
78 | ->expects(static::atLeastOnce())
79 | ->method('info')
80 | ;
81 |
82 | $this->listener->onWithdrawSucces($withdrawSuccessEvent);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/tests/EventListener/IncreaseTaggedBalanceListenerTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\EventListener;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\BuySuccessEvent;
17 | use Jorijn\Bitcoin\Dca\EventListener\IncreaseTaggedBalanceListener;
18 | use Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder;
19 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
20 | use PHPUnit\Framework\TestCase;
21 | use Psr\Log\LoggerInterface;
22 |
23 | /**
24 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\EventListener\IncreaseTaggedBalanceListener
25 | *
26 | * @covers ::__construct
27 | *
28 | * @internal
29 | */
30 | final class IncreaseTaggedBalanceListenerTest extends TestCase
31 | {
32 | private \PHPUnit\Framework\MockObject\MockObject|\Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface $repository;
33 |
34 | private \Psr\Log\LoggerInterface|\PHPUnit\Framework\MockObject\MockObject $logger;
35 | private IncreaseTaggedBalanceListener $listener;
36 |
37 | protected function setUp(): void
38 | {
39 | parent::setUp();
40 |
41 | $this->repository = $this->createMock(TaggedIntegerRepositoryInterface::class);
42 | $this->logger = $this->createMock(LoggerInterface::class);
43 | $this->listener = new IncreaseTaggedBalanceListener($this->repository, $this->logger);
44 | }
45 |
46 | /**
47 | * @covers ::onBalanceIncrease
48 | */
49 | public function testBalanceIsIncreasedForTag(): void
50 | {
51 | $tag = 'tag'.random_int(1000, 2000);
52 | $amount = random_int(1000, 2000);
53 | $fees = random_int(0, 999);
54 |
55 | $completedBuyOrder = (new CompletedBuyOrder())
56 | ->setAmountInSatoshis($amount)
57 | ->setFeesInSatoshis($fees)
58 | ;
59 |
60 | $this->repository
61 | ->expects(static::once())
62 | ->method('increase')
63 | ->with($tag, $amount - $fees)
64 | ;
65 |
66 | $this->logger
67 | ->expects(static::once())
68 | ->method('info')
69 | ;
70 |
71 | $this->listener->onBalanceIncrease(new BuySuccessEvent($completedBuyOrder, $tag));
72 | }
73 |
74 | /**
75 | * @covers ::onBalanceIncrease
76 | */
77 | public function testListenerDoesNotActWithoutTag(): void
78 | {
79 | $this->repository->expects(static::never())->method('increase');
80 | $this->listener->onBalanceIncrease(new BuySuccessEvent(new CompletedBuyOrder()));
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/Client/KrakenClient.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Client;
15 |
16 | use Jorijn\Bitcoin\Dca\Exception\KrakenClientException;
17 | use Psr\Log\LoggerInterface;
18 | use Symfony\Contracts\HttpClient\HttpClientInterface;
19 |
20 | class KrakenClient implements KrakenClientInterface
21 | {
22 | final public const USER_AGENT = 'Mozilla/4.0 (compatible; Kraken PHP client; Jorijn/BitcoinDca; '.PHP_OS.'; PHP/'.PHP_VERSION.')';
23 |
24 | public function __construct(
25 | protected HttpClientInterface $httpClient,
26 | protected LoggerInterface $logger,
27 | protected ?string $publicKey,
28 | protected ?string $privateKey,
29 | protected string $version = '0'
30 | ) {
31 | }
32 |
33 | public function queryPublic(string $method, array $arguments = []): array
34 | {
35 | $serverResponse = $this->httpClient->request('POST', sprintf('%s/public/%s', $this->version, $method), [
36 | 'body' => $arguments,
37 | 'headers' => [
38 | 'User-Agent' => self::USER_AGENT,
39 | ],
40 | ]);
41 |
42 | return $this->validateResponse($serverResponse->toArray());
43 | }
44 |
45 | public function queryPrivate(string $method, array $arguments = []): array
46 | {
47 | $nonce = explode(' ', microtime());
48 | $arguments['nonce'] = $nonce[1].str_pad(substr($nonce[0], 2, 6), 6, '0');
49 |
50 | $path = sprintf('/%s/private/%s', $this->version, $method);
51 | $sign = hash_hmac(
52 | 'sha512',
53 | $path.hash('sha256', $arguments['nonce'].http_build_query($arguments, '', '&'), true),
54 | base64_decode($this->privateKey, true),
55 | true
56 | );
57 |
58 | $headers = [
59 | 'API-Key' => $this->publicKey,
60 | 'API-Sign' => base64_encode($sign),
61 | 'User-Agent' => self::USER_AGENT,
62 | ];
63 |
64 | $serverResponse = $this->httpClient->request('POST', $path, [
65 | 'body' => $arguments,
66 | 'headers' => $headers,
67 | ]);
68 |
69 | return $this->validateResponse($serverResponse->toArray());
70 | }
71 |
72 | protected function validateResponse(array $response): array
73 | {
74 | if (isset($response['error']) && !empty($response['error'])) {
75 | throw new KrakenClientException(implode(', ', $response['error']));
76 | }
77 |
78 | return $response['result'] ?? $response;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/tests/Service/Binance/BinanceBalanceServiceTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Service\Binance;
15 |
16 | use Jorijn\Bitcoin\Dca\Client\BinanceClientInterface;
17 | use Jorijn\Bitcoin\Dca\Service\Binance\BinanceBalanceService;
18 | use PHPUnit\Framework\TestCase;
19 |
20 | /**
21 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Service\Binance\BinanceBalanceService
22 | *
23 | * @covers ::__construct
24 | *
25 | * @internal
26 | */
27 | final class BinanceBalanceServiceTest extends TestCase
28 | {
29 | protected \Jorijn\Bitcoin\Dca\Client\BinanceClientInterface|\PHPUnit\Framework\MockObject\MockObject $client;
30 | protected BinanceBalanceService $service;
31 |
32 | protected function setUp(): void
33 | {
34 | parent::setUp();
35 |
36 | $this->client = $this->createMock(BinanceClientInterface::class);
37 | $this->service = new BinanceBalanceService($this->client);
38 | }
39 |
40 | /**
41 | * @covers ::getBalances
42 | */
43 | public function testGetBalances(): void
44 | {
45 | $responseStub = [
46 | 'balances' => [
47 | ['asset' => 'BTC', 'free' => '1.001', 'locked' => '1.002'],
48 | ['asset' => 'XRP', 'free' => '0.000', 'locked' => '0.000'], // as it should be
49 | ],
50 | ];
51 |
52 | $this->client
53 | ->expects(static::once())
54 | ->method('request')
55 | ->with(
56 | 'GET',
57 | 'api/v3/account',
58 | static::callback(function (array $extra): bool {
59 | self::assertArrayHasKey('extra', $extra);
60 | self::assertArrayHasKey('security_type', $extra['extra']);
61 | self::assertSame('USER_DATA', $extra['extra']['security_type']);
62 |
63 | return true;
64 | })
65 | )
66 | ->willReturn($responseStub)
67 | ;
68 |
69 | $response = $this->service->getBalances();
70 |
71 | static::assertArrayHasKey('BTC', $response);
72 | static::assertArrayNotHasKey('XRP', $response);
73 |
74 | static::assertSame(['BTC', '2.003', '1.001'], $response['BTC']);
75 | }
76 |
77 | /**
78 | * @covers ::supportsExchange
79 | */
80 | public function testSupportsExchange(): void
81 | {
82 | static::assertTrue($this->service->supportsExchange('binance'));
83 | static::assertFalse($this->service->supportsExchange('kraken'));
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Command/VerifyXPubCommand.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Command;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponentInterface;
17 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
18 | use Symfony\Component\Console\Command\Command;
19 | use Symfony\Component\Console\Helper\Table;
20 | use Symfony\Component\Console\Input\InputInterface;
21 | use Symfony\Component\Console\Output\OutputInterface;
22 | use Symfony\Component\Console\Style\SymfonyStyle;
23 |
24 | class VerifyXPubCommand extends Command
25 | {
26 | public function __construct(
27 | protected AddressFromMasterPublicKeyComponentInterface $addressFromMasterPublicKeyComponent,
28 | protected TaggedIntegerRepositoryInterface $taggedIntegerRepository,
29 | protected ?string $configuredKey,
30 | protected string $environmentKey
31 | ) {
32 | parent::__construct(null);
33 | }
34 |
35 | public function configure(): void
36 | {
37 | $this
38 | ->setDescription('If configured, this command displays derived address from your master public key')
39 | ;
40 | }
41 |
42 | public function execute(InputInterface $input, OutputInterface $output): int
43 | {
44 | $symfonyStyle = new SymfonyStyle($input, $output);
45 |
46 | if (empty($this->configuredKey)) {
47 | $symfonyStyle->error(
48 | 'Unable to find any configured X/Z/Y-pub. Did you configure '.$this->environmentKey.'?'
49 | );
50 |
51 | return 1;
52 | }
53 |
54 | $table = new Table($output);
55 | $table->setStyle('box');
56 | $table->setHeaders(['#', 'Address', 'Next Withdraw']);
57 |
58 | $nextIndex = $this->taggedIntegerRepository->get($this->configuredKey);
59 | $baseIndex = ($nextIndex - 5);
60 | $baseIndex = $baseIndex < 0 ? 0 : $baseIndex;
61 |
62 | for ($x = $baseIndex; $x < ($baseIndex + 10); ++$x) {
63 | $derivationPath = '0/'.$x;
64 | $table->addRow(
65 | [
66 | $x,
67 | $this->addressFromMasterPublicKeyComponent->derive($this->configuredKey, $derivationPath),
68 | $x === $nextIndex ? '<' : null,
69 | ]
70 | );
71 | }
72 |
73 | $table->render();
74 | $symfonyStyle->warning(
75 | 'Make sure these addresses match those in your client, do not use the withdraw function if they do not.'
76 | );
77 |
78 | return 0;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/bin/bitcoin-dca:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | loadEnv(__DIR__.'/../.env');
19 | }
20 |
21 | set_error_handler(
22 | static function ($errno, $errstr, $errfile, $errline) {
23 | if (0 === error_reporting()) {
24 | return false;
25 | }
26 |
27 | throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
28 | }
29 | );
30 |
31 | $containerCache = __DIR__.'/../var/cache/container.php';
32 | $containerConfigCache = new ConfigCache($containerCache, $_SERVER['DEBUG'] ?? false);
33 |
34 | if (!$containerConfigCache->isFresh()) {
35 | $containerBuilder = new ContainerBuilder();
36 |
37 | // load the DI config
38 | $loader = new YamlFileLoader($containerBuilder, new FileLocator(__DIR__.'/../config/'));
39 | $loader->load('services.yaml');
40 |
41 | $containerBuilder->addCompilerPass(new AddConsoleCommandPass());
42 | $containerBuilder->addCompilerPass(new RegisterListenersPass());
43 | $containerBuilder->addCompilerPass(new SerializerPass());
44 | $containerBuilder->setParameter('application.path', dirname(__DIR__));
45 | $containerBuilder->setParameter('kernel.debug', $_SERVER['DEBUG'] ?? false);
46 |
47 | $versionFile = dirname(__DIR__).DIRECTORY_SEPARATOR.'version.json';
48 | if (file_exists($versionFile)) {
49 | $version = json_decode(file_get_contents($versionFile), true, 512, JSON_THROW_ON_ERROR);
50 | if (isset($version['version'])) {
51 | $containerBuilder->setParameter('application_version', $version['version']);
52 | }
53 | }
54 |
55 | $containerBuilder->compile();
56 |
57 | // write the compiled container to file
58 | $dumper = new PhpDumper($containerBuilder);
59 | $containerConfigCache->write(
60 | $dumper->dump(['class' => 'BitcoinDcaContainer']),
61 | $containerBuilder->getResources()
62 | );
63 | }
64 |
65 | require_once $containerCache;
66 | $container = new BitcoinDcaContainer();
67 |
68 | $application = $container->get('application');
69 | $application->setCommandLoader($container->get('console.command_loader'));
70 | $application->setDispatcher($container->get('event_dispatcher'));
71 | $application->run();
72 |
--------------------------------------------------------------------------------
/tests/Command/BalanceCommandTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Command;
15 |
16 | use Jorijn\Bitcoin\Dca\Command\BalanceCommand;
17 | use Jorijn\Bitcoin\Dca\Service\BalanceService;
18 | use PHPUnit\Framework\TestCase;
19 | use Symfony\Component\Console\Tester\CommandTester;
20 |
21 | /**
22 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Command\BalanceCommand
23 | *
24 | * @covers ::__construct
25 | * @covers ::configure
26 | *
27 | * @internal
28 | */
29 | final class BalanceCommandTest extends TestCase
30 | {
31 | private \Jorijn\Bitcoin\Dca\Service\BalanceService|\PHPUnit\Framework\MockObject\MockObject $service;
32 | private CommandTester $commandTester;
33 |
34 | protected function setUp(): void
35 | {
36 | parent::setUp();
37 |
38 | $this->service = $this->createMock(BalanceService::class);
39 | $this->commandTester = new CommandTester(new BalanceCommand($this->service));
40 | }
41 |
42 | /**
43 | * @covers ::execute
44 | */
45 | public function testFailure(): void
46 | {
47 | $errorMessage = 'message'.random_int(1000, 2000);
48 | $exception = new \Exception($errorMessage);
49 |
50 | $this->service
51 | ->expects(static::once())
52 | ->method('getBalances')
53 | ->willThrowException($exception)
54 | ;
55 |
56 | $this->commandTester->execute([]);
57 |
58 | static::assertStringContainsString('ERROR', $this->commandTester->getDisplay());
59 | static::assertStringContainsString($errorMessage, $this->commandTester->getDisplay());
60 | static::assertSame(1, $this->commandTester->getStatusCode());
61 | }
62 |
63 | /**
64 | * @covers ::execute
65 | */
66 | public function testDisplaysBalanceFromApi(): void
67 | {
68 | $btcBalance = random_int(1000, 2000);
69 | $euroBalance = random_int(1000, 2000);
70 |
71 | $this->service
72 | ->expects(static::once())
73 | ->method('getBalances')
74 | ->willReturn(
75 | [
76 | ['BTC', $btcBalance, $btcBalance],
77 | ['EUR', $euroBalance, $euroBalance],
78 | ]
79 | )
80 | ;
81 |
82 | $this->commandTester->execute([]);
83 |
84 | static::assertStringContainsString((string) $btcBalance, $this->commandTester->getDisplay());
85 | static::assertStringContainsString((string) $euroBalance, $this->commandTester->getDisplay());
86 | static::assertSame(0, $this->commandTester->getStatusCode());
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/docs/getting-started.rst:
--------------------------------------------------------------------------------
1 | .. note::
2 | This guide is meant for people on Linux. You can use it on your VPS or Raspberry Pi. The Getting Started guide assumes you will be setting up Bitcoin DCA using the BL3P exchange. If you need to configure another exchange substitute the exchange specific configuration with the correct ones from :ref:`configuration`.
3 |
4 | .. _getting-started:
5 |
6 | Getting started
7 | ===============
8 | .. note::
9 | See :ref:`Installation ` on how to download the tool to your server.
10 |
11 | Configuration
12 | -------------
13 | Create a new file somewhere that will contain the configuration needed for the tool to operate. If your account is called ``bob`` and your home directory is `/home/bob` lets create a new file in ``/home/bob/.bitcoin-dca``:
14 |
15 | .. code-block:: bash
16 | :caption: /home/bob/.bitcoin-dca
17 |
18 | BL3P_PRIVATE_KEY=bl3p private key here
19 | BL3P_PUBLIC_KEY=bl3p identifier key here
20 | WITHDRAW_ADDRESS=hardware wallet address here
21 |
22 | .. note::
23 | See :ref:`configuration` for all available options.
24 |
25 | You can test that it work with:
26 |
27 | .. code-block:: bash
28 | :caption: Checking the Exchange balance
29 |
30 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest balance
31 |
32 | If successful, you should see a table containing your balances on the exchange:
33 |
34 | .. code-block:: bash
35 |
36 | +----------+----------------+----------------+
37 | | Currency | Balance | Available |
38 | +----------+----------------+----------------+
39 | | BTC | 0.00000000 BTC | 0.00000000 BTC |
40 | | EUR | 10.0000000 EUR | 10.0000000 EUR |
41 | | BCH | 0.00000000 BCH | 0.00000000 BCH |
42 | | LTC | 0.00000000 LTC | 0.00000000 LTC |
43 | +----------+----------------+----------------+
44 |
45 | Testing
46 | -------
47 | For safety, I recommend buying and withdrawing at least once manually to verify everything works before proceeding with automation.
48 |
49 | Buying €10,00 of Bitcoin
50 | ^^^^^^^^^^^^^^^^^^^^^^^^
51 | .. code-block:: bash
52 |
53 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest buy 10
54 |
55 | Withdrawing to your hardware wallet
56 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
57 |
58 | .. code-block:: bash
59 |
60 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca ghcr.io/jorijn/bitcoin-dca:latest withdraw --all
61 |
62 | **It will ask you:** Ready to withdraw 0.00412087 BTC to Bitcoin Address bc1abcdefghijklmopqrstuvwxuz123456? A fee of 0.0003 will be taken as withdraw fee [y/N]:
63 |
64 | .. warning::
65 | **When testing, make sure to verify the displayed Bitcoin address matches the one configured in your `.bitcoin-dca` configuration file. When confirming this question, withdrawal executes immediately.**
66 |
67 | Automating buying and withdrawing
68 | ---------------------------------
69 |
70 | .. include:: ./includes/cron-examples.rst
71 |
--------------------------------------------------------------------------------
/src/Service/Binance/BinanceWithdrawService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\Binance;
15 |
16 | use Jorijn\Bitcoin\Dca\Bitcoin;
17 | use Jorijn\Bitcoin\Dca\Client\BinanceClientInterface;
18 | use Jorijn\Bitcoin\Dca\Exception\BinanceClientException;
19 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
20 | use Jorijn\Bitcoin\Dca\Service\WithdrawServiceInterface;
21 |
22 | class BinanceWithdrawService implements WithdrawServiceInterface
23 | {
24 | public function __construct(protected BinanceClientInterface $binanceClient)
25 | {
26 | }
27 |
28 | public function withdraw(int $balanceToWithdraw, string $addressToWithdrawTo): CompletedWithdraw
29 | {
30 | $response = $this->binanceClient->request('POST', 'sapi/v1/capital/withdraw/apply', [
31 | 'extra' => ['security_type' => 'USER_DATA'],
32 | 'body' => [
33 | 'coin' => 'BTC',
34 | 'address' => $addressToWithdrawTo,
35 | 'amount' => bcdiv((string) $balanceToWithdraw, Bitcoin::SATOSHIS, Bitcoin::DECIMALS),
36 | ],
37 | ]);
38 |
39 | return new CompletedWithdraw($addressToWithdrawTo, $balanceToWithdraw, $response['id']);
40 | }
41 |
42 | public function getAvailableBalance(): int
43 | {
44 | $response = $this->binanceClient->request('GET', 'api/v3/account', [
45 | 'extra' => ['security_type' => 'USER_DATA'],
46 | ]);
47 |
48 | if (isset($response['balances'])) {
49 | foreach ($response['balances'] as $balance) {
50 | if ('BTC' === $balance['asset']) {
51 | return (int) bcmul((string) $balance['free'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
52 | }
53 | }
54 | }
55 |
56 | return 0;
57 | }
58 |
59 | public function getWithdrawFeeInSatoshis(): int
60 | {
61 | $response = $this->binanceClient->request('GET', 'sapi/v1/asset/assetDetail', [
62 | 'extra' => ['security_type' => 'USER_DATA'],
63 | ]);
64 |
65 | if (!isset($response['BTC'])) {
66 | throw new BinanceClientException('BTC asset appears to be unknown on Binance');
67 | }
68 |
69 | $assetDetails = $response['BTC'];
70 |
71 | if (false === $assetDetails['withdrawStatus'] ?? false) {
72 | throw new BinanceClientException('withdrawal for BTC is disabled on Binance');
73 | }
74 |
75 | return (int) bcmul((string) ($assetDetails['withdrawFee'] ?? 0), Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
76 | }
77 |
78 | public function supportsExchange(string $exchange): bool
79 | {
80 | return 'binance' === $exchange;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/tests/Factory/DeriveFromMasterPublicKeyComponentFactoryTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Factory;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponentInterface;
17 | use Jorijn\Bitcoin\Dca\Exception\NoDerivationComponentAvailableException;
18 | use Jorijn\Bitcoin\Dca\Factory\DeriveFromMasterPublicKeyComponentFactory;
19 | use PHPUnit\Framework\TestCase;
20 |
21 | /**
22 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Factory\DeriveFromMasterPublicKeyComponentFactory
23 | *
24 | * @covers ::__construct
25 | *
26 | * @internal
27 | */
28 | final class DeriveFromMasterPublicKeyComponentFactoryTest extends TestCase
29 | {
30 | private const SUPPORTED = 'supported';
31 |
32 | /**
33 | * @covers ::createDerivationComponent
34 | */
35 | public function testOrderIsHandledCorrectly(): void
36 | {
37 | $service1 = $this->createMock(AddressFromMasterPublicKeyComponentInterface::class);
38 | $service1->expects(static::once())->method(self::SUPPORTED)->willReturn(true);
39 | $service2 = $this->createMock(AddressFromMasterPublicKeyComponentInterface::class);
40 | $service2->expects(static::never())->method(self::SUPPORTED);
41 |
42 | $deriveFromMasterPublicKeyComponentFactory = new DeriveFromMasterPublicKeyComponentFactory([
43 | $service1,
44 | $service2,
45 | ]);
46 |
47 | static::assertSame($service1, $deriveFromMasterPublicKeyComponentFactory->createDerivationComponent());
48 | }
49 |
50 | /**
51 | * @covers ::createDerivationComponent
52 | */
53 | public function testSupported(): void
54 | {
55 | $service1 = $this->createMock(AddressFromMasterPublicKeyComponentInterface::class);
56 | $service1->expects(static::once())->method(self::SUPPORTED)->willReturn(true);
57 | $service2 = $this->createMock(AddressFromMasterPublicKeyComponentInterface::class);
58 | $service2->expects(static::once())->method(self::SUPPORTED)->willReturn(false);
59 |
60 | $deriveFromMasterPublicKeyComponentFactory = new DeriveFromMasterPublicKeyComponentFactory([
61 | $service2,
62 | $service1,
63 | ]);
64 |
65 | static::assertSame($service1, $deriveFromMasterPublicKeyComponentFactory->createDerivationComponent());
66 | }
67 |
68 | /**
69 | * @covers ::createDerivationComponent
70 | */
71 | public function testSupportedIsEmpty(): void
72 | {
73 | $deriveFromMasterPublicKeyComponentFactory = new DeriveFromMasterPublicKeyComponentFactory([]);
74 | $this->expectException(NoDerivationComponentAvailableException::class);
75 | $deriveFromMasterPublicKeyComponentFactory->createDerivationComponent();
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/tests/Model/CompletedBuyOrderTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Model;
15 |
16 | use Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder;
17 | use PHPUnit\Framework\TestCase;
18 |
19 | /**
20 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder
21 | *
22 | * @internal
23 | */
24 | final class CompletedBuyOrderTest extends TestCase
25 | {
26 | /**
27 | * @covers ::__construct
28 | * @covers ::getAmountInSatoshis
29 | * @covers ::setAmountInSatoshis
30 | * @covers ::getFeesInSatoshis
31 | * @covers ::setFeesInSatoshis
32 | * @covers ::getDisplayAmountBought
33 | * @covers ::setDisplayAmountBought
34 | * @covers ::getDisplayAmountSpent
35 | * @covers ::setDisplayAmountSpent
36 | * @covers ::getDisplayAmountSpentCurrency
37 | * @covers ::setDisplayAmountSpentCurrency
38 | * @covers ::getDisplayAveragePrice
39 | * @covers ::setDisplayAveragePrice
40 | * @covers ::getDisplayFeesSpent
41 | * @covers ::setDisplayFeesSpent
42 | * @covers ::getPurchaseMadeAt
43 | */
44 | public function testGettersAndSetters(): void
45 | {
46 | $completedBuyOrder = new CompletedBuyOrder();
47 |
48 | $completedBuyOrder
49 | ->setAmountInSatoshis($amountInSatoshis = random_int(1000, 2000))
50 | ->setFeesInSatoshis($feesInSatoshis = random_int(1000, 2000))
51 | ->setDisplayFeesSpent($feesSpent = '0.'.random_int(1000, 2000).' BTC')
52 | ->setDisplayAveragePrice($averagePrice = '€'.random_int(1000, 2000))
53 | ->setDisplayAmountSpent($amountSpent = '€'.random_int(1000, 2000))
54 | ->setDisplayAmountSpentCurrency($currency = 'EUR')
55 | ->setDisplayAmountBought($amountBought = random_int(1000, 2000).' BTC')
56 | ;
57 |
58 | static::assertSame($amountInSatoshis, $completedBuyOrder->getAmountInSatoshis());
59 | static::assertSame($feesInSatoshis, $completedBuyOrder->getFeesInSatoshis());
60 | static::assertSame($feesSpent, $completedBuyOrder->getDisplayFeesSpent());
61 | static::assertSame($averagePrice, $completedBuyOrder->getDisplayAveragePrice());
62 | static::assertSame($amountSpent, $completedBuyOrder->getDisplayAmountSpent());
63 | static::assertSame($currency, $completedBuyOrder->getDisplayAmountSpentCurrency());
64 | static::assertSame($amountBought, $completedBuyOrder->getDisplayAmountBought());
65 |
66 | static::assertEqualsWithDelta(
67 | new \DateTimeImmutable(),
68 | \DateTimeImmutable::createFromFormat(
69 | \DateTimeInterface::ATOM,
70 | $completedBuyOrder->getPurchaseMadeAt()
71 | ),
72 | 10
73 | );
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/tests/Provider/XpubWithdrawAddressProviderTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Provider;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponent;
17 | use Jorijn\Bitcoin\Dca\Provider\XpubWithdrawAddressProvider;
18 | use Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface;
19 | use Jorijn\Bitcoin\Dca\Validator\ValidationInterface;
20 | use PHPUnit\Framework\TestCase;
21 |
22 | /**
23 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Provider\XpubWithdrawAddressProvider
24 | *
25 | * @covers ::__construct
26 | *
27 | * @internal
28 | */
29 | final class XpubWithdrawAddressProviderTest extends TestCase
30 | {
31 | private \PHPUnit\Framework\MockObject\MockObject|\Jorijn\Bitcoin\Dca\Repository\TaggedIntegerRepositoryInterface $xpubRepository;
32 |
33 | private \Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponent|\PHPUnit\Framework\MockObject\MockObject $keyFactory;
34 |
35 | private \PHPUnit\Framework\MockObject\MockObject|\Jorijn\Bitcoin\Dca\Validator\ValidationInterface $validation;
36 | private XpubWithdrawAddressProvider $provider;
37 | private string $configuredXPub;
38 |
39 | protected function setUp(): void
40 | {
41 | parent::setUp();
42 |
43 | $this->xpubRepository = $this->createMock(TaggedIntegerRepositoryInterface::class);
44 | $this->keyFactory = $this->createMock(AddressFromMasterPublicKeyComponent::class);
45 | $this->configuredXPub = 'xpub'.random_int(1000, 2000);
46 | $this->validation = $this->createMock(ValidationInterface::class);
47 | $this->provider = new XpubWithdrawAddressProvider(
48 | $this->validation,
49 | $this->keyFactory,
50 | $this->xpubRepository,
51 | $this->configuredXPub,
52 | );
53 | }
54 |
55 | /**
56 | * @covers ::provide
57 | */
58 | public function testProvideResultsInDerivedAddress(): void
59 | {
60 | $activeIndex = random_int(1000, 2000);
61 | $generatedAddress = 'address'.random_int(1000, 2000);
62 |
63 | $this->xpubRepository
64 | ->expects(static::atLeastOnce())
65 | ->method('get')
66 | ->with($this->configuredXPub)
67 | ->willReturn($activeIndex)
68 | ;
69 |
70 | $this->keyFactory
71 | ->expects(static::atLeastOnce())
72 | ->method('derive')
73 | ->with($this->configuredXPub, '0/'.$activeIndex)
74 | ->willReturn($generatedAddress)
75 | ;
76 |
77 | $this->validation
78 | ->expects(static::once())
79 | ->method('validate')
80 | ->with($generatedAddress)
81 | ;
82 |
83 | static::assertSame($generatedAddress, $this->provider->provide());
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tests/Command/VersionCommandTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Command;
15 |
16 | use Jorijn\Bitcoin\Dca\Command\VersionCommand;
17 | use PHPUnit\Framework\TestCase;
18 | use Symfony\Component\Console\Application;
19 | use Symfony\Component\Console\Tester\CommandTester;
20 |
21 | /**
22 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Command\VersionCommand
23 | *
24 | * @covers ::__construct
25 | * @covers ::configure
26 | *
27 | * @internal
28 | */
29 | final class VersionCommandTest extends TestCase
30 | {
31 | protected VersionCommand $command;
32 | protected string $versionFile;
33 |
34 | protected function setUp(): void
35 | {
36 | parent::setUp();
37 |
38 | $this->versionFile = tempnam(sys_get_temp_dir(), 'version');
39 | $this->command = new VersionCommand($this->versionFile);
40 | }
41 |
42 | protected function tearDown(): void
43 | {
44 | parent::tearDown();
45 |
46 | if (file_exists($this->versionFile)) {
47 | unlink($this->versionFile);
48 | }
49 | }
50 |
51 | /**
52 | * @covers ::execute
53 | */
54 | public function testVersionIsParsed(): void
55 | {
56 | $data = ['key1' => 'value1', 'key2' => 'value2'];
57 | file_put_contents($this->versionFile, json_encode($data, JSON_THROW_ON_ERROR));
58 |
59 | $commandTester = $this->createCommandTester();
60 | $commandTester->execute([
61 | 'command' => $this->command->getName(),
62 | ]);
63 |
64 | $display = $commandTester->getDisplay(true);
65 | static::assertStringContainsString('key1', $display);
66 | static::assertStringContainsString('key2', $display);
67 | static::assertStringContainsString('value1', $display);
68 | static::assertStringContainsString('value2', $display);
69 | static::assertSame(0, $commandTester->getStatusCode());
70 | }
71 |
72 | /**
73 | * @covers ::execute
74 | */
75 | public function testVersionNotPresent(): void
76 | {
77 | unlink($this->versionFile);
78 |
79 | $commandTester = $this->createCommandTester();
80 | $commandTester->execute([
81 | 'command' => $this->command->getName(),
82 | ]);
83 |
84 | $display = $commandTester->getDisplay(true);
85 | static::assertStringContainsString('version', $display);
86 | static::assertStringContainsString('no version file present', $display);
87 | static::assertSame(0, $commandTester->getStatusCode());
88 | }
89 |
90 | protected function createCommandTester(): CommandTester
91 | {
92 | $application = new Application();
93 | $application->add($this->command->setName('version'));
94 |
95 | return new CommandTester($this->command);
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/tests/Component/ExternalAddressFromMasterPublicKeyComponentTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\Component;
15 |
16 | use Jorijn\Bitcoin\Dca\Component\ExternalAddressFromMasterPublicKeyComponent;
17 | use PHPUnit\Framework\TestCase;
18 | use Psr\Log\LoggerInterface;
19 |
20 | /**
21 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\Component\ExternalAddressFromMasterPublicKeyComponent
22 | *
23 | * @covers ::__construct
24 | *
25 | * @internal
26 | */
27 | final class ExternalAddressFromMasterPublicKeyComponentTest extends TestCase
28 | {
29 | use MasterPublicKeyScenarioTrait;
30 |
31 | private const XPUB_PYTHON_CLI = 'XPUB_PYTHON_CLI';
32 |
33 | private \Psr\Log\LoggerInterface|\PHPUnit\Framework\MockObject\MockObject $logger;
34 | private ExternalAddressFromMasterPublicKeyComponent $component;
35 |
36 | protected function setUp(): void
37 | {
38 | parent::setUp();
39 |
40 | if (false === getenv(self::XPUB_PYTHON_CLI)) {
41 | static::markTestSkipped('setting XPUB_PYTHON_CLI is empty or does not exists');
42 | }
43 |
44 | $this->logger = $this->createMock(LoggerInterface::class);
45 | $this->component = new ExternalAddressFromMasterPublicKeyComponent(
46 | $this->logger,
47 | getenv(self::XPUB_PYTHON_CLI)
48 | );
49 | }
50 |
51 | /**
52 | * @dataProvider providerOfScenarios
53 | *
54 | * @covers ::derive
55 | */
56 | public function testDerive(string $xpub, array $expectedAddressList): void
57 | {
58 | foreach ($expectedAddressList as $index => $expectedAddress) {
59 | static::assertSame(
60 | $expectedAddress,
61 | $this->component->derive($xpub, '0/'.$index)
62 | );
63 | }
64 | }
65 |
66 | /**
67 | * @covers ::derive
68 | */
69 | public function testDeriveWithEmptyXpubKey(): void
70 | {
71 | $this->expectException(\InvalidArgumentException::class);
72 | $this->component->derive('');
73 | }
74 |
75 | /**
76 | * @covers ::derive
77 | */
78 | public function testDeriveWithChangeAddress(): void
79 | {
80 | $this->expectException(\InvalidArgumentException::class);
81 | $this->component->derive('dummy', '1/0');
82 | }
83 |
84 | /**
85 | * @covers ::derive
86 | */
87 | public function testDeriveWithUnsupportedKey(): void
88 | {
89 | $this->expectException(\Throwable::class);
90 | $this->component->derive('(╯°□°)╯︵ ┻━┻');
91 | }
92 |
93 | /**
94 | * @covers ::supported
95 | */
96 | public function testSupported(): void
97 | {
98 | static::assertTrue($this->component->supported());
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/Component/AddressFromMasterPublicKeyComponent.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Component;
15 |
16 | use BitWasp\Bitcoin\Address\AddressCreator;
17 | use BitWasp\Bitcoin\Bitcoin;
18 | use BitWasp\Bitcoin\Key\Deterministic\HdPrefix\GlobalPrefixConfig;
19 | use BitWasp\Bitcoin\Key\Deterministic\HdPrefix\NetworkConfig;
20 | use BitWasp\Bitcoin\Key\Deterministic\Slip132\Slip132;
21 | use BitWasp\Bitcoin\Key\KeyToScript\KeyToScriptHelper;
22 | use BitWasp\Bitcoin\Network\NetworkFactory;
23 | use BitWasp\Bitcoin\Network\Slip132\BitcoinRegistry;
24 | use BitWasp\Bitcoin\Serializer\Key\HierarchicalKey\Base58ExtendedKeySerializer;
25 | use BitWasp\Bitcoin\Serializer\Key\HierarchicalKey\ExtendedKeySerializer;
26 | use Jorijn\Bitcoin\Dca\Exception\NoMasterPublicKeyAvailableException;
27 |
28 | class AddressFromMasterPublicKeyComponent implements AddressFromMasterPublicKeyComponentInterface
29 | {
30 | public function derive(string $masterPublicKey, $path = '0/0'): string
31 | {
32 | if (empty($masterPublicKey)) {
33 | throw new \InvalidArgumentException('Master Public Key cannot be empty');
34 | }
35 |
36 | $ecAdapter = Bitcoin::getEcAdapter();
37 | $network = NetworkFactory::bitcoin();
38 | $slip132 = new Slip132(new KeyToScriptHelper($ecAdapter));
39 | $bitcoinRegistry = new BitcoinRegistry();
40 |
41 | switch ($masterPublicKey[0] ?? null) {
42 | case 'x':
43 | $pubPrefix = $slip132->p2pkh($bitcoinRegistry);
44 | $pub = $masterPublicKey;
45 |
46 | break;
47 |
48 | case 'y':
49 | $pubPrefix = $slip132->p2shP2wpkh($bitcoinRegistry);
50 | $pub = $masterPublicKey;
51 |
52 | break;
53 |
54 | case 'z':
55 | $pubPrefix = $slip132->p2wpkh($bitcoinRegistry);
56 | $pub = $masterPublicKey;
57 |
58 | break;
59 |
60 | default:
61 | throw new NoMasterPublicKeyAvailableException('no master public key available');
62 | }
63 |
64 | $base58ExtendedKeySerializer = new Base58ExtendedKeySerializer(
65 | new ExtendedKeySerializer(
66 | $ecAdapter,
67 | new GlobalPrefixConfig([
68 | new NetworkConfig($network, [
69 | $pubPrefix,
70 | ]),
71 | ])
72 | )
73 | );
74 |
75 | $key = $base58ExtendedKeySerializer->parse($network, $pub);
76 | $hierarchicalKey = $key->derivePath($path);
77 |
78 | return $hierarchicalKey->getAddress(new AddressCreator())->getAddress();
79 | }
80 |
81 | public function supported(): bool
82 | {
83 | // this component only works on PHP 64-bits
84 | return \PHP_INT_SIZE === 8;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/Service/MockExchange/MockExchangeBuyService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service\MockExchange;
15 |
16 | use Jorijn\Bitcoin\Dca\Bitcoin;
17 | use Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder;
18 | use Jorijn\Bitcoin\Dca\Service\BuyServiceInterface;
19 |
20 | /**
21 | * @codeCoverageIgnore This file is solely used for testing
22 | */
23 | class MockExchangeBuyService implements BuyServiceInterface
24 | {
25 | private int $bitcoinPrice;
26 | private string $feeAmount;
27 | private string $feeCurrency;
28 |
29 | public function __construct(protected bool $isEnabled, protected string $baseCurrency)
30 | {
31 | $this->setBitcoinPrice(random_int(10000, 50000));
32 | $this->setFeeAmount(bcdiv((string) random_int(100, 200), Bitcoin::SATOSHIS, Bitcoin::DECIMALS));
33 | $this->setFeeCurrency('BTC');
34 | }
35 |
36 | public function setBitcoinPrice(int $bitcoinPrice): self
37 | {
38 | $this->bitcoinPrice = $bitcoinPrice;
39 |
40 | return $this;
41 | }
42 |
43 | public function setFeeAmount(string $feeAmount): self
44 | {
45 | $this->feeAmount = $feeAmount;
46 |
47 | return $this;
48 | }
49 |
50 | public function setFeeCurrency(string $feeCurrency): self
51 | {
52 | $this->feeCurrency = $feeCurrency;
53 |
54 | return $this;
55 | }
56 |
57 | public function supportsExchange(string $exchange): bool
58 | {
59 | return $this->isEnabled;
60 | }
61 |
62 | public function initiateBuy(int $amount): CompletedBuyOrder
63 | {
64 | return $this->createRandomBuyOrder($amount);
65 | }
66 |
67 | public function checkIfOrderIsFilled(string $orderId): CompletedBuyOrder
68 | {
69 | // not implemented right now
70 | }
71 |
72 | public function cancelBuyOrder(string $orderId): void
73 | {
74 | // void method, always succeeds
75 | }
76 |
77 | protected function createRandomBuyOrder(int $amount): CompletedBuyOrder
78 | {
79 | $bitcoinBought = bcdiv((string) $amount, (string) $this->bitcoinPrice, Bitcoin::DECIMALS);
80 | $satoshisBought = bcmul($bitcoinBought, Bitcoin::SATOSHIS, Bitcoin::DECIMALS);
81 |
82 | return (new CompletedBuyOrder())
83 | ->setAmountInSatoshis((int) $satoshisBought)
84 | ->setFeesInSatoshis(
85 | 'BTC' === $this->feeCurrency ? (int) bcmul($this->feeAmount, Bitcoin::SATOSHIS, Bitcoin::DECIMALS) : 0
86 | )
87 | ->setDisplayAmountBought($bitcoinBought.' BTC')
88 | ->setDisplayAveragePrice($this->bitcoinPrice.' '.$this->baseCurrency)
89 | ->setDisplayAmountSpent($amount.' '.$this->baseCurrency)
90 | ->setDisplayFeesSpent($this->feeAmount.' '.$this->feeCurrency)
91 | ->setDisplayAmountSpentCurrency($this->baseCurrency)
92 | ;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # Bitcoin-DCA: Automated self-hosted Bitcoin DCA tool for multiple Exchanges
6 |
7 | 
8 | [](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca)
9 | [](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca)
10 | [](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca)
11 | [](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca)
12 | [](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca)
13 |
14 | ## Requirements
15 | * You need to have an account on a supported exchange;
16 | * You need to have Docker installed: https://docs.docker.com/get-docker/;
17 | * You need to have an API key active on a supported exchange. It needs **read**, **trade** and **withdraw** permission.
18 |
19 | ## Supported Exchanges
20 | | Exchange | URL | Currencies | XPUB withdraw supported |
21 | |------|------|------|------|
22 | | BL3P | https://bl3p.eu/ | EUR | Yes |
23 | | Bitvavo | https://bitvavo.com/ | EUR | No * |
24 | | Kraken | https://kraken.com/ | USD EUR CAD JPY GBP CHF AUD | No |
25 | | Binance | https://binance.com/ | USDT BUSD EUR USDC USDT GBP AUD TRY BRL DAI TUSD RUB UAH PAX BIDR NGN IDRT VAI | Yes |
26 |
27 | \* Due to regulatory changes in The Netherlands, Bitvavo currently requires you to provide proof of address ownership, thus temporarily disabling Bitcoin-DCA's XPUB feature.
28 |
29 | ## About this software
30 | The DCA tool is built with flexibility in mind, allowing you to specify your schedule of buying and withdrawing. A few examples that are possible:
31 |
32 | * Buy each week, never withdraw.
33 | * Buy monthly and withdraw at the same time to reduce exchange risk.
34 | * Buy each week but withdraw only at the end of the month to save on withdrawal fees.
35 |
36 | ## Documentation
37 | | Format | Location |
38 | |------|------|
39 | | Online | https://bitcoin-dca.readthedocs.io/en/latest/ |
40 | | PDF | https://bitcoin-dca.readthedocs.io/_/downloads/en/latest/pdf/ |
41 | | ZIP | https://bitcoin-dca.readthedocs.io/_/downloads/en/latest/htmlzip/ |
42 | | ePub | https://bitcoin-dca.readthedocs.io/_/downloads/en/latest/epub/ |
43 |
44 | ## Support
45 | You can visit the Bitcoin DCA Support channel on Telegram: https://t.me/bitcoindca
46 |
47 | ## Contributing
48 | Contributions are highly welcome! Feel free to submit issues and pull requests on https://github.com/jorijn/bitcoin-dca.
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/docs/xpub-withdraw.rst:
--------------------------------------------------------------------------------
1 | .. _xpub:
2 |
3 | Deriving new Bitcoin addresses from your XPUB
4 | =============================================
5 |
6 | .. note::
7 | You need persistent storage to keep track of which address index the tool should use. See :ref:`persistent-storage`
8 |
9 | Instead of withdrawing to the same static Bitcoin address every time you make a withdrawal, it's also possible to supply a Master Public Key to Bitcoin DCA.
10 |
11 | After configuring, Bitcoin DCA will start at the first address (index #0) it can derive from your XPUB.
12 |
13 | Configuring a XPUB
14 | ------------------
15 | For the sake of demonstration, we'll be using the following XPUB here:
16 |
17 | .. code-block:: bash
18 | :caption: /home/bob/.bitcoin-dca
19 |
20 | WITHDRAW_XPUB=zpub6rLtzSoXnXKPXHroRKGCwuRVHjgA5YL6oUkdZnCfbDLdtAKNXb1FX1EmPUYR1uYMRBpngvkdJwxqhLvM46trRy5MRb7oYdSLbb4w5VC4i3z
21 |
22 | .. warning::
23 | It's **very important** that you verify the configured XPUB to make sure your Bitcoin will be sent to addresses in your possession.
24 |
25 | Verifying the configured XPUB
26 | -----------------------------
27 |
28 | You can verify that Bitcoin DCA will derive the correct addresses using the following command:
29 |
30 | .. code-block:: bash
31 | :caption: Verifying the configured XPUB
32 |
33 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca-bobby ghcr.io/jorijn/bitcoin-dca:latest verify-xpub
34 | ┌───┬────────────────────────────────────────────┬───────────────┐
35 | │ # │ Address │ Next Withdraw │
36 | ├───┼────────────────────────────────────────────┼───────────────┤
37 | │ 0 │ bc1qvqatyv2xynyanrej2fcutj6w5yugy0gc9jx2nn │ < │
38 | │ 1 │ bc1q360p67y3jvards9f2eud5rlu07q8ampfp35vp7 │ │
39 | │ 2 │ bc1qs4k3p9w4ke5np3lr3lgnma9jcaxedau8mpwawu │ │
40 | │ 3 │ bc1qpk48z0s7gvyrupm2wmd7nr0fdzkxa42372ver2 │ │
41 | │ 4 │ bc1q0uam3l30y43q0wjhq0kwf050uyg23mz7p3frr4 │ │
42 | │ 5 │ bc1qef62h9xt937lu9x5ydv204r7lpk3sjdc575kax │ │
43 | │ 6 │ bc1q2rl0he7zca8a88ax7hf9259c33kd2ux5ffhkqw │ │
44 | │ 7 │ bc1qr9ffza3w6tae4g5m4ydnjvphg8tpgarf5yjgqz │ │
45 | │ 8 │ bc1qr65srxamrmx8zumgv5puljnd93u3sj7lw6cnrg │ │
46 | │ 9 │ bc1q2ufc8j9uw6x7hwqfsdakungk63etanxtkplel0 │ │
47 | └───┴────────────────────────────────────────────┴───────────────┘
48 |
49 | [WARNING] Make sure these addresses match those in your client, do not use the withdraw function if they do not.
50 |
51 | You can check that the correct address is being used when attempting to withdraw your Bitcoin:
52 |
53 | .. code-block:: bash
54 | :caption: Here, it takes address #0 (bc1qvqatyv2xynyanrej2fcutj6w5yugy0gc9jx2nn) for withdrawal
55 |
56 | $ docker run --rm -it --env-file=/home/bob/.bitcoin-dca-bobby ghcr.io/jorijn/bitcoin-dca:latestwithdraw --all
57 | Ready to withdraw 0.0013 BTC to Bitcoin Address bc1qvqatyv2xynyanrej2fcutj6w5yugy0gc9jx2nn? A fee of 0.0003 will be taken as withdraw fee. (yes/no) [no]:
58 |
59 | After successful withdrawal, the tool will increase the inner index and use address #1 the next time a withdrawal is being made.
60 |
--------------------------------------------------------------------------------
/docker/php-development.ini:
--------------------------------------------------------------------------------
1 | [PHP]
2 | engine = On
3 | short_open_tag = Off
4 | precision = 14
5 | output_buffering = 4096
6 | zlib.output_compression = Off
7 | implicit_flush = Off
8 | unserialize_callback_func =
9 | serialize_precision = -1
10 | disable_functions =
11 | disable_classes =
12 | zend.enable_gc = On
13 | zend.exception_ignore_args = Off
14 | zend.exception_string_param_max_len = 15
15 | expose_php = On
16 | max_execution_time = 30
17 | max_input_time = 60
18 | memory_limit = 128M
19 | error_reporting = E_ALL
20 | display_errors = On
21 | display_startup_errors = On
22 | log_errors = On
23 | ignore_repeated_errors = Off
24 | ignore_repeated_source = Off
25 | report_memleaks = On
26 | variables_order = "GPCS"
27 | request_order = "GP"
28 | register_argc_argv = Off
29 | auto_globals_jit = On
30 | post_max_size = 8M
31 | auto_prepend_file =
32 | auto_append_file =
33 | default_mimetype = "text/html"
34 | default_charset = "UTF-8"
35 | doc_root =
36 | user_dir =
37 | enable_dl = Off
38 | file_uploads = On
39 | upload_max_filesize = 2M
40 | max_file_uploads = 20
41 | allow_url_fopen = On
42 | allow_url_include = Off
43 | default_socket_timeout = 60
44 |
45 | [CLI Server]
46 | cli_server.color = On
47 |
48 | [Pdo_mysql]
49 | pdo_mysql.default_socket=
50 |
51 | [mail function]
52 | SMTP = localhost
53 | smtp_port = 25
54 | mail.add_x_header = Off
55 |
56 | [ODBC]
57 | odbc.allow_persistent = On
58 | odbc.check_persistent = On
59 | odbc.max_persistent = -1
60 | odbc.max_links = -1
61 | odbc.defaultlrl = 4096
62 | odbc.defaultbinmode = 1
63 |
64 | [MySQLi]
65 | mysqli.max_persistent = -1
66 | mysqli.allow_persistent = On
67 | mysqli.max_links = -1
68 | mysqli.default_port = 3306
69 | mysqli.default_socket =
70 | mysqli.default_host =
71 | mysqli.default_user =
72 | mysqli.default_pw =
73 | mysqli.reconnect = Off
74 |
75 | [mysqlnd]
76 | mysqlnd.collect_statistics = On
77 | mysqlnd.collect_memory_statistics = On
78 |
79 | [PostgreSQL]
80 | pgsql.allow_persistent = On
81 | pgsql.auto_reset_persistent = Off
82 | pgsql.max_persistent = -1
83 | pgsql.max_links = -1
84 | pgsql.ignore_notice = 0
85 | pgsql.log_notice = 0
86 |
87 | [bcmath]
88 | bcmath.scale = 0
89 |
90 | [Session]
91 | session.save_handler = files
92 | session.use_strict_mode = 0
93 | session.use_cookies = 1
94 | session.use_only_cookies = 1
95 | session.name = PHPSESSID
96 | session.auto_start = 0
97 | session.cookie_lifetime = 0
98 | session.cookie_path = /
99 | session.cookie_domain =
100 | session.cookie_httponly = 1
101 | session.cookie_samesite =
102 | session.serialize_handler = php
103 | session.gc_probability = 1
104 | session.gc_divisor = 1000
105 | session.gc_maxlifetime = 1440
106 | session.referer_check =
107 | session.cache_limiter = nocache
108 | session.cache_expire = 180
109 | session.use_trans_sid = 0
110 | session.sid_length = 26
111 | session.trans_sid_tags = "a=href,area=href,frame=src,form="
112 | session.sid_bits_per_character = 5
113 |
114 | [Assertion]
115 | zend.assertions = 1
116 |
117 | [Tidy]
118 | tidy.clean_output = Off
119 |
120 | [soap]
121 | soap.wsdl_cache_enabled=1
122 | soap.wsdl_cache_dir="/tmp"
123 | soap.wsdl_cache_ttl=86400
124 | soap.wsdl_cache_limit = 5
125 |
126 | [ldap]
127 | ldap.max_links = -1
128 |
--------------------------------------------------------------------------------
/src/Service/BuyService.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Service;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\BuySuccessEvent;
17 | use Jorijn\Bitcoin\Dca\Exception\BuyTimeoutException;
18 | use Jorijn\Bitcoin\Dca\Exception\NoExchangeAvailableException;
19 | use Jorijn\Bitcoin\Dca\Exception\PendingBuyOrderException;
20 | use Jorijn\Bitcoin\Dca\Model\CompletedBuyOrder;
21 | use Psr\EventDispatcher\EventDispatcherInterface;
22 | use Psr\Log\LoggerInterface;
23 |
24 | class BuyService
25 | {
26 | public function __construct(
27 | protected EventDispatcherInterface $eventDispatcher,
28 | protected LoggerInterface $logger,
29 | protected string $configuredExchange,
30 | protected iterable $registeredServices = [],
31 | protected int $timeout = 30
32 | ) {
33 | }
34 |
35 | public function buy(int $amount, string $tag = null): CompletedBuyOrder
36 | {
37 | $logContext = [
38 | 'exchange' => $this->configuredExchange,
39 | 'amount' => $amount,
40 | 'tag' => $tag,
41 | ];
42 |
43 | $this->logger->info('performing buy for {amount}', $logContext);
44 |
45 | foreach ($this->registeredServices as $registeredService) {
46 | if ($registeredService->supportsExchange($this->configuredExchange)) {
47 | $this->logger->info('found service that supports buying for {exchange}', $logContext);
48 |
49 | $buyOrder = $this->buyAtService($registeredService, $amount);
50 | $this->eventDispatcher->dispatch(new BuySuccessEvent($buyOrder, $tag));
51 |
52 | return $buyOrder;
53 | }
54 | }
55 |
56 | $errorMessage = 'no exchange was available to perform this buy';
57 | $this->logger->error($errorMessage, $logContext);
58 |
59 | throw new NoExchangeAvailableException($errorMessage);
60 | }
61 |
62 | protected function buyAtService(
63 | BuyServiceInterface $buyService,
64 | int $amount,
65 | int $try = 0,
66 | int $start = null,
67 | string $orderId = null
68 | ): CompletedBuyOrder {
69 | if (null === $start) {
70 | $start = time();
71 | }
72 |
73 | try {
74 | $buyOrder = 0 === $try ? $buyService->initiateBuy($amount) : $buyService->checkIfOrderIsFilled(
75 | (string) $orderId
76 | );
77 | } catch (PendingBuyOrderException $exception) {
78 | if (time() < ($start + $this->timeout)) {
79 | sleep(1);
80 |
81 | return $this->buyAtService($buyService, $amount, ++$try, $start, $exception->getOrderId());
82 | }
83 |
84 | $buyService->cancelBuyOrder($exception->getOrderId());
85 |
86 | $error = 'buy did not fill within given timeout';
87 | $this->logger->error($error);
88 |
89 | throw new BuyTimeoutException($error);
90 | }
91 |
92 | return $buyOrder;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ##################################################################################################################
2 | # Dependency Stage
3 | ##################################################################################################################
4 | FROM composer:latest AS vendor
5 |
6 | WORKDIR /app/
7 |
8 | COPY composer.json composer.lock /app/
9 |
10 | COPY . /app/
11 |
12 | RUN composer install \
13 | --ignore-platform-reqs \
14 | --no-interaction \
15 | --no-plugins \
16 | --no-scripts \
17 | --prefer-dist \
18 | --classmap-authoritative \
19 | --no-ansi \
20 | --no-dev
21 |
22 | ##################################################################################################################
23 | # Base Stage
24 | ##################################################################################################################
25 | FROM php:8.2-cli-alpine3.17 as base_image
26 |
27 | RUN apk --no-cache update \
28 | && apk --no-cache add gmp-dev python3 py3-pip \
29 | && docker-php-ext-install -j$(nproc) gmp bcmath opcache
30 |
31 | COPY . /app/
32 | COPY --from=vendor /app/vendor/ /app/vendor/
33 |
34 | WORKDIR /app/resources/xpub_derive
35 |
36 | RUN pip3 install --no-cache -r requirements.txt
37 |
38 | COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
39 | RUN chmod +x /usr/local/bin/docker-entrypoint
40 | ENTRYPOINT ["docker-entrypoint"]
41 |
42 | WORKDIR /app/
43 |
44 | ##################################################################################################################
45 | # Development Stage
46 | ##################################################################################################################
47 | FROM base_image as development_build
48 |
49 | RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
50 |
51 | COPY docker/php-development.ini "$PHP_INI_DIR/php.ini"
52 | COPY --from=vendor /usr/bin/composer /usr/bin/composer
53 |
54 | # php code coverage & development
55 | RUN apk --no-cache update \
56 | && apk --no-cache add autoconf g++ make linux-headers \
57 | && pecl install pcov xdebug \
58 | && docker-php-ext-enable pcov xdebug
59 |
60 | ##################################################################################################################
61 | # Test Stage
62 | ##################################################################################################################
63 | FROM development_build AS testing_stage
64 |
65 | # run the test script(s) from composer, this validates the application before allowing the build to succeed
66 | # this does make the tests run multiple times, but with different architectures
67 | RUN composer install --no-interaction --no-plugins --no-scripts --prefer-dist --no-ansi --ignore-platform-reqs
68 | RUN vendor/bin/phpunit --testdox --coverage-clover /tmp/tests_coverage.xml --log-junit /tmp/tests_log.xml
69 |
70 | ##################################################################################################################
71 | # Production Stage
72 | ##################################################################################################################
73 | FROM base_image as production_build
74 |
75 | COPY docker/php-production.ini "$PHP_INI_DIR/php.ini"
76 |
77 | # run the app to precompile the DI container
78 | RUN /app/bin/bitcoin-dca
79 |
--------------------------------------------------------------------------------
/docker/php-production.ini:
--------------------------------------------------------------------------------
1 | [PHP]
2 | engine = On
3 | short_open_tag = Off
4 | precision = 14
5 | output_buffering = 4096
6 | zlib.output_compression = Off
7 | implicit_flush = Off
8 | unserialize_callback_func =
9 | serialize_precision = -1
10 | disable_functions =
11 | disable_classes =
12 | zend.enable_gc = On
13 | zend.exception_ignore_args = On
14 | zend.exception_string_param_max_len = 0
15 | expose_php = On
16 | max_execution_time = 30
17 | max_input_time = 60
18 | memory_limit = 128M
19 | error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
20 | display_errors = Off
21 | display_startup_errors = Off
22 | log_errors = On
23 | ignore_repeated_errors = Off
24 | ignore_repeated_source = Off
25 | report_memleaks = On
26 | variables_order = "GPCS"
27 | request_order = "GP"
28 | register_argc_argv = Off
29 | auto_globals_jit = On
30 | post_max_size = 8M
31 | auto_prepend_file =
32 | auto_append_file =
33 | default_mimetype = "text/html"
34 | default_charset = "UTF-8"
35 | doc_root =
36 | user_dir =
37 | enable_dl = Off
38 | file_uploads = On
39 | upload_max_filesize = 2M
40 | max_file_uploads = 20
41 | allow_url_fopen = On
42 | allow_url_include = Off
43 | default_socket_timeout = 60
44 |
45 | # Custom
46 | opcache.enable=1
47 | opcache.jit_buffer_size=100M
48 | opcache.jit=tracing
49 | opcache.revalidate_freq=0
50 | opcache.validate_timestamps=0
51 | opcache.max_accelerated_files=7963
52 | opcache.memory_consumption=192
53 | opcache.interned_strings_buffer=16
54 | opcache.fast_shutdown=1
55 |
56 | [mail function]
57 | SMTP = localhost
58 | smtp_port = 25
59 | mail.add_x_header = Off
60 |
61 | [ODBC]
62 | odbc.allow_persistent = On
63 | odbc.check_persistent = On
64 | odbc.max_persistent = -1
65 | odbc.max_links = -1
66 | odbc.defaultlrl = 4096
67 | odbc.defaultbinmode = 1
68 |
69 | [MySQLi]
70 | mysqli.max_persistent = -1
71 | mysqli.allow_persistent = On
72 | mysqli.max_links = -1
73 | mysqli.default_port = 3306
74 | mysqli.default_socket =
75 | mysqli.default_host =
76 | mysqli.default_user =
77 | mysqli.default_pw =
78 | mysqli.reconnect = Off
79 |
80 | [mysqlnd]
81 | mysqlnd.collect_statistics = On
82 | mysqlnd.collect_memory_statistics = Off
83 |
84 | [PostgreSQL]
85 | pgsql.allow_persistent = On
86 | pgsql.auto_reset_persistent = Off
87 | pgsql.max_persistent = -1
88 | pgsql.max_links = -1
89 | pgsql.ignore_notice = 0
90 | pgsql.log_notice = 0
91 |
92 | [bcmath]
93 | bcmath.scale = 0
94 |
95 | [Session]
96 | session.save_handler = files
97 | session.use_strict_mode = 0
98 | session.use_cookies = 1
99 | session.use_only_cookies = 1
100 | session.name = PHPSESSID
101 | session.auto_start = 0
102 | session.cookie_lifetime = 0
103 | session.cookie_path = /
104 | session.cookie_domain =
105 | session.cookie_httponly = 1
106 | session.cookie_samesite =
107 | session.serialize_handler = php
108 | session.gc_probability = 1
109 | session.gc_divisor = 1000
110 | session.gc_maxlifetime = 1440
111 | session.referer_check =
112 | session.cache_limiter = nocache
113 | session.cache_expire = 180
114 | session.use_trans_sid = 0
115 | session.sid_length = 26
116 | session.trans_sid_tags = "a=href,area=href,frame=src,form="
117 | session.sid_bits_per_character = 5
118 |
119 | [Assertion]
120 | zend.assertions = -1
121 |
122 | [Tidy]
123 | tidy.clean_output = Off
124 |
125 | [soap]
126 | soap.wsdl_cache_enabled=1
127 | soap.wsdl_cache_dir="/tmp"
128 | soap.wsdl_cache_ttl=86400
129 | soap.wsdl_cache_limit = 5
130 |
131 | [ldap]
132 | ldap.max_links = -1
133 |
--------------------------------------------------------------------------------
/src/Model/CompletedBuyOrder.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Jorijn\Bitcoin\Dca\Model;
15 |
16 | class CompletedBuyOrder
17 | {
18 | private int $amountInSatoshis = 0;
19 | private int $feesInSatoshis = 0;
20 | private readonly \DateTimeInterface $purchaseMadeAt;
21 |
22 | private ?string $displayAmountBought = null;
23 | private ?string $displayAmountSpent = null;
24 | private ?string $displayAmountSpentCurrency = null;
25 | private ?string $displayAveragePrice = null;
26 | private ?string $displayFeesSpent = null;
27 |
28 | public function __construct()
29 | {
30 | $this->purchaseMadeAt = new \DateTimeImmutable();
31 | }
32 |
33 | public function getDisplayAmountSpentCurrency(): ?string
34 | {
35 | return $this->displayAmountSpentCurrency;
36 | }
37 |
38 | public function setDisplayAmountSpentCurrency(?string $displayAmountSpentCurrency): self
39 | {
40 | $this->displayAmountSpentCurrency = $displayAmountSpentCurrency;
41 |
42 | return $this;
43 | }
44 |
45 | public function getPurchaseMadeAt(): string
46 | {
47 | return $this->purchaseMadeAt->format(\DateTimeInterface::ATOM);
48 | }
49 |
50 | public function getAmountInSatoshis(): int
51 | {
52 | return $this->amountInSatoshis;
53 | }
54 |
55 | public function setAmountInSatoshis(int $amountInSatoshis): self
56 | {
57 | $this->amountInSatoshis = $amountInSatoshis;
58 |
59 | return $this;
60 | }
61 |
62 | public function getFeesInSatoshis(): int
63 | {
64 | return $this->feesInSatoshis;
65 | }
66 |
67 | public function setFeesInSatoshis(int $feesInSatoshis): self
68 | {
69 | $this->feesInSatoshis = $feesInSatoshis;
70 |
71 | return $this;
72 | }
73 |
74 | public function getDisplayAmountBought(): ?string
75 | {
76 | return $this->displayAmountBought;
77 | }
78 |
79 | public function setDisplayAmountBought(?string $displayAmountBought): self
80 | {
81 | $this->displayAmountBought = $displayAmountBought;
82 |
83 | return $this;
84 | }
85 |
86 | public function getDisplayAmountSpent(): ?string
87 | {
88 | return $this->displayAmountSpent;
89 | }
90 |
91 | public function setDisplayAmountSpent(?string $displayAmountSpent): self
92 | {
93 | $this->displayAmountSpent = $displayAmountSpent;
94 |
95 | return $this;
96 | }
97 |
98 | public function getDisplayAveragePrice(): ?string
99 | {
100 | return $this->displayAveragePrice;
101 | }
102 |
103 | public function setDisplayAveragePrice(?string $displayAveragePrice): self
104 | {
105 | $this->displayAveragePrice = $displayAveragePrice;
106 |
107 | return $this;
108 | }
109 |
110 | public function getDisplayFeesSpent(): ?string
111 | {
112 | return $this->displayFeesSpent;
113 | }
114 |
115 | public function setDisplayFeesSpent(?string $displayFeesSpent): self
116 | {
117 | $this->displayFeesSpent = $displayFeesSpent;
118 |
119 | return $this;
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/tests/EventListener/Notifications/TesterOfAbstractSendEmailListener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Jorijn\Bitcoin\Dca\EventListener\Notifications\AbstractSendEmailListener;
17 | use Jorijn\Bitcoin\Dca\Model\NotificationEmailConfiguration;
18 | use Jorijn\Bitcoin\Dca\Model\NotificationEmailTemplateInformation;
19 | use League\HTMLToMarkdown\HtmlConverterInterface;
20 | use PHPUnit\Framework\MockObject\MockObject;
21 | use PHPUnit\Framework\TestCase;
22 | use Symfony\Component\Mailer\MailerInterface;
23 |
24 | /**
25 | * @codeCoverageIgnore
26 | */
27 | abstract class TesterOfAbstractSendEmailListener extends TestCase
28 | {
29 | /** @var MailerInterface|MockObject */
30 | protected $notifier;
31 |
32 | /** @var HtmlConverterInterface|MockObject */
33 | protected $htmlConverter;
34 | protected string $to;
35 | protected string $subjectPrefix;
36 | protected string $from;
37 | protected NotificationEmailConfiguration $emailConfiguration;
38 | protected string $exchange;
39 | protected string $logoLocation;
40 | protected string $iconLocation;
41 | protected NotificationEmailTemplateInformation $templateConfiguration;
42 | protected string $quotesLocation;
43 |
44 | /** @var AbstractSendEmailListener|MockObject */
45 | protected $listener;
46 | protected string $templateLocation;
47 |
48 | protected function setUp(): void
49 | {
50 | parent::setUp();
51 |
52 | $this->notifier = $this->createMock(MailerInterface::class);
53 | $this->htmlConverter = $this->createMock(HtmlConverterInterface::class);
54 |
55 | $this->to = random_int(1000, 2000).'@protonmail.com';
56 | $this->from = random_int(1000, 2000).'@protonmail.com';
57 | $this->subjectPrefix = '['.random_int(1000, 2000).']';
58 | $this->emailConfiguration = new NotificationEmailConfiguration($this->to, $this->from, $this->subjectPrefix);
59 |
60 | $this->exchange = 'e'.random_int(1000, 2000);
61 |
62 | $this->logoLocation = realpath(
63 | __DIR__.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'resources'.\DIRECTORY_SEPARATOR.'images'.\DIRECTORY_SEPARATOR
64 | ).\DIRECTORY_SEPARATOR.'logo-small.png';
65 |
66 | $this->iconLocation = realpath(
67 | __DIR__.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'resources'.\DIRECTORY_SEPARATOR.'images'.\DIRECTORY_SEPARATOR
68 | ).\DIRECTORY_SEPARATOR.'github-logo-colored.png';
69 |
70 | $this->quotesLocation = tempnam(sys_get_temp_dir(), 'quotes');
71 | $this->templateLocation = tempnam(sys_get_temp_dir(), 'quotes');
72 | $this->templateConfiguration = new NotificationEmailTemplateInformation(
73 | $this->exchange,
74 | $this->logoLocation,
75 | $this->iconLocation,
76 | $this->quotesLocation
77 | );
78 |
79 | file_put_contents($this->quotesLocation, '{}');
80 | }
81 |
82 | protected function tearDown(): void
83 | {
84 | parent::tearDown();
85 |
86 | unlink($this->quotesLocation);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/tests/EventListener/Notifications/SendEmailOnWithdrawListenerTest.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * This source file is subject to the MIT license that is bundled
11 | * with this source code in the file LICENSE.
12 | */
13 |
14 | namespace Tests\Jorijn\Bitcoin\Dca\EventListener\Notifications;
15 |
16 | use Jorijn\Bitcoin\Dca\Event\WithdrawSuccessEvent;
17 | use Jorijn\Bitcoin\Dca\EventListener\Notifications\SendEmailOnWithdrawListener;
18 | use Jorijn\Bitcoin\Dca\Model\CompletedWithdraw;
19 | use Symfony\Component\Mime\Email;
20 |
21 | /**
22 | * @internal
23 | *
24 | * @coversDefaultClass \Jorijn\Bitcoin\Dca\EventListener\Notifications\SendEmailOnWithdrawListener
25 | */
26 | final class SendEmailOnWithdrawListenerTest extends TesterOfAbstractSendEmailListener
27 | {
28 | protected function setUp(): void
29 | {
30 | parent::setUp();
31 |
32 | $this->listener = new SendEmailOnWithdrawListener(
33 | $this->notifier,
34 | $this->htmlConverter,
35 | $this->emailConfiguration,
36 | $this->templateConfiguration,
37 | true
38 | );
39 |
40 | $this->listener->setTemplateLocation($this->templateLocation);
41 | }
42 |
43 | /**
44 | * @covers ::onWithdraw
45 | */
46 | public function testListenerDoesNotActWhenDisabled(): void
47 | {
48 | $this->listener = new SendEmailOnWithdrawListener(
49 | $this->notifier,
50 | $this->htmlConverter,
51 | $this->emailConfiguration,
52 | $this->templateConfiguration,
53 | false
54 | );
55 |
56 | $this->notifier->expects(static::never())->method('send');
57 |
58 | $withdrawSuccessEvent = new WithdrawSuccessEvent(new CompletedWithdraw('address', 1, '1'));
59 | $this->listener->onWithdraw($withdrawSuccessEvent);
60 | }
61 |
62 | /**
63 | * @covers ::onWithdraw
64 | */
65 | public function testAssertThatEmailIsSentOnWithdrawEvent(): void
66 | {
67 | $address = 'a'.random_int(10000, 20000);
68 | $id = (string) random_int(10, 20);
69 | $amount = random_int(10000, 20000);
70 | $completedWithdraw = (new CompletedWithdraw($address, $amount, $id));
71 | $tag = 't'.random_int(1000, 2000);
72 |
73 | $withdrawSuccessEvent = new WithdrawSuccessEvent($completedWithdraw, $tag);
74 |
75 | $this->notifier
76 | ->expects(static::once())
77 | ->method('send')
78 | ->with(
79 | static::callback(function (Email $email) use ($amount): bool {
80 | self::assertSame(
81 | sprintf(
82 | '[%s] %s',
83 | $this->subjectPrefix,
84 | sprintf(
85 | SendEmailOnWithdrawListener::NOTIFICATION_SUBJECT_LINE,
86 | number_format($amount),
87 | ucfirst($this->exchange)
88 | )
89 | ),
90 | $email->getSubject()
91 | );
92 |
93 | return true;
94 | })
95 | )
96 | ;
97 |
98 | $this->listener->onWithdraw($withdrawSuccessEvent);
99 | }
100 | }
101 |
--------------------------------------------------------------------------------