diff --git a/.env.dist b/.env.dist index 479dc8b..6319877 100644 --- a/.env.dist +++ b/.env.dist @@ -12,13 +12,14 @@ WITHDRAW_ADDRESS= # WITHDRAW_XPUB= # Choose the cryptocurrency exchange this Bitcoin DCA tool will operate on. The default value is "bl3p". -# Available options: bl3p, bitvavo, kraken +# Available options: bl3p, bitvavo, kraken, binance EXCHANGE=bl3p # This setting is for the base currency you're buying in. Options are: # BL3P: EUR # Bitvavo: EUR # Kraken: USD EUR CAD JPY GBP CHF AUD +# Binance: USDT BUSD EUR USDC USDT GBP AUD TRY BRL DAI TUSD RUB UAH PAX BIDR NGN IDRT VAI BASE_CURRENCY=EUR ################################################################################## @@ -82,3 +83,13 @@ BL3P_PRIVATE_KEY= # See https://support.kraken.com/hc/en-us/articles/360036157952 # # KRAKEN_TRADING_AGREEMENT=agree + +################################################################################## +# Binance exchange settings +################################################################################## + +# This is the identifying part of the API key that you created on the Binance exchange. +# BINANCE_API_KEY= + +# This is the private part of your API connection to Binance. It’s a secret granting access to your Binance account. +# BINANCE_API_SECRET= diff --git a/README.md b/README.md index 7ef168d..4e9ebbe 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Bitcoin DCA Logo

