Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
930678f
perf(shielded): skip a private catch-up the pool's own count says is …
Kukks Nov 11, 2026
6e2ea78
fix(shielded): skip the private catch-up only on the browser's sealed…
Kukks Nov 11, 2026
d21dcaf
feat(shielded): re-draw every private timer and slow the boarding poll
Kukks Nov 11, 2026
85bba31
feat(shielded): delay a private submit and read its inclusion off the…
Kukks Nov 12, 2026
b7a1928
fix(shielded): pace a sealed deposit's signing poll and ask nothing o…
Kukks Nov 12, 2026
847561a
feat(shielded): settle a private journal entry by its nullifier and t…
Kukks Nov 12, 2026
48a5210
docs(shielded): say what the journal's age bound rests on
Kukks Nov 12, 2026
c180880
fix(shielded): give the journal no verdict on a stale or cached pool …
Kukks Nov 12, 2026
3079122
feat(app): prefetch both proving circuits on a private open and verif…
Kukks Nov 12, 2026
8e9c517
fix(app): align a private verify to the catch-up page grid and guard …
Kukks Nov 12, 2026
aa76ffc
feat(shielded): remember the pool head's deadline while the pool stil…
Kukks Nov 12, 2026
dcd2d3c
fix(shielded): re-read a passed head deadline and back off a head rea…
Kukks Nov 12, 2026
1784507
fix(shielded): skip auto-shield's and finish()'s catch-up on a sealed…
Kukks Nov 12, 2026
03ca78b
fix(shielded): poll every leg of a sealed deposit unit as a deposit
Kukks Nov 12, 2026
3018c90
test(shielded): pin a watched spend's verdict reads to rounds 5 and 10
Kukks Nov 12, 2026
401722b
docs: correct the native polling-accessor and test outage-margin comm…
Kukks Nov 12, 2026
dfbb015
Merge origin/main into feat/private-pool-content
Kukks Nov 12, 2026
852e7fb
fix(shielded): give the journal no verdict while the account is unche…
Kukks Nov 12, 2026
866c7f8
fix(shielded): age a private journal entry out only once the pool has…
Kukks Nov 12, 2026
2e85079
fix(shielded): forget a remembered head deadline once the pool names …
Kukks Nov 12, 2026
1c3f8bf
fix(shielded): release an aged journal entry once the pool count leav…
Kukks Nov 12, 2026
766084f
fix(shielded): keep the remembered head deadline through a status tha…
Kukks Nov 12, 2026
ad310db
fix(shielded): read a newly named head's deadline at once after a fai…
Kukks Nov 12, 2026
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: 12 additions & 3 deletions app/src/Disclose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {Hash} from '../../packages/protocol/src/rollup/notes.ts';
import {rollupAddressOf} from '../../packages/protocol/src/rollup/wallet.ts';
import {epochMatchesPool,epochPoolOf} from '../../src/shielded/epoch-checkpoint.ts';
import {epochIncomingNotes} from '../../src/shielded/epoch-disclosure.ts';
import {isPrivatePoolApi,isSealedFetch,privatePoolPage} from '../../src/shielded/network-privacy.ts';
import {privateTransport} from '../../src/shielded/private-transport.ts';
import {epochSyncAccount,poolApiOf,rollupApi,type PoolApi,type RollupPoolStatus} from './rollup-client.ts';

