Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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&&!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
2 changes: 1 addition & 1 deletion app/src/RollupWallet.tsx
Original file line number Diff line number Diff line change
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:reportLightning,shieldedAddress:()=>created!.shieldedAddress()}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
signal:startController.signal,
onLnurl:startLnurl,active:()=>session.active(),handOver:parts=>{companion=parts.companion;lnurl=parts.lnurl;created!.swaps=parts.swaps;},report:reportLightning,
});
Expand Down
8 changes: 8 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,9 @@ export function dismissTransientWalletViews(controls:TransientWalletControls){
controls.closeRequest();
controls.closeSecurity();
}

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');
}
24 changes: 21 additions & 3 deletions src/shielded/lnurl-receive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ 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)=>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 @@ -156,7 +159,22 @@ export function createLnurlReceive(config:LnurlReceiveConfig){
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;}
};
const restoreWatches=async(state:ReceiveState)=>{if(!watched){await watch(state.payouts);watched=true;}};
const watchWithGrace=async(rows:Destination[])=>{
const pending=watch(rows);let timer:ReturnType<typeof setTimeout>|undefined;
try{
const settled=await Promise.race([
pending.then(()=>true),
new Promise<false>(resolve=>{timer=setTimeout(()=>resolve(false),Math.max(0,config.watchStartupGraceMs??WATCH_STARTUP_GRACE_MS));}),
]);
if(!settled)void pending.catch(error=>{
if(abort.signal.aborted)return;
watched=false;
try{config.onPayoutError?.(error);}catch{}
});
return settled;
}finally{if(timer)clearTimeout(timer);}
};
const restoreWatches=async(state:ReceiveState)=>{if(!watched)watched=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 @@ -171,12 +189,12 @@ export function createLnurlReceive(config:LnurlReceiveConfig){
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);
};
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
11 changes: 11 additions & 0 deletions tests/lnurl-receive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ 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;const held=new Promise<void>(resolve=>{release=resolve;});
const service=createLnurlReceive({...f.config,watchStartupGraceMs:20,onPayouts:async(_rows,signal)=>{await held;if(!signal.aborted)finished++;}});
const receiver=await Promise.race([service.ensureUnified(),new Promise<never>((_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.equal(finished,0);
release();await new Promise(resolve=>setImmediate(resolve));
assert.equal(finished,1);
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();
Expand Down
32 changes: 23 additions & 9 deletions tests/native-lightning-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(done=>{resolve=done;});return {promise,resolve};};
const bounded=async<T,>(promise:Promise<T>,stage='startup')=>{
const bounded=async<T,>(promise:Promise<T>,stage='startup',timeout=1500)=>{
let timer:ReturnType<typeof setTimeout>|undefined;
try{return await Promise.race([promise,new Promise<never>((_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<never>((_resolve,reject)=>{timer=setTimeout(()=>reject(new Error(`${stage} waited for the unrelated HD look-ahead scan`)),timeout);})]);}
finally{clearTimeout(timer);}
};
async function fixture(){
Expand Down Expand Up @@ -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'};
Expand All @@ -60,24 +60,38 @@ 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<typeof start>;
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{
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]();
for(let i=0;i<10;i++){
try{await parts?.lnurl.dispose();break;}catch(error){if(i===9)throw error;await new Promise(resolve=>setTimeout(resolve,10));}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
await parts?.swaps[Symbol.asyncDispose]();await parts?.companion.dispose();f.indexer.retire();await journal[Symbol.asyncDispose]();
}
});

Expand Down
8 changes: 7 additions & 1 deletion tests/wallet-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -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<string,string>();
Expand Down Expand Up @@ -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']);
});
9 changes: 7 additions & 2 deletions tools/native-artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export function verifyMutinynet(projectRoot) {
}
}

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()
Expand Down Expand Up @@ -55,8 +60,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',
Expand Down
7 changes: 6 additions & 1 deletion tools/native-artifacts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -42,6 +42,11 @@ test('artifact manifests hash actual packages and retain network, source and pro
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');
Expand Down
Loading