-# Automated Bitcoin DCA tool for multiple Exchanges +# Automated self-hosted Bitcoin DCA tool for multiple Exchanges ![Docker Pulls](https://img.shields.io/docker/pulls/jorijn/bitcoin-dca) [![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) @@ -19,14 +19,15 @@ ## Supported Exchanges | Exchange | URL | Currencies | XPUB withdraw supported | |------|------|------|------| -| BL3P | https://bl3p.eu/ | EUR | No * | +| BL3P | https://bl3p.eu/ | EUR | Yes | | Bitvavo | https://bitvavo.com/ | EUR | No * | | Kraken | https://kraken.com/ | USD EUR CAD JPY GBP CHF AUD | No | +| Binance | https://binance.com/ | USDT BUSD EUR USDC USDT GBP AUD TRY BRL DAI TUSD RUB UAH PAX BIDR NGN IDRT VAI | Yes | -\* Due to regulatory changes in The Netherlands, BL3P and Bitvavo currently require you to provide proof of address ownership, thus temporarily disabling Bitcoin-DCA's XPUB feature. +\* 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. ## About this software -The DCA tool is built with flexibility in mind, allowing you to specify your own schedule of buying and withdrawing. A few examples that are possible: +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: * Buy each week, never withdraw. * Buy monthly and withdraw at the same time to reduce exchange risk. @@ -46,4 +47,4 @@ You can visit the Bitcoin DCA Support channel on Telegram: https://t.me/bitcoind ## Contributing Contributions are highly welcome! Feel free to submit issues and pull requests on https://github.com/jorijn/bitcoin-dca. -Like my work? Buy me a 🍺 by sending some sats on https://jorijn.com/donate/ +Like my work? Please buy me a 🍺 by sending some sats on https://jorijn.com/donate/ diff --git a/composer.json b/composer.json index d4e0449..62ccc4f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "jorijn/bitcoin-dca", - "description": "Tool for automatically buying and withdrawing Bitcoin on Bl3P", + "description": "Tool for automatically buying and withdrawing Bitcoin", "type": "project", "license": "MIT", "authors": [ @@ -38,7 +38,6 @@ "test": "vendor/bin/phpunit --testdox" }, "require-dev": { - "roave/security-advisories": "dev-master", "friendsofphp/php-cs-fixer": "^2.16", "phpunit/phpunit": "^9.1" } diff --git a/composer.lock b/composer.lock index 0edef0e..73698f7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "63058111ecd2a392b4f1de52196fbf4d", + "content-hash": "b031b97c36510640019b3ecd74372851", "packages": [ { "name": "bitwasp/bech32", @@ -738,16 +738,16 @@ }, { "name": "symfony/config", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "212d54675bf203ff8aef7d8cee8eecfb72f4a263" + "reference": "3817662ada105c8c4d1afdb4ec003003efd1d8d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/212d54675bf203ff8aef7d8cee8eecfb72f4a263", - "reference": "212d54675bf203ff8aef7d8cee8eecfb72f4a263", + "url": "https://api.github.com/repos/symfony/config/zipball/3817662ada105c8c4d1afdb4ec003003efd1d8d8", + "reference": "3817662ada105c8c4d1afdb4ec003003efd1d8d8", "shasum": "" }, "require": { @@ -796,7 +796,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v5.2.4" + "source": "https://github.com/symfony/config/tree/v5.2.7" }, "funding": [ { @@ -812,20 +812,20 @@ "type": "tidelift" } ], - "time": "2021-02-23T23:58:19+00:00" + "time": "2021-04-07T16:07:52+00:00" }, { "name": "symfony/console", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d6d0cc30d8c0fda4e7b213c20509b0159a8f4556" + "reference": "90374b8ed059325b49a29b55b3f8bb4062c87629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d6d0cc30d8c0fda4e7b213c20509b0159a8f4556", - "reference": "d6d0cc30d8c0fda4e7b213c20509b0159a8f4556", + "url": "https://api.github.com/repos/symfony/console/zipball/90374b8ed059325b49a29b55b3f8bb4062c87629", + "reference": "90374b8ed059325b49a29b55b3f8bb4062c87629", "shasum": "" }, "require": { @@ -893,7 +893,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.2.4" + "source": "https://github.com/symfony/console/tree/v5.2.7" }, "funding": [ { @@ -909,20 +909,20 @@ "type": "tidelift" } ], - "time": "2021-02-23T10:08:49+00:00" + "time": "2021-04-19T14:07:32+00:00" }, { "name": "symfony/dependency-injection", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f7d89110c55d88620dc811f342f94393b8a045d4" + "reference": "6ca378b99e3c9ba6127eb43b68389fb2b7348577" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f7d89110c55d88620dc811f342f94393b8a045d4", - "reference": "f7d89110c55d88620dc811f342f94393b8a045d4", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6ca378b99e3c9ba6127eb43b68389fb2b7348577", + "reference": "6ca378b99e3c9ba6127eb43b68389fb2b7348577", "shasum": "" }, "require": { @@ -980,7 +980,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v5.2.4" + "source": "https://github.com/symfony/dependency-injection/tree/v5.2.7" }, "funding": [ { @@ -996,20 +996,20 @@ "type": "tidelift" } ], - "time": "2021-03-04T15:41:09+00:00" + "time": "2021-04-24T14:32:26+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.2.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665" + "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5fa56b4074d1ae755beb55617ddafe6f5d78f665", - "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627", + "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627", "shasum": "" }, "require": { @@ -1018,7 +1018,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", @@ -1047,7 +1047,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/master" + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.4.0" }, "funding": [ { @@ -1063,7 +1063,7 @@ "type": "tidelift" } ], - "time": "2020-09-07T11:33:47+00:00" + "time": "2021-03-23T23:28:01+00:00" }, { "name": "symfony/dotenv", @@ -1222,16 +1222,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v2.2.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "0ba7d54483095a198fa51781bc608d17e84dffa2" + "reference": "69fee1ad2332a7cbab3aca13591953da9cdb7a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/0ba7d54483095a198fa51781bc608d17e84dffa2", - "reference": "0ba7d54483095a198fa51781bc608d17e84dffa2", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/69fee1ad2332a7cbab3aca13591953da9cdb7a11", + "reference": "69fee1ad2332a7cbab3aca13591953da9cdb7a11", "shasum": "" }, "require": { @@ -1244,7 +1244,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", @@ -1281,7 +1281,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.2.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.4.0" }, "funding": [ { @@ -1297,20 +1297,20 @@ "type": "tidelift" } ], - "time": "2020-09-07T11:33:47+00:00" + "time": "2021-03-23T23:28:01+00:00" }, { "name": "symfony/filesystem", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "710d364200997a5afde34d9fe57bd52f3cc1e108" + "reference": "056e92acc21d977c37e6ea8e97374b2a6c8551b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/710d364200997a5afde34d9fe57bd52f3cc1e108", - "reference": "710d364200997a5afde34d9fe57bd52f3cc1e108", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/056e92acc21d977c37e6ea8e97374b2a6c8551b0", + "reference": "056e92acc21d977c37e6ea8e97374b2a6c8551b0", "shasum": "" }, "require": { @@ -1343,7 +1343,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v5.2.4" + "source": "https://github.com/symfony/filesystem/tree/v5.2.7" }, "funding": [ { @@ -1359,20 +1359,20 @@ "type": "tidelift" } ], - "time": "2021-02-12T10:38:38+00:00" + "time": "2021-04-01T10:42:13+00:00" }, { "name": "symfony/http-client", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "c7d1f35a31ef153a302e3f80336170e1280b983d" + "reference": "cdaf3df771d3ea9b05696c9e91281ffd056aff66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/c7d1f35a31ef153a302e3f80336170e1280b983d", - "reference": "c7d1f35a31ef153a302e3f80336170e1280b983d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/cdaf3df771d3ea9b05696c9e91281ffd056aff66", + "reference": "cdaf3df771d3ea9b05696c9e91281ffd056aff66", "shasum": "" }, "require": { @@ -1429,7 +1429,7 @@ "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-client/tree/v5.2.4" + "source": "https://github.com/symfony/http-client/tree/v5.2.7" }, "funding": [ { @@ -1445,20 +1445,20 @@ "type": "tidelift" } ], - "time": "2021-03-01T00:40:14+00:00" + "time": "2021-04-07T16:27:53+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v2.3.1", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41db680a15018f9c1d4b23516059633ce280ca33" + "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41db680a15018f9c1d4b23516059633ce280ca33", - "reference": "41db680a15018f9c1d4b23516059633ce280ca33", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/7e82f6084d7cae521a75ef2cb5c9457bbda785f4", + "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4", "shasum": "" }, "require": { @@ -1469,9 +1469,8 @@ }, "type": "library", "extra": { - "branch-version": "2.3", "branch-alias": { - "dev-main": "2.3-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", @@ -1508,7 +1507,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v2.3.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v2.4.0" }, "funding": [ { @@ -1524,7 +1523,7 @@ "type": "tidelift" } ], - "time": "2020-10-14T17:08:19+00:00" + "time": "2021-04-11T23:07:08+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2014,21 +2013,21 @@ }, { "name": "symfony/service-contracts", - "version": "v2.2.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1" + "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1", - "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb", + "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb", "shasum": "" }, "require": { "php": ">=7.2.5", - "psr/container": "^1.0" + "psr/container": "^1.1" }, "suggest": { "symfony/service-implementation": "" @@ -2036,7 +2035,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", @@ -2073,7 +2072,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/master" + "source": "https://github.com/symfony/service-contracts/tree/v2.4.0" }, "funding": [ { @@ -2089,20 +2088,20 @@ "type": "tidelift" } ], - "time": "2020-09-07T11:33:47+00:00" + "time": "2021-04-01T10:43:52+00:00" }, { "name": "symfony/string", - "version": "v5.2.4", + "version": "v5.2.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "4e78d7d47061fa183639927ec40d607973699609" + "reference": "ad0bd91bce2054103f5eaa18ebeba8d3bc2a0572" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/4e78d7d47061fa183639927ec40d607973699609", - "reference": "4e78d7d47061fa183639927ec40d607973699609", + "url": "https://api.github.com/repos/symfony/string/zipball/ad0bd91bce2054103f5eaa18ebeba8d3bc2a0572", + "reference": "ad0bd91bce2054103f5eaa18ebeba8d3bc2a0572", "shasum": "" }, "require": { @@ -2156,7 +2155,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.2.4" + "source": "https://github.com/symfony/string/tree/v5.2.6" }, "funding": [ { @@ -2172,20 +2171,20 @@ "type": "tidelift" } ], - "time": "2021-02-16T10:20:28+00:00" + "time": "2021-03-17T17:12:15+00:00" }, { "name": "symfony/yaml", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "7d6ae0cce3c33965af681a4355f1c4de326ed277" + "reference": "76546cbeddd0a9540b4e4e57eddeec3e9bb444a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/7d6ae0cce3c33965af681a4355f1c4de326ed277", - "reference": "7d6ae0cce3c33965af681a4355f1c4de326ed277", + "url": "https://api.github.com/repos/symfony/yaml/zipball/76546cbeddd0a9540b4e4e57eddeec3e9bb444a5", + "reference": "76546cbeddd0a9540b4e4e57eddeec3e9bb444a5", "shasum": "" }, "require": { @@ -2231,7 +2230,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v5.2.4" + "source": "https://github.com/symfony/yaml/tree/v5.2.7" }, "funding": [ { @@ -2247,22 +2246,22 @@ "type": "tidelift" } ], - "time": "2021-02-22T15:48:39+00:00" + "time": "2021-04-29T20:47:09+00:00" } ], "packages-dev": [ { "name": "composer/xdebug-handler", - "version": "1.4.5", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "f28d44c286812c714741478d968104c5e604a1d4" + "reference": "31d57697eb1971712a08031cfaff5a846d10bdf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/f28d44c286812c714741478d968104c5e604a1d4", - "reference": "f28d44c286812c714741478d968104c5e604a1d4", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/31d57697eb1971712a08031cfaff5a846d10bdf5", + "reference": "31d57697eb1971712a08031cfaff5a846d10bdf5", "shasum": "" }, "require": { @@ -2270,7 +2269,8 @@ "psr/log": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8" + "phpstan/phpstan": "^0.12.55", + "symfony/phpunit-bridge": "^4.2 || ^5" }, "type": "library", "autoload": { @@ -2296,7 +2296,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/1.4.5" + "source": "https://github.com/composer/xdebug-handler/tree/2.0.0" }, "funding": [ { @@ -2312,7 +2312,7 @@ "type": "tidelift" } ], - "time": "2020-11-13T08:04:11+00:00" + "time": "2021-04-09T19:40:06+00:00" }, { "name": "doctrine/annotations", @@ -2535,21 +2535,21 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.18.2", + "version": "v2.18.6", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "18f8c9d184ba777380794a389fabc179896ba913" + "reference": "5fed214993e7863cef88a08f214344891299b9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/18f8c9d184ba777380794a389fabc179896ba913", - "reference": "18f8c9d184ba777380794a389fabc179896ba913", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/5fed214993e7863cef88a08f214344891299b9e4", + "reference": "5fed214993e7863cef88a08f214344891299b9e4", "shasum": "" }, "require": { "composer/semver": "^1.4 || ^2.0 || ^3.0", - "composer/xdebug-handler": "^1.2", + "composer/xdebug-handler": "^1.2 || ^2.0", "doctrine/annotations": "^1.2", "ext-json": "*", "ext-tokenizer": "*", @@ -2606,6 +2606,7 @@ "tests/Test/IntegrationCaseFactoryInterface.php", "tests/Test/InternalIntegrationCaseFactory.php", "tests/Test/IsIdenticalConstraint.php", + "tests/Test/TokensWithObservedTransformers.php", "tests/TestCase.php" ] }, @@ -2626,7 +2627,7 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues", - "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v2.18.2" + "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v2.18.6" }, "funding": [ { @@ -2634,7 +2635,7 @@ "type": "github" } ], - "time": "2021-01-26T00:22:21+00:00" + "time": "2021-04-19T19:45:11+00:00" }, { "name": "myclabs/deep-copy", @@ -3076,16 +3077,16 @@ }, { "name": "phpspec/prophecy", - "version": "1.12.2", + "version": "1.13.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "245710e971a030f42e08f4912863805570f23d39" + "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/245710e971a030f42e08f4912863805570f23d39", - "reference": "245710e971a030f42e08f4912863805570f23d39", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/be1996ed8adc35c3fd795488a653f4b518be70ea", + "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea", "shasum": "" }, "require": { @@ -3137,22 +3138,22 @@ ], "support": { "issues": "https://github.com/phpspec/prophecy/issues", - "source": "https://github.com/phpspec/prophecy/tree/1.12.2" + "source": "https://github.com/phpspec/prophecy/tree/1.13.0" }, - "time": "2020-12-19T10:15:11+00:00" + "time": "2021-03-17T13:42:18+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.5", + "version": "9.2.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "f3e026641cc91909d421802dd3ac7827ebfd97e1" + "reference": "f6293e1b30a2354e8428e004689671b83871edde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f3e026641cc91909d421802dd3ac7827ebfd97e1", - "reference": "f3e026641cc91909d421802dd3ac7827ebfd97e1", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f6293e1b30a2354e8428e004689671b83871edde", + "reference": "f6293e1b30a2354e8428e004689671b83871edde", "shasum": "" }, "require": { @@ -3208,7 +3209,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.5" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.6" }, "funding": [ { @@ -3216,7 +3217,7 @@ "type": "github" } ], - "time": "2020-11-28T06:44:49+00:00" + "time": "2021-03-28T07:26:59+00:00" }, { "name": "phpunit/php-file-iterator", @@ -3461,16 +3462,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.2", + "version": "9.5.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f661659747f2f87f9e72095bb207bceb0f151cb4" + "reference": "c73c6737305e779771147af66c96ca6a7ed8a741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f661659747f2f87f9e72095bb207bceb0f151cb4", - "reference": "f661659747f2f87f9e72095bb207bceb0f151cb4", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c73c6737305e779771147af66c96ca6a7ed8a741", + "reference": "c73c6737305e779771147af66c96ca6a7ed8a741", "shasum": "" }, "require": { @@ -3548,7 +3549,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.2" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.4" }, "funding": [ { @@ -3560,336 +3561,7 @@ "type": "github" } ], - "time": "2021-02-02T14:45:58+00:00" - }, - { - "name": "roave/security-advisories", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "ba8a590c26ddca3e0011a055cae7fd5ec28dd5f5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ba8a590c26ddca3e0011a055cae7fd5ec28dd5f5", - "reference": "ba8a590c26ddca3e0011a055cae7fd5ec28dd5f5", - "shasum": "" - }, - "conflict": { - "3f/pygmentize": "<1.2", - "adodb/adodb-php": "<5.20.12", - "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", - "amphp/artax": "<1.0.6|>=2,<2.0.6", - "amphp/http": "<1.0.1", - "amphp/http-client": ">=4,<4.4", - "api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6", - "asymmetricrypt/asymmetricrypt": ">=0,<9.9.99", - "aws/aws-sdk-php": ">=3,<3.2.1", - "bagisto/bagisto": "<0.1.5", - "barrelstrength/sprout-base-email": "<1.2.7", - "barrelstrength/sprout-forms": "<3.9", - "baserproject/basercms": ">=4,<=4.3.6|>=4.4,<4.4.1", - "bolt/bolt": "<3.7.1", - "bolt/core": "<4.1.13", - "brightlocal/phpwhois": "<=4.2.5", - "buddypress/buddypress": "<5.1.2", - "bugsnag/bugsnag-laravel": ">=2,<2.0.2", - "cakephp/cakephp": ">=1.3,<1.3.18|>=2,<2.4.99|>=2.5,<2.5.99|>=2.6,<2.6.12|>=2.7,<2.7.6|>=3,<3.5.18|>=3.6,<3.6.15|>=3.7,<3.7.7", - "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", - "cartalyst/sentry": "<=2.1.6", - "centreon/centreon": "<18.10.8|>=19,<19.4.5", - "cesnet/simplesamlphp-module-proxystatistics": "<3.1", - "codeigniter/framework": "<=3.0.6", - "composer/composer": "<=1-alpha.11", - "contao-components/mediaelement": ">=2.14.2,<2.21.1", - "contao/core": ">=2,<3.5.39", - "contao/core-bundle": ">=4,<4.4.52|>=4.5,<4.9.6|= 4.10.0", - "contao/listing-bundle": ">=4,<4.4.8", - "datadog/dd-trace": ">=0.30,<0.30.2", - "david-garcia/phpwhois": "<=4.3.1", - "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1", - "doctrine/annotations": ">=1,<1.2.7", - "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", - "doctrine/common": ">=2,<2.4.3|>=2.5,<2.5.1", - "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2", - "doctrine/doctrine-bundle": "<1.5.2", - "doctrine/doctrine-module": "<=0.7.1", - "doctrine/mongodb-odm": ">=1,<1.0.2", - "doctrine/mongodb-odm-bundle": ">=2,<3.0.1", - "doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1", - "dolibarr/dolibarr": "<11.0.4", - "dompdf/dompdf": ">=0.6,<0.6.2", - "drupal/core": ">=7,<7.74|>=8,<8.8.11|>=8.9,<8.9.9|>=9,<9.0.8", - "drupal/drupal": ">=7,<7.74|>=8,<8.8.11|>=8.9,<8.9.9|>=9,<9.0.8", - "endroid/qr-code-bundle": "<3.4.2", - "enshrined/svg-sanitize": "<0.13.1", - "erusev/parsedown": "<1.7.2", - "ezsystems/demobundle": ">=5.4,<5.4.6.1", - "ezsystems/ez-support-tools": ">=2.2,<2.2.3", - "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1", - "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1|>=5.4,<5.4.11.1|>=2017.12,<2017.12.0.1", - "ezsystems/ezplatform": ">=1.7,<1.7.9.1|>=1.13,<1.13.5.1|>=2.5,<2.5.4", - "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6", - "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1", - "ezsystems/ezplatform-kernel": ">=1,<1.0.2.1", - "ezsystems/ezplatform-user": ">=1,<1.0.1", - "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.14.2|>=6,<6.7.9.1|>=6.8,<6.13.6.3|>=7,<7.2.4.1|>=7.3,<7.3.2.1|>=7.5,<7.5.7.1", - "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.2|>=2011,<2017.12.7.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.5.1", - "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", - "ezsystems/repository-forms": ">=2.3,<2.3.2.1", - "ezyang/htmlpurifier": "<4.1.1", - "facade/ignition": "<=2.5.1,>=2.0|<=1.16.13", - "firebase/php-jwt": "<2", - "flarum/sticky": ">=0.1-beta.14,<=0.1-beta.15", - "flarum/tags": "<=0.1-beta.13", - "fooman/tcpdf": "<6.2.22", - "fossar/tcpdf-parser": "<6.2.22", - "friendsofsymfony/oauth2-php": "<1.3", - "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2", - "friendsofsymfony/user-bundle": ">=1.2,<1.3.5", - "friendsoftypo3/mediace": ">=7.6.2,<7.6.5", - "fuel/core": "<1.8.1", - "getgrav/grav": "<1.7-beta.8", - "getkirby/cms": ">=3,<3.4.5", - "getkirby/panel": "<2.5.14", - "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3", - "gree/jose": "<=2.2", - "gregwar/rst": "<1.0.3", - "guzzlehttp/guzzle": ">=4-rc.2,<4.2.4|>=5,<5.3.1|>=6,<6.2.1", - "illuminate/auth": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.10", - "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.99999|>=4.2,<=4.2.99999|>=5,<=5.0.99999|>=5.1,<=5.1.99999|>=5.2,<=5.2.99999|>=5.3,<=5.3.99999|>=5.4,<=5.4.99999|>=5.5,<=5.5.49|>=5.6,<=5.6.99999|>=5.7,<=5.7.99999|>=5.8,<=5.8.99999|>=6,<6.18.31|>=7,<7.22.4", - "illuminate/database": "<6.20.14|>=7,<7.30.4|>=8,<8.24", - "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", - "illuminate/view": ">=7,<7.1.2", - "ivankristianto/phpwhois": "<=4.3", - "james-heinrich/getid3": "<1.9.9", - "joomla/archive": "<1.1.10", - "joomla/session": "<1.3.1", - "jsmitty12/phpwhois": "<5.1", - "kazist/phpwhois": "<=4.2.6", - "kitodo/presentation": "<3.1.2", - "kreait/firebase-php": ">=3.2,<3.8.1", - "la-haute-societe/tcpdf": "<6.2.22", - "laravel/framework": "<6.20.14|>=7,<7.30.4|>=8,<8.24", - "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10", - "league/commonmark": "<0.18.3", - "librenms/librenms": "<1.53", - "livewire/livewire": ">2.2.4,<2.2.6", - "magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3", - "magento/magento1ce": "<1.9.4.3", - "magento/magento1ee": ">=1,<1.14.4.3", - "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", - "marcwillmann/turn": "<0.3.3", - "mautic/core": "<2.16.5|>=3,<3.2.4|= 2.13.1", - "mediawiki/core": ">=1.27,<1.27.6|>=1.29,<1.29.3|>=1.30,<1.30.2|>=1.31,<1.31.9|>=1.32,<1.32.6|>=1.32.99,<1.33.3|>=1.33.99,<1.34.3|>=1.34.99,<1.35", - "mittwald/typo3_forum": "<1.2.1", - "monolog/monolog": ">=1.8,<1.12", - "namshi/jose": "<2.2", - "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", - "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", - "nystudio107/craft-seomatic": "<3.3", - "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", - "october/backend": ">=1.0.319,<1.0.470", - "october/cms": "= 1.0.469|>=1.0.319,<1.0.469", - "october/october": ">=1.0.319,<1.0.466", - "october/rain": "<1.0.472|>=1.1,<1.1.2", - "onelogin/php-saml": "<2.10.4", - "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5", - "openid/php-openid": "<2.3", - "openmage/magento-lts": "<19.4.8|>=20,<20.0.4", - "orchid/platform": ">=9,<9.4.4", - "oro/crm": ">=1.7,<1.7.4", - "oro/platform": ">=1.7,<1.7.4", - "padraic/humbug_get_contents": "<1.1.2", - "pagarme/pagarme-php": ">=0,<3", - "paragonie/random_compat": "<2", - "passbolt/passbolt_api": "<2.11", - "paypal/merchant-sdk-php": "<3.12", - "pear/archive_tar": "<1.4.12", - "personnummer/personnummer": "<3.0.2", - "phpfastcache/phpfastcache": ">=5,<5.0.13", - "phpmailer/phpmailer": "<6.1.6", - "phpmussel/phpmussel": ">=1,<1.6", - "phpmyadmin/phpmyadmin": "<4.9.6|>=5,<5.0.3", - "phpoffice/phpexcel": "<1.8.2", - "phpoffice/phpspreadsheet": "<1.16", - "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3", - "phpwhois/phpwhois": "<=4.2.5", - "phpxmlrpc/extras": "<0.6.1", - "pimcore/pimcore": "<6.8.8", - "pocketmine/pocketmine-mp": "<3.15.4", - "prestashop/autoupgrade": ">=4,<4.10.1", - "prestashop/contactform": ">1.0.1,<4.3", - "prestashop/gamification": "<2.3.2", - "prestashop/productcomments": ">=4,<4.2.1", - "prestashop/ps_facetedsearch": "<3.4.1", - "privatebin/privatebin": "<1.2.2|>=1.3,<1.3.2", - "propel/propel": ">=2-alpha.1,<=2-alpha.7", - "propel/propel1": ">=1,<=1.7.1", - "pterodactyl/panel": "<0.7.19|>=1-rc.0,<=1-rc.6", - "pusher/pusher-php-server": "<2.2.1", - "rainlab/debugbar-plugin": "<3.1", - "robrichards/xmlseclibs": "<3.0.4", - "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1", - "sabre/dav": ">=1.6,<1.6.99|>=1.7,<1.7.11|>=1.8,<1.8.9", - "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", - "sensiolabs/connect": "<4.2.3", - "serluck/phpwhois": "<=4.2.6", - "shopware/core": "<=6.3.4", - "shopware/platform": "<=6.3.5", - "shopware/shopware": "<5.6.9", - "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1", - "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2", - "silverstripe/cms": "<4.3.6|>=4.4,<4.4.4", - "silverstripe/comments": ">=1.3,<1.9.99|>=2,<2.9.99|>=3,<3.1.1", - "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", - "silverstripe/framework": "<4.4.7|>=4.5,<4.5.4", - "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.1.2|>=3.2,<3.2.4", - "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1", - "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4", - "silverstripe/subsites": ">=2,<2.1.1", - "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", - "silverstripe/userforms": "<3", - "simple-updates/phpwhois": "<=1", - "simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4", - "simplesamlphp/simplesamlphp": "<1.18.6", - "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", - "simplito/elliptic-php": "<1.0.6", - "slim/slim": "<2.6", - "smarty/smarty": "<3.1.39", - "socalnick/scn-social-auth": "<1.15.2", - "socialiteproviders/steam": "<1.1", - "spoonity/tcpdf": "<6.2.22", - "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", - "ssddanbrown/bookstack": "<0.29.2", - "stormpath/sdk": ">=0,<9.9.99", - "studio-42/elfinder": "<2.1.49", - "sulu/sulu": "<1.6.34|>=2,<2.0.10|>=2.1,<2.1.1", - "swiftmailer/swiftmailer": ">=4,<5.4.5", - "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2", - "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", - "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", - "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", - "sylius/sylius": "<1.6.9|>=1.7,<1.7.9|>=1.8,<1.8.3", - "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99", - "symbiote/silverstripe-versionedfiles": "<=2.0.3", - "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", - "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", - "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", - "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7", - "symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.4.13|>=5,<5.1.5", - "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", - "symfony/mime": ">=4.3,<4.3.8", - "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/polyfill": ">=1,<1.10", - "symfony/polyfill-php55": ">=1,<1.10", - "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/routing": ">=2,<2.0.19", - "symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=4.4,<4.4.7|>=5,<5.0.7", - "symfony/security-bundle": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", - "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<2.8.37|>=3,<3.3.17|>=3.4,<3.4.7|>=4,<4.0.7", - "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", - "symfony/security-guard": ">=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", - "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7", - "symfony/serializer": ">=2,<2.0.11", - "symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.4.13|>=5,<5.1.5", - "symfony/translation": ">=2,<2.0.17", - "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3", - "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", - "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", - "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7", - "t3g/svg-sanitizer": "<1.0.3", - "tecnickcom/tcpdf": "<6.2.22", - "thelia/backoffice-default-template": ">=2.1,<2.1.2", - "thelia/thelia": ">=2.1-beta.1,<2.1.3", - "theonedemon/phpwhois": "<=4.2.5", - "titon/framework": ">=0,<9.9.99", - "truckersmp/phpwhois": "<=4.3.1", - "twig/twig": "<1.38|>=2,<2.7", - "typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.38|>=9,<9.5.23|>=10,<10.4.10", - "typo3/cms-core": ">=8,<8.7.38|>=9,<9.5.23|>=10,<10.4.10", - "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.10|>=3.1,<3.1.7|>=3.2,<3.2.7|>=3.3,<3.3.5", - "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4", - "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", - "typo3fluid/fluid": ">=2,<2.0.8|>=2.1,<2.1.7|>=2.2,<2.2.4|>=2.3,<2.3.7|>=2.4,<2.4.4|>=2.5,<2.5.11|>=2.6,<2.6.10", - "ua-parser/uap-php": "<3.8", - "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2", - "verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4", - "vrana/adminer": "<4.7.9", - "wallabag/tcpdf": "<6.2.22", - "willdurand/js-translation-bundle": "<2.1.1", - "yii2mod/yii2-cms": "<1.9.2", - "yiisoft/yii": ">=1.1.14,<1.1.15", - "yiisoft/yii2": "<2.0.38", - "yiisoft/yii2-bootstrap": "<2.0.4", - "yiisoft/yii2-dev": "<2.0.15", - "yiisoft/yii2-elasticsearch": "<2.0.5", - "yiisoft/yii2-gii": "<2.0.4", - "yiisoft/yii2-jui": "<2.0.4", - "yiisoft/yii2-redis": "<2.0.8", - "yourls/yourls": "<1.7.4", - "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", - "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2", - "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2", - "zendframework/zend-db": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.10|>=2.3,<2.3.5", - "zendframework/zend-developer-tools": ">=1.2.2,<1.2.3", - "zendframework/zend-diactoros": ">=1,<1.8.4", - "zendframework/zend-feed": ">=1,<2.10.3", - "zendframework/zend-form": ">=2,<2.2.7|>=2.3,<2.3.1", - "zendframework/zend-http": ">=1,<2.8.1", - "zendframework/zend-json": ">=2.1,<2.1.6|>=2.2,<2.2.6", - "zendframework/zend-ldap": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.8|>=2.3,<2.3.3", - "zendframework/zend-mail": ">=2,<2.4.11|>=2.5,<2.7.2", - "zendframework/zend-navigation": ">=2,<2.2.7|>=2.3,<2.3.1", - "zendframework/zend-session": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.9|>=2.3,<2.3.4", - "zendframework/zend-validator": ">=2.3,<2.3.6", - "zendframework/zend-view": ">=2,<2.2.7|>=2.3,<2.3.1", - "zendframework/zend-xmlrpc": ">=2.1,<2.1.6|>=2.2,<2.2.6", - "zendframework/zendframework": "<2.5.1", - "zendframework/zendframework1": "<1.12.20", - "zendframework/zendopenid": ">=2,<2.0.2", - "zendframework/zendxml": ">=1,<1.0.1", - "zetacomponents/mail": "<1.8.2", - "zf-commons/zfc-user": "<1.2.2", - "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", - "zfr/zfr-oauth2-server-module": "<0.1.2" - }, - "type": "metapackage", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "role": "maintainer" - }, - { - "name": "Ilya Tribusean", - "email": "slash3b@gmail.com", - "role": "maintainer" - } - ], - "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", - "support": { - "issues": "https://github.com/Roave/SecurityAdvisories/issues", - "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" - }, - "funding": [ - { - "url": "https://github.com/Ocramius", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", - "type": "tidelift" - } - ], - "time": "2021-03-08T17:27:54+00:00" + "time": "2021-03-23T07:16:29+00:00" }, { "name": "sebastian/cli-parser", @@ -5131,16 +4803,16 @@ }, { "name": "symfony/process", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "313a38f09c77fbcdc1d223e57d368cea76a2fd2f" + "reference": "98cb8eeb72e55d4196dd1e36f1f16e7b3a9a088e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/313a38f09c77fbcdc1d223e57d368cea76a2fd2f", - "reference": "313a38f09c77fbcdc1d223e57d368cea76a2fd2f", + "url": "https://api.github.com/repos/symfony/process/zipball/98cb8eeb72e55d4196dd1e36f1f16e7b3a9a088e", + "reference": "98cb8eeb72e55d4196dd1e36f1f16e7b3a9a088e", "shasum": "" }, "require": { @@ -5173,7 +4845,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.2.4" + "source": "https://github.com/symfony/process/tree/v5.3.0-BETA1" }, "funding": [ { @@ -5189,20 +4861,20 @@ "type": "tidelift" } ], - "time": "2021-01-27T10:15:41+00:00" + "time": "2021-04-08T10:27:02+00:00" }, { "name": "symfony/stopwatch", - "version": "v5.2.4", + "version": "v5.2.7", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "b12274acfab9d9850c52583d136a24398cdf1a0c" + "reference": "d99310c33e833def36419c284f60e8027d359678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b12274acfab9d9850c52583d136a24398cdf1a0c", - "reference": "b12274acfab9d9850c52583d136a24398cdf1a0c", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/d99310c33e833def36419c284f60e8027d359678", + "reference": "d99310c33e833def36419c284f60e8027d359678", "shasum": "" }, "require": { @@ -5235,7 +4907,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v5.2.4" + "source": "https://github.com/symfony/stopwatch/tree/v5.3.0-BETA1" }, "funding": [ { @@ -5251,7 +4923,7 @@ "type": "tidelift" } ], - "time": "2021-01-27T10:15:41+00:00" + "time": "2021-03-29T15:28:41+00:00" }, { "name": "theseer/tokenizer", @@ -5364,9 +5036,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "roave/security-advisories": 20 - }, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/config/services.yaml b/config/services.yaml index 3032abc..3f45020 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -23,6 +23,11 @@ parameters: env(KRAKEN_FEE_STRATEGY): 'include' env(KRAKEN_TRADING_AGREEMENT): ~ + # binance settings + env(BINANCE_API_URL): 'https://api.binance.com/' + env(BINANCE_API_KEY): ~ + env(BINANCE_API_SECRET): ~ + # generic application settings env(WITHDRAW_ADDRESS): '%env(BL3P_WITHDRAW_ADDRESS)%' env(WITHDRAW_XPUB): '%env(BL3P_WITHDRAW_XPUB)%' @@ -154,6 +159,21 @@ services: arguments: - base_uri: '%env(string:KRAKEN_API_URL)%' + api.client.binance: + class: Jorijn\Bitcoin\Dca\Client\BinanceClient + arguments: + - '@http_client.binance' + - '%env(string:BINANCE_API_KEY)%' + - '%env(string:BINANCE_API_SECRET)%' + + http_client.binance: + class: Symfony\Contracts\HttpClient\HttpClientInterface + factory: + - Symfony\Component\HttpClient\HttpClient + - create + arguments: + - base_uri: '%env(string:BINANCE_API_URL)%' + ###################################################################### # Address Providers ###################################################################### @@ -344,6 +364,31 @@ services: tags: - { name: exchange-balance-service } + ## + ## Binance + ## + service.buy.binance: + class: Jorijn\Bitcoin\Dca\Service\Binance\BinanceBuyService + arguments: + - '@api.client.binance' + - '%env(BASE_CURRENCY)%' + tags: + - { name: exchange-buy-service } + + service.withdraw.binance: + class: Jorijn\Bitcoin\Dca\Service\Binance\BinanceWithdrawService + arguments: + - '@api.client.binance' + tags: + - { name: exchange-withdraw-service } + + service.balance.binance: + class: Jorijn\Bitcoin\Dca\Service\Binance\BinanceBalanceService + arguments: + - '@api.client.binance' + tags: + - { name: exchange-balance-service } + ###################################################################### # Third Party Components ###################################################################### diff --git a/docs/README.md b/docs/README.md index d679c82..a6910f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ Version: Python 3.7 (`brew install python@3.7` & `brew link --overwrite python@3.7`) -Install Sphinx Autobuild: `pip3 install sphinx-autobuild` +Install Sphinx Autobuild: `pip3 install sphinx-autobuild sphinx_rtd_theme` Run: `python3 /usr/local/bin/sphinx-autobuild . _build/html --port 10000` diff --git a/docs/conf.py b/docs/conf.py index 5e51cb1..b526602 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ import sphinx_rtd_theme project = 'Bitcoin DCA' -copyright = '2020, Jorijn Schrijvershof' +copyright = '2021, Jorijn Schrijvershof' author = 'Jorijn Schrijvershof' extensions = [] templates_path = ['_templates'] @@ -14,3 +14,8 @@ 'navigation_depth': 4, } master_doc = 'index' +html_logo = '../resources/images/logo-white.png' + +# I use a privacy focussed service https://usefathom.com/ to track how the documentation +# is being used. This allows me to improve its contents. +html_js_files = [('https://krill.jorijn.com/script.js', {'data-site': 'MXGDAIWO'})] diff --git a/docs/configuration.rst b/docs/configuration.rst index ec73636..dc75383 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -38,7 +38,7 @@ EXCHANGE """""""" This configuration value determines which exchange will be used for buys and withdrawals. The default value is BL3P. -Available options: ``bl3p``, ``bitvavo``, ``kraken`` +Available options: ``bl3p``, ``bitvavo``, ``kraken``, ``binance`` **Example**: ``EXCHANGE=bl3p`` @@ -128,6 +128,35 @@ See https://support.kraken.com/hc/en-us/articles/360036157952 If you agree, fill this value with ``agree``, like this: ``KRAKEN_TRADING_AGREEMENT=agree`` +Exchange: Binance +^^^^^^^^^^^^^^^^^ + +Your Binance API key should hold at least the following permissions: + +* Enable Reading +* Enable Spot & Margin Trading +* Enable Withdrawals + +You should enable IP access restrictions to use withdrawal through the API. Enter the IP address that matches your outgoing connection. When in doubt, you can check your IP here: https://nordvpn.com/nl/ip-lookup/ + +BINANCE_API_KEY +""""""""""""""" +This is the identifying part of the API key that you created on the Binance exchange. + +**Example**: ``BINANCE_API_KEY=mkYEtmPzI9q9qrwvYzTe44nB495joEM17bhUDspFEkKHjzLmKwT1exvQYxGcL6db`` + +BINANCE_API_SECRET +"""""""""""""""""" +This is the private part of your API connection to Binance. It’s a secret granting access to your Binance account. + +**Example**: ``BINANCE_API_SECRET=xXFw9vEiSdgllWfLs55uGC3ZBS3VyZMy1aGj4mYYlIIhX6hQ98AsGsQHLSKI4uj6`` + +BINANCE_API_URL (optional) +""""""""""""""""""""""""" +The endpoint where the tool should connect to. + +**Default**: ``BINANCE_API_URL=https://api.binance.com/`` + Feeding configuration into the DCA tool --------------------------------------- diff --git a/docs/faq.rst b/docs/faq.rst index 3b458d9..2efe41e 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -9,12 +9,12 @@ Frequently Asked Questions faq -I already have MyNode running, can I use this tool too? -------------------------------------------------------- +I already have MyNode / Umbrel running, can I use this tool too? +---------------------------------------------------------------- -Yes! MyNode is based on Linux and has Docker already installed. You can use all features of Bitcoin DCA. +Yes! MyNode and Umbrel are both based on Linux and have Docker pre-installed. You can use all features of Bitcoin DCA. -Things you should keep in mind: The default user, ``admin`` doesn't have permission to run Docker by default. +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``. .. include:: ./includes/add-user-to-docker-group.rst diff --git a/docs/index.rst b/docs/index.rst index d387fd8..3072ec6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,7 @@ Welcome to Bitcoin DCA's documentation! About this software ------------------- -This DCA (Dollar Cost Averaging) tool is built with flexibility in mind, allowing you to specify your own schedule for buying and withdrawing. +This self-hosted DCA (Dollar Cost Averaging) tool is built with flexibility in mind, allowing you to specify your schedule for buying and withdrawing. A few examples of possible scenario's: @@ -31,23 +31,27 @@ Supported Exchanges * - Exchange - URL - - Currencies - XPUB withdraw supported + - Currencies * - BL3P - https://bl3p.eu/ + - Yes - EUR - - No * * - Bitvavo - https://bitvavo.com/ - - EUR - No * + - EUR * - Kraken - https://kraken.com/ - - USD EUR CAD JPY GBP CHF AUD - No + - USD EUR CAD JPY GBP CHF AUD + * - Binance + - https://binance.com/ + - Yes + - USDT BUSD EUR USDC USDT GBP AUD TRY BRL DAI TUSD RUB UAH PAX BIDR NGN IDRT VAI .. note:: - Due to regulatory changes in The Netherlands, BL3P and Bitvavo currently require you to provide proof of address ownership, thus temporarily disabling Bitcoin-DCA's XPUB feature. + 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. Telegram / Support ------------------ @@ -57,4 +61,4 @@ Contributing ------------ Contributions are highly welcome! Feel free to submit issues and pull requests on https://github.com/jorijn/bitcoin-dca. -Like my work? Buy me a 🍺 by sending some sats to https://jorijn.com/donate/ +Like my work? Please buy me a 🍺 by sending some sats to https://jorijn.com/donate/ diff --git a/docs/logging.rst b/docs/logging.rst deleted file mode 100644 index ce0fc89..0000000 --- a/docs/logging.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. _logging: - -Setting up logging -================== diff --git a/resources/images/github-logo-colored.png b/resources/images/github-logo-colored.png index 5d09d08..c5ed877 100644 Binary files a/resources/images/github-logo-colored.png and b/resources/images/github-logo-colored.png differ diff --git a/resources/images/logo-small.png b/resources/images/logo-small.png index d168510..0399309 100644 Binary files a/resources/images/logo-small.png and b/resources/images/logo-small.png differ diff --git a/resources/images/logo-white.png b/resources/images/logo-white.png new file mode 100644 index 0000000..eca5a6c Binary files /dev/null and b/resources/images/logo-white.png differ diff --git a/resources/images/logo.png b/resources/images/logo.png index 54bf617..5b986b1 100644 Binary files a/resources/images/logo.png and b/resources/images/logo.png differ diff --git a/src/Client/BinanceClient.php b/src/Client/BinanceClient.php new file mode 100644 index 0000000..df4cea7 --- /dev/null +++ b/src/Client/BinanceClient.php @@ -0,0 +1,119 @@ +apiKey = $apiKey; + $this->apiSecret = $apiSecret; + $this->httpClient = $httpClient; + } + + /** + * This decorator implementation on the HttpClientInterface will check the given options for + * a specific type of security key, depending on which — the decorator will sign the request + * and add the API key to the header array. + * + * @noinspection PhpMissingBreakStatementInspection + */ + public function request(string $method, string $url, array $options = []): array + { + $extra = $options['extra'] ?? []; + + if (!isset($options['headers']) || !\is_array($options['headers'])) { + $options['headers'] = []; + } + + $options['headers']['User-Agent'] = self::USER_AGENT; + + switch ($extra['security_type'] ?? null) { + case 'TRADE': + case 'USER_DATA': + [$method, $url, $options] = $this->addSignatureToRequest($method, $url, $options); + // no break + case 'USER_STREAM': + case 'MARKET_DATA': + // @noinspection SuspiciousAssignmentsInspection + [$method, $url, $options] = $this->addApiKeyToRequest($method, $url, $options); + // no break + case 'NONE': + default: + return $this->parse($this->httpClient->request($method, $url, $options)); + } + } + + protected function addSignatureToRequest(string $method, string $url, array $options): array + { + // fetch the query and add the required timestamp + $parameters = $options['query'] ?? []; + + // check and validate any present body + if (isset($options['body'])) { + if (!\is_array($options['body'])) { + throw new \InvalidArgumentException( + 'passing any other request body than type `array` on '.__CLASS__.' is not supported' + ); + } + + $parameters = array_merge($parameters, $options['body']); + } + + // clear up the request as we are overwriting + unset($options['query'], $options['body']); + + // add the timestamp so the exchange can invalidate when there is too much network lag + $parameters['timestamp'] = number_format(microtime(true) * 1000, 0, '.', ''); + + // build the query string and hash it into a signature + $parameterString = http_build_query($parameters, '', '&'); + $signature = hash_hmac(self::HASH_ALGO, $parameterString, $this->apiSecret); + + // if the request was designed to be a POST request, take all the options and move them to the body -- + // only signature is allowed as query string + if ('POST' === $method) { + $options['body'] = array_merge($parameters, ['signature' => $signature]); + } else { + $options['query'] = array_merge($parameters, ['signature' => $signature]); + } + + return [$method, $url, $options]; + } + + protected function addApiKeyToRequest(string $method, string $url, array $options): array + { + $options['headers'] = array_merge($options['headers'] ?? [], ['X-MBX-APIKEY' => $this->apiKey]); + + return [$method, $url, $options]; + } + + /** + * This method should translate the response into a usable array object. + */ + protected function parse(ResponseInterface $response): array + { + $result = $response->toArray(false); + + if (isset($result['code'], $result['msg'])) { + throw new BinanceClientException($result['msg'], $result['code']); + } + + return $result; + } +} diff --git a/src/Client/BinanceClientInterface.php b/src/Client/BinanceClientInterface.php new file mode 100644 index 0000000..63ff4ac --- /dev/null +++ b/src/Client/BinanceClientInterface.php @@ -0,0 +1,10 @@ +client = $client; + } + + public function supportsExchange(string $exchange): bool + { + return 'binance' === $exchange; + } + + public function getBalances(): array + { + $response = $this->client->request('GET', 'api/v3/account', [ + 'extra' => ['security_type' => 'USER_DATA'], + ]); + + return array_filter(array_reduce($response['balances'], static function (array $balances, array $asset) { + $decimals = \strlen(explode('.', $asset['free'])[1]); + + // binance holds a gazillion altcoins, no interest in showing hundreds if their balance + // is zero. + if (bccomp($asset['free'], '0', $decimals) <= 0) { + $balances[$asset['asset']] = false; + + return $balances; + } + + $balances[$asset['asset']] = [ + $asset['asset'], + bcadd($asset['free'], $asset['locked'], $decimals), + $asset['free'], + ]; + + return $balances; + }, [])); + } +} diff --git a/src/Service/Binance/BinanceBuyService.php b/src/Service/Binance/BinanceBuyService.php new file mode 100644 index 0000000..31b25b8 --- /dev/null +++ b/src/Service/Binance/BinanceBuyService.php @@ -0,0 +1,134 @@ +client = $client; + $this->baseCurrency = $baseCurrency; + $this->tradingPair = sprintf('BTC%s', $this->baseCurrency); + } + + public function supportsExchange(string $exchange): bool + { + return 'binance' === $exchange; + } + + public function initiateBuy(int $amount): CompletedBuyOrder + { + $response = $this->client->request('POST', self::ORDER_URL, [ + 'extra' => ['security_type' => 'TRADE'], + 'body' => [ + 'symbol' => $this->tradingPair, + 'side' => 'BUY', + 'type' => 'MARKET', + 'quoteOrderQty' => $amount, + 'newOrderRespType' => 'FULL', + ], + ]); + + if ('FILLED' !== $response['status']) { + throw new PendingBuyOrderException($response['orderId']); + } + + return $this->getCompletedBuyOrderFromResponse($response); + } + + public function checkIfOrderIsFilled(string $orderId): CompletedBuyOrder + { + $response = $this->client->request('GET', self::ORDER_URL, [ + 'extra' => ['security_type' => 'TRADE'], + 'body' => [ + 'symbol' => $this->tradingPair, + 'orderId' => $orderId, + ], + ]); + + if ('FILLED' !== $response['status']) { + throw new PendingBuyOrderException($response['orderId']); + } + + $response['fills'] = $this->client->request('GET', 'api/v3/myTrades', [ + 'extra' => ['security_type' => 'USER_DATA'], + 'body' => [ + 'symbol' => $this->tradingPair, + 'startTime' => $response['time'], + ], + ]); + + return $this->getCompletedBuyOrderFromResponse($response); + } + + public function cancelBuyOrder(string $orderId): void + { + $this->client->request('DELETE', self::ORDER_URL, [ + 'extra' => ['security_type' => 'TRADE'], + 'body' => [ + 'symbol' => $this->tradingPair, + 'orderId' => $orderId, + ], + ]); + } + + protected function getCompletedBuyOrderFromResponse(array $orderInfo): CompletedBuyOrder + { + [$feeAmount, $feeCurrency] = $this->getFeeInformationFromOrderInfo($orderInfo); + + return (new CompletedBuyOrder()) + ->setAmountInSatoshis((int) bcmul($orderInfo['executedQty'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS)) + ->setFeesInSatoshis('BTC' === $feeCurrency + ? (int) bcmul($feeAmount, Bitcoin::SATOSHIS, Bitcoin::DECIMALS) + : 0) + ->setDisplayAmountBought($orderInfo['executedQty'].' BTC') + ->setDisplayAmountSpent($orderInfo['cummulativeQuoteQty'].' '.$this->baseCurrency) + ->setDisplayAveragePrice($this->getAveragePrice($orderInfo).' '.$this->baseCurrency) + ->setDisplayFeesSpent($feeAmount.' '.$feeCurrency) + ; + } + + protected function getAveragePrice($data): float + { + $dividend = $divisor = 0; + $totalSats = (int) bcmul($data['executedQty'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS); + + foreach ($data['fills'] as $fill) { + $filledSats = (int) bcmul($fill['qty'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS); + $percent = ($filledSats / $totalSats) * 100; + + $dividend += ($percent * (float) $fill['price']); + $divisor += $percent; + } + + return $dividend / $divisor; + } + + protected function getFeeInformationFromOrderInfo(array $orderInfo): array + { + $feeCurrency = null; + $fee = '0'; + + foreach ($orderInfo['fills'] as $fill) { + $feeDecimals = \strlen(explode('.', $fill['commission'])[1]); + $feeCurrency = $fill['commissionAsset']; + $fee = bcadd($fee, $fill['commission'], $feeDecimals); + } + + return [$fee, $feeCurrency]; + } +} diff --git a/src/Service/Binance/BinanceWithdrawService.php b/src/Service/Binance/BinanceWithdrawService.php new file mode 100644 index 0000000..39398f1 --- /dev/null +++ b/src/Service/Binance/BinanceWithdrawService.php @@ -0,0 +1,76 @@ +client = $client; + } + + public function withdraw(int $balanceToWithdraw, string $addressToWithdrawTo): CompletedWithdraw + { + $response = $this->client->request('POST', 'sapi/v1/capital/withdraw/apply', [ + 'extra' => ['security_type' => 'USER_DATA'], + 'body' => [ + 'coin' => 'BTC', + 'address' => $addressToWithdrawTo, + 'amount' => bcdiv((string) $balanceToWithdraw, Bitcoin::SATOSHIS, Bitcoin::DECIMALS), + ], + ]); + + return new CompletedWithdraw($addressToWithdrawTo, $balanceToWithdraw, $response['id']); + } + + public function getAvailableBalance(): int + { + $response = $this->client->request('GET', 'api/v3/account', [ + 'extra' => ['security_type' => 'USER_DATA'], + ]); + + if (isset($response['balances'])) { + foreach ($response['balances'] as $balance) { + if ('BTC' === $balance['asset']) { + return (int) bcmul($balance['free'], Bitcoin::SATOSHIS, Bitcoin::DECIMALS); + } + } + } + + return 0; + } + + public function getWithdrawFeeInSatoshis(): int + { + $response = $this->client->request('GET', 'sapi/v1/asset/assetDetail', [ + 'extra' => ['security_type' => 'USER_DATA'], + ]); + + if (!isset($response['BTC'])) { + throw new BinanceClientException('BTC asset appears to be unknown on Binance'); + } + + $assetDetails = $response['BTC']; + + if (false === $assetDetails['withdrawStatus'] ?? false) { + throw new BinanceClientException('withdrawal for BTC is disabled on Binance'); + } + + return (int) bcmul((string) ($assetDetails['withdrawFee'] ?? 0), Bitcoin::SATOSHIS, Bitcoin::DECIMALS); + } + + public function supportsExchange(string $exchange): bool + { + return 'binance' === $exchange; + } +} diff --git a/tests/Client/BinanceClientTest.php b/tests/Client/BinanceClientTest.php new file mode 100644 index 0000000..31875e9 --- /dev/null +++ b/tests/Client/BinanceClientTest.php @@ -0,0 +1,167 @@ +apiKey = 'api_key_'.random_int(1000, 2000); + $this->apiSecret = 'api_secret_'.random_int(1000, 2000); + $this->httpClient = $this->createMock(HttpClientInterface::class); + + $this->client = new BinanceClient($this->httpClient, $this->apiKey, $this->apiSecret); + } + + /** + * @covers ::parse + * @covers ::request + */ + public function testUserAgentIsAddedToRequest(): void + { + $this->httpClient + ->expects(static::once()) + ->method('request') + ->with('GET', 'foo', static::callback(function (array $options) { + self::assertArrayHasKey('headers', $options); + self::assertArrayHasKey('User-Agent', $options['headers']); + self::assertSame(BinanceClient::USER_AGENT, $options['headers']['User-Agent']); + + return true; + })) + ->willReturn($this->getResponseMock()) + ; + + $this->client->request('GET', 'foo', []); + } + + /** + * @covers ::parse + * @covers ::request + */ + public function testErrorIsWrappedInException(): void + { + $errorCode = random_int(100, 200); + $errorMessage = 'error_message_'.random_int(1000, 2000); + + $this->httpClient + ->expects(static::once()) + ->method('request') + ->willReturn($this->getResponseMock(['msg' => $errorMessage, 'code' => $errorCode])) + ; + + $this->expectException(BinanceClientException::class); + $this->expectExceptionMessage($errorMessage); + $this->expectExceptionCode($errorCode); + + $this->client->request('GET', 'foo', []); + } + + /** + * @covers ::addApiKeyToRequest + * @covers ::addSignatureToRequest + * @covers ::parse + * @covers ::request + */ + public function testSignatureAndApiKeyIsAddedToRequest(): void + { + $path = 'path_'.random_int(1000, 2000); + $body = ['foo' => 'bar_'.random_int(1000, 2000)]; + + $this->httpClient + ->expects(static::once()) + ->method('request') + ->with('GET', $path, static::callback(function (array $options) use ($body) { + self::assertArrayHasKey('query', $options); + self::assertArrayHasKey('headers', $options); + self::assertArrayHasKey('timestamp', $options['query']); + self::assertArrayHasKey('signature', $options['query']); + self::assertArrayHasKey('X-MBX-APIKEY', $options['headers']); + self::assertSame($this->apiKey, $options['headers']['X-MBX-APIKEY']); + + $expectedHash = hash_hmac( + BinanceClient::HASH_ALGO, + http_build_query($body + ['timestamp' => $options['query']['timestamp']], '', '&'), + $this->apiSecret + ); + + self::assertSame($expectedHash, $options['query']['signature']); + + return true; + })) + ->willReturn($this->getResponseMock()) + ; + + $this->client->request('GET', $path, ['extra' => ['security_type' => 'TRADE'], 'body' => $body]); + } + + /** + * @covers ::addApiKeyToRequest + * @covers ::addSignatureToRequest + * @covers ::parse + * @covers ::request + */ + public function testCredentialsAreAddedToPostRequest(): void + { + $path = 'path_'.random_int(1000, 2000); + $body = ['foo' => 'bar_'.random_int(1000, 2000)]; + + $this->httpClient + ->expects(static::once()) + ->method('request') + ->with('POST', $path, static::callback(function (array $options) { + self::assertArrayHasKey('body', $options); + self::assertArrayHasKey('timestamp', $options['body']); + self::assertArrayHasKey('signature', $options['body']); + + return true; + })) + ->willReturn($this->getResponseMock()) + ; + + $this->client->request('POST', $path, ['extra' => ['security_type' => 'TRADE'], 'body' => $body]); + } + + /** + * @covers ::addSignatureToRequest + * @covers ::parse + * @covers ::request + */ + public function testSignatureAddThrowsExceptionOnCorruptBody(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->client->request('GET', 'foo', ['extra' => ['security_type' => 'TRADE'], 'body' => 'foo']); + } + + private function getResponseMock(array $response = []): ResponseInterface + { + $mock = $this->createMock(ResponseInterface::class); + $mock->method('toArray')->willReturn($response); + + return $mock; + } +} diff --git a/tests/Exception/PendingBuyOrderExceptionTest.php b/tests/Exception/PendingBuyOrderExceptionTest.php index 0b81b66..68ae768 100644 --- a/tests/Exception/PendingBuyOrderExceptionTest.php +++ b/tests/Exception/PendingBuyOrderExceptionTest.php @@ -15,6 +15,7 @@ final class PendingBuyOrderExceptionTest extends TestCase { /** + * @covers ::__construct * @covers ::getOrderId */ public function testGetOrderId(): void diff --git a/tests/Service/Binance/BinanceBalanceServiceTest.php b/tests/Service/Binance/BinanceBalanceServiceTest.php new file mode 100644 index 0000000..d04fe64 --- /dev/null +++ b/tests/Service/Binance/BinanceBalanceServiceTest.php @@ -0,0 +1,77 @@ +client = $this->createMock(BinanceClientInterface::class); + $this->service = new BinanceBalanceService($this->client); + } + + /** + * @covers ::getBalances + */ + public function testGetBalances(): void + { + $responseStub = [ + 'balances' => [ + ['asset' => 'BTC', 'free' => '1.001', 'locked' => '1.002'], + ['asset' => 'XRP', 'free' => '0.000', 'locked' => '0.000'], // as it should be + ], + ]; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'GET', + 'api/v3/account', + static::callback(function (array $extra) { + self::assertArrayHasKey('extra', $extra); + self::assertArrayHasKey('security_type', $extra['extra']); + self::assertSame('USER_DATA', $extra['extra']['security_type']); + + return true; + }) + ) + ->willReturn($responseStub) + ; + + $response = $this->service->getBalances(); + + static::assertArrayHasKey('BTC', $response); + static::assertArrayNotHasKey('XRP', $response); + + static::assertSame(['BTC', '2.003', '1.001'], $response['BTC']); + } + + /** + * @covers ::supportsExchange + */ + public function testSupportsExchange(): void + { + static::assertTrue($this->service->supportsExchange('binance')); + static::assertFalse($this->service->supportsExchange('kraken')); + } +} diff --git a/tests/Service/Binance/BinanceBuyServiceTest.php b/tests/Service/Binance/BinanceBuyServiceTest.php new file mode 100644 index 0000000..dfc473b --- /dev/null +++ b/tests/Service/Binance/BinanceBuyServiceTest.php @@ -0,0 +1,336 @@ +client = $this->createMock(BinanceClientInterface::class); + $this->baseCurrency = 'BC'.random_int(1, 9); + $this->tradingPair = 'BTC'.$this->baseCurrency; + + $this->service = new BinanceBuyService($this->client, $this->baseCurrency); + } + + /** + * @covers ::supportsExchange + */ + public function testSupportsExchange(): void + { + static::assertTrue($this->service->supportsExchange('binance')); + static::assertFalse($this->service->supportsExchange('kraken')); + } + + /** + * @covers ::getAveragePrice + * @covers ::getCompletedBuyOrderFromResponse + * @covers ::getFeeInformationFromOrderInfo + * @covers ::initiateBuy + */ + public function testBuySucceedsFirstTime(): void + { + $amount = random_int(100, 200); + + $apiResponse = [ + 'executedQty' => '0.005', + 'cummulativeQuoteQty' => $amount, + 'transactTime' => 123, + 'status' => 'FILLED', + 'fills' => [ + ['commission' => '1.0', 'commissionAsset' => 'BNB', 'qty' => '0.002', 'price' => '1000.25'], + ['commission' => '1.0', 'commissionAsset' => 'BNB', 'qty' => '0.002', 'price' => '2000.75'], + ], + ]; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'POST', + BinanceBuyService::ORDER_URL, + static::callback(function (array $options) use ($amount) { + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('body', $options); + self::assertSame(['security_type' => 'TRADE'], $options['extra']); + self::assertSame($this->tradingPair, $options['body']['symbol']); + self::assertArrayHasKey('quoteOrderQty', $options['body']); + self::assertSame($amount, $options['body']['quoteOrderQty']); + self::assertArrayHasKey('symbol', $options['body']); + self::assertArrayHasKey('side', $options['body']); + self::assertSame('MARKET', $options['body']['type']); + self::assertArrayHasKey('newOrderRespType', $options['body']); + self::assertSame('FULL', $options['body']['newOrderRespType']); + self::assertSame('BUY', $options['body']['side']); + self::assertArrayHasKey('type', $options['body']); + + return true; + }) + ) + ->willReturn($apiResponse) + ; + + $result = $this->service->initiateBuy($amount); + + static::assertSame(500000, $result->getAmountInSatoshis()); + static::assertSame(0, $result->getFeesInSatoshis()); + static::assertSame('0.005 BTC', $result->getDisplayAmountBought()); + static::assertSame($amount.' '.$this->baseCurrency, $result->getDisplayAmountSpent()); + static::assertSame('1500.5 '.$this->baseCurrency, $result->getDisplayAveragePrice()); + static::assertSame('2.0 BNB', $result->getDisplayFeesSpent()); + } + + /** + * @covers ::getAveragePrice + * @covers ::getCompletedBuyOrderFromResponse + * @covers ::getFeeInformationFromOrderInfo + * @covers ::initiateBuy + */ + public function testBuyWithFeesInBitcoin(): void + { + $amount = random_int(100, 200); + + $apiResponse = [ + 'executedQty' => '0.006', + 'cummulativeQuoteQty' => $amount, + 'transactTime' => 123, + 'status' => 'FILLED', + 'fills' => [ + ['commission' => '0.0002', 'commissionAsset' => 'BTC', 'qty' => '0.004', 'price' => '5000.25'], + ['commission' => '0.0001', 'commissionAsset' => 'BTC', 'qty' => '0.004', 'price' => '6000.75'], + ], + ]; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'POST', + BinanceBuyService::ORDER_URL, + ) + ->willReturn($apiResponse) + ; + + $result = $this->service->initiateBuy($amount); + + static::assertSame(30000, $result->getFeesInSatoshis()); + static::assertSame('5500.5 '.$this->baseCurrency, $result->getDisplayAveragePrice()); + static::assertSame('0.0003 BTC', $result->getDisplayFeesSpent()); + static::assertSame($amount.' '.$this->baseCurrency, $result->getDisplayAmountSpent()); + static::assertSame('0.006 BTC', $result->getDisplayAmountBought()); + static::assertSame(600000, $result->getAmountInSatoshis()); + } + + /** + * @covers ::getAveragePrice + * @covers ::getCompletedBuyOrderFromResponse + * @covers ::getFeeInformationFromOrderInfo + * @covers ::initiateBuy + */ + public function testBuyButNotFilledYet(): void + { + $orderId = (string) random_int(100, 200); + + $apiResponse = [ + 'transactTime' => 123, + 'orderId' => $orderId, + 'status' => 'PARTIAL', + ]; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'POST', + BinanceBuyService::ORDER_URL, + ) + ->willReturn($apiResponse) + ; + + $this->expectException(PendingBuyOrderException::class); + $this->service->initiateBuy(10); + + /** @var PendingBuyOrderException $exception */ + $exception = $this->getExpectedException(); + static::assertSame($orderId, $exception->getOrderId()); + } + + /** + * @covers ::checkIfOrderIsFilled + * @covers ::getAveragePrice + * @covers ::getCompletedBuyOrderFromResponse + * @covers ::getFeeInformationFromOrderInfo + */ + public function testBuyFulfillsAfterCheck(): void + { + $amount = random_int(100, 200); + $orderId = (string) random_int(100, 200); + $time = time(); + + $getResponse = [ + 'executedQty' => '0.006', + 'cummulativeQuoteQty' => $amount, + 'transactTime' => 123, + 'orderId' => $orderId, + 'status' => 'FILLED', + 'time' => $time, + ]; + + $infoResponse = [ + ['commission' => '0.0002', 'commissionAsset' => 'BTC', 'qty' => '0.003', 'price' => '2000.25'], + ['commission' => '0.0001', 'commissionAsset' => 'BTC', 'qty' => '0.003', 'price' => '3000.75'], + ]; + + $this->client + ->expects(static::exactly(2)) + ->method('request') + ->withConsecutive( + [ + 'GET', + BinanceBuyService::ORDER_URL, + static::callback(function (array $options) use ($orderId) { + self::assertArrayHasKey('symbol', $options['body']); + self::assertSame(['security_type' => 'TRADE'], $options['extra']); + self::assertArrayHasKey('orderId', $options['body']); + self::assertSame($this->tradingPair, $options['body']['symbol']); + self::assertSame($orderId, $options['body']['orderId']); + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('body', $options); + + return true; + }), + ], + [ + 'GET', + 'api/v3/myTrades', + static::callback(function (array $options) use ($time) { + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('symbol', $options['body']); + self::assertSame($this->tradingPair, $options['body']['symbol']); + self::assertArrayHasKey('startTime', $options['body']); + self::assertArrayHasKey('body', $options); + self::assertSame($time, $options['body']['startTime']); + self::assertSame(['security_type' => 'USER_DATA'], $options['extra']); + + return true; + }), + ], + ) + ->willReturnOnConsecutiveCalls($getResponse, $infoResponse) + ; + + $result = $this->service->checkIfOrderIsFilled($orderId); + + static::assertSame(600000, $result->getAmountInSatoshis()); + static::assertSame(30000, $result->getFeesInSatoshis()); + static::assertSame('0.006 BTC', $result->getDisplayAmountBought()); + static::assertSame($amount.' '.$this->baseCurrency, $result->getDisplayAmountSpent()); + static::assertSame('2500.5 '.$this->baseCurrency, $result->getDisplayAveragePrice()); + static::assertSame('0.0003 BTC', $result->getDisplayFeesSpent()); + } + + /** + * @covers ::checkIfOrderIsFilled + * @covers ::getAveragePrice + * @covers ::getCompletedBuyOrderFromResponse + * @covers ::getFeeInformationFromOrderInfo + */ + public function testBuyIsStillNotFilled(): void + { + $amount = random_int(100, 200); + $orderId = (string) random_int(100, 200); + $time = time(); + + $getResponse = [ + 'executedQty' => '0.005', + 'cummulativeQuoteQty' => $amount, + 'transactTime' => 123, + 'orderId' => $orderId, + 'status' => 'PARTIAL', + 'time' => $time, + ]; + + $this->client + ->expects(static::exactly(1)) + ->method('request') + ->with( + 'GET', + BinanceBuyService::ORDER_URL, + static::callback(function (array $options) use ($orderId) { + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('body', $options); + self::assertSame(['security_type' => 'TRADE'], $options['extra']); + self::assertArrayHasKey('symbol', $options['body']); + self::assertSame($this->tradingPair, $options['body']['symbol']); + self::assertArrayHasKey('orderId', $options['body']); + self::assertSame($orderId, $options['body']['orderId']); + + return true; + }) + ) + ->willReturn($getResponse) + ; + + $this->expectException(PendingBuyOrderException::class); + + $this->service->checkIfOrderIsFilled($orderId); + + /** @var PendingBuyOrderException $exception */ + $exception = $this->getExpectedException(); + static::assertSame($orderId, $exception->getOrderId()); + } + + /** + * @covers ::cancelBuyOrder + */ + public function testOrderCancellation(): void + { + $orderId = 'oid'.random_int(1000, 2000); + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'DELETE', + BinanceBuyService::ORDER_URL, + static::callback(function (array $options) use ($orderId) { + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('body', $options); + self::assertSame(['security_type' => 'TRADE'], $options['extra']); + + self::assertArrayHasKey('symbol', $options['body']); + self::assertSame($this->tradingPair, $options['body']['symbol']); + + self::assertArrayHasKey('orderId', $options['body']); + self::assertSame($orderId, $options['body']['orderId']); + + return true; + }) + ) + ; + + $this->service->cancelBuyOrder($orderId); + } +} diff --git a/tests/Service/Binance/BinanceWithdrawServiceTest.php b/tests/Service/Binance/BinanceWithdrawServiceTest.php new file mode 100644 index 0000000..b52b23e --- /dev/null +++ b/tests/Service/Binance/BinanceWithdrawServiceTest.php @@ -0,0 +1,205 @@ +client = $this->createMock(BinanceClientInterface::class); + $this->service = new BinanceWithdrawService($this->client); + } + + /** + * @covers ::withdraw + */ + public function testWithdraw(): void + { + $address = 'a'.random_int(1000, 2000); + $amount = random_int(1000, 2000); + $responseID = 'id'.random_int(1000, 2000); + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'POST', + 'sapi/v1/capital/withdraw/apply', + static::callback(function (array $options) use ($amount, $address) { + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('security_type', $options['extra']); + self::assertSame('USER_DATA', $options['extra']['security_type']); + self::assertArrayHasKey('body', $options); + self::assertArrayHasKey('coin', $options['body']); + self::assertSame('BTC', $options['body']['coin']); + self::assertArrayHasKey('address', $options['body']); + self::assertSame($address, $options['body']['address']); + self::assertArrayHasKey('amount', $options['body']); + self::assertSame( + bcdiv((string) $amount, Bitcoin::SATOSHIS, Bitcoin::DECIMALS), + $options['body']['amount'] + ); + + return true; + }) + ) + ->willReturn(['id' => $responseID]) + ; + + $result = $this->service->withdraw($amount, $address); + + static::assertSame($result->getId(), $responseID); + static::assertSame($result->getNetAmount(), $amount); + static::assertSame($result->getRecipientAddress(), $address); + } + + /** + * @covers ::getAvailableBalance + */ + public function testAvailableBalance(): void + { + $balance = '0.00123'; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'GET', + 'api/v3/account', + static::callback(function (array $options) { + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('security_type', $options['extra']); + self::assertSame('USER_DATA', $options['extra']['security_type']); + + return true; + }) + ) + ->willReturn(['balances' => [['asset' => 'BTC', 'free' => $balance]]]) + ; + + static::assertSame(123000, $this->service->getAvailableBalance()); + } + + /** + * @covers ::getAvailableBalance + */ + public function testAvailableBalanceReturnsZero(): void + { + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'GET', + 'api/v3/account', + ) + ->willReturn(['balances' => [['asset' => 'ETH', 'free' => '5.000']]]) + ; + + static::assertSame(0, $this->service->getAvailableBalance()); + } + + /** + * @covers ::getWithdrawFeeInSatoshis + */ + public function testGetWithdrawalFee(): void + { + $fee = '0.0005'; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'GET', + self::URL_ASSET_DETAIL, + static::callback(function (array $options) { + self::assertArrayHasKey('extra', $options); + self::assertArrayHasKey('security_type', $options['extra']); + self::assertSame('USER_DATA', $options['extra']['security_type']); + + return true; + }) + ) + ->willReturn(['BTC' => ['withdrawStatus' => true, 'withdrawFee' => $fee]]) + ; + + static::assertSame(50000, $this->service->getWithdrawFeeInSatoshis()); + } + + /** + * @covers ::getWithdrawFeeInSatoshis + */ + public function testGetWithdrawalFeeButBitcoinIsMissing(): void + { + $fee = '0.0004'; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'GET', + self::URL_ASSET_DETAIL, + ) + ->willReturn(['ETH' => ['withdrawStatus' => true, 'withdrawFee' => $fee]]) + ; + + $this->expectExceptionMessage('BTC asset appears to be unknown on Binance'); + $this->expectException(BinanceClientException::class); + + $this->service->getWithdrawFeeInSatoshis(); + } + + /** + * @covers ::getWithdrawFeeInSatoshis + */ + public function testGetWithdrawalFeeButWithdrawalIsDisabled(): void + { + $fee = '0.0006'; + + $this->client + ->expects(static::once()) + ->method('request') + ->with( + 'GET', + self::URL_ASSET_DETAIL, + ) + ->willReturn(['BTC' => ['withdrawStatus' => false, 'withdrawFee' => $fee]]) + ; + + $this->expectExceptionMessage('withdrawal for BTC is disabled on Binance'); + $this->expectException(BinanceClientException::class); + + $this->service->getWithdrawFeeInSatoshis(); + } + + /** + * @covers ::supportsExchange + */ + public function testSupportsExchange(): void + { + static::assertTrue($this->service->supportsExchange('binance')); + static::assertFalse($this->service->supportsExchange('kraken')); + } +}