Expand All @@ -36,10 +37,18 @@ export function noteStateOf(account:EpochAccount){
}
/** /verify: the disclosure resolves against whichever pool answers, so the verdict carries that pool's token. */
export async function checkDisclosure(hash:Hash,disclosure:Disclosure,api:PoolApi=poolApiOf('')):Promise<Checked>{
const batches:Record<number,RollupEpochRecord|undefined>={};
for(const n of new Set(disclosure.notes.map(note=>Math.floor(note.index/32))))batches[n]=(await api<{batches:RollupEpochRecord[]}>(`/batches?from=${n}&limit=1`)).batches[0];
// Windows only on the browser's sealed transport. An api carries no sealed brand (its fetcher does) and the default api is built on the global fetch; native's is private but unsealed, and reads one batch at a time.
const sealed=isPrivatePoolApi(api)&&isSealedFetch(globalThis.fetch),size=sealed?privatePoolPage(api):1,status=()=>api<RollupPoolStatus>('/status').catch(()=>undefined);
// A window is the page privateCatchUp reads for that batch, so it needs the epoch length first; without a usable one it is a plain multiple of the page.
const early=sealed?await status():undefined,known=early?.pool?.epochLen,epochLen=typeof known==='number'&&Number.isSafeInteger(known)&&known>0?known:0;
const batches:Record<number,RollupEpochRecord|undefined>={},windows=new Map<number,RollupEpochRecord[]>();
for(const n of new Set(disclosure.notes.map(note=>Math.floor(note.index/32)))){
const start=epochLen?Math.floor(n/epochLen)*epochLen:0,from=start+Math.floor((n-start)/size)*size,limit=Math.min(size,start+(epochLen||Infinity)-from);
if(!windows.has(from))windows.set(from,(await api<{batches:RollupEpochRecord[]}>(`/batches?from=${from}&limit=${limit}`)).batches);
batches[n]=windows.get(from)![n-from];
}
const ok=await verifyDisclosure(hash,disclosure,index=>batches[Math.floor(index/32)]?.slots[Math.floor((index%32)/2)]?.commitments[index%2]);
return {disclosure,ok,batches,pool:(await api<RollupPoolStatus>('/status').catch(()=>undefined))?.pool?.token};
return {disclosure,ok,batches,pool:(sealed?early:await status())?.pool?.token};
}

