Technical background on Bitcoin DCA: Building a self-hosted tool

Automating my Bitcoin Dollar Cost Averaging (DCA) became a personal project in early 2020 after my go-to service, Bittr, unfortunately closed down due to the AMLD5 directive. To fill that gap for myself and potentially others, I built Bitcoin DCA, a self-hosted, open-source tool. This post dives into the technical background – why I built it, the unexpected challenges faced along the way (hello, 32-bit Raspberry Pi!), and how I solved them.

An introduction: The need for a new DCA tool

Like many Bitcoiners, I follow the Dollar Cost Averaging (DCA) strategy – putting aside a small portion of my income into Bitcoin every month to smooth out volatility. I used to rely on a convenient automated service called Bittr. Their concept was simple: set up a recurring bank transfer, they convert it to Bitcoin upon arrival, and send it straight to your wallet.

Unfortunately, Bittr shut down in response to the AMLD5 directive, citing principles and hefty registration fees. When I looked for a replacement, I quickly realized there wasn't a readily available self-hosted, open-source alternative that met my needs. So, I decided to build one myself: Bitcoin DCA, a free-to-use application.

This post dives into the technical journey, the challenges faced, and the solutions implemented while building this tool.

What goal was I trying to achieve?

The core requirements for my Bitcoin DCA tool were:

  • Automated Buying: Effortlessly buy Bitcoin unattended on a schedule.
  • Multi-Exchange Support: Communicate with various exchanges via their APIs.
  • Automated Withdrawals: Recurringly withdraw purchased Bitcoin from the exchange to a configured wallet.
  • Address Privacy: Generate a fresh, unused wallet address for each withdrawal using a Master Public Key (MPK / xPub).
  • Portability: Run easily everywhere, without complex PHP setup hassles for users. Docker seemed the obvious choice.

Supporting multiple exchanges using Symfony service tags

My personal favorite exchange is BL3P, a straightforward Dutch platform. Initially, Bitcoin DCA only supported BL3P. However, to make the tool more versatile and future-proof, I designed it to support multiple exchanges from early on.

This required abstracting the specific logic for each exchange. I defined several PHP interfaces representing common actions:

  • BuyServiceInterface
  • WithdrawServiceInterface
  • BalanceServiceInterface
  • WithdrawAddressProviderInterface

Each exchange-specific implementation (e.g., for Bitvavo, BL3P, Kraken) implements these interfaces and is tagged using Symfony's service tags in the configuration:

# Example service definitions
service.buy.bitvavo:
  class: Jorijn\Bitcoin\Dca\Service\Bitvavo\BitvavoBuyService
  tags:
    - { name: exchange-buy-service }

service.buy.bl3p:
  class: Jorijn\Bitcoin\Dca\Service\Bl3p\Bl3pBuyService
  tags:
    - { name: exchange-buy-service }
# ... other exchange services

A coordinating service (like BuyService) then receives all tagged implementations via Symfony's Dependency Injection container, using the !tagged_iterator directive:

service.buy:
  class: Jorijn\Bitcoin\Dca\Service\BuyService
  arguments:
    # Injects all services tagged 'exchange-buy-service'
    - !tagged_iterator exchange-buy-service
    # Injects the configured exchange name from environment variables
    - "%env(EXCHANGE)%"

This means the BuyService itself doesn't need hardcoded knowledge of specific exchanges. It simply iterates through the injected services and uses the one that supports the exchange configured by the user:

<?php // Simplified example

namespace Jorijn\Bitcoin\Dca\Service;

// ... use statements ...

class BuyService
{
    /** @var BuyServiceInterface[]|iterable */
    protected iterable $registeredServices;
    protected string $configuredExchange;

    public function __construct(iterable $registeredServices, string $configuredExchange)
    {
        $this->registeredServices = $registeredServices;
        $this->configuredExchange = $configuredExchange;
    }

    public function buy(int $amount, string $tag = null): CompletedBuyOrder
    {
        foreach ($this->registeredServices as $registeredService) {
            // Check if the current service implementation supports the user's chosen exchange
            if ($registeredService->supportsExchange($this->configuredExchange)) {
                return $registeredService->initiateBuy($amount);
            }
        }

        // If no suitable service was found
        throw new NoExchangeAvailableException('No service available for configured exchange: '.$this->configuredExchange);
    }
}

This approach keeps the core logic clean and makes adding support for new exchanges much easier – just create a new service implementing the interfaces and tag it correctly.

Generating a new wallet address for each withdrawal

A fundamental privacy practice in Bitcoin is to use a new address for every incoming transaction. Reusing addresses allows others to link transactions, potentially revealing your balance and spending habits by analyzing the public blockchain.

Most modern Bitcoin wallets are Hierarchical Deterministic (HD) wallets. They allow generating a vast tree of public/private key pairs from a single master seed. You can share the Master Public Key (MPK or xPub) without compromising the private keys. My goal was to use the provided MPK to generate a new, unused address from this hierarchy each time the tool performed a withdrawal.

This presented a challenge: how could the tool know if a derived address had been used before? Querying the blockchain directly introduces complexity:

  • Infrastructure: It would require either a connection to a hosted blockchain indexer service (which I wanted to avoid, keeping the tool self-contained) or requiring the user to run their own indexer (a significant burden).
  • Privacy/Trust: Connecting to an external service to check addresses raises trust issues within the privacy-conscious Bitcoin community. I didn't want the tool (or any service I might provide) to have knowledge of user addresses.

I opted for a pragmatic, if slightly less elegant, solution that maintains trustlessness. I recommend users create a brand new wallet dedicated solely to receiving DCA withdrawals. With this assumption, the tool can start deriving addresses from index 0 (m/0/0), increment an internal counter stored locally (within the Docker volume), and use the next address index for each subsequent withdrawal (m/0/1, m/0/2, etc.). This avoids external dependencies and assumes the user follows the recommendation for a dedicated wallet, ensuring generated addresses are indeed fresh.

Thinking about how to run the application reliably

Packaging a PHP application to run consistently across different user setups can be tricky. PHP versions, available extensions, and system configurations vary widely. Relying on users to have the correct environment (like PHP 7.4+ for the modern features I wanted to use) is problematic.

Docker was the clear solution. It allows packaging the application with all its dependencies (including the correct PHP version and extensions) into a container image. This image runs predictably on any system with Docker installed, regardless of the host OS configuration. Since the tool didn't need a GUI, a Command Line Interface (CLI) application within a Docker container was perfect.

However, Docker containers are ephemeral – they don't retain state between runs by default. This requires careful handling of configuration and data persistence. Symfony's excellent support for environment variables makes configuration easy within Docker. For data that does need to persist (like the withdrawal address index counter), users can map a Docker volume to a local directory for storage.

To ensure reliability and catch issues early, I used Docker's multi-stage builds in the Dockerfile:

# Stage 1: Install dependencies using Composer
FROM composer:2 as composer_vendor
# ... copy composer files and install dependencies ...
# This stage creates the /app/vendor directory

# Stage 2: Base PHP environment
FROM php:7.4-cli-alpine as bitcoin_dca_base
# ... install required PHP extensions ...
# Copy dependencies from the first stage
COPY --from=composer_vendor /app/vendor /app/vendor
# Copy application code
COPY . /app
WORKDIR /app

# Stage 3: Run tests
FROM bitcoin_dca_base as bitcoin_dca_test
# ... configure PHP for development/testing ...
# Run PHPUnit tests
RUN vendor/bin/phpunit

# Stage 4: Production image
FROM bitcoin_dca_base as bitcoin_dca_prod
# Skip dev configuration, maybe add production optimisations
# Set entrypoint, etc.
# This stage is only built if the test stage succeeds

This Dockerfile defines four stages:

  1. Dependency Stage: Installs PHP dependencies using Composer. Only the vendor folder is needed later.
  2. Base Stage: Sets up the correct PHP version (7.4-alpine) and installs necessary extensions. Copies code and dependencies.
  3. Test Stage: Builds upon the base stage, adds development settings, and runs all automated tests (PHPUnit). If tests fail, the build stops here.
  4. Production Stage: Also builds upon the base stage (ensuring the same core environment) but skips test configurations. Adds final production settings and entry points. Docker only creates this final image if all previous stages, including tests, succeed.

The result is a well-tested Docker image that should run consistently for everyone, configured simply via environment variables:

# Example run command
$ docker run --rm -it \
    -e EXCHANGE=BL3P \
    -e BL3P_API_KEY=YourApiKey \
    -e BL3P_PRIVATE_KEY=YourPrivateKey \
    # ... other env vars for amount, frequency, wallet xPub ...
    jorijn/bitcoin-dca:latest buy 10 # Example: buy 10 EUR worth

The Raspberry Pi problem: 32-bit woes

Shortly after the public launch, issues started appearing related to running the tool on Raspberry Pi devices. These are popular choices for running tools like Bitcoin DCA because they are low-power, always-on Linux devices. The problem? Most Raspberry Pi models use ARMv7 architecture, which is 32-bit.

This caused two main obstacles:

Obstacle 1: Big Integers in 32-bit PHP

The PHP library I initially used for HD key derivation (Bit-Wasp/bitcoin-php) relies on calculations involving large integers. Unfortunately, 32-bit versions of PHP cannot reliably handle integers large enough for these cryptographic operations. Deriving keys using an index beyond a certain small number failed. After many frustrating nights searching for workarounds within PHP, I couldn't find a reliable fix.

However, the Python ecosystem has mature Bitcoin libraries that do handle this correctly on 32-bit systems. In collaboration with a friend, we quickly developed a simple Python script. This script accepts an MPK, a derivation path offset, and a length, and returns the required list of derived addresses.

Obstacle 2: Bridging PHP and Python

How could the main PHP application (running in Docker) communicate with this separate Python script reliably? Standard inter-process communication methods (like HTTP APIs or TCP sockets) felt overly complex given the containerized environment.

I settled on a simpler approach: executing the Python script as a command-line process from within the PHP application. Thanks to Docker, I could ensure the Python script and its dependencies were present within the container image at a known location.

Obstacle 3: Graceful Fallback

