<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marcelinus Ani</title>
    <description>The latest articles on DEV Community by Marcelinus Ani (@amc_animarcelinus_b4a718).</description>
    <link>https://dev.to/amc_animarcelinus_b4a718</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4059761%2F91519a8d-6a1e-488d-ad64-28345d25f7d5.png</url>
      <title>DEV Community: Marcelinus Ani</title>
      <link>https://dev.to/amc_animarcelinus_b4a718</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amc_animarcelinus_b4a718"/>
    <language>en</language>
    <item>
      <title>Atomic Money: Making a PHP/MySQL Wallet Safe Under Concurrency</title>
      <dc:creator>Marcelinus Ani</dc:creator>
      <pubDate>Mon, 03 Aug 2026 03:09:47 +0000</pubDate>
      <link>https://dev.to/amc_animarcelinus_b4a718/atomic-money-making-a-phpmysql-wallet-safe-under-concurrency-1b0m</link>
      <guid>https://dev.to/amc_animarcelinus_b4a718/atomic-money-making-a-phpmysql-wallet-safe-under-concurrency-1b0m</guid>
      <description>&lt;p&gt;The lost-update bug that quietly corrupts homegrown wallet balances — and the five disciplines we used across PayWithToken to make money movement correct under concurrency.&lt;/p&gt;

&lt;p&gt;There is a bug that lives in a large share of the world's homegrown wallet systems. It doesn't throw an error. It doesn't show up in tests. It surfaces months later as a balance that is quietly, inexplicably wrong — and in a payments system, a wrong balance is either a customer who has lost money or a company that has given it away. This is the story of that bug, why the "obvious" wallet code causes it, and the handful of disciplines we used across PayWithToken to make money movement correct under concurrency.&lt;/p&gt;

&lt;p&gt;The bug: lost updates&lt;br&gt;
Here is wallet code almost everyone writes first. Credit a user's balance:&lt;/p&gt;

&lt;p&gt;// DON'T do this&lt;br&gt;
$row = $db-&amp;gt;query("SELECT balance FROM users WHERE id = $id")-&amp;gt;fetch();&lt;br&gt;
$new = $row['balance'] + $amount;&lt;br&gt;
$db-&amp;gt;exec("UPDATE users SET balance = $new WHERE id = $id");&lt;br&gt;
Read the balance, add to it in PHP, write it back. It works perfectly — until two things happen at the same time.&lt;/p&gt;

&lt;p&gt;Picture a wallet at ₦1,000. Two credits of ₦500 arrive simultaneously — say a bank webhook and the user tapping "confirm" on their phone:&lt;/p&gt;

