Skip to content

feat: migrate to @cashu/coco v2.0.0-rc.0 — quote-first, multi-unit, bolt12/onchain receive, onchain send#251

Open
Kelbie wants to merge 180 commits into
mainfrom
feat/coco-v2-migration
Open

feat: migrate to @cashu/coco v2.0.0-rc.0 — quote-first, multi-unit, bolt12/onchain receive, onchain send#251
Kelbie wants to merge 180 commits into
mainfrom
feat/coco-v2-migration

Conversation

@Kelbie

@Kelbie Kelbie commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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 from log.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

  • Dependency: exact pins 2.0.0-rc.0 / cashu-ts 4.5.1 (single hoisted copy; installed artifact verified to bundle migrations 001–036).
  • Quote-first: colada's executeMintQuote/executeMelt + app wrappers create canonical quote rows (manager.quotes.*) before durable operations; importQuotequotes.mint.import semantics; quote identity is {mintUrl, quoteId}.
  • Events: removed mint-op:quote-state-changedmint-quote:updated (payment-status listener re-keys PAID off the quote row; mint-op:pending fetches the canonical quote for the NPC freshness heuristic); proofs:reserved.amount is UnitAmount; all subscriptions compile against CoreEvents.
  • History: v2 operation-projected history consumed directly — colada's melt supplement deleted (would double-count), in-flight-receive supplement kept (v2 doesn't project executing receives); ONE normalizer at the read-model boundary maps op states to the legacy vocabulary all consumers speak.
  • Amounts: cashu-ts v4 Amount value objects convert to numbers once at the colada/app read-model boundary; JSON history keeps numeric amounts; app-constructed entries use the syntheticHistory bridge.
  • Repositories overlay: number-healing pipeline deleted (would corrupt Amounts); keyring ephemeral overlay is now purpose-aware — the profile signer key can never answer a nut20_mint_quote query (leak test added); new v2 repos pass through.
  • Lifecycle: kept manager= provider (RestoreGate NUT-13 counter ordering, late NPC plugin, DB ownership); phase 2 mirrors initializeCoco order and adds enableMeltQuoteWatcher, enableMeltSettlementProcessor, payment-request recovery sweep, and watchExistingPendingQuotesOnStart.
  • Multi-unit: persisted per-profile 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 with FiatCurrencyPill, which now renders sat-only. NearPay stays sat-pinned.
  • Receive tabs: capability-gated per active unit; Bolt12/Onchain are app-enforced singletons (one standing offer/address per mint+method+unit via colada's 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.
  • Onchain send: executeMelt detects onchain targets; quote-first melt surfaces NUT-30 fee_options through an actionMenuPopup fee picker before prepare (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.
  • NUT-18 incoming saga: consciously deferred (existing nutCreq/NPC standing requests stay); new payment-request repositories pass through and the recovery sweep is a safe no-op.

Safety rails / observability

  • Forward-only SQL migration (024–036 rewrite money columns to TEXT, restructure melt quotes): first v2 boot takes a one-time <db>.pre-v2 file backup (plus WAL/SHM) before repositories.init(), logs schema_dump.before/after, migration.applied {fromCount, toCount}, and a per-unit ready-proof balance_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).
  • New log events across the coco boundary follow log-doctor conventions (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

  1. Fee picker shape: the plan's audit preferred a dedicated FEE_OPTIONS machine state; implemented as a config callback that resolves before ops.melt.prepare, preserving the property the machine state protected (no proof reservation during user think-time). Flagging for reviewer judgment.
  2. mintStore versioning: additive activeUnit with .default('sat').catch('sat') at version 2 (no bump) — the golden persistSchemaDrift snapshot and rehydration tests cover the invariant.
  3. Stage-B defaults taken while the owner was away (confirm): standing-address Onchain tab (fresh per fixed-amount request), onchain send in scope, bolt12 one-offer-per-mint with no rotate affordance, NUT-18 deferral, switcher units sat/usd/eur/gbp.

Security / privacy

  • Profile signer key: never persisted to coco's plaintext keyring, never returned for NUT-20 purposes (new tests).
  • .pre-v2 backups contain spendable proofs: sandbox-only, excluded from dumps, deleted on account deletion.
  • Bolt12 standing offers are long-lived payment identifiers (deliberate linkability trade-off of the singleton design).
  • Never logged: invoices, offers, addresses, quote requests, quoteDataJson (holds NUT-20 keypairs).

Testing

  • bun run type-check — 0 errors (app + wallet)
  • app jest: 175 suites / 1798 tests green
  • wallet vitest: 58 files / 826 tests green (includes real-coco MemoryRepositories integration suite)
  • lint 0 errors, prettier clean, knip clean
  • New coverage: keyring purpose-leak, backup lifecycle, storage-inventory backup classification, mintStore rehydration, receive-tab matrix, state-normalization table, reusable-quote + onchain-melt unit tests, quote-first contract assertions

Verification table (first build — needs a device/real v1 DB)

# What to verify How to test Expected result Variants Status
1 v1→v2 DB migration is safe Cold-boot this build over an existing wallet; run log-doctor startup/coco db_backup.done, migration.applied {toCount: 36-ish}, schema_dump.after lists canonical tables, balance_snapshot.pre_migration matches post-boot balance fresh install (no backup, no migration), existing wallet, profile switch
2 Bolt11 receive + send regression Mint via invoice; pay an invoice Paid toast on mint-quote:updated; melt finalizes; history rows correct NPC receive, LNURL send
3 Unit switcher Tap the unit pill; pick USD (mint supporting usd) Balance/keyboard/receive quotes/history flip to usd; fiat pill hidden; unsupported units disabled with reason sat-only mints (pill shows only sat), profile switch persistence
4 Bolt12 tab Open Receive → Bolt12; revisit Same offer both times (quotes.reusable.reused); paying it twice auto-mints both mint without bolt12 → tab hidden or empty state
5 Onchain receive Onchain tab twice → same address; Fixed Amount → as Onchain twice → two distinct addresses/BIP-321 URIs, two pending rows Paying one resolves the right row; deposits auto-mint mempool status labels, confirmations timeline
6 Onchain send Paste/scan an address → amount → confirm Fee sheet with ~blocks + fee reserve; each index works; dismiss cancels with nothing reserved; pending settles via watcher mock-fail toggle exercises the cancel-rescue
7 Redaction log-doctor redaction --latest after the above High-signal tier empty
8 NPC plugin npub.cash receive end-to-end Sync + toast still work against v2 internals (highest external-compat risk; fallback = pin/patch plugin)

Known risks

  • coco-cashu-plugin-npc is typed/built against v1 (bridged by nominal casts); needs the live smoke test above.
  • Orphaned reusable quotes accumulate if users churn mints (coco never GCs) — expiry-sweep is a follow-up candidate.
  • Bolt12 SEND (paying offers) and bolt12 offer rotation are out of scope.
  • Published rc.0 verified against the audited coco master checkout (migration ids match); behavioral drift between them would surface in verification row 1.

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.

  • NUT-04/05 bounds (wallet/src/mint-capabilities.ts): per-(method,unit) minAmount/maxAmount parsed from cached NUT-06 info; AMOUNT_BELOW_MINT_MIN/AMOUNT_ABOVE_MINT_MAX reasons (with structured params) disable method rails citing the least-strict cross-mint bound; new getUnitAmountEnvelope(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).
  • Unit-native amount entry (colada amount-actions rewrite, no shims): inputMode 'sat'|'fiat''unit'|'fiat'; fiat accounts (usd/eur/gbp) type major-denomination decimals resolved to integer cents (effectiveAmount: {value, unit} replaces effectiveSatAmount everywhere), no sats math and no "≈ sats" secondary; the display-currency swapper only exists on the sat account. machine.enterAmount takes a UnitAmount; the reducer rejects an event unit that doesn't match ctx.unit. Unit switches mid-entry reset the draft; amountDraftStore is unit-gated across the mint-selector round trip; quick-send chips are cent-native on fiat accounts.
  • Keypad clamp: typing is hard-capped (by replacement, so CustomKeyboard stays in sync) at the envelope max, with a warning-tinted "Maximum …" notice; minimums gate Next through the existing least-strict rail reasons.
  • Account visibility: fiat accounts render UnitSwitcherPill under the amount — the receiving account is named and switchable mid-entry.
  • Funds-correctness fix: executeMelt treated fiat-unit minor amounts as sats for LNURL invoice requests and onchain amountSats. New getSatsPerUnitMinor config (wired from pricelist per wallet unit) converts; a missing rate throws typed UnitRateUnavailableError before any reservation.
  • CDK investigation verdict (requested): the "configure cashu-ts to use cdk for heavy operations" mechanism does not exist — cashu-ts has no wasm/ffi backend seam (only the sync, unsupported 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.
  • Post-implementation adversarial review (8 finder angles + verification) found and fixed one real gap: the colada-internal walletContextTracker still 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 and getContext() serves a cached per-active-unit view (getActiveUnit config, wired from mintStore). Also: envelope memoized per (walletContext, unit, destination) so typing doesn't re-derive bounds per keystroke; Colada's rate guard reuses isFiatUnit; stale 'sat' log defaults renamed. Verified-REFUTED (defensive, unreachable): the AMOUNT_ENTERED unit-mismatch guard — both sides read the same live flowCtx.unit. Known residuals (reported, deliberately not changed): pickAmountBoundsReason shows an iteration-order-dependent message when two mints advertise disjoint ranges; getUnitAmountEnvelope's destination→rails mapping must be kept in sync with amountEntryAvailability when onchain melt ships; UnitRateUnavailableError surfaces 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)

# What to verify How to test Expected result Variants Status
A1 USD account fixed-amount receive inputs USD usd unit → Receive → Fixed amount $ keypad with 2 decimals, no "≈ sats"; quote created as {amount: cents, unit:'usd'} (coco.operations.mint.quote_created) usd, eur; bolt11 + onchain rails
A2 Sat account unchanged sat unit → Receive → Fixed amount sat keypad + display-currency toggle still swap correctly toggle both directions mid-entry
A3 Account obvious + switchable open amount screen per unit fiat units show the account pill; switching resets the draft sat↔usd mid-entry
A4 Typed max clamp Send → Lightning, type beyond every mint's melt max input caps at the least-strict max, "Maximum …" notice mint without an advertised max ⇒ uncapped
A5 Min gating type below every mint's min for a method rail disabled with "Minimum X" (least-strict); Next gated when nothing can fire lightning, onchain receive
A6 Ecash uncapped receive / send ecash with a large amount no NUT clamp; balance rules only send (balance-bound), receive
A7 LNURL melt from usd account usd unit → pay a lud16 address invoice requested in converted sats, melt quote in usd (lnurl.convert log); typed error if no rate with/without pricelist
A8 Mint-selector round trip type → change mint → back draft restored only when the unit didn't flip unit follows mint (flip)
A9 Offline bounds airplane mode, fresh boot rails/clamps still populated from persisted mint info

🤖 Generated with Claude Code

Kelbie added 17 commits July 2, 2026 13:06
…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.
@Kelbie

Kelbie commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial review pass (Stage F) — findings applied in ff5dc2a

Eight 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

  1. Standing-quote collision — fixed-amount onchain quotes could displace the receive tab's standing address (reusable quotes never leave listPending; shapes are identical). The singleton now resolves by recorded identity (mintStore.standingQuotes, additive tolerant field) instead of a newest-first scan. Collision regression tests added.
  2. Fee-sheet dismissal showed a red failure toast → named MeltUserCancelledError + new onPaymentCancelled notification: quiet dismissal, no error.
  3. Stale account.unit closures in WalletScreen action callbacks after a unit switch (missing deps) — fixed.
  4. Malformed n_sigs classified multisig mesh tokens as single-sig "mine" — present-but-invalid now counts as multisig.
  5. byMintAndUnit guarded pre-first-load; poll-success mint entries now serialize through serializeHistoryEntry (matches the fallback's legacy-state + numeric-amount shape).

Product gaps
6. Units the wallet holds stay switchable even if their mint loses trust (balance-fed availableUnits) — non-sat funds can't become invisible.
7. Bolt12/onchain standing-quote deposits now fire a confirmed receive toast (previously silent auto-mints).
8. NPC pending handler gates to bolt11 before its quote fetch + unsubscribe guard.

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 Amount.from throw post-migration), honest normalization-boundary docs, dead code removed.

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; useAppBalance deliberately stays sat-denominated for cross-profile comparison; short-v2-keyset-ID mesh tokens classify invalid (inherent to cashu-ts 4.5 without mint keysets).

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.
@Kelbie

Kelbie commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

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 coco.feedback.* logging layer:

  • Version-stamped: installed coco-core/coco-react/coco-expo-sqlite/cashu-ts versions logged once per manager init and attached to every report.
  • Upstream vocabulary: failed coco API calls record the coco error class name (QuoteIdentityConflictError, UnitMismatchError, ProofValidationError, …) plus which API (Manager.initialize/repositories.init, enableMeltQuoteWatcher, quotes.mint.create/ops.mint.prepare, ops.*.recovery.run, …).
  • Anomaly detectors: a migration boot that applied zero migrations, non-canonical v1 amount rows (would make Amount.from throw post-migration), and a duplicate-id tripwire on coco's history projection.

Two ways to collect it:

  1. bunx tsx codereview/log-doctor/index.ts upstream — versions header + migration trail + grouped anomalies, paste-ready for a GitHub issue.
  2. Settings → Storage → "Copy coco v2 Report" on device/release builds (built from the log ring buffer, same redaction rules — no payment strings).

Proof it works: run against today's pre-migration session log it already surfaces three candidate upstream reports — ReceiveOperationService.receive_outputs_not_persisted_after_recovery_attempt ×18 (recovery loop), SendOperationService.found_operation_stuck_in_rollingback_state, and a SubscriptionManager.polling_task_error (HTTP 429 not backed off).

Kelbie added 10 commits July 2, 2026 17:37
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'.
Kelbie added 30 commits July 9, 2026 08:04
'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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant