Skip to content

Land 23 device-tested fixes as one bundle: exit, refresh, vault, chain source - #208

Merged
Bamskki merged 72 commits into
mainfrom
qa/integration-2026-08-19
Aug 23, 2026
Merged

Bamskki merged 72 commits into
mainfrom
qa/integration-2026-08-19

Conversation

@CypherBoxLLC

Copy link
Copy Markdown
Owner

Lands 23 already-reviewed PRs as the configuration they were actually tested in.

Merging them individually would create 23 intermediate states on main that nobody has ever run. This branch is the only arrangement that has been exercised end to end, so it ships as one unit. GitHub should auto-close the listed PRs once their commits are reachable from main.

Unilateral exit

The bulk of this work, and the part with the most on-device evidence behind it.

#175 never run a cooperative round while a unilateral exit is live
#179 keep the unilateral exit reachable when esplora fails
#181 batch the exit claim instead of paying a fee per capsule
#183 let users see and fund the exit-fee reserve during an exit
#186 stop the exit-destination backfill logging duplicate ticks
#187 price the exit claim explicitly instead of letting bark bid for speed
#188 stop a stale armed reserve claiming the exit is funded
#189 stop auto-board boarding away the exit-fee reserve
#191 exit triage: discard capsules an exit cannot economically recover
#192 wait on the ripening schedule, not a clock, so an exit lands as one UTXO
#193 stop the exit-funding confirm screen hiding the amount and the fee

Refresh and capsule state

#172 stop telling users a possibly-sent payment was not sent
#176 treat delegated refreshes as mid-round everywhere
#190 surface dust capsules on the home screen at the right threshold
#195 stop the expiry-reminder switch claiming protection it does not have

Vault and recovery

#171 spend the capsules the user selected
#173 never delete the last backup on a tick that wrote nothing
#178 make keychain recovery passphrase-agnostic
#180 stop the address list crashing the app when the wallet is unavailable

Chain source, network and payments

#182 stop a coinos double-send on withdraw timeout, and fund exit fees from another wallet
#184 say which side of the network is down instead of pasting the SDK error
#198 give the chain-source fallback somewhere to fall back to

Docs

#185 roadmap a user-supplied chain source, and stop hedging on the exit

Verification

Device-tested as a bundle, not as individual PRs. This branch ran on an iPhone through a complete 44-hour mainnet unilateral exit against a deliberately dead ASP endpoint, from startExitForVtxos through eleven exit-tree transactions and their CPFP children to a single batched claim.

That run produced measurements rather than a smoke test:

  • the exit landed as one UTXO, 7 inputs to 1 output, 573 vB, 574 sats at exactly 1.00 sat/vB, confirmed at height 963660
  • the batch held 15 hours 36 minutes past the point the previous clock-based logic would have swept, which is Wait on the ripening schedule, not a clock, so an exit lands as one UTXO #192 doing the thing it was written for. The old behaviour would have produced 3 UTXOs
  • exit-tree cost came to 3,585 sats, verified on chain as eleven CPFP children summing exactly to the reserve delta

Automated checks on the merged branch:

  • npx jest -b -i tests/unit: 52 suites, 546 passed, 1 skipped
  • tsc --noEmit: 400 errors, against 405 on main, so this reduces the baseline by 5 and adds none

Merges to main with no conflicts.

Two things to know before merging

The merge commits on this branch are unsigned. If the ruleset requires signed commits on main, they will need re-signing.

Do not push to this branch before approving it. require_last_push_approval will deadlock if the approver is also the last pusher.

Not included

#202, #203 and #205 are branched off main and target it directly. They should be rebased onto the new main and device-tested after this lands, since the phone was tied up with the exit run until now.

Selecting capsules is mandatory before sending from a vault: every entry point into ColdStorage passes both the UTXO set and the selection (ids), and Capsules gates each of its four send paths on ids.length > 0. The screen filtered them into fUtxo, but fUtxo only fed the displayed balance. Both the fee pre-calc and createTransaction received the unfiltered set, so coinselect sorted by value and spent the largest coin in the vault regardless of what the user picked.

So every vault send ignored the selection, and the change figure on the confirm screen, which is derived from the selection, could differ from the real change by orders of magnitude. That matters most when change is routed to a Strike or CoinOS deposit address.

Both the estimate and the build now use the selection and nothing else. There is deliberately no whole-wallet fallback: an empty selection is a caller bug, and silently spending the rest of the vault is the worst available response, so the build alerts and stops instead.
When a Lightning settlement wait times out with the HTLC still live, send.ts throws deliberately so the caller does not retry: the payment may yet settle. The review screen caught that error and appended "Your funds were not moved." unconditionally, making the false claim the last sentence the user read, then cleared isSending. Since canSend does not include errorMsg, the Send button came back armed with the same destination and amount.

Retrying an ln-address or ln-offer mints a fresh invoice with a new payment hash, so the retry can settle alongside the original and pay the recipient twice, irreversibly, once per tap. The error also told the user to check Activity, but recordEvent only fires on the success path, so an in-flight send is invisible there and checking appeared to confirm the false message.

The indeterminate outcome is now flagged on the error rather than left to string matching, exposed as isArkSendIndeterminate(). The review screen branches on it: it keeps the honest wording, and latches a flag that permanently disables Send on that screen so a retry cannot be offered.
writeArkAutoBackup treats every destination as best-effort: the local write goes through writeLocalBackupResilient (which never throws) and the iCloud, Drive and SAF mirrors are each wrapped in a silent-fail try. It then called migrateLegacyBackupsForActiveWallet unconditionally, which DELETES the legacy .cbark at every destination, and returned a success object regardless of what had actually been stored.

So a tick where every destination failed still removed the previous good snapshot, and could leave the wallet with no .cbark anywhere. It also stamped arkLastBackupAt, so the capsule UI read "Backed up" when nothing had been written. For a Bark vault the .cbark carries the pre-signed exit chain, so losing every copy also loses the ability to exit unilaterally.

Track each destination's real outcome, gate the legacy cleanup on at least one having succeeded, and throw when none did so callers skip the timestamp. All four call sites already have a .catch, so the throw is contained.
A refresh is a cooperative spend: it consumes the VTXO and takes a new one from the ASP. Running it against a coin that is mid-unilateral-exit commits the same coin twice, once cooperatively and once on-chain, and one of the two loses. Confirmed with Second on 2026-08-13 that bark does not defend against this: "we allow VTXOs to be marked for exit and for the user to still spend them". They suggest locking the VTXOs, but no lock/unlock API is exposed in bark-react-native 0.16.1 (nor any exit cancellation), so the client is the only thing standing here.

assertNoActiveArkExit already guarded send, offboard and fee conversion, but refresh.ts, backgroundRefresh.ts and foregroundSweep.ts had no exit reference at all, and arkBgRefreshEnabled defaults to true. The background wake was the worst case: maintenanceDelegated picks its own VTXOs inside bark, so there is no input list to filter, and it runs headless from a silent push outside useArkSync's exit early-return.