export type Checked={disclosure:Disclosure;ok:boolean[];batches:Record<number,RollupEpochRecord|undefined>;pool?:string};
Expand Down
8 changes: 6 additions & 2 deletions app/src/RollupWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()=
useEffect(()=>{refreshRef.current?.request();},[wallet,tick]);
useEffect(()=>{
if(!wallet||!nativeHost&&!PRIVATE)return;
if(!nativeHost){let live=true;void PRIVATE!.reconcile().then(pending=>{if(!live)return;setNativePending(pending);setJournalError('');},error=>{if(!live)return;setNativePending(true);setJournalError(errorText(error));});return()=>{live=false;};}
if(!nativeHost){let live=true;void PRIVATE!.reconcile(wallet).then(pending=>{if(!live)return;setNativePending(pending);setJournalError('');},error=>{if(!live)return;setNativePending(true);setJournalError(errorText(error));});return()=>{live=false;};}
try{setNativePending(pendingPoolNullifiers(POOL_ORIGIN).length>0||pendingPoolOutpoints(POOL_ORIGIN).length>0);}
catch(error){setPoolError(errorText(error));}
},[wallet,tick,nativeHost]);
Expand All @@ -476,7 +476,11 @@ export default function RollupWallet({storage=globalThis.localStorage,reload=()=
const wantsInvoice=receiveMode==='unified'&&receiveAmount(lnAmount)!==undefined;
useEffect(()=>{if(wantsInvoice&&!suspended)readLnBounds();},[wantsInvoice,wallet,wallet?.swaps,suspended]);

useEffect(()=>{if(tab==='send'&&pool)prefetchProvingKeys(pool,'spend','',sessionFetch);},[tab,pool,sessionFetch]);
useEffect(()=>{
if(!pool)return;
if(PRIVATE){prefetchProvingKeys(pool,'spend','',sessionFetch);if(pool.proving?.join)prefetchProvingKeys(pool,'join','',sessionFetch);return;}
if(tab==='send')prefetchProvingKeys(pool,'spend','',sessionFetch);
},[tab,pool,sessionFetch]);
const docked=narrow&&pool?.phase==='ready';
useEffect(()=>{
if(!nativeHost)return;
Expand Down
20 changes: 16 additions & 4 deletions src/native/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {ROLLUP_FIELD} from '../../packages/protocol/src/rollup/constants.ts';
type JournalStorage=Pick<Storage,'getItem'|'setItem'|'removeItem'>;
export interface NativeMutation {url:string;method:string;body?:string}
export interface NativeMutationResponse extends NativeMutation {status:number;requestBody?:string}
interface PendingMutation {origin:string;id:string;body:string;nullifiers:string[];coin?:string;createdAt:number}
interface PendingMutation {origin:string;id:string;body:string;nullifiers:string[];coin?:string;createdAt:number;batchAt?:number}
const KEY='shielded-native-pool-mutations-v1',MAX_BYTES=4*1024*1024,MAX_ENTRIES=256,TRACKED_MS=3_600_000;
const REFUSED_CODES=new Set(['slot-shape','group-invalid','recover-unarchived','window-tag','nullifier-range','stale-anchor','fee-missing']);
const REFUSED_BODY=new Set(['The request body is not valid JSON.','A spend id is 8 to 32 random bytes in hex.','A spend carries its seven public inputs.','Malformed epoch slot.','An epoch slot names its anchor, not a root.','An epoch slot names exactly one of rCur, for a fast-path spend, and origin, for a recovery.','A recovery names its origin epoch as a whole number.','A window leaf names a whole epoch and its root.','A group has a nonzero id and two or three members; a lone spend has neither.','Expected a decimal field element.','An asset id is 68 lowercase hex characters.','Expected lowercase hex of the right length.','Malformed Groth16 proof.']);
Expand Down Expand Up @@ -35,12 +35,12 @@ export function makeNativeMutationJournal(storage:JournalStorage,{graceMs,now=()
if(data?.v!==1||!Array.isArray(data.entries)||data.entries.length>MAX_ENTRIES)throw invalid();
const entries:PendingMutation[]=[],ids=new Set<string>();
for(const value of data.entries){
if(!value||typeof value.origin!=='string'||typeof value.body!=='string'||!Number.isSafeInteger(value.createdAt)||value.createdAt<0)throw invalid();
if(!value||typeof value.origin!=='string'||typeof value.body!=='string'||!Number.isSafeInteger(value.createdAt)||value.createdAt<0||value.batchAt!==undefined&&(!Number.isSafeInteger(value.batchAt)||value.batchAt<0))throw invalid();
let origin:string;try{origin=new URL(value.origin).origin;}catch{throw invalid();}
if(origin!==value.origin||!value.origin.startsWith('https://'))throw invalid();
const parsed=parseBody(value.body),key=origin+'/'+parsed.id;
if(value.id!==parsed.id||JSON.stringify(value.nullifiers)!==JSON.stringify(parsed.nullifiers)||value.coin!==parsed.coin||ids.has(key))throw invalid();
ids.add(key);entries.push({origin,...parsed,body:value.body,createdAt:value.createdAt});
ids.add(key);entries.push({origin,...parsed,body:value.body,createdAt:value.createdAt,...(value.batchAt===undefined?{}:{batchAt:value.batchAt})});
}
return entries;
};
Expand Down Expand Up @@ -104,7 +104,19 @@ export function makeNativeMutationJournal(storage:JournalStorage,{graceMs,now=()
}))]);}finally{clearTimeout(deadline);controller.abort();}
return {pending:load().filter(row=>row.origin===origin).length};
};
return {beforeNativeMutation,cancelNativeMutation,pendingPoolNullifiers,pendingPoolOutpoints,observeNativeResponse,reconcileNativeMutations};
/** Browser only: a sealed copy can still be admitted graceMs after the POST and its id answers for an hour after that; past both, only this account's own batches are left to say, once the pool count differs from the one the entry was first judged at: a stalled operator still answers /status, and a new genesis restarts the count lower. */
const settleLandedMutations=(poolOrigin:string,settled:(nullifiers:readonly bigint[])=>boolean|undefined,count:number)=>{
if(graceMs===undefined||!Number.isSafeInteger(count)||count<0)return;
const origin=new URL(poolOrigin).origin,at=now(),entries=load();let marked=false;
const remaining=entries.filter(row=>{
const verdict=row.origin===origin?settled(row.nullifiers.map(BigInt)):undefined;
if(verdict!==false)return verdict!==true;
if(row.batchAt===undefined){row.batchAt=count;marked=true;}
return at-row.createdAt<=TRACKED_MS+graceMs||count===row.batchAt;
});
if(marked||remaining.length!==entries.length)save(remaining);
};
return {beforeNativeMutation,cancelNativeMutation,pendingPoolNullifiers,pendingPoolOutpoints,observeNativeResponse,reconcileNativeMutations,settleLandedMutations};
}

