Sitemap

Building a Reliable Crypto Balance Manager using Laravel

7 min readMay 31, 2026

--

Press enter or click to view image in full size

Reliable balance management is a deceptively complex problem. At first glance, operations like “add funds” or “withdraw funds” appear trivial. In reality, they must operate correctly under concurrency, retries, partial failures, and strict financial invariants.

This article presents a production-style wallet system implemented in PHP (Laravel 11, PHP 8.3) and expands it into a broader set of engineering principles for building financially correct systems. The focus is not only on implementation, but on why certain patterns — such as immutable ledgers, idempotency, and state machines — are necessary.

The key idea underpinning the entire system is simple:

Balance is a projection. The ledger is truth.

The Real Problem: Concurrency and Consistency

Most wallet systems fail not because of cryptography, but because of race conditions.

Consider a wallet with 100 units and two concurrent withdrawals (80 and 50). Without proper synchronization, both may succeed, resulting in a negative balance. The system does not crash — it silently corrupts financial state.

A classic lost update scenario:

T1: Read 100 => Check ≥80 => Plan to write 20
T2: Read 100 => Check ≥50 => Plan to write 50

Both succeed → final balance = -30.

Nothing crashed. The system behaved exactly as written.

Wallet systems are not CRUD applications.
They are financial consistency systems operating in unreliable distributed environments.

Key Principle: Balance is a Projection, Ledger is Truth

Press enter or click to view image in full size

System Architecture

1. High-Level Architecture

Press enter or click to view image in full size

WalletService is a readonly class orchestrating logic:

readonly class WalletService
{
public function __construct(
private WalletRepositoryInterface $walletRepository,
private PrecisionRepositoryInterface $precisionRepository,
private OperationRepositoryInterface $operationRepository,
) {}
}

This design allows swapping implementations (e.g., for testing) and keeps business logic clean of database details.

2. ER Diagram

Core Models:

Press enter or click to view image in full size

The Operation model uses soft deletes to never hard-delete financial records, preserving auditability.

3. Operation State Machine

Press enter or click to view image in full size

States:

  • OS_INPROCESS — operation created, balance unchanged
  • OS_COMPLETE — operation completed, balance updated
  • OS_FAILED — processing error

This supports asynchronous deposits (e.g., waiting for blockchain confirmations).

Deep Dive into Components

1. Service Layer: WalletService

public function addBalance(
string $amount,
string $walletUuid,
OpStateEnum $opState = OpStateEnum::OS_COMPLETE,
?Operation $operation = null
): ?Operation {
$amount = $this->validate(
amount: $amount,
walletUuid: $walletUuid,
);

return $this->walletRepository->credit(
amount: $amount,
walletUuid: $walletUuid,
opState: $opState,
operation : $operation
);
}

Validation includes:

  • UUID validity
  • Wallet existence
  • Precision support for currency
  • Minimum amount threshold

After validation, it delegates to walletRepository->credit() or debit(). The actual database transaction (begin, update wallet, insert operation, commit) is hidden inside the repository.

2. Precision Handling: Why String + BCMath?

Press enter or click to view image in full size

Example:

$balance = bcsub($balance, $amount, 8);
bccomp($operation->amount, $amount) === 0 // precise comparison

This is essential for comparing decimal strings without floating-point errors.

3. Concurrency Control: Pessimistic Locking

Press enter or click to view image in full size

Code with locking (\App\Repositories\WalletRepository::credit):

return DB::transaction(function () use ($amount, $walletUuid, $opState, $operation) {
[$wallet, $precisionDTO] = $this->getWalletAndPrecision(
walletUuid: $walletUuid,
lockForUpdate: true
);

if ($operation === null) {
Wallet::query()->whereKey($wallet->id)->increment('amount', $amount);

return $this->operationRepository->add(
new OperationDTO(
uuid: Str::uuid()->toString(),
amount: $amount,
precision: $precisionDTO,
walletId: $wallet->id,
opType: OpTypeEnum::OP_TYPE_CREDIT,
opState: $opState,
createdAt: now(),
)
);
}

return $this->changeOperationState($operation, $opState, $wallet, $precisionDTO);
}, 3);

This serializes access. The second request waits and sees the updated state.

Correctness > Throughput in financial systems.

4. Idempotency

Without idempotency, retrying a request creates duplicate deposits.

$existing = Operation::where('idempotency_key', $key)->first();

if ($existing) {
return $existing; // return the same result
}
// create new operation

Recommendation: require an Idempotency-Key header for API deposits and check against the operations.data JSON field.

Get bugfix666’s stories in your inbox

Join Medium for free to get updates from this writer.

Exactly-once execution is usually an illusion. Reliable systems implement at-least-once delivery + idempotent handling.

5. CLI Command: Problematic Pattern

