├── 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 | Bitcoin DCA 3 |

4 | 5 | # Bitcoin-DCA: Automated self-hosted Bitcoin DCA tool for multiple Exchanges 6 | 7 | ![Docker Pulls](https://img.shields.io/docker/pulls/jorijn/bitcoin-dca) 8 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Jorijn_bitcoin-dca&metric=alert_status)](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca) 9 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Jorijn_bitcoin-dca&metric=coverage)](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca) 10 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=Jorijn_bitcoin-dca&metric=ncloc)](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca) 11 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Jorijn_bitcoin-dca&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=Jorijn_bitcoin-dca) 12 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Jorijn_bitcoin-dca&metric=security_rating)](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 | Bitcoin DCA Logo 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 | --------------------------------------------------------------------------------