Land 23 device-tested fixes as one bundle: exit, refresh, vault, chain source - #208
Merged
Merged
Conversation
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.
… qa/integration-2026-08-19
…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.
Bamskki
approved these changes
Aug 23, 2026
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lands 23 already-reviewed PRs as the configuration they were actually tested in.
Merging them individually would create 23 intermediate states on
mainthat 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 frommain.Unilateral exit
The bulk of this work, and the part with the most on-device evidence behind it.
Refresh and capsule state
Vault and recovery
Chain source, network and payments
Docs
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
startExitForVtxosthrough eleven exit-tree transactions and their CPFP children to a single batched claim.That run produced measurements rather than a smoke test:
Automated checks on the merged branch:
npx jest -b -i tests/unit: 52 suites, 546 passed, 1 skippedtsc --noEmit: 400 errors, against 405 onmain, so this reduces the baseline by 5 and adds noneMerges to
mainwith 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_approvalwill deadlock if the approver is also the last pusher.Not included
#202, #203 and #205 are branched off
mainand target it directly. They should be rebased onto the newmainand device-tested after this lands, since the phone was tied up with the exit run until now.