public function handle(WalletService $walletService)
{
$amount = $this->argument('amount');
$walletUuid = $this->argument('wallet');

for ($i = 0; $i < 100; $i++) { // ⚠️ test artifact
$operation = $walletService->addBalance(
amount: $amount,
walletUuid: $walletUuid,
opState: OpStateEnum::OS_INPROCESS
);

BalanceProcessCallbackJob::dispatch(
new BalanceProcessCallbackDTO(
opState: OpStateEnum::OS_COMPLETE,
operationUuid: $operation->uuid
)
);
}
}

Problems:

Press enter or click to view image in full size

Recommendation: remove the loop; accept only one deposit per command invocation.

Testing Strategy

1. Functional Tests

$amount = '12.345678';
$operation = $walletService->addBalance($amount, $walletUuid);

$this->assertTrue(bccomp($operation->amount, $amount) === 0);

$wallet = $walletService->findByUuid($walletUuid);
$this->assertTrue(bccomp($wallet->amount, $amount) === 0);

Tests include:

  • testWalletAddBalance — deposit, verify, withdraw, balance returns to zero
  • testWalletFails — withdraw from empty wallet → exception
  • API tests for /api/v1/wallet/add-balance

2. What’s Missing

  • Unit tests with mocked repositories
  • Concurrency tests (2 parallel withdrawals)
  • Edge precision value tests

Reconciliation: The Safety Net

Even the best systems drift. Reconciliation is not a bug — it’s operational maturity.

Press enter or click to view image in full size

What to reconcile:

Press enter or click to view image in full size

Any mismatch triggers alerts.

Monitoring Financial Metrics

Press enter or click to view image in full size

Financial alerts are more important than server alerts.

Comparison of Approaches

Press enter or click to view image in full size

The choice depends on needs: for BTC/ETH — string + BCMath; for USD fiat — integer cents is acceptable.

Production Readiness Checklist

Press enter or click to view image in full size

Verdict: The system is prototype-ready. It passes functional tests and demonstrates core patterns. For production deployment with real funds, critical items must be addressed.

Architectural Strengths

Press enter or click to view image in full size

Results

Functional Outcomes

All required features are implemented and verified by tests:

  • The system correctly rejects withdrawals from empty wallets (testWalletFails passes)
  • Handles high-precision amounts (e.g., 12.345678 BTC) without floating-point rounding
  • API responses validate JSON structures for /api/v1/users, /api/v1/operations, /api/v1/wallet/add-balance

Error Handling

When validation fails, the service throws specific exceptions:

  • InvalidUuidStringException — malformed UUID
  • WalletNotFoundException — UUID valid but wallet missing
  • WalletCurrencyPrecisionNotSetException — blockchain/currency not configured
  • ProcessingAmountIsInvalidException — amount below minimum allowed

Key Takeaways

  1. Ledger is truth, balance is projection. Always store an immutable operation log.
  2. Never use float for money. Only integer or string + BCMath.
  3. Lock rows on mutations. lockForUpdate() prevents race conditions.
  4. Always add idempotency. Idempotency-Key headers are mandatory.
  5. Model operations as a state machine. Critical for async settlement.
  6. Soft deletes on financial records. Auditability must never be lost.
  7. Implement reconciliation from day one. Systems drift — it’s a matter of time.
  8. Financial alerts > server alerts. Track drift, negative attempts, pending volume.

Comparison with Alternative Approaches

Many wallet systems store balance as an integer (cents or satoshis) to avoid decimal issues. This project uses strings and BCMath — more flexible but requires careful precision management.

The repository pattern is more sophisticated than typical Laravel “fat model” designs. The async job pattern is similar to Stripe’s “payment intent” model: create an object in in_process state, then confirm later.

Conclusion

This case study dissected a real-world Laravel balance management system using only its own source code as evidence. The architecture cleanly separates user identity (User), balance storage (Wallet), and transaction log (Operation). The service layer delegates to repositories, enabling precise decimal handling and asynchronous state transitions.

The CLI command, despite its loop quirk, correctly demonstrates creating in-process operations and dispatching completion jobs. Functional tests confirm deposits, withdrawals, error conditions, and API responses.

Reliable financial systems are not defined by elegant APIs. They are defined by the integrity of state transitions.

Money does not tolerate ambiguity. Reliable architecture systematically removes it through:

  • Ledgers (immutable log)
  • Locks (pessimistic locking)
  • Transactions (atomicity)
  • State machines (operation lifecycle)
  • Idempotency (safe retries)
  • Reconciliation (verification)

Individually, these mechanisms may feel heavy. Together, they produce something valuable:

Predictable financial behavior under imperfect conditions.

That is ultimately what users trust — not frameworks, not blockchains, but the guarantee that the system will preserve financial truth even when parts of it fail.

And that trust is not accidental.

It is engineered.

Built with lessons from production wallet systems that have survived real traffic, failures, and the harsh realities of financial correctness.

--

--

bugfix666
bugfix666

CTO / Tech Lead / Developer