Adds hasActiveArkExitRecords(), which asks bark's DB rather than zustand because the background wake can run before the store hydrates, and assertNoActiveArkExitAsync() built on it. Guards refreshArkVtxos, refreshArkVtxosDelegated and maintenanceArkDelegated; the AndSync wrappers and foregroundSweep inherit it. backgroundRefresh bails early with a new exit_in_progress telemetry outcome so a deliberate skip is not recorded as a failure or counted toward the failure escalation.
Since bark 0.6.0 a delegated refresh leaves the VTXO Spendable rather than Locked, so every surface that tested state === 'locked' to mean "mid-round" silently reported nothing in flight. That regression has now been fixed three times in three places (778e41d for the capsule rows, then the home banners, now this), so the predicate moves into one exported helper instead of being re-derived per screen.

isVtxoMidRound() is a UNION, not a replacement: Locked is still real, bark sets it for the pre-action subsystems (round, offboard, board, lightning receive). sumMidRoundVtxos() wraps the common count+sats reduction.

Sites corrected: Account.tsx pendingRoundSats/Count and the capsule-slot refreshing flag (this card exists to mirror the home card and had drifted into contradicting it); cancellingState pendingRoundSatsFromStore, which gates the cancel UI and read 0 for the whole life of a delegated round; WalletsView pendingRound and soonestDaysLeft; ArkWallet soonestDaysLeft; useArkSync stuck-round sats, the auto-cancel window scan, and the refresh-failure escalation. The two remaining 'locked' comparisons are deliberate: one is the liveIds staleness filter, which means something else, and the capsule row derivation now routes through the helper.
The soonestDaysLeft derivation called isVtxoMidRound but the import line
only picked up sumMidRoundVtxos, so the home screen threw a ReferenceError
during render for any wallet holding a VTXO with a real expiry height.

Caught by tsc (TS2304). The unit suite does not render this screen and CI
has no typecheck gate, so nothing else would have caught it before device.
Four defects on the emergency-exit path, all present in shipped 0.1.7, all
found during on-device QA on 2026-08-16. Together they can leave a funded
wallet unable to start an exit at all, with the UI telling the user to send
money they already hold.

1. sync.ts asked for the on-chain handle with getArkOnchainHandle(), a bare
   getter. rotateArkOnchainEsplora() nulls that handle so the next spawn picks
   a different provider, but nothing in the sync loop ever re-spawned it: the
   getter returned null and the whole on-chain block was skipped, silently and
   permanently. One rotation killed the on-chain wallet for the rest of the
   process. setLastOnchainBalanceSats was never reached, so the exit-fee wallet
   read 0 sats and Emergency Exit stayed gated behind "Fund exit fees first"
   while the funds sat confirmed on-chain. The rotate docstring already assumed
   this call site used ensure. It does now.

2. rotateArkOnchainEsplora() reset to the primary rather than advancing. That
   was written when the primary was mempool and blockstream was the endpoint
   worth avoiding; the 2026-07-09 flip made blockstream the primary and
   inverted the logic, so `sessionEsploraUrl === ESPLORA_URL` was true for a
   handle already pinned to a bot-blocking blockstream and the early return
   fired every time. It now advances through ESPLORA_URLS, matching the
   wallet-open loop in restore.ts, which recovers from the same bot-block.

3. The rotation was gated on a regex over the error text, and errors that did
   not stringify the expected way skipped it entirely. Rotation is now
   unconditional on failure, which is harmless: it only re-spawns the handle
   against the other provider next tick. The failure is also logged with its
   inner message instead of collapsing to a bare "Error", which is what made
   this undiagnosable on device.

4. clearArkAuth() reset arkExitFeeReserveSats and arkExitDestinationAddress.
   It is not only a logout: the boot path calls it on a no-datadir restore
   result, so a transient read discarded both. A zeroed reserve flips sync.ts
   out of the hold-funds-on-chain branch into boardAll(), boarding away the
   exact sats reserved to pay for an exit. A nulled destination lets
   useArkExitDestinationBackfill, which only fills when unset, substitute a
   fresh Hot Vault address; a live exit was observed being redirected away from
   the address the user had chosen.

The exit itself never needed the Ark server. Wallet open, sync, progressExits
and the CPFP broadcasts were all confirmed working against an unreachable
server during this QA. What made the exit unavailable was our own esplora
plumbing failing silently.
Every refresh control is disabled once an exit is active, so the guard beneath
it is unreachable by tapping. The background wake has no UI at all: it runs
headless from a silent push, picks its own VTXOs inside bark, and can execute
before the store hydrates. Both are covered here instead.

16 cases over hasActiveArkExitRecords and assertNoActiveArkExitAsync: every
non-terminal exit phase counts as active (AwaitingDelta alone is the ~24h CSV
wait, so matching only Processing would report no exit for most of an exit's
life), terminal records do not, one active record among terminal ones still
blocks, and a failed DB read returns false because callers use this to block an
action and a failed read is not evidence of an exit.

