import { SYSVAR_CLOCK_PUBKEY } from '@solana/web3.js'; import { getWallets } from '@wallet-standard/app'; import { openWallet, walletChoices } from './browser-wallets.mjs'; import { ALL_APPS, siteOrigin } from './partners.mjs'; import { AUDIENCE, DEVNET_PROGRAM_ID, DEVNET_RPC, GRANT_SIZE, STATE_SIZE, audienceBytes, buildTransaction, checkDeployment, createGrantIx, grantPda, isAddress, listChainGrants, normalizedGrant, openDevnet, readClock, readGrant, readState, revokeAllIx, revokeIx, sendPrepared, vaultPda, } from './registry.mjs'; export {isAddress, AUDIENCE}; export const sampleAddresses = Object.freeze({ owner:'DMwTybuvWMmdmmL9jF5dTkxr2u9MPzobYZvgwiKstTzo', delegate:'6kv5ef7oMeUjnM8mBAnEHjfg8jLzAvor4v82aAQyH418', asset:'GcS6fPRij7pZEiUtuuE6Sq4NmcZb7jzwmxgxEBHFdhzy', collection:'9vgwEZFhrbnkcRmk5W1Hnj1xUe9RrFhf75WeXsje9pLv' }); const sampleKey='solmate.sample.grants.v1'; let memorySample=[]; let active=null, walletProvider=null, releaseEvents=()=>{}; const preparedReviews=new WeakMap(); const listeners=new Set(); const sessionListeners=new Set(); const now=()=>Math.floor(Date.now()/1000); function deploymentId(){return process.env.NEXT_PUBLIC_SOLBOUND_DEVNET_PROGRAM_ID ?? process.env.NEXT_PUBLIC_SOLMATE_DEVNET_PROGRAM_ID ?? DEVNET_PROGRAM_ID;} function client(){return openDevnet(deploymentId(),process.env.NEXT_PUBLIC_SOLBOUND_DEVNET_RPC || process.env.NEXT_PUBLIC_SOLMATE_DEVNET_RPC || DEVNET_RPC);} export function walletOptions(){ return [...walletChoices(getWallets(),'signTransaction').map(({id,name,available})=>({id,name,available})),{id:'sample',name:'Sample wallet',available:true}]; } export async function getNetworkStatus(){ if(!deploymentId()) return {configured:false,available:false,message:'Devnet registry awaiting deployment. Sample wallet is ready.'}; try {await checkDeployment(client());return {configured:true,available:true,message:'Devnet registry online.'};} catch(e){return {configured:true,available:false,message:e.message};} } export function onWalletDisconnect(fn){listeners.add(fn);return ()=>listeners.delete(fn);} export function onWalletSessionChange(fn){sessionListeners.add(fn);return ()=>sessionListeners.delete(fn);} export function getWalletSession(){ if(!active) return null; try {requireSession(active);return active;} catch {return null;} } function connected(session){ for(const fn of sessionListeners) fn(session); return session; } function invalidate(){ releaseEvents();releaseEvents=()=>{};active=null;walletProvider=null; for(const fn of listeners) fn(); for(const fn of sessionListeners) fn(null); } export async function disconnectWallet(){ const previous=walletProvider;invalidate();if(previous?.disconnect) await previous.disconnect(); } export async function connectWallet(id){ if(active && active.id===id){requireSession(active);return active;} if(active) await disconnectWallet(); if(id==='sample'){ active=Object.freeze({mode:'sample',id,address:sampleAddresses.owner,name:'Sample wallet'}); return connected(active); } const choice=walletChoices(getWallets(),'signTransaction').find(v=>v.id===id); const p=await openWallet(choice,'signTransaction'); const address=p.address; if(!isAddress(address)) throw Error('The wallet returned an invalid Solana account.'); active=Object.freeze({id,mode:'devnet',name:choice.name,address}); walletProvider=p; releaseEvents=p.watch(invalidate); return connected(active); } function requireSession(session){ if(!session || session!==active) throw new Error('Reconnect your wallet before continuing.'); if(session.mode==='devnet' && walletProvider?.address!==session.address){invalidate();throw new Error('The wallet account changed. Connect again.');} } function sampleRead(){ if(typeof window==='undefined') return memorySample; try{ const raw=window.localStorage.getItem(sampleKey); if(raw===null) return memorySample; const value=JSON.parse(raw); return Array.isArray(value)?value.filter(g=>g && g.mode==='sample' && g.owner===sampleAddresses.owner && typeof g.expiresAt==='string'):[]; }catch{return memorySample;} } function sampleChange(fn){ const update=()=>{ const result=fn(sampleRead());memorySample=result; try{window.localStorage.setItem(sampleKey,JSON.stringify(result));}catch{} }; if(globalThis.navigator?.locks?.request) return navigator.locks.request(sampleKey,update); return update(); } // Used exclusively by the browser's labeled practice gate. export function hasSamplePass(origin, asset = sampleAddresses.asset) { const site = siteOrigin(origin); return sampleRead().some(g => g.mode === 'sample' && g.owner === sampleAddresses.owner && g.delegate === sampleAddresses.delegate && (g.audience === ALL_APPS || g.audience === site) && g.status === 'active' && Date.parse(g.expiresAt) > Date.now() && (g.scope === 'wallet' || (g.scope === 'asset' && g.asset === asset) || (g.scope === 'collection' && g.asset === sampleAddresses.collection && asset === sampleAddresses.asset))); } function knownSites() { try { const rows = JSON.parse(window.localStorage.getItem('solmate.grant-sites') || '[]'); return rows.slice(0,80).filter(s => { try { return siteOrigin(s) === s; } catch { return false; } }); } catch { return []; } } export async function listGrants(session,{direction='outgoing'}={}){ requireSession(session); if(!['incoming','outgoing'].includes(direction)) throw new Error('Unknown grant direction.'); if(session.mode==='sample'){ return sampleRead().filter(g=>(direction==='incoming'?g.delegate:g.owner)===session.address) .map(g=>({...g,status:g.status==='revoked'?'revoked':Date.parse(g.expiresAt)<=Date.now()?'expired':'active'})).sort((a,b)=>b.expiresAt.localeCompare(a.expiresAt)); } return listChainGrants(client(),session.address,direction,knownSites()); } export async function reviewGrant(session,draft){ requireSession(session); let grant,depositLamports=0,feeLamports=0,programId=null,prepared=null,grantId=null; if(session.mode==='sample') grant=normalizedGrant(session.address,draft,now()); else{ const rpc=client();await checkDeployment(rpc); const {connection,program}=rpc; const clock=await connection.getAccountInfo(SYSVAR_CLOCK_PUBKEY,'finalized'); grant=normalizedGrant(session.address,draft,readClock(clock)); const audience=await audienceBytes(grant.audience,grant.scope); const ix=createGrantIx(program,grant,audience); const state=vaultPda(program,grant.owner); const id=grantPda(program,grant.owner,grant.delegate,audience,grant.scope,grant.asset); grantId=id.toBase58(); const [accounts,stateRent,grantRent]=await Promise.all([ connection.getMultipleAccountsInfo([state,id],'finalized'), connection.getMinimumBalanceForRentExemption(STATE_SIZE),connection.getMinimumBalanceForRentExemption(GRANT_SIZE) ]); // State cannot close. A grant may close during review, so quote its full deposit ceiling. const existingState=accounts[0]?.owner.equals(program); if(existingState) readState(program,grant.owner,accounts[0]); depositLamports=(existingState?0:stateRent)+grantRent; programId=program.toBase58();prepared=await buildTransaction(rpc,session.address,ix);feeLamports=prepared.feeLamports; } requireSession(session); const review=Object.freeze({...grant,mode:session.mode,programId,feeLamports,depositLamports}); preparedReviews.set(review,{session,prepared,grantId});return review; } export async function submitGrant(session,review){ requireSession(session); const prior=preparedReviews.get(review); if(!prior||prior.session!==session) throw new Error('Review this grant with your connected wallet.'); preparedReviews.delete(review); if(Date.parse(review.expiresAt)<=Date.now()) throw new Error('The grant has expired. Choose another expiry.'); if(session.mode==='sample'){ const id=['sample',review.owner,review.delegate,review.scope,review.asset||'all',review.audience].join(':'); const grant={...review,id,status:'active'}; await sampleChange(rows=>[grant,...rows.filter(g=>g.id!==id)]); return {grant,signature:null,finalized:false,mode:'sample'}; } const rpc=client(); await checkDeployment(rpc); const receipt=await sendPrepared(rpc,prior.prepared,tx=>{requireSession(session);return walletProvider.signTransaction(tx);}); const grant={...review,id:prior.grantId,status:'active'}; if (grant.audience !== ALL_APPS) { try { window.localStorage.setItem('solmate.grant-sites',JSON.stringify([...new Set([grant.audience,...knownSites()])].slice(0,80))); } catch {} } return {...receipt,grant,mode:'devnet'}; } export async function revokeGrant(session,record){ requireSession(session); if(!record || record.owner!==session.address || record.mode!==session.mode) throw new Error('Only the vault owner can revoke this grant.'); if(session.mode==='sample'){ await sampleChange(rows=>rows.map(g=>g.id===record.id?{...g,status:'revoked'}:g)); return {signature:null,mode:'sample',finalized:false}; } const rpc=client();await checkDeployment(rpc); const instruction=revokeIx(rpc.program,session.address,record.id); const id=instruction.keys[1].pubkey, account=await rpc.connection.getAccountInfo(id,'finalized'); if(readGrant(rpc.program,id,account).owner!==session.address) throw new Error('This grant belongs to another vault.'); return signRevoke(rpc,session,instruction); } export async function revokeAll(session){ requireSession(session); if(session.mode==='sample'){ await sampleChange(rows=>rows.map(g=>g.owner===session.address?{...g,status:'revoked'}:g)); return {signature:null,mode:'sample',finalized:false}; } const rpc=client();await checkDeployment(rpc); return signRevoke(rpc,session,revokeAllIx(rpc.program,session.address)); } async function signRevoke(rpc,session,ix){ const prepared=await buildTransaction(rpc,session.address,ix); const receipt=await sendPrepared(rpc,prepared,tx=>{requireSession(session);return walletProvider.signTransaction(tx);}); return {...receipt,mode:'devnet'}; }