import { Buffer } from 'buffer'; import { ComputeBudgetProgram, Connection, PublicKey, SystemProgram, SYSVAR_CLOCK_PUBKEY, Transaction, TransactionInstruction, VersionedTransaction, } from '@solana/web3.js'; import { ALL_APPS, AUDIENCE, LEGACY_MAIL, grantAudience } from './partners.mjs'; export { AUDIENCE }; export const DEVNET_RPC = 'https://api.devnet.solana.com'; export const DEVNET_PROGRAM_ID = 'FX2zKgkTSrmUpQcfNsrahQN7Mer5gfPo6Ec8FLX3SuaR'; export const DEVNET_GENESIS = 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG'; const registryDigest = '9fdcd1f09c0910a6318eb8f5fd9f963a622d4453987d8c815da9466cc17163f1'; export const GRANT_SIZE = 146; export const STATE_SIZE = 41; export const YEAR = 366 * 86400; const upgradeableLoader = new PublicKey('BPFLoaderUpgradeab1e11111111111111111111111'); const zero = Buffer.alloc(32); const ascii = s => Buffer.from(s, 'utf8'); export function isAddress(value) { if (typeof value !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value)) return false; try { return new PublicKey(value).toBase58() === value; } catch { return false; } } export function normalizedGrant(owner, draft, nowSeconds) { const delegate = String(draft.delegate ?? '').trim(); const scope = draft.scope; const asset = scope === 'wallet' ? null : String(draft.asset ?? '').trim(); if (!isAddress(owner) || !isAddress(delegate) || !PublicKey.isOnCurve(new PublicKey(delegate).toBytes()) || delegate === SystemProgram.programId.toBase58()) { throw new Error('Enter a valid signing wallet address for the delegate.'); } if (owner === delegate) throw new Error('Choose a different wallet as your delegate.'); if (!['wallet','asset','collection'].includes(scope)) throw new Error('Choose a wallet, collection or asset.'); if (scope !== 'wallet' && (!isAddress(asset) || asset === SystemProgram.programId.toBase58())) { throw new Error('Enter the full Solana asset address.'); } const end = Math.floor(new Date(draft.expiresAt).getTime() / 1000); if (!Number.isSafeInteger(end) || end <= nowSeconds || end > nowSeconds + YEAR) { throw new Error('Choose a future expiry within 366 days.'); } return {owner,delegate,scope,asset,expiresAt:new Date(end * 1000).toISOString(), audience:grantAudience(draft.audience)}; } export async function audienceBytes(origin = ALL_APPS, scope = 'wallet') { if (grantAudience(origin) !== origin) throw new Error('Audience must be a canonical HTTPS origin or the all-apps permission.'); const domain = scope === 'collection' ? 'urn:solmate:verified-collection:v1:' + origin : origin; return Buffer.from(await globalThis.crypto.subtle.digest('SHA-256', ascii(domain))); } export function vaultPda(program, vault) { return PublicKey.findProgramAddressSync([ascii('vault'),new PublicKey(vault).toBuffer()],program)[0]; } export function grantPda(program, vault, delegate, audience, scope, asset) { if (!['wallet','asset','collection'].includes(scope)) throw new Error('Unsupported asset scope.'); const assetBytes = scope === 'wallet' ? zero : new PublicKey(asset).toBuffer(); return PublicKey.findProgramAddressSync([ ascii('grant'),new PublicKey(vault).toBuffer(),new PublicKey(delegate).toBuffer(), audience,Buffer.from([scope === 'wallet' ? 0 : 1]),assetBytes ],program)[0]; } const meta = (pubkey,isSigner,isWritable) => ({pubkey:new PublicKey(pubkey),isSigner,isWritable}); export function createGrantIx(program, grant, audience) { const data = Buffer.alloc(106); data[0] = 0; data[1] = grant.scope === 'wallet' ? 0 : 1; new PublicKey(grant.delegate).toBuffer().copy(data,2); Buffer.from(audience).copy(data,34); if (grant.scope !== 'wallet') new PublicKey(grant.asset).toBuffer().copy(data,66); data.writeBigInt64LE(BigInt(Date.parse(grant.expiresAt)/1000),98); const state = vaultPda(program,grant.owner); const id = grantPda(program,grant.owner,grant.delegate,audience,grant.scope,grant.asset); return new TransactionInstruction({programId:program,keys:[ meta(grant.owner,true,true),meta(state,false,true),meta(id,false,true),meta(SystemProgram.programId,false,false) ],data}); } export function revokeIx(program, owner, grantId) { return new TransactionInstruction({programId:program,keys:[meta(owner,true,true),meta(grantId,false,true)],data:Buffer.from([1])}); } export function revokeAllIx(program, owner) { return new TransactionInstruction({programId:program,keys:[meta(owner,true,false),meta(vaultPda(program,owner),false,true)],data:Buffer.from([2])}); } function recordBytes(info, program, tag, size) { if (!info || !info.owner.equals(program) || info.executable) throw new Error('Invalid or missing registry record.'); const bytes = Buffer.from(info.data); if (bytes.length !== size || bytes[0] !== tag) throw new Error('Unsupported registry record.'); return bytes; } const addressAt = (bytes,offset) => new PublicKey(bytes.subarray(offset,offset+32)).toBase58(); export function readState(program, vault, info) { const bytes = recordBytes(info,program,1,STATE_SIZE); if(addressAt(bytes,1)!==vault) throw new Error('Vault state belongs to a different address.'); return bytes.readBigUInt64LE(33); } export function readGrant(program, address, info) { const data = recordBytes(info,program,2,GRANT_SIZE); const owner = addressAt(data,1), delegate = addressAt(data,33); const audienceHash = data.subarray(65,97); const scope = data[97] === 0 ? 'wallet' : data[97] === 1 ? 'asset' : null; if (!scope || (scope === 'wallet' && !data.subarray(98,130).equals(zero))) throw new Error('Malformed grant scope.'); const asset = scope === 'asset' ? addressAt(data,98) : null; const id = grantPda(program,owner,delegate,audienceHash,scope,asset).toBase58(); if (id !== new PublicKey(address).toBase58()) throw new Error('Non-canonical registry record.'); const epoch = data.readBigUInt64LE(130), expiry = Number(data.readBigInt64LE(138)); if (!Number.isSafeInteger(expiry) || !Number.isFinite(new Date(expiry*1000).getTime())) throw new Error('Invalid grant expiry.'); return {owner,delegate,scope,asset,id,audienceHash,epoch,expiry,expiresAt:new Date(expiry*1000).toISOString()}; } export function readClock(info) { const data = info && Buffer.from(info.data); if (!data || data.length < 40 || info.owner.toBase58() !== 'Sysvar1111111111111111111111111111111111111') throw new Error('Could not verify the Solana clock.'); const seconds = Number(data.readBigInt64LE(32)); if (!Number.isSafeInteger(seconds) || seconds < 1) throw new Error('Invalid Solana clock.'); return seconds; } export function grantStatus(record,epoch,time) { if (epoch !== record.epoch) return 'revoked'; return record.expiry <= time ? 'expired' : 'active'; } export function openDevnet(programAddress,endpoint = DEVNET_RPC) { if (!programAddress) throw new Error('The devnet registry is awaiting deployment. Use a sample wallet to explore.'); if (!isAddress(programAddress) || programAddress === SystemProgram.programId.toBase58()) throw new Error('Devnet registry configuration is invalid.'); if (new URL(endpoint).protocol !== 'https:') throw new Error('Use an HTTPS devnet RPC.'); return {connection:new Connection(endpoint,'finalized'),program:new PublicKey(programAddress)}; } export async function checkDeployment({connection,program}) { if (await connection.getGenesisHash() !== DEVNET_GENESIS) throw new Error('Wallet actions are restricted to Solana devnet.'); const info = await connection.getAccountInfo(program,'finalized'); if (!info?.executable || !info.owner.equals(upgradeableLoader) || info.data.length !== 36 || Buffer.from(info.data).readUInt32LE(0) !== 2) { throw new Error('The configured devnet registry is unavailable.'); } const code = new PublicKey(info.data.subarray(4)); const canonicalCode = PublicKey.findProgramAddressSync([program.toBuffer()],upgradeableLoader)[0]; if (!code.equals(canonicalCode)) throw new Error('The registry has an invalid deployment address.'); const stored = await connection.getAccountInfo(code,'finalized'); if (!stored || stored.executable || !stored.owner.equals(upgradeableLoader) || stored.data.length <= 45 || Buffer.from(stored.data).readUInt32LE(0) !== 3) { throw new Error('The configured devnet program has no live program data.'); } const digest = await globalThis.crypto.subtle.digest('SHA-256', stored.data.subarray(45)); if (Buffer.from(digest).toString('hex') !== registryDigest) { throw new Error('The deployed devnet code has changed. A verified Solmate release is required.'); } } export async function listChainGrants(client, address, direction='outgoing', knownOrigins=[]) { const {connection,program} = client; await checkDeployment(client); const found = await connection.getProgramAccounts(program,{commitment:'finalized',filters:[ {dataSize:GRANT_SIZE},{memcmp:{offset:direction==='incoming'?33:1,bytes:address}} ],dataSlice:{offset:0,length:0},withContext:true}); const rows = []; const names = new Map(); for (const origin of new Set([ALL_APPS,AUDIENCE,LEGACY_MAIL,...knownOrigins])) { for (const kind of ['wallet','collection']) names.set((await audienceBytes(origin,kind)).toString('hex'),{origin,kind}); } // Discover addresses first. Grant, revocation counter and clock below are read at a common slot. for (let i=0;iv.pubkey); const discovery = await connection.getMultipleAccountsInfoAndContext(keys,{commitment:'finalized',minContextSlot:found.context.slot}); const live = keys.flatMap((key,j)=>discovery.value[j]?[{key,owner:readGrant(program,key,discovery.value[j]).owner}]:[]); if (!live.length) continue; const states = live.map(v=>vaultPda(program,v.owner)); const bundle = await connection.getMultipleAccountsInfoAndContext([...live.map(v=>v.key),...states,SYSVAR_CLOCK_PUBKEY],{commitment:'finalized',minContextSlot:discovery.context.slot}); const time = readClock(bundle.value.at(-1)); live.forEach(({key,owner},j)=>{ if(!bundle.value[j]) return; const grant = readGrant(program,key,bundle.value[j]); if ((direction==='incoming'?grant.delegate:grant.owner)!==address) return; const status = grantStatus(grant,readState(program,owner,bundle.value[live.length+j]),time); const hash=grant.audienceHash.toString('hex'), label=names.get(hash); rows.push({id:grant.id,owner,delegate:grant.delegate,scope:label?.kind==='collection'?'collection':grant.scope,asset:grant.asset,expiresAt:grant.expiresAt,status, audience:label?.origin ?? 'SHA-256 '+hash,mode:'devnet'}); }); } return rows.sort((a,b)=>b.expiresAt.localeCompare(a.expiresAt)); } export async function buildTransaction(client,owner,ix) { const {connection} = client; const latest = await connection.getLatestBlockhash('confirmed'); // Specify the budget before review so wallets can sign without adding their own fee. // At 1 micro-lamport per unit, the 200,000-unit limit costs one lamport after rounding. const tx = new Transaction({feePayer:new PublicKey(owner),...latest}).add( ComputeBudgetProgram.setComputeUnitLimit({units:200_000}), ComputeBudgetProgram.setComputeUnitPrice({microLamports:1}), ix, ); const msg = tx.compileMessage(); const fee = await connection.getFeeForMessage(msg,'confirmed'); if (!Number.isSafeInteger(fee.value) || fee.value < 0) throw new Error('Fee unavailable. Please review again.'); const sim = await connection.simulateTransaction(new VersionedTransaction(msg),{commitment:'confirmed',sigVerify:false}); if (sim.value.err) throw new Error('Devnet simulation failed. Check the wallet balance, grant details and expiry.'); return {tx,latest,feeLamports:fee.value,bytes:Buffer.from(tx.serializeMessage())}; } export async function sendPrepared(client,prepared,sign) { const {connection} = client; if (await connection.getBlockHeight('confirmed') > prepared.latest.lastValidBlockHeight) throw new Error('This review expired. Review the transaction again.'); const returned = await sign(prepared.tx); // Verify locally, including for injected wallets that return their own Transaction object. let signed; try { signed = Transaction.from(returned.serialize({requireAllSignatures:false,verifySignatures:false})); } catch { throw new Error('Solmate could not read the signed transaction. Reconnect your vault wallet and review again.'); } if (!Buffer.from(signed.serializeMessage()).equals(prepared.bytes)) { throw new Error('The wallet changed the reviewed transaction. Solmate stopped before sending. Review again using the displayed network fee.'); } if (!signed.verifySignatures()) { throw new Error('The vault signature could not be verified. Select the same vault account in your wallet and review again.'); } const wire = signed.serialize(); const signature = await connection.sendRawTransaction(wire,{skipPreflight:false,preflightCommitment:'confirmed',maxRetries:3}); try { const result = await connection.confirmTransaction({...prepared.latest,signature},'finalized'); if(result.value.err) throw new Error('Failed at finalization.'); } catch (cause) { const error = new Error('Check this devnet transaction before retrying: '+signature,{cause}); error.signature=signature; throw error; } return {signature,finalized:true}; }