Adds an @cypher jest moduleNameMapper mirroring the babel and tsconfig aliases,
without which any test touching a module that imports @Cypher/* cannot resolve.
This path cannot be reached from the UI, since you cannot make the local write,
iCloud, Drive and SAF all fail by tapping. A background tick on a device with
no free space and no network does it for free, which is exactly when the last
.cbark matters most: for a Bark vault that file carries the pre-signed exit
chain, so losing every copy also loses the ability to exit unilaterally.

Nine cases. Five drive every destination to fail and assert the call rejects
(so callers skip the arkLastBackupAt stamp and the capsule UI does not read
'Backed up') and that no legacy backup is deleted, covering local, iCloud,
Drive and SAF failures independently. Four assert the opposite direction: any
single surviving destination still resolves, so a local-write failure on a full
disk does not veto a tick that reached a mirror.

Verified against the pre-fix backup.ts: all five failure cases fail there,
including the legacy deletion, and all four success cases pass, so they work as
regression guards rather than restating the implementation.

The legacy-deletion assertion filters .tmp paths, since atomicWriteFile unlinks
its own scratch file on the failure path and that is unrelated cleanup.
The selection rule moves out of the screen into src/helpers/capsuleSelection.ts
so it can be tested directly. ColdStorage now calls selectCapsules() instead of
filtering inline; behaviour is unchanged, the outpoint keying is identical.

Nine cases. Eight cover the rule itself: the ticked capsule is returned rather
than the largest, keying uses txid AND vout so sibling outputs of one funding tx
stay distinct, caller ordering is preserved for coinselect, an empty or null
selection returns empty with no whole-wallet fallback, ids with no matching utxo
are ignored rather than fabricating an input, and a double-tap cannot duplicate
an outpoint into the transaction.

The ninth runs the real wallet and asserts the money behaviour directly: the
same targets and fee rate produce a transaction spending the 500k coin when the
selection is ignored, and the 20k coin when it is honoured. That is the defect
this PR fixes, expressed as a test rather than described in a comment.

Empty selection returning empty is the case that matters most. Falling back to
the whole wallet there is what let coinselect break open whatever coin it liked
while the confirm screen showed change derived from the selection, a figure that
could differ by orders of magnitude and is routed to a deposit address.
clearArkAuth is not only a logout: useArkRestoreOnBoot calls it whenever a
restore returns no-datadir, so a transient read on a device that does have a
vault discarded both of these silently. Seen on device 2026-08-16.

Both consequences move real money. A zeroed arkExitFeeReserveSats flips sync.ts
out of its hold-funds-on-chain branch into boardAll(), boarding away the exact
sats set aside to pay for a unilateral exit. A nulled arkExitDestinationAddress
lets useArkExitDestinationBackfill, which only fills when unset, substitute a
fresh Hot Vault address, and a live exit was observed being redirected away from
the address the user had chosen.

Four cases: each setting survives a clear, both survive repeated clears since
the boot path can fire on every launch, and a fourth asserts clearArkAuth still
performs the wallet-scoped reset it is responsible for, so the test cannot be
satisfied by gutting the function.

Verified against the pre-fix store: the three survival cases fail there, the
responsibility case passes in both.
The hardest path to reach on a device: it needs a real Lightning payment still
unsettled after 75 seconds, which cannot be conjured on demand. Here the bark
handle never reports settlement and the clock is advanced past the deadline, so
the branch runs deterministically in milliseconds.

Five cases drive executeArkSend to the timeout with an ln-address destination
and assert the contract callers depend on: it throws rather than resolving, the
error carries the indeterminate flag so callers branch on a marker instead of
string matching, the wording never claims the funds are safe or unmoved, it
tells the user not to send again, and the retry loop does NOT re-dispatch, since
a second attempt to an ln-address mints a fresh invoice and can pay the
recipient twice.

Nine more pin isArkSendIndeterminate itself. It must be true only for the real
marker: an ordinary refunded-after-retries failure stays false, or a recoverable
error would disable Send forever, and a truthy-but-not-true flag value does not
count.

Verified against the pre-fix send.ts: ten of the fourteen fail there. The four
that pass cover behaviour that was already correct, that it throws and does not
retry.

Scope: this covers the SERVICE contract. The false 'Your funds were not moved.'
sentence lived in ArkSendReviewScreen, and the screen's latch that keeps Send
disabled is not covered here, since rendering it needs the navigation and store
tree.
A BIP39 passphrase is a deniability tool. Its value is that nobody can tell the
hidden wallet exists: under duress you hand over twelve words and the wallet
they open is the only wallet anyone can see. Keychain recovery broke that, on
both of its branches.

With the sidecar flag set, recovery announced "This vault is passphrase-
protected. Enter the passphrase to finish recovery." after the biometric read.
Anyone holding the unlocked phone, which under duress is the premise, learned a
second wallet existed.

Clearing the flag did not help. The sidecar's walletID was derived WITH the
passphrase, so a passphrase-free import produced a different id and the
verification in fastImportHotVault reported "the passphrase does not match this
vault" instead. Same disclosure, different wording. There was no silent path.

So both signals go:

  - hasPassphrase is no longer written by the create flow, no longer persisted
    by backupHotVaultMeta, and a legacy value from an older build is dropped on
    read rather than surfaced, so nothing downstream can branch on it. Existing
    sidecars are not migrated: rewriting one needs the seed and a biometric
    prompt, and ignoring the field achieves the same result.
  - The walletID mismatch alert is gone. A differing id is simply the decoy, and
    it now imports in silence exactly as typing the words by hand would. The
    id-equality check still gates the "backed up to Keychain" marker, so that is
    only ever set on a vault that genuinely owns the entry.

handleKeychainRecover also loses its knownMeta parameter, so this flow cannot
read the sidecar's passphrase state at all.

The cost is accepted deliberately: a passphrase user who taps keychain recovery
lands in their decoy with no explanation. Explaining it is the leak. Reaching
the real vault means adding the passphrase on the manual recovery path, where
the toggle already lives and always has.

Six tests pin both halves and fail against the previous behaviour.
Bark already accepts either an esplora endpoint or Bitcoin Core RPC, and the
app passes a public explorer for the former and nothing for the latter. Exposing
the esplora URL would let node runners keep their addresses on their own
hardware, and gives everyone a way out when the public providers are
unreachable.

Deferred rather than rushed: an earlier attempt to change providers had to be
reverted twice, and the chain source feeds refresh, unilateral exit, backup and
recovery alike, so it needs all four re-tested before it ships.
Line 40 carried '(not completed)' while the feature list eight lines below
stated the exit needs no server cooperation. A reader got both answers.

The parenthetical was stale. Verified on device against an unreachable Ark
server: the wallet opens, syncs, reads balance and capsules, runs progressExits,
and broadcasts the CPFP transactions, which confirmed on-chain. Second run their
own unilateral-exit integration tests with the server down.

The two remaining ⏳ items are roadmap entries, which is where an unfinished
thing belongs.
… missing

Field report: tapping the address in the Hot Vault to pick a different one
closes the app outright and forces a fresh login.

WalletAddresses resolves its wallet by walletID from the route, then
dereferenced the result unconditionally:

    const wallet = wallets.find(w => w.getID() === walletID);
    const balanceUnit = wallet.getPreferredBalanceUnit();

`find` returns undefined whenever that pointer no longer matches a stored
wallet, and six different flows navigate here by walletID, so the pointer can
outlive the wallet it names: after a reset, a re-import, or the same drift
RecoverSavingVault already heals for. When that happened the call threw during
render, and a render-time throw has no error boundary above it, so a release
build terminates the process. That is the reported crash.

There were two crash sites, not one. getAddresses() runs from useFocusEffect and
walks walletInstance.next_free_change_address_index, so it threw from an effect
on the same missing wallet.

Both are now guarded, and the screen renders an explanation instead. The bail
sits below every hook so hook order is identical whether or not the wallet
resolves, and the empty state matters: the list's ListEmptyComponent is an
ActivityIndicator, so without a message a missing wallet would show a spinner
that never resolves.

The screen stays unusable when the wallet is genuinely gone, which is correct.
It just no longer takes the app down with it.
drainExits([]) already sweeps every currently-claimable capsule into ONE
transaction, so the cost of claiming is per-claim, not per-capsule. The drive
fired the moment the first capsule ripened, which meant a separate transaction
and a separate fee for each one.

Seen on a live five-capsule exit: the first claim paid 225 sats to move 877,
with four more queued behind it, on an exit-fee wallet that had already fallen
from 3654 to 699 sats. Four more solo claims would not have fit in what was
left, and a capsule that cannot pay its claim stays exited-but-unswept until the
user tops the wallet up.

Capsules ripen apart because each has its own exit branch and its own CSV, timed
from when THAT branch's transaction confirmed. A single block between two
confirmations is enough to split the batch, so this is the normal case rather
than an unlucky one.

The sweep now waits for stragglers, bounded at six hours so one wedged leaf
cannot hold healthy capsules hostage. Waiting is safe: past its CSV a claimable
exit output is an ordinary UTXO that only this wallet can spend, with no
deadline and nobody to race.

The decision itself moves to decideExitClaimBatch() in services/ark, a pure
function taking counts and timestamps, so it can be tested without standing up
the sync loop. The window start persists in the store because an exit outlives
the process by a day or more, and it is cleared both when a claim broadcasts and
when nothing is claimable, so a later capsule opens a fresh window rather than
inheriting stale elapsed time.

Twelve tests cover both directions: batching when stragglers remain, claiming
immediately when nothing else is coming, holding the window start steady across
ticks rather than restarting the clock, the exact boundary, and a wedged capsule
being abandoned once the ceiling is reached.

Also stops re-fetching the exit list twice per drive tick, since the batching
decision needs the same data the liveness check below already asked for.
…e SDK error

The Bark Vault talks to two independent services, and a failure in either
reached the user as the same raw SDK string. They need opposite responses.

A chain-source outage is fixable by the user. Observed on device: Blockstream
served its Cloudflare bot-block page while mempool.space TCP-timed out from the
same Wi-Fi, and both answered instantly from a browser on that network.
Switching to mobile data fixed it. Worth suggesting.

An Ark-server outage is not. Changing network achieves nothing if the ASP is
down, and telling someone to fiddle with Wi-Fi while their balance looks stuck
wastes their time. What matters there is that an emergency exit does not need
that server, which is now what the message says.

Discrimination is by hostname, deliberately. The tempting shortcut, matching
BarkError.ServerConnection, is wrong: config.ts already records that exact tag
appearing for an esplora 429 during recovery, so it identifies no side. Phrase
matching is a fallback for errors naming no host, and anything unrecognised
returns 'unknown' with no message rather than guessing. A wrong suggestion costs
more than no suggestion, so callers keep their own context and only append ours
when we actually know something.

Wired into the Ark Lightning invoice screen first, since minting an invoice
needs the ASP and that is where this was reported from the field. The helper is
pure and takes its endpoints as arguments, so other call sites can adopt it
without further plumbing.

18 tests, every error string taken from a real device log, including the two
that must NOT produce network advice.
POST /bitcoin/send crosses the network, so a dropped connection leaves the
outcome unknown: CoinOS may have accepted the request and only the reply was
lost. The withdraw screen treated every failure as "never sent". It showed
"Failed to Send to bitcoin. Please try again." and re-armed the swipe button.
Following that instruction sends the money a second time, irreversibly.

An idempotency key alone does not fix this, because it only works if the server
honours it and that is unconfirmed for CoinOS. One is now sent anyway, stable
per mount so a retry of the same intended withdrawal carries the same key, but
nothing here depends on it. The protection that actually works is client-side:

  - Before sending, look for a withdrawal to the same address for the same
    amount in the last ten minutes. That is the signature of a retry after a
    lost reply, and it is caught before a second request is made. A failed
    history read does NOT block the send: being unable to read history is not
    evidence of a duplicate, and refusing there would strand the user.

  - When the send fails in a network-shaped way, the error is flagged
    indeterminate rather than reported as a failure, and the screen latches its
    send control off instead of inviting a retry.

Matching needs both address and amount. Address alone would flag a legitimate
second payment to the same destination; amount alone would flag any same-sized
payment to anyone. Where a record carries no usable timestamp it counts as a
match, because a false positive costs a confirmation tap and a false negative
costs the user the money.

Same reasoning as the indeterminate Lightning send in services/ark/send.ts: an
unknown outcome must never be presented as a safe one.

29 tests over the matcher and the classifier, including the malformed records
the history endpoint can return and the server rejections that are NOT
indeterminate, since those are safe to retry.
Groundwork for "Fund exit fees from another wallet". No provider is wired yet:
this is the model and the list, so the rules can be settled and tested before
any money moves.

The funding sheet's Receive tab hands over an address and leaves the user to
find bitcoin. That path is ASP-independent and always works, which is why it
exists, but it is also the least useful thing to show someone whose exit has
stalled while they hold sats in this very app.

buildExitFundingSources() is pure, state in and sources out, so ordering and
availability are unit-testable without a screen. Two rules in it are easy to get
wrong and are pinned by tests:

Unavailable sources are RETURNED with a reason rather than filtered out. A user
who cannot find their Hot Vault in the list concludes the feature is broken; one
who sees it dimmed with "no spendable capsules" has learned what to fix. The
list renders those rows disabled so the dead end is visible rather than
discovered by tapping.

Cold Vault is ordered last and flagged slow. It needs a PSBT round-trip through
an airgapped signer, minutes to hours, and presenting it as a peer of Hot Vault
to someone mid-exit invites the worst choice available. Its row says it needs
the signing device.

Balances that could not be read are treated as usable rather than blocking,
since a failed read is not a balance of zero and refusing there would strand the
user. A source holding less than the shortfall is still offered, because partial
funding can be the difference between a stalled exit and a finished one, and the
row says "partial" up front instead of after a confirm screen.

15 tests. Ordering, every unavailability reason, the floor boundary, unreadable
and nonsense balances, and the empty state that tells the caller to fall back to
the receive-an-address path.
First wallet source behind the funding picker. CoinOS goes first because it
settles without a signing device and is where most spare sats sit. The send is
an ordinary on-chain withdrawal to Bark's own on-chain address, so it needs no
ASP and works during an outage, which is when the reserve tends to be needed.

Two orderings decide whether this works at all, and both are pinned by tests.

ARM THE RESERVE BEFORE SENDING. sync.ts boards confirmed on-chain funds into Ark
unless arkExitFeeReserveSats says to hold them. During an active exit boarding
is already off, but topping up BEFORE starting an exit would otherwise quietly
undo itself on the next sync tick, boarding away the exact sats just bought.
Arming first means they are protected the moment they confirm. Arming never
lowers a reserve the user already set higher.

CHECK FOR A DUPLICATE BEFORE SENDING. A previous attempt whose reply was lost is
already on its way; sending again pays twice. A failed history read does not
block, because being unable to read history is not evidence of a duplicate and
refusing there would strand someone mid-exit.

The arithmetic lives in planExitFunding() because it is where this goes wrong in
both directions: too little leaves the exit stalled after the user believes they
fixed it, too much strips a wallet they still need. The subtlety is that the
miner fee comes off the top, so a wallet holding exactly the shortfall cannot
deliver the shortfall. It sends what fits and reports partial rather than
refusing, since partial funding can be the difference between an exit that
finishes and one that stalls. A balance that could not be read is planned for in
full rather than refused: a failed read is not a zero balance.

An unknown outcome leaves the reserve ARMED on purpose. If the send did go
through, those sats must not be boarded away when they land.

Network calls are injected, so the money path is testable without the API layer
and the caller keeps the auth-bearing fetches.

28 tests across the planner and the orchestrator, including both orderings, the
fee-off-the-top trap, an unreadable balance, a server refusal that is safe to
retry, and an indeterminate send that is not.
Makes the CoinOS funding path reachable. The screen shows the numbers and hands
the work to services/ark/exitFundingCoinos; nothing that decides an outcome
lives in the component, so the money path stays under unit test.

It answers the two questions the old flow left open.

WHAT ACTUALLY LANDS. The miner fee comes off the top, so the amount that reaches
the reserve is not the amount typed. Amount, network fee with its percentage,
and "leaves your wallet" are shown as separate lines with fiat beside each. When
the source cannot cover the full shortfall the screen says so before the swipe
rather than after it, because a partial top-up is still worth making and the
user should choose it knowingly.

WHEN IT TAKES EFFECT. An on-chain deposit is useless until it confirms, and a
screen that goes quiet after a successful send reads as a failure. Both the
confirm copy and the success screen say Emergency Exit unlocks once the deposit
confirms.

The swipe is disabled while the plan is not viable, so an impossible top-up
cannot be started, and it latches off permanently once an outcome is
indeterminate, mirroring the Ark send review: a top-up that may already have
gone out must never be offered again from the same screen.

The idempotency key is stable per mount, so a retry of the same intended top-up
carries the same key instead of looking like a fresh request.

Only CoinOS is wired; other sources leave the control disabled until their
provider lands.
Last piece of the CoinOS funding path. The sheet had two tabs: Receive Bitcoin,
which hands over an address and leaves the user to find sats, and Convert from
balance, which needs the ASP and is blocked during an exit. Neither helps
someone mid-exit who already holds sats in this app.

The new tab lists their wallets with the shortfall prefilled and the Bark
on-chain address filled in for them, then routes to the confirm screen.

All four sources are listed, including ones that cannot be used, dimmed and
carrying their reason. Someone who cannot find their Hot Vault concludes the
feature is broken; someone who sees it greyed out with a reason has learned what
to fix. Cold Vault sits last and says it needs the signing device, because a
PSBT round-trip through an airgapped signer is the slowest option on the list
and should never look like a peer of Hot Vault to someone in a degraded exit.

Only CoinOS has a provider today. Selecting another source says so rather than
opening a screen that cannot complete, which keeps the roadmap visible without
pretending.

Balance is passed as null, meaning unknown, so the plan sizes for the full
shortfall and the provider rejects it if the funds are not there. Refusing on a
balance we have not read would strand the user.
Two bugs found while running a real unilateral exit, both of which made the exit
harder to complete at the exact moment it mattered.

1. THE FUNDING UI DISAPPEARED DURING AN EXIT.

Settings.tsx replaced the whole exit section with an "Emergency exit in
progress" panel, so the reserve could be neither seen nor topped up while an
exit ran. That panel also promises funds sweep automatically once the timelock
expires, while removing the means to make that true: every exit branch needs a
CPFP broadcast and every claim needs a fee.

Observed live on 2026-08-18. A five-capsule exit ran the reserve down from 3654
to 699 sats with four claims still owed, and there was no way to add more from
that screen. The capsules would have sat exited-but-unclaimed indefinitely.

The panel now shows the on-chain reserve balance and offers a top-up that opens
the existing funding sheet. The Convert tab is hidden mid-exit because it is a
cooperative offboard, ASP-gated by assertNoActiveArkExit, so it cannot work
there; Receive and From-a-wallet both can, since they are ASP-independent, which
matters because an exit is often running precisely when the server is
unreachable. A user sitting on Convert when an exit begins is moved to Receive,
otherwise the sheet would render nothing at all.

2. A LOCKED WALLET LEFT THE RECEIVE SHEET SPINNING FOREVER.

ReceivedListNew fetched the Ark address and the on-chain address with
Promise.all, so a failure in either discarded BOTH. The Ark address needs the
Ark wallet; the on-chain address is a local BDK call and is the ASP-independent
way to fund an exit. With the wallet locked the Ark fetch threw "Ark wallet not
initialized" and took the perfectly obtainable on-chain address down with it,
leaving null state and a spinner that never resolved, with only a three-second
toast saying "Failed to fetch Ark addresses".

Now allSettled, so each address stands on its own, and the message names the
actual cause: a locked vault says so and tells the user to open it.
… no-op

ensureArkOnchainHandle tries [sessionEsploraUrl, ...alternates] and returns on
the first that works, but never wrote back which one that was. When the
preferred endpoint failed to spawn and the loop fell through to the alternate,
sessionEsploraUrl kept naming a provider the handle was not using.

rotateArkOnchainEsplora computes "next" from that value, so a stale session
could rotate straight onto the endpoint that had just failed.

Seen live on 2026-08-18 during a unilateral exit, two minutes apart:

  07:27:26 Sync failed: mempool.space ... TimedOut
  07:27:26 onchain esplora rotated -> https://blockstream.info/api
  07:29:59 Sync failed: mempool.space ... TimedOut
  07:29:59 onchain esplora rotated -> https://mempool.space/api

The second rotation moved onto mempool while mempool was the thing failing,
because the recorded session still said blockstream after the spawn had fallen
through. The rotation was a no-op precisely when it mattered.

One line: record the provider that actually worked.
…(state)

bark 0.6.1 turned `ExitVtxo.state` into a UniFFI tagged-enum object.
`barkState.ts` exists to absorb that, and its own doc names `String(v.state)`
as the pattern it replaces, but two call sites were never migrated. Both
regexed `String(v.state)` for /^(Processing|Awaiting)/, which against a real
object tests "[object Object]" and matches nothing.

`hasActiveArkExitRecords()` is the serious one. It is documented as the
authoritative "is an exit live" signal, it is what `assertNoActiveArkExitAsync`
consults to BLOCK a cooperative round, and it is what useArkSync uses to re-arm
a lost exit flag. With the regex dead it collapsed to `v.isClaimable`, so it
reported NO EXIT during Start, Processing, AwaitingDelta and ClaimInProgress,
which is essentially the whole exit. Read off the device during a live mainnet
exit with 2794 sats in flight (three capsules AwaitingDelta, one
ClaimInProgress, every one isClaimable=false) it returned false. The guard
against spending a coin already committed on-chain was open.

The second site is the exit status panel. It derives its figure from the
per-VTXO records precisely because pendingExitsTotalSats() reads 0 mid-
broadcast, but with the filter dead activeSats was always 0, so the panel fell
straight back to the SDK quirk it was written to work around. And when that
read 0 it then fell back to the at-start snapshot, so once every capsule was
claimed it re-displayed the original total and kept showing it for the rest of
the exit. The snapshot now covers only its actual purpose, the window before
the first live read lands.

Tests: the existing suite stayed green through all of this because every
fixture used the pre-0.6.1 string shape. Added the real tagged-enum payloads
captured off the device, plus the end-to-end case of a live exit with a clean
store. Mutation-checked: 10 of them fail against the pre-fix code. Also
corrected the terminal-state fixtures, which asserted on 'Done' and 'Exited';
neither is an ExitState variant. The real set is Claimed, VtxoAlreadySpent and
Canceled, per the SDK's generated ExitState_Tags, and an unrecognised state is
now pinned as ACTIVE on purpose.
`wallets` comes from BlueStorageContext with a fresh array identity on nearly
every provider render, so this effect re-runs whenever the provider renders,
even though none of the four values it reports have changed. The diagnostic at
the top logged unconditionally, so it emitted duplicate identical lines in
bursts alongside the exit-drive output it sits next to.

Measured on device during a live unilateral exit: two identical ticks inside
the same second in a 35-second sample, and two more in the cold-launch buffer.
After this change, one line per cold launch.

The log is now keyed on a signature of the four values it actually reports, so
it prints when something changes and stays quiet otherwise. The effect body is
untouched: it already returns early once the destination is set, so the work
was never the problem, only the logging.
… for speed

`claimArkExitsToAddress` takes an optional fee rate and useArkSync never passed
one, so `drainExits(ids, dest, undefined)` let bark choose. It chooses for
speed, and that is the wrong objective for this transaction.

The two costs in an exit are not the same problem. The CPFP children bumping the
exit tree are time-critical, because the tree has to confirm before the VTXO
expires, and they are paid from the on-chain reserve. A claim is the opposite:
it spends an output whose CSV has already matured, it races nothing, and its fee
comes out of the claimed value itself. Bidding for speed there buys no safety
and takes the money straight off the top.

Observed live on mainnet, 2026-08-19, mid unilateral exit: bark priced a single
capsule claim at 779 sats against a 698 sat output and refused to build it,
"Claim Fee Exceeds Output". That is about 6.6 sat/vB across the measured 117.5
vB claim, at a moment when the mempool wanted 1 and the two claims that had
already succeeded went at 1.1 and 1.9. The exit could not complete.

Worse, the failure was invisible. The catch logged a generic warning and left
state untouched, so the drive rebuilt the identical doomed claim every tick, on
a loop that could never succeed, with nothing surfaced to the user.

Two changes. `fetchClaimFeeRateSatPerVb` takes the 1-hour rate rather than the
fastest and clamps it to [1, 5], falling back to the floor rather than the
reserve's congestion hedge, since an over-priced claim does not fail slowly, it
fails permanently. And "Claim Fee Exceeds Output" is now recognised and logged
as an uneconomic claim naming the rate, instead of hiding among generic retry
warnings.

This does not yet surface in the UI, and it should: a claim that cannot clear at
any rate the wallet will pay is a state the user has to be told about. Tracked
separately with the wider exit-triage work, which this bug validates.
… land

Follow-up to 54878f3, which fixed half of this. That change stopped the panel
falling back to the at-start snapshot when the live figure was 0, but left it
falling back whenever the live figure was merely UNAVAILABLE, which turns out to
be the common case.

The panel polls fetchArkExitVtxos every 10s. That call throws while the wallet
handle is closed, and the effect swallows the error, so pendingExitSats stays
null for as long as the handle is shut. On a cold launch that is the entire
window between app start and the user clearing the biometric, which in practice
can be hours. Throughout it the panel quoted arkExitStartedSats, and that value
is fixed at exit start and never decrements as capsules are claimed.

Observed live on mainnet: 1801 of 3671 sats already claimed and confirmed on
chain, across three separate transactions the Hot Vault had already picked up
and displayed, with the exit panel still reading "3671 sats pending exit".

Fixed by making the fallback a ladder instead of a pair: live poll, then the
store's pendingExitSats, then the at-start snapshot. The middle rung is the
one that matters. useArkSync's exit drive refreshes it every cycle, and the
auth store has no partialize, so the whole store persists and that figure
survives a cold launch. The snapshot is now genuinely last-resort, for the case
where no live figure has ever been recorded.
…s as one UTXO

drainExits sweeps every claimable capsule into ONE transaction, so a whole
exit can arrive as a single UTXO. It did not: the last mainnet exit
delivered 2,961 sats as five separate UTXOs.

Batching held on a 6-hour wall-clock ceiling. The three capsules still in
flight on that exit reported claimableHeight 963101, 963142 and 963145,
a 44-block spread of about 7.3 hours, so the window expired roughly 80
minutes BEFORE the last one ripened, with one of three claimable. The
sweep fired early and split the exit in two. No ceiling tuned in hours
fixes this in general: block intervals are stochastic and the spread is
a property of when each branch confirmed.

The schedule is knowable in advance. AwaitingDelta carries the exact
block each straggler becomes claimable at, fixed from the moment its leaf
confirmed. So hold until the tip reaches the last of them. That wait
provably terminates, because the heights do not move and the chain only
goes forward.

The wall-clock backstop stays for the case it is good for: a straggler
with no known ripening height, still broadcasting, or an unreadable
chain tip. There is no schedule to wait on there and something has to
bound it. The wait also stops if the tip passes every scheduled height
and the stragglers still are not claimable, which means wedged rather
than slow.

The decision now also reports blocks remaining, so the wait can be shown
as "2 blocks to go" rather than a seconds countdown that stalls and
jumps.

21 unit tests over the measured heights. Mutation-checked: restoring the
blind ceiling fails 3, taking min instead of max fails 2, waiting on a
partial schedule fails 1, holding forever past the schedule fails 1.
Triage correctly refuses to exit capsules whose reserve cost dwarfs what
they return, and on a real wallet at real fees that means it refuses
everything. Measured on device 2026-08-20 at the live mempool rate of 7
sat/vB: all seven capsules classified reserve-dwarfs-value, so Emergency
Exit selected nothing and the exit could not be started at all.

That is the right default. It is the wrong only option. Spec principle 4
says the user may knowingly spend more than the funds are worth, for
instance to get their money out of a server they no longer trust, and
until now they could not.

includeMarginal becomes a three-way economicPolicy:

  profitable-only     only capsules whose reserve cost comes back
  default             plus capsules under water by less than the multiple
  recover-everything  plus capsules whose reserve cost dwarfs what they
                      return

Two floors survive every policy, because neither is a trade a user could
want. A capsule whose own claim fee is at least its whole value can never
deliver anything: bark refuses to build a claim whose fee exceeds its
output and the drive rebuilds that same doomed claim forever, so forcing
it in wedges the batch and rescues nothing. And a capsule inside its
expiry runway loses both itself AND the reserve spent on it when the
server sweeps it mid-exit. Structural exclusions are likewise not
opinions to overrule.

The plan now reports netLossSats, plus the count and value of capsules
the override would bring back, so the UI can offer it only where it
changes something and can state the cost in sats first. On the measured
wallet that is 84,504 sats of reserve to recover 2,330, a loss of
82,174, and the user sees that number before agreeing to it. An override
nobody was shown the price of is not a choice.

Offered in both places it can arise: when triage selects nothing, and
when it selects some and leaves others behind on cost alone.

50 unit tests. Mutation-checked: letting the override reach
returns-nothing fails 1, letting it reach the expiry runway fails 1,
ignoring the policy fails 5, defaulting to recover-everything fails 18,
never reporting the loss fails 2.
…test

The reserve was sized at the mempool's fastest rate unconditionally. The
exit tree is not racing the next block, it is racing the capsule's
EXPIRY, and a capsule with weeks of runway has no reason to bid for
next-block confirmation.

Measured on device 2026-08-20: the live capsules sat about 3,800 blocks
clear of the runway they needed, roughly 26 days, while the app priced
their exit at a fastestFee of 7 against an economyFee of 2. A 3.5x
over-demand for urgency that did not exist. Same mistake #187 fixed for
the claim, in the other direction: there a rate chosen for speed made a
claim impossible to build, here it makes a reserve impossible to fund.

Urgency comes from the tightest slack in the exit set and picks the
matching mempool band: relaxed takes economyFee, moderate hourFee, soon
halfHourFee, urgent fastestFee. Unknown runway, an unreadable tip, or an
empty set all read as urgent, because over-reserving parks sats the user
still owns while under-reserving stalls the exit and can cost a capsule.

Urgency reads the SELECTED set, not the candidate list, and that
distinction was not obvious. Pricing the candidates got it backwards on
the first real wallet: two dust capsules 22 blocks off their runway
priced the entire exit as urgent, when neither was going to be exited at
all. Capsules the exit is abandoning do not get to decide what it pays.

That makes pricing and selection mutually dependent, since the rate
decides what is worth exiting. Resolved in two passes over one fee
fetch rather than a fixed point: pass 1 at the fastest rate is exactly
the old behaviour and therefore always a safe fallback, and its selection
reveals the real urgency. A cheaper second pass can only ADD capsules, so
its set is re-read, and if a newcomer is tighter than anything in pass 1
the cheaper rate was not justified and pass 1 stands.

COUPLED, and not optional: progressExits was called with no rate at all,
so bark chose one and whatever it chose had nothing to do with the
reserve the user had been asked to fund. That was survivable only while
the reserve was sized at the fastest rate and therefore almost always
generous. Lowering the reserve without controlling the bid would
under-reserve, so the drive now bids the same runway-derived rate. The
reserve carries SPIKE_MULT over the bid, so it covers it by construction.

The deadline HEIGHT is persisted rather than the band, so the drive
re-derives urgency each tick against the current tip. A band computed at
exit start would go stale over an exit that runs for days; a capsule
drifting toward expiry now starts bidding harder on its own, with no
extra wallet read.

65 unit tests. Mutation-checked: always-urgent fails 11, unknown runway
reading as relaxed fails 2, pricing off the loosest member fails 4,
persisting slack instead of the deadline height fails 2.
# Conflicts:
#	src/screens/Strike/CheckingAccountNew/Settings.tsx
#	src/services/ark/exitFunding.ts
Three defects in yesterday's runway pricing, all found by pointing it at
the device rather than by review.

1. A single fee API is a single point of failure, and it fired the same
   day. mempool.space was unreachable from both the phone and the dev
   machine on 2026-08-20, so every band collapsed to one constant.
   Sources are now chained: mempool.space named tiers, then esplora
   /fee-estimates keyed by confirmation target across the providers the
   wallet already rotates (blockstream answered when mempool.space did
   not), then constants.

2. The fallback was a flat congestion hedge for every band, which quietly
   undid the runway pricing at exactly the moment it could not be
   checked. Measured live: a wallet with 26 days of runway priced at 10
   sat/vB, demanding 120,720 sats for a forced exit instead of about
   24,000. The hedge now scales with the band. It exists because we
   cannot see the market, so it should track how little time we have to
   correct a bad guess: with weeks of runway an under-bid is recoverable,
   since the drive re-prices every tick and the band climbs as the runway
   shrinks, while an over-bid is charged immediately as a reserve the
   user has to go and fund.

3. When nothing survived at the dearest rate there was no selection to
   read an urgency off, and the empty set's 'urgent' pinned the rate high
   and kept the answer permanently empty. A wallet exitable at economy
   but not at fastest got nothing. The cheapest band is now probed first
   in that case, and if anything is exitable down there its own runway
   decides the rate.

Rate tables are also normalised now: missing bands inherit the dearer
neighbour, and a cheaper band may never cost more than a dearer one. An
inverted table would have a relaxed exit outbidding an urgent one, which
is the exact failure this path exists to prevent, and fee providers are
not obliged to be sane.

73 unit tests. Mutation-checked: a flat fallback, a missing monotonic
clamp, filling gaps from the cheaper neighbour, rounding an esplora
target up instead of down, and inventing a table from an unusable
response each fail one.
…the fee

The screen showed only "From CoinOS". The amount being sent, the network
fee, the total leaving the wallet and the destination address were all
absent, on a screen whose entire job is to state those numbers before the
user swipes to move money.

Cause is GradientCard. It hard-codes height: 60 on both its
TouchableOpacity wrapper and its LinearGradient, because it is the
single-row input pill, and every other caller overrides that through
linearStyle. This screen passed only a margin, so the card stayed 60px
tall and clipped every row below the first. Nothing errored and nothing
logged; the numbers were simply not on screen.

A details panel is not a pill. Replaced with the plain bordered View the
sibling Ark review screens already use, which grows with its content and
cannot silently truncate.

Found on device while topping up a real exit-fee reserve.
The screen sent the shortfall and nothing else. There was no field, no
way to send more or less, and the fee it quoted was for an amount the
user never got to pick.

Now: an amount field seeded with the suggested shortfall, then who it
comes from, where it goes, the deposit address, the network fee, and what
leaves the wallet in total.

Three things the field drags in with it:

The fee follows the amount. A fee quoted for the mount-time shortfall
goes stale the moment the field is edited, and "Leaves your wallet" is
built from it, so the estimate is re-quoted on a 500ms debounce rather
than per keystroke, since it is a network call. A failed re-quote keeps
the previous figure instead of blanking the row.

The send uses the typed amount. It previously passed the mount-time
shortfall to fundExitFeesFromCoinos, which would now silently ignore what
the field said.

The column scrolls. A field, five rows, two notices and a system keyboard
do not fit a small screen, and the layout had scrolling disabled, which
made anything pushed off the bottom unreachable rather than merely
awkward. The address also wraps to two lines instead of truncating.

An empty field now reads as "enter an amount", not as a plan failure.
Sending less than suggested is allowed and says what it means: the exit
runs as far as the fees allow.

GradientCard is used for the field, where its fixed 60px height is the
intended behaviour, and deliberately NOT for the details panel below,
which is what clipped the numbers in the previous commit.
…ats over Lightning

After funding the exit-fee reserve the user saw "Payment Sent / 0 sats /
$0.00 / Lightning Network", with a Lightning bolt, for a real on-chain
deposit that had actually left CoinOS. Every part of that was wrong: the
amount, the currency figure, the rail, and the implied finality.

The cause is a parameter mismatch nobody would see without running it.
ArkSendSuccessScreen reads value, valueUsd and currency. The funding
screen sent title, message and txid. None of those names meet, so the
screen fell back to its defaults, which are '0', '0.00' and a hardcoded
Lightning headline and rail, and the message explaining that the deposit
still has to confirm was dropped on the floor.

The screen now takes optional title, networkLabel, isOnchain and note,
defaulting to exactly its previous Lightning behaviour so the existing
send path is untouched. isOnchain swaps the bolt for the Bitcoin visual
the on-chain broadcast screen already uses, rather than a new asset.

The funding screen passes the sats it actually sent, the rail it actually
used, and keeps the sentence about needing a confirmation, which is the
part that matters: an on-chain deposit is not spendable on arrival, and a
success animation that implies otherwise is how "nothing happened" gets
reported a minute later.

Also fetches a fiat rate. The caller never passed one, so rate defaulted
to 0 and every fiat figure on the funding screen, not just the success
screen, rendered blank or 0.00. Fetched the same way the non-Strike rails
do in SwapAmount; a failure leaves fiat blank rather than showing a wrong
number.
… not have

The Bark Vault settings switch read green while the home screen said
"Notifications off. Capsules can expire without warning." Both were
working as written. They report different facts, and only the home screen
was reporting the one that matters.

The switch showed `arkBgRefreshEnabled`, which is a stored preference and
defaults to TRUE. The banner probes the OS permission. iOS resets
notification permission on every install, and permission is only ever
requested inside setArkBackgroundRefreshEnabled(true), which runs when the
switch is FLIPPED. A switch that came back on by itself was therefore
never flipped, so iOS was never asked, so no reminder could ever arrive.

This is the worst failure available to this particular control. Its own
subtext promises five reminders and warns that recovery is not guaranteed
once a capsule expires, so a user reading a green switch has affirmative
evidence of protection they do not have. It reproduces after every
reinstall, restore and recovery, which is exactly when someone is most
likely to be relying on it.

The switch now shows the EFFECTIVE state: the preference AND the OS
permission. Green means a reminder will reach you.

Turning it on re-checks whether the OS actually agreed. iOS prompts at
most once per install, so a user who declined earlier gets no prompt and
no permission, and reporting "Reminders enabled" there would be the same
lie in a different place. That case offers to open system settings
instead.

The subtext gains a third state for wanted-but-blocked, since "off" and
"blocked" need different actions from the user. Permission is granted
outside the app, so the screen re-probes on foreground.

Found on device: a fresh install showed the switch on and the banner
warning, at the same time, on a wallet holding live capsules.
# Conflicts:
#	src/screens/Strike/CheckingAccountNew/Settings.tsx
… refresh them

Capsules do not arrive near expiry in good condition. They get there by
failing to refresh, repeatedly: a cancelled round, a server that would
not cosign, an app nobody opened. Each failure leaves the capsule where
it was and lets the clock run, and the ones involving out-of-round
spending push exitDepth up too. So the nearly-expired population is
disproportionately the deep, expensive one, which is exactly what an exit
handles worst, since cost is linear in depth while the value is not.

Refreshing resets BOTH the depth and the expiry clock for about a sat.
That is worth an order of magnitude more than exiting, so the honest
advice for a stale capsule is to refresh it and exit later.

This is an ECONOMIC floor, not a safety one. The temporal axis already
refuses capsules that cannot clear their timelock in time, and those stay
hard-excluded under every policy. A capsule with three days left is
perfectly safe to exit and still a bad idea.

So it is overridable. Refreshing needs the ASP, and a user reaching for a
unilateral exit may have no ASP to reach; making this a law would strand
exactly the person the feature exists for. 'recover-everything' takes
them, with the cost stated. What it stops is the common case, where an
exit quietly spends a fortune dragging out stragglers.

Freshness is evaluated LAST, after the economic axis, and the ordering is
the advice rather than cosmetics. Telling someone to refresh a 400-sat
capsule is useless: it is below the per-input round minimum and cannot be
refreshed alone. That one is correctly reported as costing more than it
holds. The freshness reason is reserved for capsules that are worth
exiting on the numbers and are merely stale.

An unreadable chain tip does not mean stale. Guessing would abandon a
healthy wallet on a failed network read.

Falls out of this, and worth knowing: the floor makes the 'soon' and
'urgent' fee bands unreachable under the default policy, since a selected
capsule always has 420+ blocks of slack. That is coherent rather than
dead code. Exit only fresh capsules and the exit is never in a hurry, so
it never bids for speed. The urgent bands stay live under
'recover-everything', which is precisely when the forced capsule really
is racing its expiry.

88 unit tests. Mutation-checked: removing the floor fails 5, making it
unoverridable fails 13, running it before the economic axis fails 2, and
treating an unknown tip as stale fails 2.
The rotation list held two entries. On 2026-08-21 one of them was down
for an entire session while the other rate-limited the device, so there
was nowhere to rotate: five open attempts alternating between a 429 and a
dead host, repeatedly, for hours, and the wallet would not open at all.

A unilateral exit that was already in flight stalled with it. The drive
cannot advance an exit tree without a chain source, so seven capsules sat
untouched while bark's view of the tip fell 14 blocks behind, and the UI
showed nothing wrong the entire time.

Two entries is not redundancy. It is a single point of failure with a
spare that has to be perfect.

Two more providers, both verified answering /blocks/tip/hash with a valid
64-char hash and serving /fee-estimates. Ordered by operator independence
rather than preference: the community mirror sits between the two
mempool.space entries so one operator's outage cannot burn consecutive
attempts, which is exactly what happened. The regional mempool.space node
goes last because it stayed up while the apex did not, making it a
backstop rather than a first choice.

The list moves to a pure module with the attempt count, because the
invariant tying them together was unenforceable where it lived: a
provider added past OPEN_ATTEMPTS is never tried and nothing says so.
config.ts cannot be imported under jest, it pulls in the SDK, so there
was no way to test any of this. esploraProviders.ts has no imports at
all, matching exitTriage.ts and the other decision helpers.

Privacy is unchanged for a working wallet. Rotation only happens once the
provider before it has FAILED, and the wallet still opens against the
first entry alone, so a healthy wallet talks to exactly one provider as
it always did. The extra entries change who sees an address only when the
alternative is not working.

7 unit tests. Mutation-checked: restoring the two-entry list fails 1,
setting attempts below the provider count fails 1, putting same-operator
entries adjacent fails 1, and silently reordering the primary fails 1.
@CypherBoxLLC
CypherBoxLLC requested a review from Bamskki as a code owner August 23, 2026 03:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants