Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions app/src/NativeWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -22,6 +23,7 @@ const getSecret=()=>invoke<string|null>('plugin:shielded-platform|secret_get');
const putSecret=(value:string)=>invoke<void>('plugin:shielded-platform|secret_set',{value});
const clearSecret=()=>invoke<void>('plugin:shielded-platform|secret_clear');
type Session={generation:number;cache:ReturnType<typeof createNativeStorage>;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<Session>;candidate?:NativeWalletView<Session>}>({}),[phase,setPhase]=useState('Connecting to Tor'),[error,setError]=useState('');
Expand All @@ -35,6 +37,7 @@ export default function NativeWallet(){
const selectedMode=useRef<NativeMode>('tor'),transition=useRef(false),clearing=useRef(false),creating=useRef<Promise<void>|undefined>(undefined);
const setupAllowed=useRef(false);
const active=useRef<Session|undefined>(undefined),attempt=useRef(0),cleanup=useRef(createNativeCleanup()),closed=useRef<Promise<void>>(Promise.resolve()),manualLock=useRef(false);
const resumeRevision=useRef(0);
const connectionPending=useRef(false);
const opening=useRef<Promise<void>|undefined>(undefined);
const openingGeneration=useRef<number|undefined>(undefined);
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions app/src/RollupWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()=
const dockLens=useRef<HTMLSpanElement>(null),mainLens=useRef<HTMLSpanElement>(null),panelLens=useRef<HTMLSpanElement>(null);
const [listed,setListed]=useState(''),[restoreError,setRestoreError]=useState(''),[clearError,setClearError]=useState(''),[showView,setShowView]=useState(false),[showFull,setShowFull]=useState(false),[securityView,setSecurityView]=useState<WalletSecurityView|null>(null),[restoreStarted,setRestoreStarted]=useState(false),[aboutOpen,setAboutOpen]=useState(false);
const [listingError,setListingError]=useState<{id:string;message:string}>();
const [invoice,setInvoice]=useState<Invoice>(),[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<InvoiceBounds>();
const [invoice,setInvoice]=useState<Invoice>(),[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<InvoiceBounds>();
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<ClaimView>(),[claimRead,setClaimRead]=useState(0),[minted,setMinted]=useState('');
const lnurlRef=useRef<ReturnType<typeof createLnurlReceive>|undefined>(undefined),unifiedAddressRef=useRef(''),lnLimits=useRef<unknown>(undefined),lnLimitsRead=useRef(0),startingRef=useRef<Promise<unknown>|undefined>(undefined),securityRef=useRef<HTMLDetailsElement>(null);
const lnurlRef=useRef<ReturnType<typeof createLnurlReceive>|undefined>(undefined),unifiedAddressRef=useRef(''),payoutWatchRevision=useRef(0),lnLimits=useRef<unknown>(undefined),lnLimitsRead=useRef(0),startingRef=useRef<Promise<unknown>|undefined>(undefined),securityRef=useRef<HTMLDetailsElement>(null);
const [,setQuoteRevision]=useState(0);
const quoteRef=useRef<ReturnType<typeof pageQuotes>|undefined>(undefined);
const fundsRef=useRef<{wallet:ShieldedWallet;watch:ReturnType<typeof pageQuoteFunds>}|undefined>(undefined);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -705,6 +705,7 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()=
{(privateNotice==='foreign'||privateNotice==='own'||privateNotice==='refused')&&<div className="stock-actions">{privateNotice==='own'&&<button type="button" className="stock-ghost stock-mini" disabled={lnBusy||!wallet} onClick={()=>void setPrivate(true)}>Keep it on</button>}{turnOff(privateNotice==='refused'?'Dismiss':'Turn off')}</div>}
<details className="stock-more"><summary>How private receive works</summary><p className="stock-muted">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.</p></details></>}
{lnError&&<p className="stock-blocked" role="alert">{lnError}</p>}
{payoutWatchError&&<p className="stock-blocked" role="alert">Payout watches are still catching up: {payoutWatchError} Shielded will retry them without blocking your address.</p>}
<details className="stock-more" open={requestOpen} onToggle={e=>setRequestOpen(e.currentTarget.open)}><summary>Invoice amount</summary>{lnBounds?<p id="stock-ln-limits" className={lnOutside?'stock-blocked':'stock-muted'}>{invoiceLimitsLine(lnBounds)}</p>:lnBoundsError?<><p className="stock-blocked" role="alert">{lnBoundsError}</p><button type="button" className="stock-ghost stock-mini" onClick={readLnBounds}>Retry invoice limits</button></>:<p className="stock-muted" role="status">Checking invoice limits…</p>}<button type="button" className="stock-ghost stock-mini" disabled={lnBusy||!wallet?.swaps||invoiceAmount===undefined} onClick={()=>void createInvoice()}>Create invoice</button>
{invoice&&<div className="stock-invoice-result"><strong>Lightning invoice · {sats(invoice.pays)} sats</strong>{liveInvoice&&<Copyable key={invoice.id} value={invoice.bolt11} qr={invoiceQr(invoice.bolt11)} label="QR code for this Lightning invoice"/>}<p className="stock-muted" role="status">{invoiceLine(invoice,fee)}</p></div>}
</details>
Expand Down
9 changes: 9 additions & 0 deletions app/src/wallet-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const PRIVATE_RECEIVE_RESTART_COOLDOWN_MS=120_000;
export const PRIVATE_RECEIVE_STALL_MS=90_000;

type StorageLike=Pick<Storage,'getItem'|'setItem'|'removeItem'>;
type DocumentRoot={classList:{remove:(name:string)=>void};style:{removeProperty:(name:string)=>void}};
type InertNode={removeAttribute:(name:string)=>void};
type TransientWalletControls={
closeSheet:()=>void;
closeScanner:()=>void;
Expand Down Expand Up @@ -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');
}
55 changes: 50 additions & 5 deletions src/shielded/lnurl-receive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,ServerState>}
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<void>;
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.
Expand Down Expand Up @@ -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<string>|undefined,tail:Promise<unknown>=Promise.resolve(),timer:ReturnType<typeof setInterval>|undefined,timerBusy=false,disposed=false,watched=false,offEvents:(()=>void)|undefined,closing:Promise<void>|undefined;
let tokenOnce:Promise<string>|undefined,tail:Promise<unknown>=Promise.resolve(),timer:ReturnType<typeof setInterval>|undefined,timerBusy=false,disposed=false,healthRevision=0,offEvents:(()=>void)|undefined,closing:Promise<void>|undefined;
const pendingWrites=new Set<Promise<unknown>>();
const knownScripts=new Set<string>(),watchedScripts=new Set<string>();
const pendingWatches=new Map<string,{promise:Promise<void>;background:boolean;scripts:string[]}>();
const token=()=>tokenOnce??=deriveSessionTokenForIdentity(config.identity,domain);
const dataWork=<T>(work:Promise<T>)=>{pendingWrites.add(work);void work.then(()=>pendingWrites.delete(work),()=>pendingWrites.delete(work));return untilStopped(work);};
const serial=<T,>(fn:()=>Promise<T>):Promise<T>=>{const pending=tail.then(()=>{if(disposed||abort.signal.aborted)throw new Error('Lightning address service is disposed');return fn();});tail=pending.catch(()=>{});return pending;};
Expand Down Expand Up @@ -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<typeof setTimeout>|undefined;
try{
const settled=await Promise.race([
owner.promise.then(()=>true),
new Promise<false>(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<typeof receiveBranch>,from:number,gap:number)=>{
Expand All @@ -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<typeof receiveBranch>,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);
Expand Down
Loading
Loading