feat: migrate to @cashu/coco v2.0.0-rc.0 — quote-first, multi-unit, bolt12/onchain receive, onchain send#251
feat: migrate to @cashu/coco v2.0.0-rc.0 — quote-first, multi-unit, bolt12/onchain receive, onchain send#251Kelbie wants to merge 180 commits into
Conversation
…hments Root cause of "cost unavailable": 4 of TIER_MATRIX's 9 hardcoded model ids (claude-3.5-haiku, grok-3-mini, grok-4.1-fast, grok-4) no longer exist in the live /v1/models catalog, so estimateTurnCostSats returned null and the picker rendered the literal fallback string. - Delete TIER_MATRIX; derive top-3 chat-capable models per provider (OpenAI/Claude/Grok/Google) from the live catalog in the new deep module shared/lib/routstr/lineup.ts: text-output-only qualification, rolling '~'-alias dedup in favor of dated concrete rows, one documented capability ordering (created desc → context desc → id), tiers assigned capability-first (Max = newest). - Persist a compact tolerant last-known lineup snapshot in routstrStore so the menu keeps prices offline; per-provider zero-row drift substitutes from it, annotated "last known". Provider/tier unions now import from the shared module (previous hand-lockstep duplicate removed). - Model picker: 4th Google tab, vision glyph on image-input rows, partial rows for thin providers, neutral loading row on first-run offline. - Composer [+] attaches images (PostComposer picker UX): EXIF-strip + ≤1536px downscale + base64 data-URL image_url content parts inline in chat/completions (routstr-core transport), memoized per-session encode, 2-newest-turn / 4-image inline window, vision-aware candidate-chain fallback, dim-disabled [+] on non-vision models, thumbnails in bubbles with missing-file placeholders. - Persisted message attachments are bounded local-URI metadata (never base64) behind a tolerant schema; routstrStore joins the schema-drift registry with legacy/malformed-blob regression fixtures.
…egrade, stale-attachment clear Folded from three parallel real-diff reviews (redteam, security-privacy, data-contracts; zero leak/data-loss defects found): - Advance the send-path candidate chain on model-rejection errors (404, or 400 whose message references the model), not just network/5xx — without this a dead last-known model id hard-failed on the first candidate and the chain's documented dead-id absorption never ran. - When an image-bearing request has NO vision-capable candidate (retry of an old image turn after switching to a text-only slot), strip the image parts and send text-only instead of guaranteeing a non-retryable 400 (new stripImageParts + test). - Clear pending composer attachments when the resolved model loses vision input after picking, so submit can never carry images the selected model must reject. - Correct the format.ts docblock's description of what persists (the (provider, tier) selection is session-only; persisted selectedModel is the legacy UserMessagesScreen surface).
… mount
On-device diagnosis (log.txt 2026-07-02): the app gated sends against a
persisted balance of 299 sats while the server reported 216 mSats
available ("Insufficient balance: 85577 mSats required. 216 available."),
so the cheapest lineup model (gemma-4-26b, ~107-sat reservation) passed
every client-side affordability check and 402'd on every send — an
endless insufficient-balance popup. The only balance refresh point was
the post-SUCCESSFUL-stream diff in useAiSend, which a 402 never reaches,
so the stale figure could never self-correct.
- throwResponseError now syncs the store balance from the 402's parsed
`available` mSats, next to the existing 401 key-clear — the pill,
picker fades, and estimates turn truthful the moment the server
disagrees (every Routstr surface benefits).
- ModelChip mount refreshes checkBalance alongside the models fetch, so
a stale balance self-heals on opening the AI tab.
- The 401/402 store access switches from dynamic import() to a lazy
require() accessor: identical cycle-avoidance, but executable under
Jest's CJS VM — which is what makes the new regression test possible
(the 402 body in the test is byte-shaped like the live response).
…fecycle Before coco v2's forward-only SQLite migration runs in repositories.init(), take a one-time file copy of each still-v1 database (<dbName>.pre-v2 + WAL/SHM sidecars) and log schema table/migration counts plus per-unit ready-proof sums before and after init, so the first v2 boot is verifiable from log.txt. Backups hold spendable proofs: deleteDatabase/completeReset now remove the backup set with the live DB, and the storage inventory lists backups as a separate group (never included in dump/share flows).
…n lockstep Exact pins (no ^) so a later rc cannot slip in silently. cashu-ts 4.5.1 sits inside coco-core's '4.5' range, so root/app/wallet all dedupe to one hoisted copy (verified via node_modules). Installed artifact parity checked against the audited coco master checkout: published 2.0.0-rc.0 bundles migrations 001-036 including the 024-036 v2 set. Adds jest moduleNameMapper entries for the new @cashu/coco-core/adapter and /plugin subpath exports. Type-check is intentionally red from this commit until the v2 API migration lands (steps 3-8): 156 app + 18 wallet errors snapshot as the work checklist.
Wallet.createMeltQuoteBolt11(invoice) keeps its positional signature in 4.5 — the rebalance probes stay unchanged except fee_reserve, which is now an Amount value object and goes through amountToNumber instead of Number coercion. WalletService.getWallet is cached per (mint, unit) in coco v2 and requires the unit: the managerInternals seam and its callers (legacy migration, rebalance orchestrator) now pass 'sat' explicitly. Migrated legacy proofs carry the required CoreProof.unit field.
…aware keyring Deletes the toPersistedCocoNumber number-healing pipeline and every normalization repo wrapper: it existed to heal corrupt persisted numbers under v1's INTEGER schema, and its 'toNumber' branch would silently coerce coco v2's hydrated Amount value objects back to numbers. v2 stores money as TEXT and owns hydration; the overlay now only overlays the keyring. The keyring overlay threads v2's KeypairPurpose through every read/delete and consults the in-memory ephemeral overlay only for p2pk-compatible queries — the profile signer key can never answer a nut20_mint_quote query, and a colliding-pubkey NUT-20 keypair persists through the delegate. Legacy-row scrub now targets purpose 'p2pk' explicitly. New v2 repositories (legacyMintQuote, paymentRequestReceive*) pass through the scope spread untouched (regression-tested). Types now import from @cashu/coco-core/adapter per the v2 export map; managerInternals getWallet carries the required unit.
…ewatch Mirrors initializeCoco's enable order in the app's gated two-phase lifecycle (kept manual so the mint-op processor stays behind the NUT-13 RestoreGate and the NPC plugin can register late): the mint-op watcher now rewatches pending canonical quote rows on start (reusable bolt12/onchain quotes), and phase 2 enables the new MeltQuoteWatcherService + MeltSettlementProcessor. Cleanup and disableWatchers tear both down. CocoProvider runs the payment-request receive recovery sweep beside the existing op recoveries (safe no-op while the NUT-18 incoming saga is unused). Plugin type now imports from the @cashu/coco-core/plugin subpath.
executeMintQuote and executeMelt (plus the app's cocoOperations wrappers) now
create the canonical quote row via manager.quotes.*.create and prepare the
durable operation against it — the v1 one-shot prepare shapes are gone. The
onchain mint arm replaces the 1.0.1 'not supported' throw: each onchain quote
gets a fresh deposit address (fixed-amount receive relies on that for payment
attribution) and the operation amount is passed explicitly, as v2 requires
for reusable quotes. requireSatUnit is deleted; the unit parameter threads
through to quote creation (call sites still pass sat until the active-unit
work lands).
Send memo now persists via ops.send.execute(id, {memo}). Synthetic history
entries move to colada-local types with numeric amounts — coco v2 Amount
value objects convert once at this boundary and are never JSON-stringified.
classify.ts adapts to cashu-ts 4.5: getDecodedToken requires a keyset list
(empty on the mesh path; short v2 keyset IDs classify as invalid), the
removed getP2PKNSigs helper is replaced by reading the NUT-11 n_sigs tag,
and proof amounts sum through amountToNumber.
…pdated The removed mint-op:quote-state-changed event is replaced by the canonical mint-quote:updated in both subscribers. The payment-status listener keys the bolt11 PAID toast off the quote row; mint-op:pending now fetches the canonical quote (v2 moved remote-state observation off operations) to preserve the NPC paid-quote toast and its freshness heuristic. The screen-actions bridge publishes quote updates keyed by quoteId only — v2 quote events carry no operationId. manager.on's CoreEvents typing makes any future event rename a compile error; no removed names remain outside comments.
Deletes the melt supplement (listMeltSupplementEntries/meltOpToHistoryEntry, their barrel re-exports, and the meltEntries merge input): v2 projects prepared/pending/finalized/rolled-back melts into history directly, so the supplement would double-count every melt. The in-flight-receive supplement stays — v2 only projects receives at finalized/rolled_back. Melt-op events now refresh history page 0 instead of re-fetching the dead supplement. Adds wallet/src/history/normalize.ts: v2 operation-family states map to the legacy vocabulary once at the useColadaTransactions read-model boundary (mint pending→UNPAID, executing→PAID, finalized→ISSUED; melt prepared→UNPAID, pending→PENDING, finalized→PAID; rolled_back→rolledBack), so filters, timeline, buckets, and presentation keep speaking one dialect. Balance snapshots and swap-group amounts convert coco v2 Amount value objects to numbers at the read model. App-constructed entries (previews, poll fallbacks, mock data, toast/swap synthetics) move to legacy-variant shapes with numeric amounts via the new shared/lib/cashu/syntheticHistory bridge — the single sanctioned cast back into HistoryEntry. The p2pk-import helpers (typed against v1 Manager) bridge with a nominal cast at the seam.
vitest.config.ts drops the coco-submodule-era alias block (its paths no longer exist; the broken @scure/bip32 alias resolved the TS source and took down 38 suites). With coco as a registry dep the @noble/@Scure realm hoists at coco's 2.x pins and nostr-tools keeps its own nested 1.x realm, so natural per-importer resolution is correct; only the @CASHU entry pins remain, plus the new /adapter and /plugin subpaths. The onchain-unsupported assertion becomes a quote-first success test (canonical quote created before prepare, explicit amount, entry fields from the quote). history-aggregate tests drop the deleted melt supplement and gain a table test over the new state normalizer. Amount value objects in test literals via Amount.from. Checkpoint: wallet vitest 57/57 (817), app jest 173/173 (1791), both type-checks clean.
Adds the persisted per-profile active wallet unit (coco v2 multi-unit) —
distinct from the fiat display currency. mintStore gains a tolerant additive
activeUnit field (sat/usd/eur/gbp, default+catch 'sat'; golden snapshot
updated, rehydration regression tests added — old and corrupt blobs never
wipe the store).
useActiveUnit() derives available units synchronously from trusted mints'
NUT-04 method-unit metadata (new colada deriveSupportedUnitsFromInfo /
SWITCHABLE_UNITS) and falls back to sat when the persisted unit loses
support. The unit scopes: WalletContext (byMintAndUnit balance slice,
unit-parameterized capability map, unit-filtered proof amounts),
useColadaBalance(unit) (total/byMint/pending/redeeming), the wallet-screen
machine + navigation params (the stale ACCOUNT={unit:'sat'} scaffolding is
deleted), the shared amount-entry flow, profile/messages sends, and the
transaction list (useHistoryWithMelts unit filter). NearPay stays sat-pinned
at the protocol level.
New UnitSwitcherPill opens the canonical actionMenuPopup with unsupported
units disabled; it shares the balance-region row with FiatCurrencyPill,
which (like displayBtc cycling) now renders only when the wallet is
sat-denominated — a fiat conversion of a fiat-denominated balance is
meaningless.
… singletons The receive hub becomes four capability-gated amountless rails. Bolt12 and Onchain tabs appear when ANY trusted mint advertises the method for the active unit (computeReceiveTabs, unit-aware; gating on the selected mint alone would flicker on mint change — the tab body shows an empty state naming the gap instead). P2PK keeps its quickAccessP2PK gate; Lightning stays first and hosts the npub.cash address as before. Coco v2 does NOT enforce reusable-quote cardinality — every create() mints a fresh bolt12 offer / onchain address. The product contract is ONE standing offer/address per (mint, method, unit): colada's new ensureReusableMintQuote (get-or-create over quotes.mint.listPending, null expiry = never expires, never rotates an open quote) + useReusableMintQuote (re-reads on mint-quote:updated) own that idempotency; one ReceiveReusableQuoteTab component renders both rails (offer QR / bare standing address; BIP-321 URIs with amounts stay exclusive to the fixed-amount flow, which creates fresh quotes for payment attribution). Deposits auto-mint via coco's watcher + processor. Icon rule: filled bolt = one-shot invoice, outline bolt (mingcute:lightning-line, added to the monicon registry) = reusable offer, blockchain glyph stays onchain. MintPaymentMethod gains 'bolt12'; isMethodImplemented flips onchain fully on and bolt12 on for minting (bolt12 send stays off). The wallet tests that encoded the old 'onchain hidden' gate now assert the live routing: EXECUTE on an address opens amount entry, onchain receive proceeds to the quote or the method-aware mint selector, and selector candidates gate per mint.
executeMelt detects onchain targets (bare address or bitcoin:/BIP-321 URI
via colada's canonical parser) and runs the coco v2 onchain melt saga:
quotes.melt.create({address, amountSats}) surfaces the mint's NUT-30
fee_options, the fee choice resolves through the app-provided
selectOnchainFeeIndex (an actionMenuPopup listing ~blocks + fee reserve;
dismiss cancels), then ops.melt.prepare({quote, feeIndex}) reserves proofs
and execute() runs with the same cancel-after-failure rescue as Lightning.
The fee choice deliberately happens BEFORE prepare — no proofs are reserved
while the picker is open, so cancellation holds nothing (the plan's
FEE_OPTIONS machine-state variant was simplified to this callback shape
because the guarded property it protected, no reservation during user
think-time, is preserved; flagged for review). Without a picker the cheapest
option is selected. Pending settlements advance via the melt quote watcher +
settlement processor from the manager lifecycle.
Deletes the dead 'Onchain send is not supported yet' gates in resolveNext
and annotate (unreachable since isMethodImplemented flipped) and the
OnchainSendScreen placeholder card — the screen renders the live melt
state, destination, and BIP-321 context. Lightning melt entries also carry
their real unit + numeric amount now.
Auto-fixable prettier drift from the migration edits, hoisted jest.mock blocks, pruned unused imports, and unexported the internal-only synthetic history / receive-tab helper types knip flagged.
Eight parallel review finders swept the PR diff; ~28 candidates were deduped and verified against coco source. Surviving findings, applied: Standing-quote collision (critical): reusable quotes never leave coco's pending list and fixed-amount onchain quotes are shape-identical to the standing one, so the newest-first listPending scan let a fixed-amount request displace the receive tab's standing address — breaking payment attribution. ensureReusableMintQuote now resolves by RECORDED identity: the app persists the standing quoteId per (mint, method, unit) in mintStore.standingQuotes (additive tolerant field, snapshot updated) and the singleton creates + records only when nothing recorded resolves live. The hook re-reads only on mint-quote:updated for that exact quote. Collision regression tests added. User-cancel routing: dismissing the onchain fee sheet threw a generic error that surfaced as a red payment-failure toast. It now throws the named MeltUserCancelledError; the machine's failure router detects it and fires the new onPaymentCancelled notification (app: quiet processing-toast dismissal). Stale unit closures: WalletScreen's action callbacks captured the old account.unit after a unit switch (deps never updated when ACCOUNT became state) — Scan QR navigated with the previous unit. Deps fixed. Multisig classification: a PRESENT but malformed n_sigs tag defaulted to 1, letting an unspendable multisig token classify as 'mine'; it now counts as multisig (absent tag stays single-sig per NUT-11). Unreachable funds: units the wallet HOLDS stay in the switcher even when no trusted mint advertises them anymore (byUnit balances feed availableUnits). Silent deposits: bolt12/onchain standing-quote deposits now fire a confirmed receive toast from mint-op:finalized (bolt11 keeps the PAID-processing path); the mint-op:pending NPC handler gates to bolt11 BEFORE its quote fetch and guards its async body against unsubscribe. Consistency + hygiene: poll-success mint entries serialize through the new serializeHistoryEntry (legacy vocabulary + numeric amount) matching the fallback shape; byMintAndUnit guarded pre-first-load; the safety rails scan for non-canonical v1 amounts the deleted healer used to coerce and gate the post-init schema dump to migration boots; the melt execute/cancel-rescue and entry builder are one shared helper across bolt11/onchain; the melt-op double page-0 refresh is gone (history:updated is the single trigger); proof fetches run concurrently; normalize.ts documents its real boundary and the failed→UNPAID compromise; dead ternary and no-op setQuote removed.
Adversarial review pass (Stage F) — findings applied in ff5dc2aEight parallel finders (line scan, removed-behavior audit, cross-file tracer, reuse/simplification, efficiency, altitude, conventions) swept the diff; ~28 candidates were deduped and verified against coco source. Fixed: Bugs
Product gaps Cleanups: shared melt execute/cancel-rescue helper (one copy of the safety-critical rescue), melt-op double page-0 refresh removed, concurrent proof fetches, schema dumps gated to migration boots + a non-canonical-v1-amount scan (surfaces rows the deleted number-healer used to coerce, which would make Verified-accepted (no change): melt-supplement deletion is safe (v2 projects all melt states — checked in the installed dist); standing-address "rotation" fear refuted (reusable quotes never leave pending); vitest realm-pin removal validated against the installed tree; Gates after fixes: type-check 0/0, app jest 175 suites / 1798, wallet vitest 59 files / 832, lint 0 errors, knip + prettier clean. The first-build verification table in the PR body is unchanged and still owns device-side validation. |
Adds a dedicated coco.feedback.* layer so field problems with coco v2 can be reported to the cashubtc/coco maintainers with the detail they need: exact installed versions (coco-core/react/expo-sqlite + cashu-ts, stamped once per manager init and attached to every report), which coco API failed with which coco error CLASS (upstream's own vocabulary — QuoteIdentityConflictError, UnitMismatchError, ProofValidationError, …), and detected anomalies: Manager/repositories init failures, watcher/processor/recovery enable failures, quote-first prepare failures, a migration-boot that applied no migrations, and a history-projection duplicate-id tripwire in colada's read model (the double-projection class). Two collection paths, both redaction-safe (names/lengths/counts, no payment strings): - log-doctor gains an 'upstream' mode: versions header, the migration trail, and grouped anomalies + coco-boundary warnings, formatted to paste straight into a GitHub issue. (Run against today's v1 session it already surfaces a ReceiveOperationService recovery loop ×18, a send op stuck in rolling_back, and a subscription polling rate-limit error.) - Settings → Storage gains 'Copy coco v2 Report' for release builds, built from the in-memory log ring buffer.
Added: upstream-feedback capture for coco maintainers (8afa9d2)Per the goal of feeding field experience back to cashubtc/coco, the branch now has a dedicated
Two ways to collect it:
Proof it works: run against today's pre-migration session log it already surfaces three candidate upstream reports — |
Metro rejects dynamic require() and coco's exports map hides package.json subpaths, so the runtime version resolution in cocoFeedback broke iOS bundling. The dependencies are pinned exactly (no ranges), so the pins ARE the installed versions: COCO_VERSIONS is now a plain constant, and a new jest guard fails the suite if a future dependency bump forgets to update it — a feedback report with a wrong version would mislead the coco maintainers.
…it-follows-mint Field-testing fixes on the coco v2 branch: Onchain fixed-amount receive threw 'Onchain mint quotes are not supported by @cashu/coco-core 1.0.1': the app's executeMintQuote override (the poll-for- persisted-row wrapper in sovranPaymentConfig) still carried the v1 guard and shadowed colada's onchain-capable default. The override now dispatches by method through a new prepareOnchainMintQuote wrapper (fresh reusable quote + explicit-amount prepare); the poll/fallback flow is method-agnostic. Standing-quote regeneration hardening: the singleton's identity key now uses coco's normalizeMintUrl (the same mint reached via different URL spellings must map to ONE standing quote), an in-flight per-key promise dedupes double-mounted tabs, and every create logs a NAMED reason (no_recorded_quote / recorded_not_found / lookup_failed / method_mismatch / unit_mismatch / not_reusable / expired) with the compared values — so if a QR still rotates on device, log-doctor points at exactly why. Per-method 'Receiving with': the Bolt12 and Onchain tabs were riding the npub.cash mint. mintStore gains a tolerant receiveMintByMethod map (mirrors npcMintStore's role); each tab shows its own RECEIVING WITH row and picks from colada's buildMethodAwareMintCandidates (capability + unit gated; unsupported mints disabled with colada's reason) via the canonical actionMenuPopup. The unsupported empty state is tappable to pick a mint. Unit follows the preferred mint: switching the wallet's mint now snaps the active unit to sat (or the mint's first supported unit) when the current unit isn't in the new mint's CACHED NUT-04 metadata — offline-safe, no info endpoint call — keyed on mint change only, so manual unit switches are never fought. No upstream feedback has been or will be submitted from this work; the coco.feedback layer only collects locally.
Field logs (quotes.reusable.recorded_discarded reason=expired, recordedExpiry=0) showed the standing bolt12 offer being discarded as expired on every tab open: the mint sends expiry 0 as a "no expiry" sentinel for bolt12 offers, but isUnexpired read it as a 1970 timestamp and rotated the offer each time. Onchain quotes carry expiry null, which is why only bolt12 regenerated. Treat any non-positive expiry as never-expiring, and warn (quotes.reusable.zero_expiry) when a reused quote carries expiry 0 — coco core's own isMintQuoteExpired treats 0 as expired too, which is upstream-feedback material since it may stop the watcher tracking such quotes.
…rules The unit menu now lists only units at least one trusted mint supports and the pill greys out when there is nothing to switch to. Two follow rules remove the remaining assumptions: - Picking a unit the preferred mint doesn't support re-points the preferred mint to the highest-balance (in that unit) trusted mint that does (colada pickMintForUnit). - Changing the preferred mint when it lacks the active unit snaps to the unit holding the highest balance AT THAT MINT, tie-broken sat-first (colada pickHighestBalanceUnit), instead of blindly to sat. Both decisions are pure colada helpers in mint-capabilities with vitest coverage; the app supplies cached NUT-04 info and byMintAndUnit balances, so everything works offline. The rules cannot ping-pong: selectUnit always lands on a supporting mint, so the mint-change sync is a no-op after a unit pick.
…elect page
The Bolt12/Onchain tabs' mint pick was an inline actionMenuPopup with its
own row styling; it now runs the exact NPC lane: the HistoryEntryRefresh
row (mint icon + "Receiving with" + pencil) opens the machine's
mint-select page via requestMintSelector with a new persist-only scope
('bolt12' / 'onchain').
colada owns the whole flow: the scope opens with clean context (same
stale-destination guard as NPC), carries the rail's method requirement so
the page lists every trusted mint with non-supporting ones disabled with
the capability reason, and MINT_SELECTED dismisses and fires the new
onReceiveMethodMintChanged notification — never advancing a flow and
never touching the preferred/NPC mints. The app only navigates, persists
the pick into mintStore.receiveMintByMethod, and renders.
New receive screen actions changeBolt12Mint/changeOnchainMint gate on a
trusted mint supporting the method for the entry's unit. Machine flow
tests mirror the NPC selector spec (clean-context open, disabled
candidates, persist-only pick, per-method notification).
The Bolt12/Onchain rails no longer fall back to the hub's npub.cash mint — the four mint selections (preferred, npc, bolt12, onchain) are now fully independent. colada's new resolveReceiveMethodMint owns the policy: an explicit pick persists and is honored while that mint stays trusted; otherwise the rail auto-derives the FIRST trusted mint supporting the method. The auto pick is computed per render, never persisted, so trusting a bolt12/onchain-capable mint populates the rail immediately and untrusting it self-heals to the next one (a stale explicit pick pointing at an untrusted mint heals the same way). Bolt12/Onchain tabs are now permanent: when no trusted mint supports the method the tab body shows "None of your mints support …" with a Find-mints button deep-linking into mint discovery pre-filtered by method (/(mint-flow)/add?method=bolt12|onchain — title, empty state and row filter follow). The filter keys off a new optional supportedMethods field on nagg's DiscoverMint row (distilled from the mint's nuts."4".methods per NUT-23/25); until nagg ships it, filtered discovery states "No known mints support X yet".
When the tab labels can't all fit at a readable width (intrinsic bold label width + 12px padding per side), the bar keeps the longest fitting prefix and moves the rest behind a trailing (…) button that opens the canonical action-menu sheet. The sheet goes through the FWO lane (actionMenuSheet) so it stacks above route modals — the receive hub is one. The (…) button carries the active underline whenever the selected tab lives in the overflow, and the menu checks the selected entry. Label widths come from an invisible measurement row rendered with the same Text primitive (bold — the widest weight a tab renders at), so partitioning respects font scaling and never squeezes the active tab. While unmeasured (first frame) all tabs render as before, and bars that fit keep the existing byte-identical flex layout. Pure partition math lives in underlineTabsLayout.ts with jest coverage; all four UnderlineTabs consumers (receive hub, contacts, share, keyring) inherit the behavior.
Device feedback: the (…) menu was swallowing tabs that could have fit. Three knobs loosened: label size 16 → 15, per-side label padding 12 → 6px (snug labels cost less than hiding a tab), and the (…) button slims 48 → 40px. Labels also clamp to one line so a borderline-tight label clips instead of wrapping the bar taller.
Tab bodies were mounted on selection, so first entering Bolt12/Onchain raced the standing-quote resolution and flashed the skeleton. All four tab panes now mount as soon as the hub entry is ready and the selected one is shown via display toggling: the bolt12 offer and onchain address resolve (and their QRs render) while the user is still on Lightning, so switching lands on finished content. Hidden panes keep their state — switching back is free.
Tab label, offer section title, unsupported/none-support empty states, the discovery deep-link title, and colada's capability-reason label all say "BOLT 12" (the spec's own spelling) instead of "Bolt12". Internal method ids stay 'bolt12'.
'Settled off-chain' was asserted from a single PAID-without-outpoint quote read. A mint that flips PAID one poll before publishing the outpoint made the timeline collapse to 'Settled off-chain' and then re-expand to the on-chain phases when the outpoint arrived. nextOffchainSettlementState is a pure reducer over quote reads: the verdict needs two consecutive PAID-no-outpoint observations, and once ANY outpoint has been seen it is permanently off the table (sticky, and the annotation- persisted outpoint extends that across remounts). useOnchainMeltQuote holds the first PAID-no-outpoint read and does one quick ~3s confirm re-check instead of stopping outright.
…redesign The timeline redesign's acceptance surface. timelineScenarioProps renders the CURRENT HistoryEntryTimeline for all 43 designSystemTimelineScenarios frames under the mocked-Reanimated harness and snapshots the resolved structure per frame: ordered text (card label, status header, step labels/info/timestamps, countdown badges normalized), every dot indicator's phase/result + ring progress + strokeWidthPx/pendingColor/waiting-tint + stagger delay, and every connector rail's fill/height/gradient stops. Also locks the previously untested buildTimeline mint arms: lightning UNPAID/PAID/ISSUED/failed and the onchain deposit ladder (no-payment, in-mempool, N/6 confirming, confirmed-waiting-on-mint, mint-PAID, ISSUED) — including the 'never claim Payment received while the quote is UNPAID' invariant.
…s fixes history/states.ts now owns every aliasing table, terminal-failure set, and progression rank that was previously spelled independently across history/normalize.ts, history/timeline.ts (twice), screen-actions/ createManager.ts, the app's onchainMelt.ts, and the design-system mirrors. normalize/timeline/design-system delegate; behavior pinned by the 43-frame scenario snapshot corpus (unchanged). Real bugs fixed at the live-update boundary: - meltOperationToScreenActionEntry mapped rolled_back/failed to 'UNPAID' — a live melt-op:rolled-back rendered a cancelled send as 'Ready to send'. Now speaks the contract vocabulary from the one owner. - The same adapter returned null for coco-v2 object Amounts, so the fallback publish carried no mintUrl/amount and the melt-preview match could never fire (the melt twin of the fixed history-path drop). Amounts downcast at the boundary; publishMeltUpdated additionally runs normalizeHistoryEntry. - mergeEntryUpdate rank guard: live events arrive on independent streams, so a late lower-rank state (executing after finalized/ISSUED) could regress a merged entry. resolveEntryState (the generalization of the onchain-melt reconciler) keeps the most-advanced state; terminal failures win outright; unknown/synthetic states keep updated-wins. Logged as mergeEntryUpdate.state.resolvedBy=rank for log-doctor visibility. - Bridge mint publishers emit contract vocabulary; onchainMelt.ts rank + rollback sets delegate to wallet (now also treating rolling_back as terminal, which the app-local set missed). wallet 1032 tests green; 43 scenario pins byte-identical.
ringGeometry.ts now owns every seam/dash/stroke formula LoadingIndicator's two ring implementations must agree on — the seam policy was previously spelled in three places that had already drifted once. Both ring styles consume it exclusively; a parity suite pins the pre-refactor numeric outputs at full float precision for every live config (timeline px-targeted size 20/3px × counts 1–24, default StatusToast/recovery configs, idle rings, pending scales, disc radii), so the device-tuned visuals are locked. Cascade desync fix: prevSegCompletedRef was written in an effect but read during render, so a re-render between a completedSegments flip and the effect flush mistimed the fill stagger and success-disc delay (verified: the new regression test fails against the old code — resultDelayMs collapsed 505→340 on an unrelated re-render). useSegmentCascade now derives the batch base with the render-adjust pattern, stable until the next flip. ConfirmationSegment keeps its own refs deliberately: they live entirely in effect time and dropping delayRef would re-pulse an already-filled segment when its cascade slot resets (now pinned by a test). Public LoadingIndicator API unchanged; all other consumers pixel-identical. Carries the in-progress onDebugEvent instrumentation already present in the working tree (preserved as-is).
…ine switch
The timeline model is now data + one algorithm: timeline/flows.ts declares 9
flow variants (lightning/onchain mint + melt, send, payment-request send,
receive, receive-recovery, payment-request receive) as ordered milestones
with MONOTONE reached-predicates over the normalized state; timeline/
engine.ts computes the max reached index, styles below/at/above it, and
applies terminal outcomes (expired / rolled-back / already-spent / failed /
settled-offchain) by truncating after the last reached milestone and
appending a terminal step that INHERITS the displaced slot's rowKey — the
device-tuned in-place morphs (3→2 rollback, off-chain collapse sharing the
network slot) become structural guarantees instead of comments. The
'Paid must be complete if any later milestone was reached' class of
invariant is now unexpressible to violate.
classify.ts is the one owner of the completed-ish set (isSettledStepType)
and of getCardLabel/getStatusHeader/getStatusColorType; context.ts carries
the entry helpers verbatim (logging taxonomy preserved, incl.
history.timeline.build.result). timeline.ts is a thin re-export so every
existing import path keeps working.
buildTimeline keeps its exact signature and field-identical output (the 43
scenario pins pass byte-identical); new buildTimelineModel exposes
{steps(id,rowKey,...), outcome, expiresAt} for the upcoming renderer swap.
wallet 1043 tests (incl. 11 new engine tests: monotonicity under
out-of-order states, rowKey inheritance, off-chain slot sharing).
Temporary tx.history_timeline.* / onDebugEvent taxonomy (change-gated, not 1s-tick spam) captured ahead of the renderer swap, which ports these events into the new timeline components.
…able, isolated expiry tick
HistoryEntryTimeline is rebuilt over the wallet flow engine
(buildTimelineModel) as timeline/{TimelineCard,TimelineRow,timelineTheme,
ExpiryCountdown}. Same exported name and props; the 43 scenario pins pass
with zero snapshot value changes (test files only re-point imports).
- timelineTheme.ts is the one metric/timing table: STROKE_PX=3 shared by the
rail and the dot ring, RAIL_HEIGHT/CONTENT_MARGIN_TOP/stagger delays/
LINE_TIMING, connectorType over the settled set, symmetric rowTransitions.
- Rows keyed by the model's semantic rowKey (terminal steps inherit the
displaced slot, so rollback 3→2 and the off-chain collapse morph in place
by construction); label blocks keyed by step id — meaning changes
crossfade, info-only updates (confirmation counts) mutate in place.
- ExpiryCountdown owns the 1s tick; buildTimeline no longer rebuilds every
second — the card re-renders once, at the expiry boundary, via a single
clamped setTimeout.
- key={prState} remount retired on ReceivePaymentRequestQuoteScreen: the
cascade fix + in-place dot transitions settle idle→done without it
(device verify).
- Verbose tx.history_timeline.* debug taxonomy ported verbatim (rowsDiag now
also reports id/rowKey; labelCrossfade keys off id).
Deviations for follow-up: onchain-mint ring ownership still derived in the
view (engine flags only the melt network row); isSettledStepType mirrored
locally pending a wallet root export.
app 2022 tests / 51 snapshots; wallet 1043; tsc clean both.
…, lint - The onchain-mint deposit row's confirmationRing is now declared in flows.ts like the melt network row; the view's isOnchainMint&&PAID re-derivation is gone (ring ownership has exactly one owner). The 43 scenario pins pass unchanged, proving the flag lands on the same rows. - isSettledStepType exported from the wallet root; timelineTheme's local mirror of the settled set removed. - TimelineCard's ported timelineSignature string round-trip removed — step types read straight off the model (signature kept only as the render-log effect gate). - Un-export knip-flagged helper interfaces; prettier fixes for the three pre-existing lint errors carried by the wip commit (AmountSelector, LightningSendRoute, AppGate). eslint: 0 errors. wallet 1043 / app 2022 / snapshots 51 — all green, tsc clean both.
Fixed Lightning targets cannot be paid with a rounded-down amount. Treat insufficient balance as terminal instead of offering an invalid partial payment.
Extend additive transaction annotations with settlement verdicts, outpoint provenance, destination facts, and acceleration state so later detail flows can recover durable context.
Propagate the melt method into processing notifications, suppress long-lived onchain progress toasts, and create a correctly correlated terminal failure when no matching toast exists.
Match rollback events to the correct payment, preserve the operation unit, dedupe only the same melt, and create a terminal failure when no matching toast remains.
Store off-chain verdicts, authoritative outpoint provenance, and destination facts so settled onchain sends reopen immediately and retain the context needed for later recovery.
Watch destination transactions when a mint withholds its outpoint, adopt only a unique exact-amount candidate, and label the explorer link as heuristic. Heuristic matches remain ineligible for paid acceleration.
Integrate the mempool.space guest accelerator with priced offers, invoice handoff, and durable status. Keep offers keyed to the active txid, prevent duplicate invoice requests, accept only explicit service states, and never accelerate heuristic outpoints.
Render onchain melts as Sending while the mint is still accepting the payment, then Sent once accepted. Expand the design-system scenarios and snapshots to cover submission and off-chain settlement.
Anchor segmented rings after spinner transitions and settle completed dots and connector rails to static SVG frames so Fabric commits cannot leave stale grey or rotated terminal states.
Give every initial timeline row a short indexed fade so quote-driven label corrections happen under the entrance transition instead of swapping abruptly at full opacity.
Build a shared Design System scenario catalog with canonical structural snapshots and a complete reusable-component inventory. Add serve-sim and isolated cocod tooling, document the deterministic device lifecycle, and stabilize component tests under the updated harness.
Require receiver-side DLEQ for offline ecash, preserve deterministic counter high-water marks, and reject duplicate or malformed token inputs. Generation-scope irreversible commits, clear stale routing context at every root entry, and pin fee, melt-change, restore, codec, and history contracts.
Degrade unknown profile, distribution, Nut Drop, and swap values locally instead of rejecting an entire persisted store. Expand schema-drift snapshots and add direct merge and per-store resilience coverage.
Exercise SecureStore refusal and cleanup paths, profile-scoped isolation, NIP-17 integrity checks, and sensitive-field redaction. Clean up the NIP-04 cache timer so the full Jest suite exits without open handles.
Validate text-record headers and declared bounds before slicing attacker-controlled NFC payloads. Pin short/normal record decoding and crash-safe Type-4 write ordering with focused tests.
Pin single-flight ownership, polling cancellation and stale-write behavior, deep-link schema rejection, bearer-downgrade consent, and owned-media deletion recovery.
Patch ExpoModulesJSI weak runtime references into forms accepted by the Swift toolchain shipped with Xcode 26.1. Document the local compatibility floor and removal condition once Xcode 26.4 becomes mandatory.
Redeem Ecash is now the only action on a freshly scanned token — Cancel becomes a fallback exit for unredeemable tokens (back nav still works). The melt Cancel X no longer renders as a disabled ghost while a pay is in flight, and Paste/Scan QR disappear from amount entry once a scanned or pasted destination is already fixed on the entry (availability now gates on meltTarget; the buttons only exist to route to a destination).
A send that paid a NUT-18 creq and a receive claimed via one both read as plain bearer ecash when reopened from history: the send's transport metadata only rides the live synthetic entry, never the persisted coco row. Persist a paymentRequest annotation (role, NUT-18 request id, transport) through the existing transaction-annotation store — written at send confirm and at receive-op finalize — and surface Type / Request ID rows on both detail screens. The canonical creq decoder now exposes the request id so the payer side can record it.
Every receive-screen visit mints a fresh request, so stale unpaid requests piled up as forever-pending rows. listPendingPaymentRequestEntries now drops active requests created more than 24h ago (coco stamps createdAt in epoch ms). Display-only: the operation stays active in coco, and a late payment still surfaces as a real receive row.
…tion isOnchainHistoryEntry only detected mints, so onchain sends (NUT-30 melts) matched the Lightning payment-type filter and were missing from Onchain entirely. Persisted coco melt rows carry no metadata, so the merged onchainMelt annotation (outpoint/fee/address) is the durable signal alongside metadata.method on fresh entries; pre-annotation historical rows remain undetectable. In-flight melts (PENDING / prepared / executing) now bucket as Pending instead of Confirmed — an onchain send can wait an hour for confirmations. Pending payment-request rows now read "Receive" like every other incoming row.
Summary
Migrates sovran-app from
@cashu/coco-*1.0.1 to 2.0.0-rc.0 (cashu-ts 3.5.0 → 4.5.1 in lockstep) and ships the v2 feature set: quote-first mint/melt, multi-unit with a wallet-unit switcher, receive tabs Lightning – Bolt12 – Onchain – P2PK, real onchain send with NUT-30 fee selection, and preemptive logging + DB safety rails so the first build is validatable fromlog.txt.Every coco v2 PR (#155–#281) was mapped against the app by parallel read-only explorers over the coco source, folded into an adversarially-audited plan, then implemented in 13 independently-gated steps (one commit each).
Resolved task
2.0.0-rc.0/ cashu-ts4.5.1(single hoisted copy; installed artifact verified to bundle migrations 001–036).executeMintQuote/executeMelt+ app wrappers create canonical quote rows (manager.quotes.*) before durable operations;importQuote→quotes.mint.importsemantics; quote identity is{mintUrl, quoteId}.mint-op:quote-state-changed→mint-quote:updated(payment-status listener re-keys PAID off the quote row;mint-op:pendingfetches the canonical quote for the NPC freshness heuristic);proofs:reserved.amountisUnitAmount; all subscriptions compile againstCoreEvents.Amountvalue objects convert to numbers once at the colada/app read-model boundary; JSON history keeps numeric amounts; app-constructed entries use thesyntheticHistorybridge.Amounts); keyring ephemeral overlay is now purpose-aware — the profile signer key can never answer anut20_mint_quotequery (leak test added); new v2 repos pass through.manager=provider (RestoreGate NUT-13 counter ordering, late NPC plugin, DB ownership); phase 2 mirrorsinitializeCocoorder and addsenableMeltQuoteWatcher,enableMeltSettlementProcessor, payment-request recovery sweep, andwatchExistingPendingQuotesOnStart.mintStore.activeUnit(additive tolerant field, golden snapshot updated, rehydration tests) scopes balance (byMintAndUnit), amount entry, receive/send defaults, capability map, proof amounts, and the transaction list.UnitSwitcherPill(actionMenuPopup, unsupported units disabled) shares the balance row withFiatCurrencyPill, which now renders sat-only. NearPay stays sat-pinned.ensureReusableMintQuote— coco does not enforce cardinality); fixed-amount onchain creates FRESH addresses per request (payment attribution) with BIP-321 URIs; deposits auto-mint via coco's watcher+processor.executeMeltdetects onchain targets; quote-first melt surfaces NUT-30fee_optionsthrough an actionMenuPopup fee picker beforeprepare(no proofs reserved while the user considers; dismiss cancels clean); cheapest option when unattended; same cancel-rescue as Lightning. Dead "not supported yet" gates deleted.Safety rails / observability
<db>.pre-v2file backup (plus WAL/SHM) beforerepositories.init(), logsschema_dump.before/after,migration.applied {fromCount, toCount}, and a per-unit ready-proofbalance_snapshot.pre_migration. Backups are visible in Settings → Storage as their own group, excluded from dump/share flows, and deleted by account deletion (regression-tested).coco.quotes.*,operations.executeMeltOnchain.*,quotes.reusable.*,wallet.unit.*,receive.tabs.computed,send.onchain.fee_*) — lengths/counts only, no payment strings.Architecture notes / plan deviations to review
FEE_OPTIONSmachine state; implemented as a config callback that resolves beforeops.melt.prepare, preserving the property the machine state protected (no proof reservation during user think-time). Flagging for reviewer judgment.activeUnitwith.default('sat').catch('sat')at version 2 (no bump) — the golden persistSchemaDrift snapshot and rehydration tests cover the invariant.Security / privacy
.pre-v2backups contain spendable proofs: sandbox-only, excluded from dumps, deleted on account deletion.Testing
bun run type-check— 0 errors (app + wallet)MemoryRepositoriesintegration suite)Verification table (first build — needs a device/real v1 DB)
startup/cocodb_backup.done,migration.applied {toCount: 36-ish},schema_dump.afterlists canonical tables,balance_snapshot.pre_migrationmatches post-boot balancemint-quote:updated; melt finalizes; history rows correctquotes.reusable.reused); paying it twice auto-mints bothredaction --latestafter the aboveKnown risks
coco-cashu-plugin-npcis typed/built against v1 (bridged by nominal casts); needs the live smoke test above.Increment (2026-07-03): unit-native amount entry + NUT-04/05 amount bounds
Five commits (
b1bbb40d..b46a8b13) finishing the multi-unit story at the amount keypad and consuming the offline mint-info bounds.wallet/src/mint-capabilities.ts): per-(method,unit)minAmount/maxAmountparsed from cached NUT-06 info;AMOUNT_BELOW_MINT_MIN/AMOUNT_ABOVE_MINT_MAXreasons (with structured params) disable method rails citing the least-strict cross-mint bound; newgetUnitAmountEnvelope(ctx, unit, destination)computes the typed-input envelope (ecash rails unbounded ⇒ receive/ecash-send uncapped; Lightning melt gets a real cap). Everything derives from persisted mint info — offline-safe by construction, no new store, coco's async capability API deliberately not used for first-frame UI (FEEDBACK.md item 17).amount-actionsrewrite, no shims):inputMode 'sat'|'fiat'→'unit'|'fiat'; fiat accounts (usd/eur/gbp) type major-denomination decimals resolved to integer cents (effectiveAmount: {value, unit}replaceseffectiveSatAmounteverywhere), no sats math and no "≈ sats" secondary; the display-currency swapper only exists on the sat account.machine.enterAmounttakes aUnitAmount; the reducer rejects an event unit that doesn't matchctx.unit. Unit switches mid-entry reset the draft;amountDraftStoreis unit-gated across the mint-selector round trip; quick-send chips are cent-native on fiat accounts.UnitSwitcherPillunder the amount — the receiving account is named and switchable mid-entry.executeMelttreated fiat-unit minor amounts as sats for LNURL invoice requests and onchainamountSats. NewgetSatsPerUnitMinorconfig (wired from pricelist per wallet unit) converts; a missing rate throws typedUnitRateUnavailableErrorbefore any reservation.outputDataCreator), cdk's React-Native binding is "Planned" with no work started, and coco exposes no wallet-construction injection. No code change; using cdk from JS today means replacing cashu-ts entirely.walletContextTrackerstill served unit-agnostic proofAmounts/mintBalances/capability maps to machine internals (exceedsBalance, offline proof composition) while the app provider was already unit-scoped. The tracker now snapshots all units andgetContext()serves a cached per-active-unit view (getActiveUnitconfig, wired from mintStore). Also: envelope memoized per (walletContext, unit, destination) so typing doesn't re-derive bounds per keystroke; Colada's rate guard reusesisFiatUnit; stale'sat'log defaults renamed. Verified-REFUTED (defensive, unreachable): the AMOUNT_ENTERED unit-mismatch guard — both sides read the same liveflowCtx.unit. Known residuals (reported, deliberately not changed):pickAmountBoundsReasonshows an iteration-order-dependent message when two mints advertise disjoint ranges;getUnitAmountEnvelope's destination→rails mapping must be kept in sync withamountEntryAvailabilitywhen onchain melt ships;UnitRateUnavailableErrorsurfaces at execute rather than gating Next (needs machine-level rate awareness).Gates re-run after this increment: type-check 0 errors (app + wallet), wallet vitest 63 files / 906 tests, app jest 178 suites / 1809 tests, lint 0 errors, knip clean.
Verification (this increment)
$keypad with 2 decimals, no "≈ sats"; quote created as{amount: cents, unit:'usd'}(coco.operations.mint.quote_created)lnurl.convertlog); typed error if no rate🤖 Generated with Claude Code