diff --git a/app/src/NativeWallet.tsx b/app/src/NativeWallet.tsx index 4a50d105..f390b8e0 100644 --- a/app/src/NativeWallet.tsx +++ b/app/src/NativeWallet.tsx @@ -14,6 +14,7 @@ import {createNativeTorMonitor,type NativeTorHealthState} from './native-tor-mon import WalletOnboarding from './WalletOnboarding.tsx'; import NativeTorHealth from './NativeTorHealth.tsx'; import {AppUpdateNotice} from './AppUpdates.tsx'; +import {clearNativeTransientDocument} from './wallet-recovery.ts'; installNativeTransport(); const loadWallet=()=>import('./RollupWallet'); @@ -22,6 +23,7 @@ const getSecret=()=>invoke('plugin:shielded-platform|secret_get'); const putSecret=(value:string)=>invoke('plugin:shielded-platform|secret_set',{value}); const clearSecret=()=>invoke('plugin:shielded-platform|secret_clear'); type Session={generation:number;cache:ReturnType;resumed:boolean;onReady?:()=>void;onStartupFailure?:(message:string)=>void}; +const clearTransientDocument=()=>clearNativeTransientDocument(document.documentElement,[...document.querySelectorAll('[inert]')]); export default function NativeWallet(){ const [views,setViews]=useState<{shown?:NativeWalletView;candidate?:NativeWalletView}>({}),[phase,setPhase]=useState('Connecting to Tor'),[error,setError]=useState(''); @@ -35,6 +37,7 @@ export default function NativeWallet(){ const selectedMode=useRef('tor'),transition=useRef(false),clearing=useRef(false),creating=useRef|undefined>(undefined); const setupAllowed=useRef(false); const active=useRef(undefined),attempt=useRef(0),cleanup=useRef(createNativeCleanup()),closed=useRef>(Promise.resolve()),manualLock=useRef(false); + const resumeRevision=useRef(0); const connectionPending=useRef(false); const opening=useRef|undefined>(undefined); const openingGeneration=useRef(undefined); @@ -44,6 +47,7 @@ export default function NativeWallet(){ const closing=cleanup.current.add(shutdown);closed.current=Promise.all([closed.current.catch(()=>{}),closing]).then(()=>{});void closed.current.catch(fail); })); const pause=useCallback(()=>{ + clearTransientDocument(); ++attempt.current; connectionPending.current=true;setReconnecting(false); setupAllowed.current=false;setSetup(false); @@ -58,6 +62,7 @@ export default function NativeWallet(){ void closed.current.catch(fail);return closed.current; },[]); const suspend=useCallback(()=>{ + clearTransientDocument(); ++attempt.current;connectionPending.current=true;setReconnecting(true); setupAllowed.current=false;setSetup(false);setBusy(false);setPhase('Private connection paused'); const previous=active.current,pending=opening.current,pendingWrite=creating.current; @@ -182,11 +187,17 @@ export default function NativeWallet(){ useEffect(()=>{ let live=true; const visibility=()=>{ + clearTransientDocument(); + const mine=++resumeRevision.current; if(document.hidden){ void suspend(); }else if(!manualLock.current){ - const mine=attempt.current; - void resumeNativeForeground(Promise.all([closed.current,opening.current]),()=>live&&mine===attempt.current&&!manualLock.current&&!clearing.current&&!document.hidden,open).catch(fail); + const restart=async()=>{ + const shown=handoff.current.snapshot.shown; + if(shown&&!shown.retired)await suspend(); + if(live&&mine===resumeRevision.current&&!manualLock.current&&!clearing.current&&!document.hidden)await open(); + }; + void resumeNativeForeground(Promise.all([closed.current,opening.current]),()=>live&&mine===resumeRevision.current&&!manualLock.current&&!clearing.current&&!document.hidden,restart).catch(fail); } }; document.addEventListener('visibilitychange',visibility); diff --git a/app/src/RollupWallet.tsx b/app/src/RollupWallet.tsx index f28d1231..e3d6c493 100644 --- a/app/src/RollupWallet.tsx +++ b/app/src/RollupWallet.tsx @@ -96,12 +96,12 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()= const dockLens=useRef(null),mainLens=useRef(null),panelLens=useRef(null); const [listed,setListed]=useState(''),[restoreError,setRestoreError]=useState(''),[clearError,setClearError]=useState(''),[showView,setShowView]=useState(false),[showFull,setShowFull]=useState(false),[securityView,setSecurityView]=useState(null),[restoreStarted,setRestoreStarted]=useState(false),[aboutOpen,setAboutOpen]=useState(false); const [listingError,setListingError]=useState<{id:string;message:string}>(); - const [invoice,setInvoice]=useState(),[lnAmount,setLnAmount]=useState(''),[unifiedAddress,setUnifiedAddress]=useState(''),[lnAddress,setLnAddress]=useState(''),[lnBusy,setLnBusy]=useState(false),[lnError,setLnError]=useState(''),[unifiedStage,setUnifiedStage]=useState('Reading your saved receiver'),[unifiedStalled,setUnifiedStalled]=useState(false),[lnBounds,setLnBounds]=useState(); + const [invoice,setInvoice]=useState(),[lnAmount,setLnAmount]=useState(''),[unifiedAddress,setUnifiedAddress]=useState(''),[lnAddress,setLnAddress]=useState(''),[lnBusy,setLnBusy]=useState(false),[lnError,setLnError]=useState(''),[payoutWatchError,setPayoutWatchError]=useState(''),[unifiedStage,setUnifiedStage]=useState('Reading your saved receiver'),[unifiedStalled,setUnifiedStalled]=useState(false),[lnBounds,setLnBounds]=useState(); const [pageVisible,setPageVisible]=useState(()=>!document.hidden); const [lnBoundsError,setLnBoundsError]=useState(''); const [privateState,setPrivateState]=useState(NOT_PRIVATE); const [claim,setClaim]=useState(()=>location.pathname.replace(/\/+$/,'')==='/claim'?location.hash.slice(1):''),[claimView,setClaimView]=useState(),[claimRead,setClaimRead]=useState(0),[minted,setMinted]=useState(''); - const lnurlRef=useRef|undefined>(undefined),unifiedAddressRef=useRef(''),lnLimits=useRef(undefined),lnLimitsRead=useRef(0),startingRef=useRef|undefined>(undefined),securityRef=useRef(null); + const lnurlRef=useRef|undefined>(undefined),unifiedAddressRef=useRef(''),payoutWatchRevision=useRef(0),lnLimits=useRef(undefined),lnLimitsRead=useRef(0),startingRef=useRef|undefined>(undefined),securityRef=useRef(null); const [,setQuoteRevision]=useState(0); const quoteRef=useRef|undefined>(undefined); const fundsRef=useRef<{wallet:ShieldedWallet;watch:ReturnType}|undefined>(undefined); @@ -283,7 +283,7 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()= useEffect(()=>{ if(!initialPool||!secret||restoring.current)return; - unifiedAddressRef.current='';setUnifiedAddress('');setLnAddress('');setPrivateState(NOT_PRIVATE);setLnError('');setInvoice(undefined);setRequestOpen(false);setListingError(undefined); + unifiedAddressRef.current='';payoutWatchRevision.current=0;setUnifiedAddress('');setLnAddress('');setPrivateState(NOT_PRIVATE);setLnError('');setPayoutWatchError('');setInvoice(undefined);setRequestOpen(false);setListingError(undefined); const status=initialPool,network=status.network; let journal:IndexedDbLightningRepository; let nativeOpened=false; @@ -378,7 +378,7 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()= starting=startPageLightning({ pairing, swaps:(paired,signal)=>createPrivateLightning({wallet:created!,companion:paired,journal,onError:reportLightning,signal,...(nativeHost?nativeSwapConfig():{})}), - lnurl:async(paired,swaps,signal)=>createLnurlReceive({identity:created!.identity,companion:paired,journal,owner:await lightningOwner(paired),pool:status.pool!.token,baseUrl:LNURL_BASE,signal,onStage:stage=>{if(mounted.current&&!restoring.current){startupWatchdog.stop();activationWatchdog.arm();setUnifiedStage(stage);}},...(lnurlFetch?{fetch:lnurlFetch}:{}),onPayouts:async(payouts,payoutSignal)=>(await swaps).registerPayouts(payouts,payoutSignal),shieldedAddress:()=>created!.shieldedAddress()}), + lnurl:async(paired,swaps,signal)=>createLnurlReceive({identity:created!.identity,companion:paired,journal,owner:await lightningOwner(paired),pool:status.pool!.token,baseUrl:LNURL_BASE,signal,onStage:stage=>{if(mounted.current&&!restoring.current){startupWatchdog.stop();activationWatchdog.arm();setUnifiedStage(stage);}},...(lnurlFetch?{fetch:lnurlFetch}:{}),onPayouts:async(payouts,payoutSignal)=>(await swaps).registerPayouts(payouts,payoutSignal),onPayoutError:(error,revision)=>{if(revision>=payoutWatchRevision.current&&session.active()&&mounted.current&&!restoring.current){payoutWatchRevision.current=revision;setPayoutWatchError(errorText(error));}},onPayoutReady:revision=>{if(revision>=payoutWatchRevision.current&&session.active()&&mounted.current&&!restoring.current){payoutWatchRevision.current=revision;setPayoutWatchError('');}},shieldedAddress:()=>created!.shieldedAddress()}), signal:startController.signal, onLnurl:startLnurl,active:()=>session.active(),handOver:parts=>{companion=parts.companion;lnurl=parts.lnurl;created!.swaps=parts.swaps;},report:reportLightning, }); @@ -705,6 +705,7 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()= {(privateNotice==='foreign'||privateNotice==='own'||privateNotice==='refused')&&
{privateNotice==='own'&&}{turnOff(privateNotice==='refused'?'Dismiss':'Turn off')}
}
How private receive works

