chemaclass/unspent

A PHP library for UTXO-like bookkeeping using unspent entries.

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 10

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/chemaclass/unspent

dev-main 2026-01-11 13:26 UTC

This package is auto-updated.

Last update: 2026-01-11 13:48:46 UTC


README

PHP 8.4+ MIT License

Track value like physical cash in your PHP apps. Every unit has an origin, can only be spent once, and leaves a complete audit trail.

// Start with 1000 units
$ledger = InMemoryLedger::withGenesis(Output::open(1000, 'funds'));

// Spend 600, keep 400 as change
$ledger = $ledger->apply(Tx::create(
    spendIds: ['funds'],
    outputs: [Output::open(600), Output::open(400)],
));

// The original 1000 is gone forever - can't be double-spent.

Why?

Traditional balance tracking (balance: 500) is just a number you mutate. There's no history, no proof of where it came from, and race conditions can corrupt it.

Unspent tracks value like physical cash. You can't photocopy a $20 bill - you spend it and get change back. This gives you:

  • Double-spend prevention - A unit can only be spent once, ever
  • Complete audit trail - Trace any value back to its origin
  • Immutable history - State changes are additive, never mutated
  • Zero external dependencies - Pure PHP 8.4+

Inspired by Bitcoin's UTXO model, decoupled as a standalone library.

When is UTXO right for you?

Need Traditional Balance Unspent
Simple spending ✅ Easy Overkill
"Who authorized this?" Requires extra logging ✅ Built-in
"Trace this value's origin" Requires event sourcing ✅ Built-in
Concurrent spending safety Race conditions ✅ Atomic
Conditional spending rules Custom logic needed ✅ Lock system
Regulatory audit trail Reconstruct from logs ✅ Native

Use Unspent when:

  • Value moves between parties (not just a single user's balance)
  • You need to prove who authorized what
  • Audit trail is a requirement, not a nice-to-have

Skip it when:

  • You just need a simple counter or balance
  • Single-user scenarios with no authorization needs
  • No audit requirements

Install

composer require chemaclass/unspent

Quick Start

Create and transfer value

// Initial value
$ledger = InMemoryLedger::withGenesis(Output::open(1000, 'funds'));

// Transfer: spend existing outputs, create new ones
$ledger = $ledger->apply(Tx::create(
    spendIds: ['funds'],
    outputs: [
        Output::open(600, 'payment'),
        Output::open(400, 'change'),
    ],
));

// Query state
$ledger->totalUnspentAmount();  // 1000
$ledger->unspent()->count();    // 2 outputs

Add authorization

When you need to control who can spend:

// Server-side ownership (sessions, JWT, etc.)
$ledger = InMemoryLedger::withGenesis(
    Output::ownedBy('alice', 1000, 'alice-funds'),
);

$ledger = $ledger->apply(Tx::create(
    spendIds: ['alice-funds'],
    outputs: [
        Output::ownedBy('bob', 600),
        Output::ownedBy('alice', 400),
    ],
    signedBy: 'alice',  // Must match the owner
));

Output types

Method Use case
Output::open(100) No lock - pure bookkeeping
Output::ownedBy('alice', 100) Server-side auth (sessions, JWT)
Output::signedBy($pubKey, 100) Ed25519 crypto (trustless)
Output::lockedWith($lock, 100) Custom locks (multisig, timelock)

Use Cases

What you're building Topics
In-game currency Ownership, double-spend prevention, implicit fees
Loyalty points Minting new value, redemption, audit trails
Internal accounting Multi-party authorization, reconciliation
Crypto wallet Ed25519 signatures, trustless verification
Event sourcing State machines, immutable history tracing
Bitcoin simulation Coinbase mining, fees, UTXO consolidation
Custom locks Timelocks, custom lock types, serialization
SQLite persistence Database storage, querying, ScalableLedger
php example/run game      # Run any example (loyalty, wallet, btc, etc.)
composer init-db          # Initialize database for persistence examples

See example/README.md for details.

Documentation

Topic What you'll learn
Core Concepts How outputs, transactions, and the ledger work
Ownership Locks, authorization, custom lock types
History Tracing value through transactions
Fees & Minting Implicit fees, coinbase transactions
Persistence JSON, SQLite, custom storage
Scalability InMemoryLedger vs ScalableLedger for large datasets
API Reference Complete method reference

FAQ

Can two outputs have the same ID?

No. Output IDs must be unique across the ledger. If you omit the ID parameter, a unique one is auto-generated using 128-bit random entropy. If you provide a custom ID that already exists, the library throws DuplicateOutputIdException.

// Auto-generated IDs (recommended) - always unique
Output::ownedBy('bob', 100);  // ID: auto-generated
Output::ownedBy('bob', 200);  // ID: different auto-generated

// Custom IDs - validated for uniqueness
Output::ownedBy('bob', 100, 'payment-1');  // OK
Output::ownedBy('bob', 200, 'payment-1');  // Throws DuplicateOutputIdException

This mirrors Bitcoin's UTXO model where each output has a unique txid:vout identifier, even when sending to the same address multiple times.

When should I use InMemoryLedger vs ScalableLedger?
Scenario Recommendation
< 100k total outputs InMemoryLedger
> 100k total outputs ScalableLedger
Need full history in memory InMemoryLedger
Memory-constrained environment ScalableLedger

ScalableLedger keeps only unspent outputs in memory and delegates history to a HistoryStore. See Scalability docs.

How are fees calculated?

Fees are implicit, like in Bitcoin. The difference between inputs and outputs is the fee:

$ledger->apply(Tx::create(
    spendIds: ['input-100'],      // Spending 100
    outputs: [Output::open(95)],  // Creating 95
));
// Fee = 100 - 95 = 5 (implicit)

See Fees & Minting docs.

Development

composer install  # Installs dependencies + pre-commit hook
composer test     # Runs cs-fixer, rector, phpstan, phpunit