DEV Community

Cover image for Build a Two-Currency Wallet Ledger in Postgres
Meghma Lahiri
Meghma Lahiri

Posted on

Build a Two-Currency Wallet Ledger in Postgres

I have a soft spot for ledger design because small modeling choices decide whether a system stays trustworthy. Some products run on two virtual currencies. One is bought for entertainment, and the other is awarded as a promotion and may be redeemable.

If you store both in a single balance column, you will eventually lose track of which coin did what.

This post shows a ledger design that keeps the two currencies apart. It uses plain SQL, integer amounts, and a small state machine. You can adapt it to any stack.

This article covers software design only. It is not legal advice. What a product may offer is a question for qualified counsel.

## Why one balance column fails

A single balance column answers one question: how much does this player have? Two-currency products need harder answers.

Where did this coin come from: a purchase, a bonus, or a win?
Which balance did this action touch?
Can we replay the history and reach the same number?

Because a column only stores the latest value, it cannot answer any of these. Therefore, you need a ledger.

## Model the ledger and not the balance

First, store every change as an immutable entry. Then derive balances from entries. Use integers in minor units, never floats.

sql
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
owner_type TEXT NOT NULL, -- 'player' or 'system'
owner_id TEXT NOT NULL,
currency TEXT NOT NULL CHECK (currency IN ('GC','SC')),
purpose TEXT NOT NULL, -- 'wallet', 'hold', 'issuance', 'burn'
UNIQUE (owner_type, owner_id, currency, purpose)
);

CREATE TABLE transactions (
id UUID PRIMARY KEY,
kind TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
jurisdiction TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE entries (
id BIGSERIAL PRIMARY KEY,
txn_id UUID NOT NULL REFERENCES transactions(id),
account_id BIGINT NOT NULL REFERENCES accounts(id),
amount BIGINT NOT NULL -- signed, minor units
);

Here, GC is the entertainment currency, and SC is the promotional one. The currency lives on the account, so an entry can never cross currencies by accident.

## Keep each currency in its own accounts

Each player gets one wallet account per currency. The system gets its own issuance accounts. Every transaction must balance to zero within each currency.

For example, a purchase that also awards a promotional bonus is one transaction with two balanced pairs.

python
def record_purchase(player, gc_amount, sc_bonus, key, state):
with db.transaction():
txn = create_txn("purchase", key, state)
post(txn, system("GC", "issuance"), -gc_amount)
post(txn, wallet(player, "GC"), +gc_amount)
post(txn, system("SC", "promo_issuance"), -sc_bonus)
post(txn, wallet(player, "SC"), +sc_bonus)
assert sums_to_zero(txn, "GC") and sums_to_zero(txn, "SC")

As a result, you can always answer how many promotional coins exist and who holds them. You can also prove that a purchase never silently minted extra coins.

## Make every write idempotent

Payment webhooks retry. Mobile clients retry. Meanwhile, a duplicate write means a duplicate credit.

Require an idempotency_key on every transaction.
Make the key unique in the database, not only in application code.
On a duplicate key, return the original result and write nothing.

Otherwise, a flaky network turns into a support queue.

Handle concurrency safely

Two requests can try to spend the same coins at once. Therefore, lock the wallet before you check the balance.

Use SELECT ... FOR UPDATE on a per-account balance row, or run serializable transactions.
Keep a materialized balance per account, updated in the same transaction as the entries.
Add CHECK (balance >= 0) on player wallets so overspending fails inside the database.

Because the check lives in the database, no code path can bypass it.

Treat redemption as a state machine

Redemption carries the most risk, so it should never be a single update. Instead, model it as explicit states.

requested: funds move from the player wallet to a hold account.
under_review: identity and eligibility checks run.
approved: a reviewer or rule engine signs off.
paid or rejected: held funds are burned or returned.

Because funds sit in a hold account during review, the player cannot spend them twice. Moreover, each transition is its own transaction, so the history shows who approved what and when.

For a sense of budget, Idea Usher, which builds platforms like this, ballparks a dual-currency wallet at $15,000 to $45,000. It scopes redemption as its own item. That sounds right once you count audit, reconciliation, and review tooling.

Enforce jurisdiction at the edge

Rules differ by region, and they change. Therefore, check the player's jurisdiction when the transaction happens, not only at login.

Store the jurisdiction snapshot on the transaction, as in the schema above.
Gate features with configuration, such as a flag per region, not hard-coded logic.
Fail closed: if location cannot be confirmed, block the action.

Consequently, a rule change becomes a configuration change. You also keep a record of which rule applied at the time.

Teams that ship these products, Idea Usher among them, tend to treat a new state ban as a settings change, not a rebuild. The flag approach above is how you get there.

Audit and reconcile every night

An immutable ledger only helps if you check it. Run these jobs on a schedule:

Every transaction sums to zero within each currency.
Player wallets, hold accounts, and system accounts sum to zero per currency.
Materialized balances match the sum of entries.

Never edit or delete an entry. To correct a mistake, post a reversing transaction and link it to the original.

Testing checklist

Replay the same request twice and confirm one credit.
Kill the process mid-transaction and confirm nothing partial remains.
Try to spend held funds and confirm it fails.
Switch a region off and confirm new transactions are blocked.
Rebuild all balances from entries and compare them to stored values.

**Wrapping up

**
A dual-ledger wallet comes down to four habits: separate accounts per currency, balanced transactions, idempotent writes, and immutable history. Together, they let you explain every coin in the system.

Idea Usher covers the legal side in a separate post on how sweepstakes casinos like Stake.us stay legal in the US. Treat it as context, not legal advice.

Questions? Ask in the comments

I would love to hear how you handle this in your own systems. If something here is unclear, or you hit a snag while building it, drop a question below.

A few prompts to get started:

  • Do you keep promo balances in separate accounts or in a separate service?

  • How do you reverse a redemption that fails after approval?
    Where do you run jurisdiction checks: at login, at write time, or both?

Corrections and better ideas are welcome too.

Top comments (0)