Repository navigation
ENG-701: change refills the card's covering ladder, ladder-aware coin selection, and an offline refusal log - #90
Merged
Merged
Conversation
… refills it (ENG-701) targetPieces is flash-mobile's ENG-700 shape (ladder, spares [1,2,4,8], the rest as the fewest powers of two; slot-limited degradation), asserted against the shared card-shape-vectors.json copied from flash-mobile 1a7023ab4. splitChangeForLadder fills the gaps of the card's target shape with the change, at most popcount + 3 pieces and the free slots, and never scores worse than splitPow2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
Read through @env-optional, so a build without it is on; false restores power-of-two change and the coin selection before ENG-701. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
AsyncStorage, the bill and the card's piece sizes only, the last 200 entries with running counts that never go down, writes serialised. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
…s the better-shaped card (ENG-701) The split is chosen once in readAndPlan (selectFittingPlan, capped by the free slots and openings) and travels on the plan: the slot pre-flight, the card's SPEND_ALL_CHANGE (spendAll refuses a bad list before any APDU) and the stage all read it. Which plans fit is unchanged (the fewest pieces). Equal-change plans rank by the card's shape afterwards, and the exact subset is the best-shaped one, not the DP's first witness. Offline CHANGE_NEEDS_MINT and ONLINE_ONLY refusals are logged, never awaited. Every pow2-change assertion is re-derived from splitChangeForLadder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
…builds (ENG-701) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
…it counts owed pieces (ENG-701) Review fixes for PR #90: - readAndPlan's fallback (the bill gets every empty slot) capped the ladder split only by min(emptySlots, freeOpenings), so its extra pieces took slots an owed piece wanted. A [16] card with 3 empty slots and [4,2] owed paying 10 ended [1,1,4] (6 sat) with 6 sat held on this terminal. selectFittingPlan now takes a split cap separate from the fit test; the fallback caps the extra pieces to the slots the owed change leaves free, so the card ends [2,4,4] as before ENG-701. - The split now counts the owed pieces this tap LOADs first as kept (withChangePieces), in both branches: keeping [4] with [1,1] landing, 6 sat of change is [2,2,2] (3 APDU pairs, target ladder) instead of [2,2,1,1] (4 pairs, worse shape). - Tests: the owed-change assertion is a literal [4] again plus an invariant (never fewer owed pieces than the fewest-pieces change leaves room for); behaviour around the pieces uses literals; every ladderChange input the file relies on is pinned by hand; new tests for splitCap, loading, and the [16,4] + owed [1,1] charge end to end. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
…ttingPlan's splitCap Review fix (ENG-701). The owed-crowded fallback's splitCap and re-split never changed anything: the room it left is at most pow2PieceCount, so splitChangeForLadder always returned splitPow2. Select with the fit test only, cut owed change by the fewest-pieces count, set changePieces once, and say so in the comments. The splitCap test becomes a withChangePieces maxPieces test; the shared-slot test now asserts splitPow2 explicitly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A charge with change gives the change back in the fewest powers of two (
splitPow2). A card that pays 3 from an 8 is left holding 4 + 1, which pays only 1, 4 and 5 exactly offline. Every later small bill then needs change, and change needs the mint (CHANGE_NEEDS_MINT). Coin selection made this worse: for an exact bill it took the DP's first witness, so a card holding [1,1,2,2] paid 4 with 1+1+2 and was left [2], which cannot pay 1. Nobody could see how often cards were refused offline.Change
src/utils/denominations.ts).targetPiecesis transcribed from flash-mobile's ENG-700cashu-card-shape.ts: the ladder, then the spares[1,2,4,8], then the rest as the fewest powers of two, with the same slot-limited degradation.__tests__/fixtures/card-shape-vectors.jsonis flash-mobile's file from 1a7023ab4 (ENG-700, PR #773, still open), copied verbatim apart from an addedsourcefield. Every vector is asserted here.splitChangeForLadder(changeSat, keep, maxPieces)fills the gaps between the card's target shape and the pieces it keeps. It returns at mostpopcount(change) + 3pieces, never more than the free slots and openings, and never a split that scores worse thansplitPow2(decision 3). With no room it falls back silently tosplitPow2.selectFittingPlan(plans, freeSlots, unspent)keeps its walk order and its fit test (pow2PieceCount), so the set of plans it accepts does not change (ENG-630/642/645). It returns the plan withchangePieces.readAndPlancuts owed change bychangePieces.length.executeChargepasses the pieces tospendAll, which writes exactly those pieces with SPEND_ALL_CHANGE. A list that is not powers of two summing tochangeSatis refused before any APDU. The stage animates the same list (changePieces(changeSat, planned)).ladderScore), and fewer slots break any remaining tie. The exact subset is now the best-shaped one (bestExactSubset), capped at 4096 count vectors; past the cap the DP witness is kept.FLASH_LADDER_CHANGE=false(@env-optional) to get today'ssplitPow2change and today's coin selection exactly. It is on when the variable is absent.src/services/refusalLog.ts). Each offlineCHANGE_NEEDS_MINTandONLINE_ONLYrefusal records{kind, amountSat, cardPieces, at}in AsyncStorage, one entry per tap. The log keeps the last 200 entries. Counts are stored separately and never go down. Writes run one at a time. A corrupt blob is reset. The call is never awaited, so it cannot delay or mask the refusal. The Card refusals row in Settings and theRefusalLogroute are in production builds, not behind__DEV__(decision 4).How it was tested
yarn lint --max-warnings 99999: 0 errors. The 1 warning is pre-existing, inuseSatPrice.tsx.yarn typecheck: clean.yarn test --runInBand: 74 suites passed, 1 skipped. 1690 tests passed, 9 skipped (the cardsim suite).CARD_E2E_CARDSIM=… CARD_E2E_MINT=… yarn jest __tests__/integration/cardsimMint.test.ts --selectProjects ios --ci --runInBand, run locally against cardsim built at the CI pin (bced1c4) and Nutshell 0.20.3 on FakeWallet: 9/9 passed. The real applet and mint accept ladder change with repeated amounts. A 16 that pays 10 beside a 4 leaves the card[1,1,2,2,4](it was[2,4,4]).16 pays 10 beside a 4: SPEND_ALL_CHANGE 4 + LOAD 4 (power-of-two change: 2 + 2).splitChangeForLadderinstead of a hand-edited number (decision 6). That covers 19 tests incashuCharge.test.ts, 4 incardsimMint.test.ts, and the 5 Maestro flows. The MaestroEXPECTs are checked against the function by a new test ine2eLib.test.ts. For example, charge-with-change now asserts[2,1,1,1]after 8 pays 3.origin/main'ssrc.denominations.testvectors and properties (100/101), therefusalLogandRefusalLogsuites (module missing), the 3 kill-switch tests infeatureFlags, 5 incashuSpend, the 30 ENG-701 and re-derived tests incashuCharge, 2 inmoney, 2 inSettings, 2 inProfile, and 7 ine2eLib.spendAllcalled with no pieces named;changePieceswith no split falling back to the fewest pieces;sourcefield.Follow-ups
yarn e2e:flashcardwas not run locally. It needs the iOS simulator and the TEST merchant login. The E2E Flashcard workflow runs it on this PR, and it is part of the merge gate.🤖 Generated with Claude Code
https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6