How could the PHP application detect when it was running on a 32-bit system and automatically use the Python script instead of the native (but failing) PHP library?

Once again, Symfony's service tags combined with a Factory pattern provided an elegant solution. I defined an interface for address derivation:

<?php

namespace Jorijn\Bitcoin\Dca\Component;

interface AddressFromMasterPublicKeyComponentInterface
{
    /** Derives an address based on the master public key and path */
    public function derive(string $masterPublicKey, string $path = '0/0'): string;

    /** Checks if this derivation component is supported on the current system */
    public function supported(): bool;
}

I created two implementations:

  1. AddressFromMasterPublicKeyComponent: Uses the native PHP library (Bit-Wasp).
  2. ExternalAddressFromMasterPublicKeyComponent: Calls the external Python script via shell_exec or similar.

Using prioritized tags in the service configuration:

# Native PHP implementation (preferred)
component.derive_from_master_public_key_bitwasp:
  class: Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponent
  tags:
    # Higher priority (less negative number)
    - { name: derive-from-master-public-key, priority: -500 }

# External Python script fallback
component.derive_from_master_public_key_external:
  class: Jorijn\Bitcoin\Dca\Component\ExternalAddressFromMasterPublicKeyComponent
  tags:
    # Lower priority
    - { name: derive-from-master-public-key, priority: -1000 }

The native PHP component's supported() method checks if the system is 64-bit:

<?php // In AddressFromMasterPublicKeyComponent

public function supported(): bool
{
    // This component only works on 64-bit PHP (\PHP_INT_SIZE === 8)
    return \PHP_INT_SIZE === 8;
}

The Python component's supported() method simply returns true (assuming Python is always available in the Docker image).

A Factory service is then injected with all tagged components, ordered by priority. It iterates through them and returns the first one whose supported() method returns true:

<?php // Simplified Factory

namespace Jorijn\Bitcoin\Dca\Factory;

// ... use statements ...

class DeriveFromMasterPublicKeyComponentFactory
{
    /** @var AddressFromMasterPublicKeyComponentInterface[]|iterable */
    protected iterable $availableComponents;

    // Components are injected here by Symfony, ordered by priority
    public function __construct(iterable $availableComponents)
    {
        $this->availableComponents = $availableComponents;
    }

    public function createDerivationComponent(): AddressFromMasterPublicKeyComponentInterface
    {
        foreach ($this->availableComponents as $availableComponent) {
            if (true === $availableComponent->supported()) {
                // Return the first supported component found
                return $availableComponent;
            }
        }

        throw new NoDerivationComponentAvailableException('No derivation component is available for this system');
    }
}

Services needing address derivation simply ask the factory for an implementation, without needing to know whether the native PHP or the Python fallback is being used:

# Service definition for the factory itself
factory.derive_from_master_public_key.component:
  class: Jorijn\Bitcoin\Dca\Factory\DeriveFromMasterPublicKeyComponentFactory
  arguments:
    # Inject all tagged components, ordered by priority
    - !tagged_iterator derive-from-master-public-key

# Define the actual service used elsewhere in the application
# It gets created by calling the factory's createDerivationComponent method
component.derive_from_master_public_key:
  class: Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponentInterface
  factory:
    [
      "@factory.derive_from_master_public_key.component",
      "createDerivationComponent",
    ]

This setup ensures the tool works automatically on both 64-bit and 32-bit (Raspberry Pi) systems, prioritizing the faster native solution when possible.

Why code quality and tests matter

I get it; writing tests can feel tedious. It's often not seen as the "fun" part of development, and crafting good unit tests covering all edge cases can sometimes take as long as writing the feature itself.

However, tests are crucial for maintainability and confident refactoring. Projects evolve, requirements change, libraries get updated. Unit tests act as a safety net, verifying that changes don't unintentionally break existing, previously working functionality. They allow us to refactor and improve code with much greater confidence.

Most developers recognize the importance of consistent code quality, even if it sometimes feels like a chore. Since I work primarily alone on Bitcoin DCA, I don't have colleagues pushing for quality. So, I recruited an automated replacement: SonarQube (via SonarCloud). It acts as a quality gatekeeper, continuously inspecting the code for maintainability issues, potential bugs, and security vulnerabilities. Crucially, the results are publicly verifiable.

At the time of writing this article, 92.3% of Bitcoin DCA’s codebase was covered by tests, with minimal issues related to maintainability or technical debt. I’m genuinely proud of this project as it reflects my commitment to high code quality and thorough test coverage.

Conclusion

Building Bitcoin DCA was a significant learning experience. I didn't expect to be tackling 32-bit architecture limitations in 2020! The support from the Dutch Bitcoin community was fantastic, and shortly after launch, the tool even received mentions in Dutch crypto news media.

What’s next?

In the next couple of months (at the time), I was looking to add features like:

  • Notifications for completed orders via email or instant messaging.
  • Support for Kraken's Lightning network withdrawals (which they announced for 2021).

Give Bitcoin DCA a try

I've written detailed documentation on how to set up and use Bitcoin DCA yourself. Feel free to visit the repository to download or inspect the code here on GitHub.