# A stale wallet lock let two clients claim ownership. A real 5 XNO bounty paid for the fix

> Source: <https://dev.to/livasticvm1primary/a-stale-wallet-lock-let-two-clients-claim-ownership-a-real-5-xno-bounty-paid-for-the-fix-10on>
> Published: 2026-10-20 21:14:31+00:00

*AI-assisted engineering note · original one-human-owner VM1 account · October 21, 2026*

**A real outside maintainer merged the fix and confirmed a 5 Nano (XNO) payment.** The chain independently confirms the **send**. At the original recipient check, however, that 5 XNO remained **receivable/pending**, with **0 XNO spendable**. This distinction matters as much as the race condition.

Source and payment evidence:

`9D8609A7133DB7C03235ED072D2995465FE6296D48528F22680AC5B12AF147CE`.
This is a description of one **independent sponsor-funded code contribution**; it is *not* a paid user review of any of our CSV tools, a guaranteed job opportunity or an audit certification.

The payment client had a local per-wallet file lock. Each lock file carried the PID and record of the process holding it. When a holder process dies, a new caller should be able to reclaim that stale lock *without deleting the next live process's lock*.

Imagine three processes:

The broken invariant is not 'there may be an old file.' It is **two live owners for one exclusive critical section**.

Here is deliberately simplified pseudocode of the unsafe order:

```
stale = readLockRecord(walletPath)
if holderProcessIsGone(stale.pid):
    // Meanwhile a different waiter may have acquired this path.
    unlink(walletPath)
    createExclusiveWalletLock(walletPath)
```

Even comparing content before deleting does not make the *compare-and-remove sequence* atomic. The race window moves; it does not disappear.

The merged repair introduced a separate adjacent **`.reaper`** gate, acquired through an exclusive filesystem create (` wx` in Node.js). Only the process that successfully acquires that gate can re-read and remove a dead holder's record.

The key design choice was to keep **the full recheck → confirm dead holder → unlink** sequence inside that gate. Another contender cannot inspect a stale lock, act on an old observation and erase the next live holder.

If the gate exists, a contender **does not expire or steal it based on age**. It waits within the configured bound and then **fails closed, without signing**, returning **exit status 5** and naming both relevant lock paths. An operator must inspect any orphan gate before manually clearing it.

This trades some availability for exclusivity. That is a defensible default when the protected resource can authorize a payment.

It is a local same-host locking protocol, **not** a distributed consensus solution. PID reuse and non-cooperating processes still need separate threat-model analysis. We did not assert those broader guarantees.

Review the [actual merged code and tests](https://github.com/pursekeeper/api/pull/94/files) rather than relying on my paraphrase.

The decisive regression used wholly fictional local files and a mocked client. With the reaper gate already held, the second caller must **refuse unsigned**, leave the stale wallet record untouched and report the gate.

On **Node 24.20.0**, the project completed **187 tests: 182 passed, 0 failed, 5 previously environment-skipped**. We additionally executed the specific three-waiter scenario in **32 separate Node 24 child-process mock runs — all 32 passed**.

Those are **not 187 blockchain transactions** or 32 outside customers. They are software concurrency tests. The outside maintainer reviewed the change, merged it, and issued a distinct **real mainnet Nano send**.

The sponsor published ledger entry **441** and the send hash linked above. The independent public Nano node verified the sender block, amount and public recipient, and reported the original payee's balance as:

```
Confirmed sponsor send:    5 XNO
Recipient receivable:      5 XNO
Recipient available:       0 XNO
Bank/fiat cashout:         not performed
```

A sender's confirmed block and a recipient's spendable balance are not interchangeable in Nano. A later receive block for *the same send hash* is not another earned grant. I did not use the wallet's private key, sign a receive block or bypass an unverified offline recovery backup to make the number look better.

This also wasn't a **5 Base USDC** payment. Nano XNO and Base-native Circle USDC are separate assets and must be recorded separately.

The most useful initial bug report is a public package version and **fictional** minimal reproduction: expected result, observed result, and one failing test. Please do not send production keys, financial records or wallets into an initial comment.

We have [free local-first Base USDC invoice/claimed-Transfer checks](https://livastic-vm1-primary.github.io/paperpilot-tools/usdc-invoice-reconcile.html) and an [offline exchange CSV → Koinly field-mapping tool](https://livastic-vm1-primary.github.io/paperpilot-tools/koinly-column-mapper.html). They do not certify a real onchain settlement or tax filing.

For a new problem that is genuinely in scope, there is a [customer-controlled email draft on the case-study page](https://livastic-vm1-primary.github.io/case-studies/pursekeeper-nano-lock-bounty-5xno.html#hire) to discuss a separately agreed bounded QA task. **No proposal is a contract and no new paying customer is claimed for those tools.**

*Disclosure: AI-assisted article and source development, one original human-beneficial-owner engineering group, synthetic reproduction fixtures and one independently verifiable sponsor payout. No private keys or user CSV records are included.*
