From 343938a13c602afb050cea06fa4fb99a77fa78ca Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Fri, 13 Nov 2026 20:39:00 +0100 Subject: [PATCH 1/4] Release a stuck shield reservation, and let the reader release it too A shield's journal entry released only when the indexer answered that its coin was unspent. A coin that was swept, renewed or spent elsewhere, or a read that failed, answered nothing, so the reservation stayed forever and auto-shield skipped the coin; a coin read that threw also aborted the whole settle pass. A shield spends one coin of the wallet's, and a coin can be spent only once, so past the pool's retention it has either landed, leaving nothing to re-shield, or it never can. A shield entry now releases once it is past that bound whatever its coin answers, and still drops early when the coin shows it landed. A coin that cannot be read no longer stops the pass. Payment entries keep their rules. The unresolved-outcomes warning gains "Release reserved funds" behind a confirmation. It releases every shield at any age, and a payment only once it is past the pool's bound and the wallet says no batch carried it, since a retry on other notes could otherwise pay twice; anything left reserved says why and, for a payment still in its window, when it becomes releasable. PoolSpendJournal.tla now counts landings, so NoDoublePayment is the real property: a deposit may land only while its coin is unspent, a note spend has no such bound, and forcing a payment free on age alone is a counterexample. --- app/src/RollupWallet.tsx | 21 +++- spec/tla/PoolSpendJournal.cfg | 6 +- spec/tla/PoolSpendJournal.tla | 110 +++++++++++------- spec/tla/PoolSpendJournal_CoinProof.cfg | 6 +- spec/tla/PoolSpendJournal_EdgeRefusal.cfg | 6 +- ....cfg => PoolSpendJournal_ForceVerdict.cfg} | 8 +- spec/tla/PoolSpendJournal_ForceWindow.cfg | 17 +++ spec/tla/PoolSpendJournal_ForeignRefusal.cfg | 6 +- spec/tla/PoolSpendJournal_Grace.cfg | 6 +- spec/tla/PoolSpendJournal_Retention.cfg | 6 +- spec/tla/PoolSpendJournal_Sim.cfg | 4 +- spec/tla/PoolSpendJournal_TrackedWindow.cfg | 6 +- spec/tla/README.md | 13 ++- src/native/mutations.ts | 34 ++++-- tests/app-private-build.test.ts | 12 ++ tests/private-transport.test.ts | 7 +- tests/tor-mutations.test.ts | 49 +++++++- 17 files changed, 229 insertions(+), 88 deletions(-) rename spec/tla/{PoolSpendJournal_CoinSpentCheck.cfg => PoolSpendJournal_ForceVerdict.cfg} (61%) create mode 100644 spec/tla/PoolSpendJournal_ForceWindow.cfg diff --git a/app/src/RollupWallet.tsx b/app/src/RollupWallet.tsx index 0302e56..4d0d370 100644 --- a/app/src/RollupWallet.tsx +++ b/app/src/RollupWallet.tsx @@ -38,7 +38,7 @@ import {WalletBrand,WalletHeaderActions} from './WalletHeaderActions.tsx'; import {WalletCreatedNotice,WalletSecurityMenu,type WalletSecurityView} from './WalletSecurityMenu.tsx'; import {isNativeHost,nativeClaimFetch,nativeOperationFetch,nativeSessionFetch,nativeSwapConfig,nativeWalletProviders} from '../../src/native/index.ts'; import {loadWalletPoseidon} from './wallet-crypto.ts'; -import {pendingPoolNullifiers,pendingPoolOutpoints,reconcileNativeJournal} from '../../src/native/mutations.ts'; +import {forceReleaseNativeJournal,pendingPoolNullifiers,pendingPoolOutpoints,reconcileNativeJournal} from '../../src/native/mutations.ts'; import {privateTransport,PRIVATE_SHIELD_COINS} from '../../src/shielded/private-transport.ts'; import {PRIVATE_FLAG} from './private-boot.ts'; @@ -89,7 +89,7 @@ const focusOnMount=(el:HTMLElement|null)=>{el?.focus();}; export default function RollupWallet({storage=globalThis.localStorage,reload=()=>location.reload(),onShutdown,onReady,onStartupFailure,onStartupStage,onClearWallet,nativeHost=INJECTED_NATIVE,paymentRequest,onPaymentRequestHandled,onResumeSnapshot,onScannerIntentChange,resumeScanner,onResumeScannerHandled,animateEntrance=true,suspended=false,viewActive=true,hidden=false}:{storage?:Storage;reload?:()=>void;onShutdown?:(shutdown:(()=>Promise)|undefined)=>void;onReady?:()=>void;onStartupFailure?:(message:string)=>void;onStartupStage?:(message:string)=>void;onClearWallet?:()=>Promise;nativeHost?:boolean;paymentRequest?:IncomingPayment;onPaymentRequestHandled?:(id:number)=>void;onResumeSnapshot?:(snapshot:NativeResumeSnapshot)=>void;onScannerIntentChange?:(requested:boolean)=>void;resumeScanner?:number;onResumeScannerHandled?:(token:number)=>void;animateEntrance?:boolean;suspended?:boolean;viewActive?:boolean;hidden?:boolean}={}){ const [sessionFetch]=useState(()=>nativeHost?nativeSessionFetch():undefined); const [pool,setPool]=useState(),[poolError,setPoolError]=useState(''),[journalError,setJournalError]=useState(''); - const [nativePending,setNativePending]=useState(false); + const [nativePending,setNativePending]=useState(false),[confirmRelease,setConfirmRelease]=useState(false),[releaseNote,setReleaseNote]=useState(''); const [startupError,setStartupError]=useState(''),[startupFailure,setStartupFailure]=useState(''),[refreshError,setRefreshError]=useState(''),[feeNotice,setFeeNotice]=useState(''),[feeConfirmed,setFeeConfirmed]=useState(false); const [startupStage,setStartupStage]=useState('Preparing your private wallet'); const [initialPool,setInitialPool]=useState(); @@ -473,6 +473,17 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()= .then(pending=>{if(live){setNativePending(pending);setJournalError('');}},error=>{if(live){setNativePending(true);setJournalError(errorText(error));}}); return()=>{live=false;}; },[wallet,tick,nativeHost,sessionFetch]); + const releaseReserved=()=>{ + setConfirmRelease(false); + try{ + const settled=(nullifiers:readonly bigint[])=>wallet?.settledNullifiers(nullifiers); + const {released,unsettled,releasableAt}=nativeHost?forceReleaseNativeJournal(POOL_ORIGIN,settled):PRIVATE!.journal.forceRelease(PRIVATE!.poolOrigin,settled); + setReleaseNote([released?`Released ${released===1?'one reservation':`${released} reservations`}.`:'Nothing could be released yet.', + releasableAt===undefined?'':`A payment stays reserved until ${when(releasableAt)}, when the pool can no longer include it.`, + unsettled?'A payment stays reserved until this wallet has caught up with the pool.':''].filter(Boolean).join(' ')); + setTick(t=>t+1); + }catch(error){setJournalError(errorText(error));} + }; useEffect(()=>{lnLimits.current=wallet?.swaps;lnLimitsRead.current++;setLnBounds(undefined);setLnBoundsError('');},[wallet,wallet?.swaps]); // markets() must load the snapshot before resolve() reads it. const readLnBounds=()=>{ @@ -738,7 +749,11 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()= const inactive=hidden||suspended||!viewActive; if(!secret||!wallet)return ; return