Een introductie: De noodzaak voor een nieuwe DCA-tool
Zoals veel Bitcoiners volg ik de Dollar Cost Averaging (DCA) strategie – elke maand een klein deel van mijn inkomen opzijzetten in Bitcoin om volatiliteit af te vlakken. Voorheen gebruikte ik hiervoor een handige geautomatiseerde service genaamd Bittr. Hun concept was simpel: stel een periodieke bankoverschrijving in, zij zetten het bij ontvangst om in Bitcoin en sturen het direct naar je wallet.
Helaas stopte Bittr als reactie op de AMLD5-richtlijn, waarbij ze principes en hoge registratiekosten aanvoerden. Toen ik zocht naar een vervanging, realiseerde ik me snel dat er geen direct beschikbare, zelf-gehoste, open-source alternatief was dat aan mijn eisen voldeed. Dus besloot ik er zelf een te bouwen: Bitcoin DCA, een gratis te gebruiken applicatie.
Deze post duikt in de technische reis, de uitdagingen die ik tegenkwam en de oplossingen die zijn geïmplementeerd tijdens het bouwen van deze tool.
Welk doel probeerde ik te bereiken?
De kernvereisten voor mijn Bitcoin DCA-tool waren:
- Geautomatiseerd Kopen: Moeiteloos en onbeheerd Bitcoin kopen volgens een schema.
- Ondersteuning voor Meerdere Exchanges: Communiceren met verschillende exchanges via hun API's.
- Geautomatiseerde Opnames: Periodiek aangekochte Bitcoin opnemen van de exchange naar een geconfigureerde wallet.
- Adresprivacy: Genereer voor elke opname een nieuw, ongebruikt wallet-adres met behulp van een Master Public Key (MPK / xPub).
- Portabiliteit: Draait gemakkelijk overal, zonder complexe PHP-installatieproblemen voor gebruikers. Docker leek de voor de hand liggende keuze.
Meerdere exchanges ondersteunen met Symfony service tags
Mijn persoonlijke favoriete exchange is BL3P, een rechttoe rechtaan Nederlands platform. Aanvankelijk ondersteunde Bitcoin DCA alleen BL3P. Om de tool echter veelzijdiger en toekomstbestendiger te maken, ontwierp ik het al vroeg om meerdere exchanges te ondersteunen.
Dit vereiste het abstraheren van de specifieke logica voor elke exchange. Ik definieerde verschillende PHP-interfaces die gemeenschappelijke acties vertegenwoordigen:
BuyServiceInterface
WithdrawServiceInterface
BalanceServiceInterface
WithdrawAddressProviderInterface
Elke exchange-specifieke implementatie (bijv. voor Bitvavo, BL3P, Kraken) implementeert deze interfaces en wordt getagd met Symfony's service tags in de configuratie:
# Voorbeeld service definities
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 }
# ... andere exchange services
Een coördinerende service (zoals BuyService
) ontvangt vervolgens alle
getagde implementaties via Symfony's Dependency Injection container, met
behulp van de !tagged_iterator
directive:
service.buy:
class: Jorijn\Bitcoin\Dca\Service\BuyService
arguments:
# Injecteert alle services getagd met 'exchange-buy-service'
- !tagged_iterator exchange-buy-service
# Injecteert de geconfigureerde exchange naam uit omgevingsvariabelen
- "%env(EXCHANGE)%"
Dit betekent dat de BuyService
zelf geen hardcoded kennis van specifieke
exchanges nodig heeft. Het itereert simpelweg door de geïnjecteerde services en
gebruikt degene die de door de gebruiker geconfigureerde exchange ondersteunt:
<?php // Vereenvoudigd voorbeeld
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) {
// Controleer of de huidige service implementatie de gekozen exchange ondersteunt
if ($registeredService->supportsExchange($this->configuredExchange)) {
return $registeredService->initiateBuy($amount);
}
}
// Als er geen geschikte service is gevonden
throw new NoExchangeAvailableException('Geen service beschikbaar voor geconfigureerde exchange: '.$this->configuredExchange);
}
}
Deze aanpak houdt de kernlogica schoon en maakt het toevoegen van ondersteuning voor nieuwe exchanges veel eenvoudiger – maak gewoon een nieuwe service die de interfaces implementeert en tag deze correct.
Een nieuw wallet-adres genereren voor elke opname
Een fundamentele privacypraktijk in Bitcoin is het gebruik van een nieuw adres voor elke inkomende transactie. Het hergebruiken van adressen stelt anderen in staat transacties te koppelen, wat mogelijk je saldo en bestedingspatroon onthult door analyse van de openbare blockchain.
De meeste moderne Bitcoin wallets zijn Hierarchical Deterministic (HD) wallets. Hiermee kun je een enorme boomstructuur van publieke/private sleutelparen genereren vanuit één enkele master seed. Je kunt de Master Public Key (MPK of xPub) delen zonder de privésleutels in gevaar te brengen. Mijn doel was om de verstrekte MPK te gebruiken om elke keer dat de tool een opname deed, een nieuw, ongebruikt adres uit deze hiërarchie te genereren.
Dit bracht een uitdaging met zich mee: hoe kon de tool weten of een afgeleid adres al eerder was gebruikt? De blockchain rechtstreeks bevragen introduceert complexiteit:
- Infrastructuur: Het vereist ofwel een verbinding met een gehoste blockchain indexer service (wat ik wilde vermijden om de tool zelfstandig te houden) ofwel dat de gebruiker zijn eigen indexer draait (een aanzienlijke last).
- Privacy/Vertrouwen: Verbinding maken met een externe service om adressen te controleren, roept vertrouwensproblemen op binnen de privacybewuste Bitcoin-gemeenschap. Ik wilde niet dat de tool (of enige dienst die ik zou kunnen aanbieden) kennis zou hebben van gebruikersadressen.
Ik koos voor een pragmatische, zij het iets minder elegante, oplossing die het
vertrouwen behoudt. Ik raad gebruikers aan een gloednieuwe wallet aan te
maken, exclusief voor het ontvangen van DCA-opnames. Met deze aanname kan de
tool beginnen met het afleiden van adressen vanaf index 0 (m/0/0
), een interne
teller lokaal opslaan (binnen het Docker-volume) en de volgende adresindex
gebruiken voor elke volgende opname (m/0/1
, m/0/2
, enz.). Dit vermijdt
externe afhankelijkheden en gaat ervan uit dat de gebruiker de aanbeveling voor
een aparte wallet volgt, waardoor gegenereerde adressen inderdaad nieuw zijn.
Nadenken over hoe de applicatie betrouwbaar te draaien
Het verpakken van een PHP-applicatie zodat deze consistent draait op verschillende gebruikersconfiguraties kan lastig zijn. PHP-versies, beschikbare extensies en systeemconfiguraties lopen sterk uiteen. Vertrouwen op gebruikers die de juiste omgeving hebben (zoals PHP 7.4+ voor de moderne features die ik wilde gebruiken) is problematisch.
Docker was de duidelijke oplossing. Het maakt het mogelijk om de applicatie met al zijn afhankelijkheden (inclusief de juiste PHP-versie en extensies) te verpakken in een container image. Deze image draait voorspelbaar op elk systeem waarop Docker is geïnstalleerd, ongeacht de configuratie van het host-besturingssysteem. Aangezien de tool geen GUI nodig had, was een Command Line Interface (CLI) applicatie binnen een Docker-container perfect.
Echter, Docker-containers zijn 'ephemeral' – ze behouden standaard geen status tussen runs. Dit vereist zorgvuldige afhandeling van configuratie en data-persistentie. Symfony's uitstekende ondersteuning voor omgevingsvariabelen maakt configuratie eenvoudig binnen Docker. Voor gegevens die wel persistent moeten zijn (zoals de opnameadres-index), kunnen gebruikers een Docker-volume koppelen aan een lokale map voor opslag.
Om betrouwbaarheid te garanderen en problemen vroegtijdig op te sporen,
gebruikte ik Docker's multi-stage builds in de Dockerfile
:
# Stage 1: Installeer dependencies met Composer
FROM composer:2 as composer_vendor
# ... kopieer composer bestanden en installeer dependencies ...
# Deze stage creëert de /app/vendor map
# Stage 2: Basis PHP omgeving
FROM php:7.4-cli-alpine as bitcoin_dca_base
# ... installeer benodigde PHP extensies ...
# Kopieer dependencies van de eerste stage
COPY --from=composer_vendor /app/vendor /app/vendor
# Kopieer applicatiecode
COPY . /app
WORKDIR /app
# Stage 3: Draai tests
FROM bitcoin_dca_base as bitcoin_dca_test
# ... configureer PHP voor development/testen ...
# Draai PHPUnit tests
RUN vendor/bin/phpunit
# Stage 4: Productie image
FROM bitcoin_dca_base as bitcoin_dca_prod
# Sla dev configuratie over, voeg evt. productie optimalisaties toe
# Stel entrypoint in, etc.
# Deze stage wordt alleen gebouwd als de test stage slaagt
Deze Dockerfile
definieert vier stadia:
- Dependency Stage: Installeert PHP-afhankelijkheden met Composer. Alleen
de
vendor
-map is later nodig. - Base Stage: Zet de juiste PHP-versie (7.4-alpine) op en installeert de benodigde extensies. Kopieert code en afhankelijkheden.
- Test Stage: Bouwt voort op de basisstage, voegt ontwikkelingsinstellingen toe en voert alle geautomatiseerde tests (PHPUnit) uit. Als tests mislukken, stopt de build hier.
- Production Stage: Bouwt ook voort op de basisstage (garandeert dezelfde kernomgeving) maar slaat testconfiguraties over. Voegt definitieve productie-instellingen en entry points toe. Docker creëert deze uiteindelijke image alleen als alle voorgaande stadia, inclusief tests, succesvol zijn.
Het resultaat is een goed geteste Docker-image die consistent zou moeten draaien voor iedereen, eenvoudig geconfigureerd via omgevingsvariabelen:
# Voorbeeld commando om te draaien
$ docker run --rm -it \
-e EXCHANGE=BL3P \
-e BL3P_API_KEY=YourApiKey \
-e BL3P_PRIVATE_KEY=YourPrivateKey \
# ... andere env vars voor bedrag, frequentie, wallet xPub ...
jorijn/bitcoin-dca:latest buy 10 # Voorbeeld: koop voor 10 EUR
Het Raspberry Pi probleem: 32-bit perikelen
Kort na de publieke lancering begonnen er problemen binnen te komen met betrekking tot het draaien van de tool op Raspberry Pi apparaten. Dit zijn populaire keuzes voor tools zoals Bitcoin DCA omdat het energiezuinige, altijd-aan Linux-apparaten zijn. Het probleem? De meeste Raspberry Pi-modellen gebruiken ARMv7 architectuur, wat 32-bit is.
Dit veroorzaakte twee belangrijke obstakels:
Obstakel 1: Grote getallen in 32-bit PHP
De PHP-bibliotheek die ik aanvankelijk gebruikte voor HD-sleutelafleiding (Bit-Wasp/bitcoin-php) is afhankelijk van berekeningen met grote getallen. Helaas kunnen 32-bit versies van PHP geen getallen verwerken die groot genoeg zijn voor deze cryptografische operaties. Het afleiden van sleutels met een index boven een bepaald klein getal mislukte. Na vele frustrerende avonden zoeken naar workarounds binnen PHP, kon ik geen betrouwbare oplossing vinden.
Echter, het Python ecosysteem heeft volwassen Bitcoin-bibliotheken die dit wel correct afhandelen op 32-bit systemen. In samenwerking met een vriend ontwikkelden we snel een eenvoudig Python-script. Dit script accepteert een MPK, een afleidingspad-offset en een lengte, en retourneert de vereiste lijst met afgeleide adressen.
Obstakel 2: PHP en Python overbruggen
Hoe kon de hoofd-PHP-applicatie (draaiend in Docker) betrouwbaar communiceren met dit aparte Python-script? Standaard inter-procescommunicatiemethoden (zoals HTTP API's of TCP sockets) voelden te complex aan gezien de container-omgeving.
Ik koos voor een eenvoudigere aanpak: het uitvoeren van het Python-script als een command-line proces vanuit de PHP-applicatie. Dankzij Docker kon ik ervoor zorgen dat het Python-script en zijn afhankelijkheden aanwezig waren binnen de container image op een bekende locatie.
Obstakel 3: Gracieuze Fallback
Hoe kon de PHP-applicatie detecteren wanneer deze op een 32-bit systeem draaide en automatisch het Python-script gebruiken in plaats van de native (maar falende) PHP-bibliotheek?
Wederom boden Symfony's service tags gecombineerd met een Factory pattern een elegante oplossing. Ik definieerde een interface voor adresafleiding:
<?php
namespace Jorijn\Bitcoin\Dca\Component;
interface AddressFromMasterPublicKeyComponentInterface
{
/** Leidt een adres af op basis van de master public key en pad */
public function derive(string $masterPublicKey, string $path = '0/0'): string;
/** Controleert of dit afleidingscomponent wordt ondersteund op het huidige systeem */
public function supported(): bool;
}
Ik creëerde twee implementaties:
AddressFromMasterPublicKeyComponent
: Gebruikt de native PHP-bibliotheek (Bit-Wasp).ExternalAddressFromMasterPublicKeyComponent
: Roept het externe Python-script aan viashell_exec
of iets dergelijks.
Door gebruik te maken van geprioriteerde tags in de serviceconfiguratie:
# Native PHP implementatie (voorkeur)
component.derive_from_master_public_key_bitwasp:
class: Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponent
tags:
# Hogere prioriteit (minder negatief getal)
- { name: derive-from-master-public-key, priority: -500 }
# Externe Python script fallback
component.derive_from_master_public_key_external:
class: Jorijn\Bitcoin\Dca\Component\ExternalAddressFromMasterPublicKeyComponent
tags:
# Lagere prioriteit
- { name: derive-from-master-public-key, priority: -1000 }
De supported()
methode van de native PHP-component controleert of het systeem
64-bit is:
<?php // In AddressFromMasterPublicKeyComponent
public function supported(): bool
{
// Dit component werkt alleen op 64-bit PHP (\PHP_INT_SIZE === 8)
return \PHP_INT_SIZE === 8;
}
De supported()
methode van de Python-component retourneert simpelweg true
(ervan uitgaande dat Python altijd beschikbaar is in de Docker image).
Een Factory service wordt vervolgens geïnjecteerd met alle getagde
componenten, gesorteerd op prioriteit. Het itereert erdoorheen en retourneert de
eerste waarvan de supported()
methode true
retourneert:
<?php // Vereenvoudigde Factory
namespace Jorijn\Bitcoin\Dca\Factory;
// ... use statements ...
class DeriveFromMasterPublicKeyComponentFactory
{
/** @var AddressFromMasterPublicKeyComponentInterface[]|iterable */
protected iterable $availableComponents;
// Componenten worden hier geïnjecteerd door Symfony, gesorteerd op prioriteit
public function __construct(iterable $availableComponents)
{
$this->availableComponents = $availableComponents;
}
public function createDerivationComponent(): AddressFromMasterPublicKeyComponentInterface
{
foreach ($this->availableComponents as $availableComponent) {
if (true === $availableComponent->supported()) {
// Retourneer het eerste ondersteunde component dat wordt gevonden
return $availableComponent;
}
}
throw new NoDerivationComponentAvailableException('Geen derivation component beschikbaar voor dit systeem');
}
}
Services die adresafleiding nodig hebben, vragen simpelweg de factory om een implementatie, zonder te hoeven weten of de native PHP of de Python fallback wordt gebruikt:
# Service definitie voor de factory zelf
factory.derive_from_master_public_key.component:
class: Jorijn\Bitcoin\Dca\Factory\DeriveFromMasterPublicKeyComponentFactory
arguments:
# Injecteer alle getagde componenten, gesorteerd op prioriteit
- !tagged_iterator derive-from-master-public-key
# Definieer de daadwerkelijke service die elders in de applicatie wordt gebruikt
# Deze wordt gecreëerd door de createDerivationComponent methode van de factory aan te roepen
component.derive_from_master_public_key:
class: Jorijn\Bitcoin\Dca\Component\AddressFromMasterPublicKeyComponentInterface
factory:
[
"@factory.derive_from_master_public_key.component",
"createDerivationComponent",
]
Deze opzet zorgt ervoor dat de tool automatisch werkt op zowel 64-bit als 32-bit (Raspberry Pi) systemen, waarbij indien mogelijk prioriteit wordt gegeven aan de snellere native oplossing.
Waarom codekwaliteit en tests ertoe doen
Ik snap het; tests schrijven kan vervelend aanvoelen. Het wordt vaak niet gezien als het "leuke" deel van ontwikkelen, en het maken van goede unit tests die alle edge cases dekken, kan soms net zo lang duren als het schrijven van de feature zelf.
Echter, tests zijn cruciaal voor onderhoudbaarheid en het met vertrouwen kunnen refactoren. Projecten evolueren, eisen veranderen, bibliotheken worden bijgewerkt. Unit tests fungeren als een vangnet en verifiëren dat wijzigingen niet onbedoeld bestaande, eerder werkende functionaliteit breken. Ze stellen ons in staat om code met veel meer vertrouwen te refactoren en te verbeteren.
De meeste ontwikkelaars erkennen het belang van consistente codekwaliteit, zelfs als het soms als een verplichting voelt. Aangezien ik voornamelijk alleen aan Bitcoin DCA werk, heb ik geen collega's die aandringen op kwaliteit. Dus rekruteerde ik een geautomatiseerde vervanging: SonarQube (via SonarCloud). Het fungeert als een kwaliteitsbewaker en inspecteert de code continu op onderhoudbaarheidsproblemen, potentiële bugs en beveiligingskwetsbaarheden. Cruciaal is dat de resultaten openbaar verifieerbaar zijn.
Op het moment van schrijven van dit artikel was 92,3% van de codebase van Bitcoin DCA gedekt door tests, met minimale problemen met betrekking tot onderhoudbaarheid of technische schuld. Ik ben oprecht trots op dit project, omdat het mijn toewijding aan hoge codekwaliteit en grondige testdekking weerspiegelt.
Conclusie
Het bouwen van Bitcoin DCA was een belangrijke leerervaring. Ik had niet verwacht dat ik in 2020 beperkingen van 32-bit architectuur zou aanpakken! De steun van de Nederlandse Bitcoin-gemeenschap was fantastisch, en kort na de lancering kreeg de tool zelfs vermeldingen in Nederlandse crypto nieuwsmedia.
Wat nu?
In de komende maanden (destijds) wilde ik functies toevoegen zoals:
- Notificaties voor voltooide orders via e-mail of instant messaging.
- Ondersteuning voor Kraken's Lightning network opnames (die ze aankondigden voor 2021).
Probeer Bitcoin DCA
Ik heb gedetailleerde documentatie geschreven over hoe je Bitcoin DCA zelf kunt instellen en gebruiken. Bezoek gerust de repository om de code te downloaden of te inspecteren hier op GitHub.