Payments to your shielded address stay private inside the pool. Turning this on lists that address on your unified receiver: anyone who looks it up learns that you use the pool and gets your shielded address, the same one every payer gets, and it never changes. The address service learns it too. Turning it off asks the service to remove it; if the service keeps it, this wallet says so and asks again while this page is open. The choice is saved on this device only: another device with your recovery phrase decides for itself, and one that has it on lists the address again.

} {lnError&&

{lnError}

} + {payoutWatchError&&

Payout watches are still catching up: {payoutWatchError} Shielded will retry them without blocking your address.

}
setRequestOpen(e.currentTarget.open)}>Invoice amount{lnBounds?

{invoiceLimitsLine(lnBounds)}

:lnBoundsError?<>

{lnBoundsError}

:

Checking invoice limits…

} {invoice&&
Lightning invoice · {sats(invoice.pays)} sats{liveInvoice&&}

{invoiceLine(invoice,fee)}

}
diff --git a/app/src/wallet-recovery.ts b/app/src/wallet-recovery.ts index 3fd0113b..e7bfa142 100644 --- a/app/src/wallet-recovery.ts +++ b/app/src/wallet-recovery.ts @@ -5,6 +5,8 @@ export const PRIVATE_RECEIVE_RESTART_COOLDOWN_MS=120_000; export const PRIVATE_RECEIVE_STALL_MS=90_000; type StorageLike=Pick; +type DocumentRoot={classList:{remove:(name:string)=>void};style:{removeProperty:(name:string)=>void}}; +type InertNode={removeAttribute:(name:string)=>void}; type TransientWalletControls={ closeSheet:()=>void; closeScanner:()=>void; @@ -78,3 +80,10 @@ export function dismissTransientWalletViews(controls:TransientWalletControls){ controls.closeRequest(); controls.closeSecurity(); } + +/** Removes sheet locks left behind when a native background transition is interrupted. */ +export function clearNativeTransientDocument(root:DocumentRoot,inert:readonly InertNode[]=[]){ + root.classList.remove('native-sheet-open'); + root.style.removeProperty('overflow'); + for(const node of inert)node.removeAttribute('inert'); +} diff --git a/src/shielded/lnurl-receive.ts b/src/shielded/lnurl-receive.ts index c7a087ca..04c55b34 100644 --- a/src/shielded/lnurl-receive.ts +++ b/src/shielded/lnurl-receive.ts @@ -21,11 +21,15 @@ interface Destination extends LnurlPayout {index:number;claimPublicKey:string;pa interface ServerState {receivers:LnurlReceiver[];pending?:string;current?:string;boundAt?:number;refused?:true;migrated?:true;recovering?:true;methods?:true;methodsPending?:true;methodsRefused?:true;withdraw?:true} interface ReceiveState {v:1;next:number;payouts:Destination[];servers:Record} interface LegacyState {v:1;receivers:LnurlReceiver[];pending?:string;payouts:LnurlPayout[]} +export const WATCH_STARTUP_GRACE_MS=5_000; export interface LnurlReceiveConfig { identity:Identity;companion:Wallet;journal:LnurlReceiveJournal;owner:string;pool:string;baseUrl?:string;domain?:string;fetch?:FetchImpl;now?:()=>number; signal?:AbortSignal; onStage?:(stage:UnifiedReceiveStage)=>void; onPayouts?:(payouts:LnurlPayout[],signal:AbortSignal)=>Promise; + onPayoutError?:(error:unknown,revision:number)=>void; + onPayoutReady?:(revision:number)=>void; + watchStartupGraceMs?:number; shieldedAddress?:()=>string; } // Each idle rotation leaves one unused index, so daily keeps a year of idle rotations inside RECOVERY_GAP. @@ -82,8 +86,10 @@ export function createLnurlReceive(config:LnurlReceiveConfig){ }); const guardedFetch:FetchImpl=(input,init)=>fetcher(input,{...init,redirect:'error',credentials:'omit',signal:AbortSignal.any([abort.signal,AbortSignal.timeout(30_000),...(init?.signal?[init.signal]:[])])}); const client=createLnurlClient({baseUrl,fetchImpl:guardedFetch}); - let tokenOnce:Promise|undefined,tail:Promise=Promise.resolve(),timer:ReturnType|undefined,timerBusy=false,disposed=false,watched=false,offEvents:(()=>void)|undefined,closing:Promise|undefined; + let tokenOnce:Promise|undefined,tail:Promise=Promise.resolve(),timer:ReturnType|undefined,timerBusy=false,disposed=false,healthRevision=0,offEvents:(()=>void)|undefined,closing:Promise|undefined; const pendingWrites=new Set>(); + const knownScripts=new Set(),watchedScripts=new Set(); + const pendingWatches=new Map;background:boolean;scripts:string[]}>(); const token=()=>tokenOnce??=deriveSessionTokenForIdentity(config.identity,domain); const dataWork=(work:Promise)=>{pendingWrites.add(work);void work.then(()=>pendingWrites.delete(work),()=>pendingWrites.delete(work));return untilStopped(work);}; const serial=(fn:()=>Promise):Promise=>{const pending=tail.then(()=>{if(disposed||abort.signal.aborted)throw new Error('Lightning address service is disposed');return fn();});tail=pending.catch(()=>{});return pending;}; @@ -154,9 +160,46 @@ export function createLnurlReceive(config:LnurlReceiveConfig){ if(failed?.has(row.script)){await dataWork(manager.updateContract(row.script,{}));failed.delete(row.script);if(!failed.size)failedContractWatches.delete(config.companion);} } if(config.onPayouts)await dataWork(config.onPayouts(rows.map(({id,descriptor,address,script})=>({id,descriptor,address,script})),abort.signal)); - }catch(error){watched=false;throw error;} + }catch(error){throw error;} }; - const restoreWatches=async(state:ReceiveState)=>{if(!watched){await watch(state.payouts);watched=true;}}; + /** Bounds payout startup while its watch continues in the background. */ + const watchWithGrace=async(rows:Destination[])=>{ + for(const row of rows)knownScripts.add(row.script); + const inFlight=new Set([...pendingWatches.values()].flatMap(watch=>watch.scripts)); + const eligible=rows.filter(row=>!watchedScripts.has(row.script)&&!inFlight.has(row.script)); + if(!eligible.length)return true; + const key=eligible.map(row=>row.script).sort().join('|'),scripts=eligible.map(row=>row.script); + let tracked=pendingWatches.get(key); + if(!tracked){ + const promise=watch(eligible).then(()=>{for(const script of scripts)watchedScripts.add(script);}); + tracked={promise,background:false,scripts};pendingWatches.set(key,tracked); + const cleanup=()=>{if(pendingWatches.get(key)===tracked)pendingWatches.delete(key);}; + void promise.then(cleanup,cleanup); + } + const ready=()=>[...knownScripts].every(script=>watchedScripts.has(script)); + // Reuse the same in-flight watch so a timeout cannot double-register identical payouts. + const owner=tracked;let graceTimer:ReturnType|undefined; + try{ + const settled=await Promise.race([ + owner.promise.then(()=>true), + new Promise(resolve=>{graceTimer=setTimeout(()=>resolve(false),Math.max(0,config.watchStartupGraceMs??WATCH_STARTUP_GRACE_MS));}), + ]); + if(settled){ + if(ready())try{config.onPayoutReady?.(++healthRevision);}catch{} + }else if(!owner.background){ + owner.background=true; + void owner.promise.then(()=>{ + if(!abort.signal.aborted&&ready())try{config.onPayoutReady?.(++healthRevision);}catch{} + },error=>{ + if(abort.signal.aborted)return; + try{config.onPayoutError?.(error,++healthRevision);}catch{} + }); + } + return settled; + }finally{if(graceTimer)clearTimeout(graceTimer);} + }; + /** Restores payout watches without letting a stalled wallet service hold receive. */ + const restoreWatches=async(state:ReceiveState)=>{if(state.payouts.some(row=>!watchedScripts.has(row.script)))await watchWithGrace(state.payouts);}; const used=async(scripts:string[])=>new Set((await untilStopped(config.companion.indexerProvider.getVtxos({scripts}))).vtxos.map(coin=>coin.script)); // Highest index from `from` on that ever held a coin; stops after `gap` unused ones. const scan=async(branch:ReturnType,from:number,gap:number)=>{ @@ -168,15 +211,17 @@ export function createLnurlReceive(config:LnurlReceiveConfig){ } return high; }; + /** Adds recovered payout keys and starts their watches without blocking the address forever. */ const adopt=async(state:ReceiveState,branch:ReturnType,from:number,high:number)=>{ const added:Destination[]=[]; for(let index=from;index<=high;index++){const row=receiveDestination(branch,index);if(!state.payouts.some(p=>p.script===row.script)){state.payouts.push(row);added.push(row);}} - state.next=Math.max(state.next,high+1);await save(state);await watch(added); + state.next=Math.max(state.next,high+1);await save(state);await watchWithGrace(added); }; + /** Persists and watches a fresh payout without blocking the address forever. */ const advance=async(state:ReceiveState,server:ServerState)=>{ const row=receiveDestination(await currentBranch(),state.next); state.next++;state.payouts.push(row);server.current=row.script;delete server.boundAt; - await save(state);await watch([row]); + await save(state);await watchWithGrace([row]); }; const bind=async(state:ReceiveState,server:ServerState)=>{ const row=state.payouts.find(p=>p.script===server.current); diff --git a/tests/lnurl-receive.test.ts b/tests/lnurl-receive.test.ts index baad3f0e..f757108c 100644 --- a/tests/lnurl-receive.test.ts +++ b/tests/lnurl-receive.test.ts @@ -142,6 +142,97 @@ test('unified setup reports progress through local state, Tor service work, watc ]); }); +test('a slow Lightning handoff cannot hold the unified address behind swap startup',async()=>{ + const f=setup();let release!:()=>void,finished=0,ready=0,attempts=0;const held=new Promise(resolve=>{release=resolve;}); + const service=createLnurlReceive({...f.config,watchStartupGraceMs:20,onPayouts:async(_rows,signal)=>{attempts++;await held;if(!signal.aborted)finished++;},onPayoutReady:()=>ready++}); + const receiver=await Promise.race([service.ensureUnified(),new Promise((_resolve,reject)=>setTimeout(()=>reject(new Error('unified address waited for swap startup')),250))]); + assert.equal(receiver.lnurl,sessionLnurl); + assert.equal(f.bound().length,1);assert.deepEqual([finished,ready,attempts],[0,0,1]); + await service.check();assert.equal(attempts,1); + release();await new Promise(resolve=>setImmediate(resolve)); + assert.deepEqual([finished,ready],[1,1]); + await service.check();assert.equal(attempts,1); + await service.dispose(); +}); + +test('a payout watch that fails after startup reports separately and retries without replacing the address',async()=>{ + const f=setup();let release!:()=>void,attempts=0,ready=0;const failure=new Error('late payout watch failed'),reported:{error:unknown;revision:number}[]=[]; + const held=new Promise(resolve=>{release=resolve;}); + const service=createLnurlReceive({...f.config,watchStartupGraceMs:20,onPayouts:async()=>{if(++attempts===1){await held;throw failure;}},onPayoutError:(error,revision)=>reported.push({error,revision}),onPayoutReady:()=>ready++}); + const receiver=await service.ensureUnified(); + assert.equal(receiver.lnurl,sessionLnurl);assert.equal(f.bound().length,1); + f.funded.add(at(0).script);await service.check(); + assert.deepEqual([attempts,ready],[2,0]); + release(); + for(let i=0;i<20&&!reported.length;i++)await new Promise(resolve=>setTimeout(resolve,10)); + assert.deepEqual(reported,[{error:failure,revision:1}]); + await service.check(); + assert.deepEqual([attempts,ready],[3,1]); + await service.check();assert.equal(attempts,3); + await service.dispose(); +}); + +test('a successful newer subset watch leaves an older failed payout eligible and keeps receive unhealthy',async()=>{ + const f=setup(),failure=new Error('older payout watch failed'),ready:number[]=[],reported:number[]=[]; + let releaseFirst!:()=>void,enteredFirst!:()=>void,attempts=0; + const firstEntered=new Promise(resolve=>{enteredFirst=resolve;}), + firstHeld=new Promise(resolve=>{releaseFirst=resolve;}); + const service=createLnurlReceive({...f.config,watchStartupGraceMs:20, + onPayouts:async()=>{ + attempts++; + if(attempts===1){enteredFirst();await firstHeld;throw failure;} + }, + onPayoutError:(_error,revision)=>reported.push(revision), + onPayoutReady:revision=>ready.push(revision), + }); + const activation=service.ensureUnified(); + await firstEntered; + await activation; + f.funded.add(at(0).script); + await service.check(); + assert.equal(attempts,2); + assert.deepEqual(ready,[],'a newer subset does not cover the still-pending payout'); + releaseFirst(); + for(let i=0;i<20&&!reported.length;i++)await new Promise(resolve=>setTimeout(resolve,10)); + assert.deepEqual(reported,[1]); + await service.check(); + assert.equal(attempts,3,'the failed payout is retried'); + assert.deepEqual(ready,[2],'receive becomes healthy only after the full payout set is covered'); + await service.dispose(); +}); + +test('full coverage emits a fresh health revision after an older watch completes late',async()=>{ + const f=setup(),failure=new Error('newer payout watch failed'),ready:number[]=[],reported:number[]=[]; + let releaseOlder!:()=>void,enteredOlder!:()=>void,releaseNewer!:()=>void,enteredNewer!:()=>void,attempts=0; + const olderEntered=new Promise(resolve=>{enteredOlder=resolve;}),olderHeld=new Promise(resolve=>{releaseOlder=resolve;}), + newerEntered=new Promise(resolve=>{enteredNewer=resolve;}),newerHeld=new Promise(resolve=>{releaseNewer=resolve;}); + const service=createLnurlReceive({...f.config,watchStartupGraceMs:20, + onPayouts:async()=>{ + attempts++; + if(attempts===1){enteredOlder();await olderHeld;} + else if(attempts===2){enteredNewer();await newerHeld;throw failure;} + }, + onPayoutError:(_error,revision)=>reported.push(revision), + onPayoutReady:revision=>ready.push(revision), + }); + const activation=service.ensureUnified(); + await olderEntered; + await activation; + f.funded.add(at(0).script); + await service.check(); + await newerEntered; + releaseNewer(); + for(let i=0;i<20&&!reported.length;i++)await new Promise(resolve=>setTimeout(resolve,10)); + assert.deepEqual(reported,[1]); + await service.check(); + assert.equal(attempts,3); + assert.deepEqual(ready,[],'the newer retry cannot cover the older pending payout'); + releaseOlder(); + for(let i=0;i<20&&!ready.length;i++)await new Promise(resolve=>setTimeout(resolve,10)); + assert.deepEqual(ready,[2],'the late older completion advances health past the newer error'); + await service.dispose(); +}); + test('a fresh journal recovers funded session payouts before binding a unified receiver',async()=>{ const f=setup({aliases:[nameless]});f.funded.add(at(3).script);f.funded.add(at(70).script); await f.service.ensureUnified(); diff --git a/tests/native-lightning-startup.test.ts b/tests/native-lightning-startup.test.ts index 9ebc29f7..bc1c8e49 100644 --- a/tests/native-lightning-startup.test.ts +++ b/tests/native-lightning-startup.test.ts @@ -14,9 +14,9 @@ import type {ShieldedWallet} from '../src/shielded/wallet.ts'; import {startPageLightning} from '../app/src/shielded-page.ts'; const deferred=()=>{let resolve!:()=>void;const promise=new Promise(done=>{resolve=done;});return {promise,resolve};}; -const bounded=async(promise:Promise,stage='startup')=>{ +const bounded=async(promise:Promise,stage='startup',timeout=1500)=>{ let timer:ReturnType|undefined; - try{return await Promise.race([promise,new Promise((_resolve,reject)=>{timer=setTimeout(()=>reject(new Error(`${stage} waited for the unrelated HD look-ahead scan`)),1500);})]);} + try{return await Promise.race([promise,new Promise((_resolve,reject)=>{timer=setTimeout(()=>reject(new Error(`${stage} waited for the unrelated HD look-ahead scan`)),timeout);})]);} finally{clearTimeout(timer);} }; async function fixture(){ @@ -47,8 +47,8 @@ async function fixture(){ releaseBoot(){held=false;boot.resolve();},holdPayout(){holdPayout=true;},releasePayout(){holdPayout=false;payout.resolve();}}; } -test('native Lightning can bind its hydrated payout while unrelated SDK HD look-ahead reads wait',async()=>{ - const f=await fixture(),journal=new InMemoryLightningRepository();let active=true,binds=0,finished=false; +test('native Lightning can bind its payouts without holding the unified address behind swap startup',async()=>{ + const f=await fixture(),journal=new InMemoryLightningRepository(),stages:string[]=[];let active=true,binds=0,finished=0; const main={self:{owner:1n},pool:{status:{pool:{token:'pool'}}},changed(){},getAddress:async()=>'test-address'} as unknown as ShieldedWallet; const clientFactory=()=>({ready:Promise.resolve(),start:async()=>{},onUpdate:()=>()=>{},[Symbol.asyncDispose]:async()=>{}}) as unknown as SwapClient; const aliases:unknown[]=[],receiver={handle:'ab'.repeat(32),username:null,lightningAddress:null,domain:'lnurl.example',status:'active',lnurl:'LNURL1SESSION',nameless:true,sessionLnurl:'LNURL1SESSION'}; @@ -60,29 +60,45 @@ test('native Lightning can bind its hydrated payout while unrelated SDK HD look- const start=startPageLightning({ pairing:createLightningWallet(f.secret,{...f.providers,lazyInitialization:true}), swaps:companion=>createPrivateLightning({wallet:main,companion,journal,clientFactory:clientFactory as (config:SwapClientConfig)=>SwapClient,repositoryFor:()=>new InMemoryAssetSwapRepository(),acquireSession:async()=>async()=>{}}), - lnurl:async(companion,swaps)=>createLnurlReceive({identity:SingleKey.fromHex('22'.repeat(32)),companion,journal,owner:await lightningOwner(companion),pool:'pool',baseUrl:'https://lnurl.example',fetch:fetcher,onPayouts:async rows=>(await swaps).registerPayouts(rows)}), + lnurl:async(companion,swaps)=>createLnurlReceive({identity:SingleKey.fromHex('22'.repeat(32)),companion,journal,owner:await lightningOwner(companion),pool:'pool',baseUrl:'https://lnurl.example',fetch:fetcher,watchStartupGraceMs:20,onStage:stage=>stages.push(stage),onPayouts:async rows=>{await (await swaps).registerPayouts(rows);finished++;}}), signal:new AbortController().signal,active:()=>active,handOver(){},report(error){throw error;}, }); let parts:Awaited; try{ await bounded(f.bootEntered.promise,'first look-ahead read'); parts=await bounded(start,'Lightning handover');assert.ok(parts); - f.holdPayout();const unified=parts.lnurl.ensureUnified().then(value=>{finished=true;return value;}); - await bounded(f.payoutEntered.promise,'payout hydration');assert.equal(finished,false);assert.equal(binds,0); - f.releasePayout();assert.equal((await bounded(unified,'unified receiver')).lnurl,'LNURL1SESSION');assert.equal(binds,1); + f.holdPayout();const unified=parts.lnurl.ensureUnified(); + await bounded(f.payoutEntered.promise,'payout hydration'); + assert.deepEqual(stages.slice(0,4),[ + 'Reading your saved receiver', + 'Checking the Lightning address service through Tor', + 'Registering your unified address', + 'Restoring payout watches', + ]); + const result=await bounded(unified,`unified receiver after ${stages.join(', ')}`,6_000); + assert.equal(result.lnurl,'LNURL1SESSION');assert.equal(binds,1); + assert.equal(finished,0); + f.releasePayout(); + for(let i=0;i<20&&!finished;i++)await new Promise(resolve=>setTimeout(resolve,10)); + assert.equal(finished,1);await new Promise(resolve=>setImmediate(resolve)); const manager=await parts.companion.getContractManager(),[registered]=await parts.lnurl.payouts(); assert.equal((await manager.getContracts({script:[registered!.script]}))[0]?.watch,'watched'); f.releaseBoot();await bounded(manager.whenBooted());assert.ok(f.readScripts.size>=43);assert.equal(manager.getSyncState().mode,'online'); const legacyScript=hex.encode(new DefaultVtxo.Script({pubKey:f.pubKey,serverPubKey:hex.decode(info.signerPubkey.slice(-64)),csvTimelock:{type:'seconds',value:BigInt(info.unilateralExitDelay)}}).pkScript); assert.ok(f.readScripts.has(legacyScript),'fixture actually read the default legacy funding script'); - }finally{ + }finally{ active=false;f.releaseBoot();f.releasePayout();parts??=await start; - await parts?.lnurl.dispose();await parts?.swaps[Symbol.asyncDispose]();await parts?.companion.dispose();f.indexer.retire();await journal[Symbol.asyncDispose](); - } + let disposalError:unknown; + for(let i=0;i<10;i++){ + try{await parts?.lnurl.dispose();disposalError=undefined;break;}catch(error){disposalError=error;await new Promise(resolve=>setTimeout(resolve,10));} + } + await parts?.swaps[Symbol.asyncDispose]();await parts?.companion.dispose();f.indexer.retire();await journal[Symbol.asyncDispose](); + if(disposalError)throw disposalError; + } }); test('the default companion still waits for SDK HD look-ahead initialization',async()=>{ const f=await fixture();let ready=false;const opening=createLightningWallet(f.secret,f.providers).then(wallet=>{ready=true;return wallet;}); - try{await bounded(f.bootEntered.promise);await new Promise(resolve=>setImmediate(resolve));assert.equal(ready,false);} + try{await bounded(f.bootEntered.promise,'first look-ahead read',5_000);await new Promise(resolve=>setImmediate(resolve));assert.equal(ready,false);} finally{f.releaseBoot();const wallet=await opening;await wallet.dispose();f.indexer.retire();} }); diff --git a/tests/wallet-recovery.test.ts b/tests/wallet-recovery.test.ts index b1c30da6..53959605 100644 --- a/tests/wallet-recovery.test.ts +++ b/tests/wallet-recovery.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import {test} from 'node:test'; -import {PRIVATE_RECEIVE_RESTART_KEY,claimPrivateReceiveRestart,clearPrivateReceiveRestart,createProgressWatchdog,dismissTransientWalletViews,privateReceiveStageLabel,restartWhenVisible,shouldAutoDismissPaymentResult} from '../app/src/wallet-recovery.ts'; +import {PRIVATE_RECEIVE_RESTART_KEY,claimPrivateReceiveRestart,clearNativeTransientDocument,clearPrivateReceiveRestart,createProgressWatchdog,dismissTransientWalletViews,privateReceiveStageLabel,restartWhenVisible,shouldAutoDismissPaymentResult} from '../app/src/wallet-recovery.ts'; class MemoryStorage { private readonly values=new Map(); @@ -62,3 +62,9 @@ test('foreground recovery dismisses every transient wallet surface',()=>{ }); assert.deepEqual(calls,['scanner','sheet','about','request','security']); }); + +test('foreground recovery clears stale document-level sheet locks and inert attributes',()=>{ + const calls:string[]=[],root={classList:{remove:(name:string)=>calls.push(`class:${name}`)},style:{removeProperty:(name:string)=>calls.push(`style:${name}`)}}; + clearNativeTransientDocument(root,[{removeAttribute:name=>calls.push(`attr:${name}`)},{removeAttribute:name=>calls.push(`attr:${name}`)}]); + assert.deepEqual(calls,['class:native-sheet-open','style:overflow','attr:inert','attr:inert']); +}); diff --git a/tools/native-artifacts.mjs b/tools/native-artifacts.mjs index 4aa2d92e..27502d78 100644 --- a/tools/native-artifacts.mjs +++ b/tools/native-artifacts.mjs @@ -14,6 +14,12 @@ export function verifyMutinynet(projectRoot) { } } +/** Hashes text with CRLF and lone CR normalized to LF across platforms. */ +export function canonicalTextSha256(bytes) { + const text = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes); + return createHash('sha256').update(text.replace(/\r\n?/g, '\n'), 'utf8').digest('hex'); +} + function files(directory) { if (!existsSync(directory)) return []; return readdirSync(directory, { withFileTypes: true }).flatMap(entry => entry.isDirectory() @@ -55,8 +61,8 @@ export function collectNativeArtifacts(projectRoot, platform, profile, commit) { const bytes = readFileSync(destination); return { file: name, profile: artifact.profile, requiresSigning: artifact.requiresSigning, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; }); - const lockfileSha256 = createHash('sha256').update(readFileSync(join(projectRoot, 'package-lock.json'))).digest('hex'); - const rustLockfileSha256 = createHash('sha256').update(readFileSync(join(projectRoot, 'src-tauri/Cargo.lock'))).digest('hex'); + const lockfileSha256 = canonicalTextSha256(readFileSync(join(projectRoot, 'package-lock.json'))); + const rustLockfileSha256 = canonicalTextSha256(readFileSync(join(projectRoot, 'src-tauri/Cargo.lock'))); const environment = { node: '24.22.2', rust: '1.96.0', @@ -71,7 +77,7 @@ export function collectNativeArtifacts(projectRoot, platform, profile, commit) { `Pool: ${origin}`, `Platform: ${platform}. Build profile: ${profile}. Source: ${commit}.`, `Reproduction environment: Node ${environment.node}, Rust ${environment.rust}, SOURCE_DATE_EPOCH ${environment.sourceDateEpoch}.`, - `Lockfile SHA-256: npm ${lockfileSha256}; Cargo ${rustLockfileSha256}.`, + `Lockfile SHA-256 (UTF-8 text, CRLF/CR normalized to LF): npm ${lockfileSha256}; Cargo ${rustLockfileSha256}.`, 'Wallet networking uses embedded Tor; this workflow does not configure a direct mode.', 'macOS archives preserve the app bundle and executable permissions. These builds are not notarized by this workflow.', 'The iPhone device IPA is unsigned and needs your own signing and provisioning before installation. The simulator archive runs only in an iOS simulator.', diff --git a/tools/native-artifacts.test.mjs b/tools/native-artifacts.test.mjs index ec382f1d..6f61a04d 100644 --- a/tools/native-artifacts.test.mjs +++ b/tools/native-artifacts.test.mjs @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import test from 'node:test'; -import { collectNativeArtifacts, verifyMutinynet } from './native-artifacts.mjs'; +import { canonicalTextSha256, collectNativeArtifacts, verifyMutinynet } from './native-artifacts.mjs'; const commit = 'a'.repeat(40); function fixture(t) { @@ -39,9 +39,15 @@ test('artifact manifests hash actual packages and retain network, source and pro assert.equal(manifest.packages[0].sha256, createHash('sha256').update(bytes).digest('hex')); assert.equal(readFileSync(join(output, manifest.packages[0].file), 'utf8'), bytes); assert.match(readFileSync(join(output, 'SHA256SUMS'), 'utf8'), new RegExp(manifest.packages[0].sha256)); + assert.match(readFileSync(join(output, 'README.txt'), 'utf8'), /CRLF\/CR normalized to LF/); assert.throws(() => collectNativeArtifacts(f.directory, 'windows-x86_64', 'release', commit), /must be empty/); }); +test('lockfile hashes are stable across LF and Windows CRLF checkouts',()=>{ + const lf='{\n "lockfileVersion": 3\n}\n'; + assert.equal(canonicalTextSha256(Buffer.from(lf)),canonicalTextSha256(Buffer.from(lf.replace(/\n/g,'\r\n')))); +}); + test('both Linux package formats are required and empty outputs are refused', t => { const f = fixture(t); f.put('src-tauri/target/debug/bundle/deb/Shielded.deb');