const current=()=>{if(!globalThis.localStorage)throw new Error('The native payment journal is unavailable; no payment was sent.');return makeNativeMutationJournal(globalThis.localStorage);};
Expand Down
7 changes: 6 additions & 1 deletion src/native/providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {EsploraProvider,RestIndexerProvider,isVtxoSpent,type EventSourceFactory,type ExplorerTransaction,type GetVtxosOptions,type IndexerProvider,type PaginationOptions,type SubscriptionResponse,type SubscriptionFilter,type SubscriptionEvent,type SubscriptionHeartbeat,type VirtualCoin} from '@arkade-os/sdk';
import {privateInterval} from '../shielded/network-privacy.ts';

export class AsyncQueue<T> implements AsyncIterableIterator<T> {
private values:T[]=[];private waiters:{resolve:(value:IteratorResult<T>)=>void;reject:(error:unknown)=>void}[]=[];private done=false;private error:unknown;
Expand Down Expand Up @@ -117,7 +118,11 @@ export class IsolatedIndexerProvider extends RestIndexerProvider {
retire(){for(const record of this.subscriptions.values()){record.closed=true;for(const remote of record.entries.values())if(remote)streamReservations--;record.entries.clear();for(const stream of [...record.streams])stream.queue.fail(new Error('Native network generation retired'));}this.subscriptions.clear();}
}
export class IsolatedOnchainProvider extends EsploraProvider {
constructor(url:string){super(url,{forcePolling:true});}
constructor(url:string,pollMs?:number){
super(url,pollMs===undefined?{forcePolling:true}:{forcePolling:true,pollingInterval:pollMs});
// The SDK re-reads this every round; its own class field would shadow an accessor declared on this class.
if(pollMs!==undefined)Object.defineProperty(this,'pollingInterval',{get:()=>privateInterval(pollMs)});
}
override async watchAddresses(addresses:string[],callback:(txs:ExplorerTransaction[])=>void):Promise<()=>void>{
const stopped:(()=>void)[]=[];
try{for(const address of new Set(addresses))stopped.push(await super.watchAddresses([address],callback));}
Expand Down
3 changes: 3 additions & 0 deletions src/shielded/network-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export const SEALED_FETCH=Symbol.for('shielded.sealed.fetch');
export const PRIVATE_POOL_API=Symbol.for('shielded.private.pool-api');
export const PRIVATE_POOL_PAGE=Symbol.for('shielded.private.pool-page');
export const PRIVATE_PAGE_DEFAULT=50;
export const PRIVATE_ONCHAIN_POLL_MS=60_000;
export const PRIVATE_SUBMIT_JITTER_MS=10_000,PRIVATE_SPEND_POLL_MS=20_000,PRIVATE_VERDICT_EVERY=5,PRIVATE_SIGN_POLL_MS=3_000;
export const privateInterval=(ms:number):number=>Math.round(ms*(1+Math.random()/4));

const brandOf=(value:unknown,key:symbol)=>typeof value==='function'?(value as unknown as Record<symbol,unknown>)[key]:undefined;
const marked=(value:unknown,key:symbol)=>brandOf(value,key)===true;
Expand Down
28 changes: 16 additions & 12 deletions src/shielded/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {base64,hex} from '@scure/base';
import {RawWitness} from '@scure/btc-signer';
import type {OwnedNote} from '../../packages/protocol/src/rollup/account.ts';
import {CARRIER_FREE_SATS,carrierFeeOf,depositFeeOf,parseDepositFee,type DepositFee,type DepositFeeSchedule} from '../../packages/protocol/src/rollup/deposit-fee.ts';
import {isPrivateFetch,isSealedFetch,markPrivatePoolApi,markPrivatePoolPage,privatePoolPage} from './network-privacy.ts';
import {PRIVATE_VERDICT_EVERY,isPrivateFetch,isSealedFetch,markPrivatePoolApi,markPrivatePoolPage,privateInterval,privatePoolPage} from './network-privacy.ts';
import {reportSyncProgress,type SyncProgressObserver,type SyncRequest} from './sync-progress.ts';

export interface RollupPoolStatus {phase:'starting'|'keys'|'funding'|'genesis'|'ready'|'blocked';message:string;network?:any;proving?:Record<'spend'|'join'|'batch'|'batchJoin',{wasm:string;zkey:string}>&{batchRecover?:{wasm:string;zkey:string}};pool?:{token:string;notice?:string;address:string;script:string;batches:number;root:string;head:{txid:string;vout:number;value:number};pending:number;padding:number;reserves?:Record<string,string>;maxReserves?:number;
Expand Down Expand Up @@ -118,20 +118,24 @@ export async function signDeposit(identity:Identity,request:SpendStatus){
}

/** Polls a spend until it is settled or the deadline passes; a rebuilt batch asks the depositor to sign again. */
export async function waitForSpend(id:string,onSigning:(request:SpendStatus)=>Promise<void>,api:PoolApi=poolApiOf(''),pollMs=1000,deadlineMs=Infinity):Promise<SpendStatus>{
export async function waitForSpend(id:string,onSigning:(request:SpendStatus)=>Promise<void>,api:PoolApi=poolApiOf(''),pollMs=1000,deadlineMs=Infinity,landed?:()=>Promise<SpendStatus|undefined>,paced=!!landed):Promise<SpendStatus>{
let signed='',interval=pollMs;
for(const end=Date.now()+deadlineMs;;){
for(let round=0,end=Date.now()+deadlineMs;;round++){
if(Date.now()>end)throw new Error('The spend did not settle in time; the pool may still include it.');
let status:SpendStatus|undefined;
try{status=await api<SpendStatus>('/spends/'+id);}
catch(error){if(!(error instanceof PoolRequestError)||!error.transient)throw error;}
if(status?.status==='included'||status?.status==='dropped')return status;
if(status?.status==='signing'){
if(status.arkTx!==signed){await onSigning(status);signed=status.arkTx!;}
interval=pollMs;
// A watched spend asks nothing just after the POST it would link to; a drop never shows in the batches, so its id is still read.
if(landed&&round>0){const found=await landed();if(found)return found;}
if(!landed||round>0&&round%PRIVATE_VERDICT_EVERY===0){
let status:SpendStatus|undefined;
try{status=await api<SpendStatus>('/spends/'+id);}
catch(error){if(!(error instanceof PoolRequestError)||!error.transient)throw error;}
if(status?.status==='included'||status?.status==='dropped')return status;
if(status?.status==='signing'){
if(status.arkTx!==signed){await onSigning(status);signed=status.arkTx!;}
interval=pollMs;
}
}
await sleep(interval);
interval=Math.min(5*pollMs,2*interval);
await sleep(paced?privateInterval(pollMs):interval);
if(!paced)interval=Math.min(5*pollMs,2*interval);
}
}

Expand Down
11 changes: 6 additions & 5 deletions src/shielded/private-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import {NATIVE_RELEASE_ENDPOINT} from '../native-release.ts';
import {journalledRoute,makeNativeMutationJournal} from '../native/mutations.ts';
import {NativePolicy} from '../native/policy.ts';
import {IsolatedIndexerProvider,IsolatedOnchainProvider} from '../native/providers.ts';
import {markPrivateFetch,markPrivatePoolPage,markSealedFetch} from './network-privacy.ts';
import {PRIVATE_ONCHAIN_POLL_MS,markPrivateFetch,markPrivatePoolPage,markSealedFetch} from './network-privacy.ts';
import {createSealedFetch,SealedFetchError} from './sealed-fetch.ts';

export const PRIVATE_PAGE=12;
export const PRIVATE_GRACE_MS=900_000;
export const PRIVATE_SHIELD_COINS=20;
export interface PrivateTransportConfig {config:Uint8Array;gateway:string;poolOrigin:string;pageOrigin:string;relays?:readonly string[];storage?:Pick<Storage,'getItem'|'setItem'|'removeItem'>;socket?:typeof WebSocket;globals?:Record<string,unknown>}
export interface PrivateTransport {fetch:typeof fetch;poolOrigin:string;pageSize:number;journal:ReturnType<typeof makeNativeMutationJournal>;reconcile():Promise<boolean>;providers(arkUrl:string,indexerUrl:string):{arkProvider:RestArkProvider;indexerProvider:IsolatedIndexerProvider;onchainProvider:(network:NetworkName)=>OnchainProvider};close():void}
export interface PrivateTransport {fetch:typeof fetch;poolOrigin:string;pageSize:number;journal:ReturnType<typeof makeNativeMutationJournal>;reconcile(wallet?:{settledNullifiers(nullifiers:readonly bigint[]):boolean|undefined;batchCount():number}):Promise<boolean>;providers(arkUrl:string,indexerUrl:string):{arkProvider:RestArkProvider;indexerProvider:IsolatedIndexerProvider;onchainProvider:(network:NetworkName)=>OnchainProvider};close():void}

let installed:PrivateTransport|undefined;
const refused=(what:string)=>class {constructor(){throw new Error('Private networking does not permit '+what);}};
Expand Down Expand Up @@ -50,13 +50,14 @@ export function installPrivateTransport(config:PrivateTransportConfig):PrivateTr
const pending=()=>journal.pendingPoolNullifiers(poolOrigin).length>0||journal.pendingPoolOutpoints(poolOrigin).length>0;
let reconciling:Promise<boolean>|undefined;
const transport:PrivateTransport={fetch:privateFetch,poolOrigin,pageSize:PRIVATE_PAGE,journal,
reconcile(){
reconciling??=(async()=>{if(pending())await journal.reconcileNativeMutations(privateFetch,poolOrigin);return pending();})().finally(()=>{reconciling=undefined;});
// The count is read beside the verdicts, after the id round: read before it, a batch the wallet synced meanwhile would be missing from it.
reconcile(wallet){
reconciling??=(async()=>{if(pending()){await journal.reconcileNativeMutations(privateFetch,poolOrigin);if(wallet)journal.settleLandedMutations(poolOrigin,n=>wallet.settledNullifiers(n),wallet.batchCount());}return pending();})().finally(()=>{reconciling=undefined;});
return reconciling;
},
providers(arkUrl,indexerUrl){
policy.registerArk(arkUrl,indexerUrl);
return {arkProvider:new RestArkProvider(arkUrl),indexerProvider:new IsolatedIndexerProvider(indexerUrl,undefined,undefined,undefined,0),onchainProvider:network=>{const url=ESPLORA_URL[network];policy.registerEsplora(url);return new IsolatedOnchainProvider(url);}};
return {arkProvider:new RestArkProvider(arkUrl),indexerProvider:new IsolatedIndexerProvider(indexerUrl,undefined,undefined,undefined,0),onchainProvider:network=>{const url=ESPLORA_URL[network];policy.registerEsplora(url);return new IsolatedOnchainProvider(url,PRIVATE_ONCHAIN_POLL_MS);}};
},
close(){sealedFetch.close();if(installed===transport)installed=undefined;},
};
Expand Down
Loading
Loading