&lt;p&gt;Request A reads balance = 1000.&lt;br&gt;
Request B reads balance = 1000 (A hasn't written yet).&lt;br&gt;
A computes 1500, writes 1500.&lt;br&gt;
B computes 1500, writes 1500.&lt;br&gt;
Two credits landed; the balance rose by ₦500. ₦500 vanished. This is a lost update, and it is a race condition, which means it is invisible until you have real concurrent traffic — exactly when you can least afford it. The debit version of the same bug lets a balance go negative or double-spends a token.&lt;/p&gt;

&lt;p&gt;Fix #1: let the database do the arithmetic&lt;br&gt;
The read-modify-write happened in PHP, across three round trips, with a gap where another request could interleave. The fix is to make the update a single atomic statement and let the database's row lock serialise it:&lt;/p&gt;

&lt;p&gt;// DO this — one atomic statement&lt;br&gt;
$db-&amp;gt;prepare("UPDATE users SET balance = balance + :amt WHERE id = :id")&lt;br&gt;
   -&amp;gt;execute([':amt' =&amp;gt; $amount, ':id' =&amp;gt; $id]);&lt;br&gt;
Now the addition happens inside the database, under the row lock it already takes for an UPDATE. Concurrent credits queue behind each other and both land. There is no window to interleave because there is no gap between read and write — they are the same operation. In PayWithToken we converted every wallet credit and debit site — 21 of them — to this pattern. Consistency matters more than cleverness: one read-modify-write left in the codebase is enough to corrupt a balance.&lt;/p&gt;

&lt;p&gt;Fix #2: store money as DECIMAL, never FLOAT&lt;br&gt;
The second silent corruption is the column type. Floating-point numbers cannot represent most decimal fractions exactly:&lt;/p&gt;

&lt;p&gt;0.1 + 0.2  →  0.30000000000000004&lt;br&gt;
Store balances as FLOAT/DOUBLE and those tiny errors accumulate across millions of operations until statements no longer reconcile to the penny. Money must be exact, so money columns are DECIMAL:&lt;/p&gt;

&lt;p&gt;ALTER TABLE users MODIFY balance DECIMAL(18,2) NOT NULL DEFAULT 0.00;&lt;br&gt;
DECIMAL(18,2) stores an exact base-10 number with two fractional digits and headroom for very large totals. Combined with atomic updates, arithmetic is now both race-free and exact.&lt;/p&gt;

&lt;p&gt;Fix #3: guard the debit&lt;br&gt;
A credit can always succeed; a debit must not overdraw. The guard belongs in the same atomic statement, not in a separate SELECT:&lt;/p&gt;

&lt;p&gt;$stmt = $db-&amp;gt;prepare(&lt;br&gt;
  "UPDATE users SET balance = balance - :amt&lt;br&gt;
   WHERE id = :id AND balance &amp;gt;= :amt");&lt;br&gt;
$stmt-&amp;gt;execute([':amt' =&amp;gt; $amount, ':id' =&amp;gt; $id]);&lt;/p&gt;

&lt;p&gt;if ($stmt-&amp;gt;rowCount() === 0) {&lt;br&gt;
    // No row updated → insufficient funds (or wrong id). The debit did NOT happen.&lt;br&gt;
    throw new InsufficientFunds();&lt;br&gt;
}&lt;br&gt;
The WHERE balance &amp;gt;= :amt makes the check and the deduction one indivisible act. You then trust rowCount(): if zero rows changed, the money did not move. A separate "check balance, then debit" would reopen exactly the race we just closed.&lt;/p&gt;

&lt;p&gt;Fix #4: credit at confirm time, keyed to a specific payment&lt;br&gt;
Atomicity stops corruption. It does not stop duplication. In a real payments system the same credit can be triggered from several places — a bank webhook, a scheduled reconciliation job, a user pressing "confirm." If each one credits the wallet, the user is paid three times for one deposit.&lt;/p&gt;

&lt;p&gt;The naive guard — "have we credited this user today?" — is wrong, because a user can legitimately deposit twice in a day. The credit must be tied to the specific payment artifact: a bank reference, a transaction id. Record that a given reference has been settled, and make claiming it a one-time, atomic act:&lt;/p&gt;

&lt;p&gt;// Idempotent: only the FIRST caller for this bank reference wins&lt;br&gt;
$claimed = $db-&amp;gt;prepare(&lt;br&gt;
  "INSERT IGNORE INTO settled_refs (bank_ref) VALUES (:ref)");&lt;br&gt;
$claimed-&amp;gt;execute([':ref' =&amp;gt; $ref]);&lt;/p&gt;

&lt;p&gt;if ($claimed-&amp;gt;rowCount() === 1) {&lt;br&gt;
    // We are the first — safe to credit exactly once&lt;br&gt;
    creditWallet($userId, $amount);&lt;br&gt;
}&lt;br&gt;
// rowCount() === 0 → already settled → do nothing&lt;br&gt;
A UNIQUE constraint on bank_ref plus INSERT IGNORE turns "have we already handled this payment?" into a single race-free question. Webhook, cron and manual reconciliation can now all run — even simultaneously — and the wallet is credited exactly once. We applied the same "execute-once claim" to dispute resolutions, so a refund can never be paid twice.&lt;/p&gt;

&lt;p&gt;Fix #5: make the whole movement transactional&lt;br&gt;
When money moves between two places — debit one wallet, credit another, write a ledger row — those writes must all commit or all roll back. A crash between the debit and the credit must not leave money destroyed:&lt;/p&gt;

&lt;p&gt;$db-&amp;gt;beginTransaction();&lt;br&gt;
try {&lt;br&gt;
    debit($sender,  $amount);   // atomic, guarded&lt;br&gt;
    credit($receiver, $amount); // atomic&lt;br&gt;
    writeLedger($sender, $receiver, $amount);&lt;br&gt;
    $db-&amp;gt;commit();&lt;br&gt;
} catch (Throwable $e) {&lt;br&gt;
    $db-&amp;gt;rollBack();            // nothing moved&lt;br&gt;
    throw $e;&lt;br&gt;
}&lt;br&gt;
The disciplines, distilled&lt;br&gt;
None of this is exotic. It is five disciplines applied without exception:&lt;/p&gt;

&lt;p&gt;Never read-modify-write a balance in application code. Let the database add and subtract in one statement.&lt;br&gt;
Store money as DECIMAL, never floating point.&lt;br&gt;
Guard debits in the WHERE clause and trust the affected-row count.&lt;br&gt;
Make credits idempotent by keying them to a specific payment reference, claimed exactly once.&lt;br&gt;
Wrap multi-step movements in a transaction so they are all-or-nothing.&lt;br&gt;
The reason to be absolute about it is that concurrency bugs don't fail loudly. They fail as a slow leak of trust — a balance that's a little off, a reconciliation that won't tie out, a customer who swears they were charged twice. In payments, "usually correct" is another way of saying "wrong." Money-grade software earns its name by being correct every time two things happen at once — and that is a property you design in deliberately, one atomic statement at a time.&lt;/p&gt;

</description>
      <category>php</category>
      <category>mysql</category>
      <category>fintech</category>
      <category>database</category>
    </item>
    <item>
      <title>Reprint-Safe Payment Tokens: Keyed HMAC-SHA256 with a Luhn Check Digit</title>
      <dc:creator>Marcelinus Ani</dc:creator>
      <pubDate>Mon, 03 Aug 2026 02:59:17 +0000</pubDate>
      <link>https://dev.to/amc_animarcelinus_b4a718/reprint-safe-payment-tokens-keyed-hmac-sha256-with-a-luhn-check-digit-37i3</link>
      <guid>https://dev.to/amc_animarcelinus_b4a718/reprint-safe-payment-tokens-keyed-hmac-sha256-with-a-luhn-check-digit-37i3</guid>
      <description>&lt;p&gt;When you build a payment rail around a token — a short, redeemable claim on value that a person can receive by SMS, read over the phone, or type into a USSD menu — the token is the money. Get its design wrong and you have either a fraud surface or an operations nightmare. This is the story of how we redesigned PayWithToken's token so it is unpredictable, leaks nothing about the sender, survives a reprint, and can be validated on a feature phone with no network — and the small number of ideas that made all of that fit into 18 digits.&lt;/p&gt;

&lt;p&gt;The trap: "obvious" tokens&lt;br&gt;
The first version of almost every token scheme looks reasonable and is quietly dangerous. A common pattern is to compose the token from things you already have — a phone number and a timestamp, maybe a sequence counter:&lt;/p&gt;

&lt;p&gt;token = last9(phone) + HHMMSS + seq&lt;br&gt;
It is easy to generate, easy to look up, and completely broken:&lt;/p&gt;

&lt;p&gt;It is predictable. If I know (or guess) your phone number and roughly when a token was issued, I can enumerate a very small space of candidates. Bearer instruments must not be guessable.&lt;br&gt;
It leaks PII. The token now contains a phone number. Anyone who sees a token — printed on a receipt, forwarded in a chat — learns something about the parties.&lt;br&gt;
It collides. Two tokens minted in the same second for the same user need ugly tie-breakers.&lt;br&gt;
It can't be checked offline. A mistyped digit sails straight through to a failed redemption at the counter.&lt;br&gt;
We wanted the opposite of all four properties.&lt;/p&gt;

&lt;p&gt;The requirements&lt;br&gt;
Before touching code, we wrote down what a token had to be:&lt;/p&gt;

&lt;p&gt;Numeric and short. It has to be entered on a phone keypad and read aloud, so: digits only, 18 of them.&lt;br&gt;
Unpredictable. Knowing any number of past tokens must not help you forge the next one.&lt;br&gt;
PII-free. The token must reveal nothing about the sender, recipient, amount, or time.&lt;br&gt;
Offline-verifiable for typos. A cashier or USSD flow should catch an obvious mistype without a round trip.&lt;br&gt;
Reprint-safe. If a customer loses the SMS, we must be able to re-issue the exact same token — without ever having stored the token itself.&lt;br&gt;
Hash-only at rest. The database must never hold a plaintext token. If the table leaks, the tokens are useless.&lt;br&gt;
Requirements 5 and 6 look contradictory — reproduce the token, but don't store it — and resolving that tension is the interesting part.&lt;/p&gt;

&lt;p&gt;The design&lt;br&gt;
The token is the output of a keyed hash, mapped to digits, with a Luhn check digit on the end.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deterministic inputs, secret key
We never hash the token; we hash the facts that define it. Those are things we already persist for the transaction anyway — a monotonic issue reference, the wallet/transaction id, a per-token counter — assembled in a fixed, canonical order:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;message = canonical(issue_ref, txn_id, counter)      # deterministic, no secrets&lt;br&gt;
mac     = HMAC_SHA256(server_key, message)            # 32 bytes, unpredictable&lt;br&gt;
server_key is a high-entropy secret that lives outside the web root, in environment configuration / a secrets manager, and is rotated on a schedule. The unpredictability of the token comes entirely from this key: without it, HMAC output is computationally infeasible to reproduce or reverse.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bytes to digits
We turn the 32-byte MAC into a big integer and take it modulo 10^17 to get 17 digits, then compute a Luhn check digit to make 18:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;n17   = int_from_bytes(mac) % 10**17          # 17 digits&lt;br&gt;
check = luhn_check_digit(n17)                  # 1 digit&lt;br&gt;
token = zero_pad(n17, 17) + str(check)         # 18 digits&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Luhn — integrity, not security&lt;br&gt;
The final digit is the same checksum the card industry uses. It is not a security control — it stops honest typos, not attackers. One transposed or wrong digit fails the Luhn test instantly, on-device, with no network. That single digit removes a whole class of "valid-looking but wrong" tokens from ever reaching the backend.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reprint-safety without storing the token&lt;br&gt;
Here is the resolution to the apparent contradiction. Because generation is a pure function of stored inputs and the key, we can always recompute the exact token on demand:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;reissue(issue_ref, txn_id, counter) -&amp;gt; same 18 digits, every time&lt;br&gt;
So to reprint or resend, we don't fetch a stored token — we regenerate it from the transaction record. Meanwhile, what we store for validation is only the token's hash:&lt;/p&gt;

&lt;p&gt;store:  token_hash = SHA256(token)             # never the token itself&lt;br&gt;
verify: SHA256(entered_token) == token_hash ?  # constant-time compare&lt;br&gt;
A database dump therefore contains no spendable value. Redemption is a hash comparison; reprint is a recomputation; the plaintext exists only in transit to the recipient.&lt;/p&gt;

&lt;p&gt;The partner-format contract&lt;br&gt;
There is one more real-world wrinkle. We also issue an 11-digit token variant for white-label partners whose systems were built expecting exactly 11 digits. When we rolled out the keyed scheme, we could not change that length without breaking every partner integration — the token's shape was effectively an API contract.&lt;/p&gt;

&lt;p&gt;The design handles this by construction: the only length-dependent step is the modulus. Take the MAC modulo 10^10, append the Luhn digit, and you have a secure 11-digit token with identical properties. Same engine, same guarantees, different envelope — and no partner had to change a line of code.&lt;/p&gt;

&lt;p&gt;n10   = int_from_bytes(mac) % 10**10&lt;br&gt;
token = zero_pad(n10, 10) + luhn_check_digit(n10)   # 11 digits, still keyed + checked&lt;br&gt;
What the token does not do (and how we cover it)&lt;br&gt;
A well-designed token value is necessary but not sufficient. Because a token is a bearer instrument, the surrounding system still has to:&lt;/p&gt;

&lt;p&gt;Make redemption one-time and atomic, so a token can't be spent twice under a race.&lt;br&gt;
Support suspension and expiry, so a compromised or disputed token can be killed instantly, everywhere.&lt;br&gt;
Rate-limit validation, so nobody can brute-force the (already vast) space by hammering the redeem endpoint.&lt;br&gt;
Protect and rotate the key, because the key is the whole security model.&lt;br&gt;
Get those right around a keyed, checksummed, hash-stored token and you have something you can safely send by SMS to a stranger.&lt;/p&gt;

&lt;p&gt;Takeaways&lt;br&gt;
Never compose a token from user data. Predictability and PII leakage are baked in from the first line.&lt;br&gt;
Let a keyed hash supply the entropy; the secret key is the security boundary, not the format.&lt;br&gt;
Store the hash, regenerate from inputs. That single decision gives you reprint-safety and a database that holds no spendable value.&lt;br&gt;
Add a Luhn digit for humans, not attackers — it pays for itself the first time someone reads a token aloud.&lt;br&gt;
Treat length as a contract. A modulus is all that separates an 18-digit consumer token from an 11-digit partner token with the same guarantees.&lt;br&gt;
Designing money to travel as eighteen typed digits forces a satisfying discipline: every property you want has to be earned explicitly. The result is a token that is unpredictable, private, human-checkable, reproducible, and worthless at rest — which is exactly what you want the thing that is the money to be.&lt;/p&gt;

</description>
      <category>security</category>
      <category>fintech</category>
      <category>architecture</category>
      <category>cryptography</category>
    </item>
  </channel>
</rss>
