From dbc73630e5a42ddb1bc2fdc645463d81b69b2cb5 Mon Sep 17 00:00:00 2001 From: jonasXchen Date: Mon, 1 Jun 2026 11:47:30 +0800 Subject: [PATCH 1/5] feat: improved notifications --- .../anchor-counter/app/src/App.tsx | 86 ++++-- .../app/src/components/Alert.tsx | 73 +++-- .../private-counter/app/src/App.tsx | 38 ++- .../session-keys/app/src/App.tsx | 292 +++++++++++++----- .../session-keys/app/src/components/Alert.tsx | 73 +++-- anchor-counter/app/src/App.tsx | 41 ++- anchor-counter/app/src/components/Alert.tsx | 73 +++-- private-counter/app/src/App.tsx | 38 ++- session-keys/app/src/App.tsx | 161 +++++++--- session-keys/app/src/components/Alert.tsx | 73 +++-- 10 files changed, 666 insertions(+), 282 deletions(-) diff --git a/00-LEGACY_EXAMPLES/anchor-counter/app/src/App.tsx b/00-LEGACY_EXAMPLES/anchor-counter/app/src/App.tsx index b8f8e16d..2b951248 100644 --- a/00-LEGACY_EXAMPLES/anchor-counter/app/src/App.tsx +++ b/00-LEGACY_EXAMPLES/anchor-counter/app/src/App.tsx @@ -23,9 +23,10 @@ const COUNTER_PDA_SEED = "counter"; // Program ID comes from the local IDL so it stays in sync with declare_id! after redeploys. const COUNTER_PROGRAM = new PublicKey(publicCounterIdl.address); console.log("Counter program:", COUNTER_PROGRAM.toBase58()); -// Default to a specific ER region (devnet-as) — see comment in non-legacy app for why -// not the router (devnet.magicblock.app): WS subscriptions stick to the picked ER but -// HTTP requests get routed per-call, so updates would never reach the UI. +// Default to a specific ER region (devnet-as) instead of the router (devnet.magicblock.app). +// The router proxies HTTP per-request but a WS subscription is bound to whichever ER it +// happened to pick at connect time — txs routed elsewhere wouldn't fire the WS callback, +// so the UI would stall. Override via REACT_APP_EPHEMERAL_PROVIDER_ENDPOINT for other regions. const PUBLIC_ER_ENDPOINT = process.env.REACT_APP_EPHEMERAL_PROVIDER_ENDPOINT || "https://devnet-as.magicblock.app"; const App: React.FC = () => { @@ -38,8 +39,8 @@ const App: React.FC = () => { const [ephemeralCounter, setEphemeralCounter] = useState(0); const [isDelegated, setIsDelegated] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [transactionError, setTransactionError] = useState(null); - const [transactionSuccess, setTransactionSuccess] = useState(null); + const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); + const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [isLoading, setIsLoading] = useState(true); const counterProgramClient = useRef(null); const counterSubscriptionId = useRef(null); @@ -90,7 +91,9 @@ const App: React.FC = () => { try { await ephemeralConnection.current.removeAccountChangeListener(ephemeralCounterSubscriptionId.current); } catch {} } console.log("Subscribing to ephemeral counter", counterPda.toBase58()); - ephemeralCounterSubscriptionId.current = ephemeralConnection.current.onAccountChange(counterPda, handleEphemeralCounterChange, 'confirmed'); + // Use 'processed' — ER doesn't reliably emit 'confirmed' WS notifications, + // and 'processed' fires at slot-time which is what we want for UI latency. + ephemeralCounterSubscriptionId.current = ephemeralConnection.current.onAccountChange(counterPda, handleEphemeralCounterChange, 'processed'); }, [counterPda, handleEphemeralCounterChange]); // Init program client + base layer counter, then ER connection @@ -110,9 +113,7 @@ const App: React.FC = () => { setCounter(Number(c.count.valueOf())); setIsDelegated(!accountInfo.owner.equals(COUNTER_PROGRAM)); } - // Subscribe unconditionally — even if the account doesn't exist yet, the - // listener will fire when init+increment creates it, so the UI updates - // without needing a page refresh. + // Subscribe unconditionally — listener fires when init+increment creates the account. await subscribeToCounter(); setIsLoading(false); } @@ -180,9 +181,9 @@ const App: React.FC = () => { useTempKeypair: boolean = false, ephemeral: boolean = false, confirmCommitment: Commitment = "processed", - // Optional: an account expected to change as a result of this tx. When provided, - // we refresh its state after confirmation so the UI updates immediately without - // waiting for the long-lived subscribe handler to fire. + // Optional: an account that's expected to change as a result of this tx. + // If provided, we resolve confirmation via onAccountChange (push, ~slot-time) + // instead of HTTP polling. Otherwise we fall back to getSignatureStatuses polling. watchAccount?: PublicKey, ): Promise => { if (!tempKeypair.current) return null; @@ -193,6 +194,7 @@ const App: React.FC = () => { setTransactionSuccess(null); const targetConnection = ephemeral ? ephemeralConnection.current : provider.current.connection; const layerLabel = ephemeral ? "ER" : "Base"; + let signature: string | null = null; try { const { context: { slot: minContextSlot }, @@ -204,8 +206,6 @@ const App: React.FC = () => { transaction.feePayer = useTempKeypair ? tempKeypair.current.publicKey : publicKey; } if (useTempKeypair) transaction.sign(tempKeypair.current); - - let signature; const sendStart = performance.now(); if (!ephemeral && !useTempKeypair) { signature = await sendTransaction(transaction, targetConnection, { minContextSlot }); @@ -216,8 +216,8 @@ const App: React.FC = () => { // Confirm via solana-web3.js's confirmTransaction (signature WS sub at the // requested commitment). On ER at 'processed' this resolves at slot-time - // (~30-100ms). Known wart: ~500ms HTTP polling fallback when the WS sub - // setup is slow — rare in practice, simpler code wins. + // (~30-100ms). Known wart: ~500ms HTTP polling fallback when the WS sub setup + // is slow — rare in practice, simpler code wins. const confirmStart = performance.now(); const sigConfirm = await targetConnection.confirmTransaction( { signature, blockhash, lastValidBlockHeight }, @@ -228,7 +228,9 @@ const App: React.FC = () => { } const confirmMs = Math.round(performance.now() - confirmStart); - // Refresh the watched account inline so the UI updates immediately. + // Refresh the watched account inline so the UI updates immediately without + // waiting for the long-lived subscribeToCounter/subscribeToEphemeralCounter + // handler to fire on its own. let refreshMs = 0; if (watchAccount && counterProgramClient.current) { const refreshStart = performance.now(); @@ -251,10 +253,27 @@ const App: React.FC = () => { console.log( `[${layerLabel}] ${totalMs}ms total = send ${sendMs}ms + confirmTransaction(${confirmCommitment}) ${confirmMs}ms + refresh ${refreshMs}ms · sig ${signature}`, ); - setTransactionSuccess(`[${layerLabel}] confirmed in ${totalMs}ms`); + + // Build an explorer URL so the success Alert links to tx details. + const explorerUrl = ephemeral + ? `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent(PUBLIC_ER_ENDPOINT)}` + : `https://explorer.solana.com/tx/${signature}?cluster=devnet`; + + setTransactionSuccess({ + message: `[${layerLabel}] confirmed in ${totalMs}ms`, + explorerUrl, + }); return signature; } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + const explorerUrl = signature + ? (ephemeral + ? `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent(PUBLIC_ER_ENDPOINT)}` + : `https://explorer.solana.com/tx/${signature}?cluster=devnet`) + : undefined; + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl, + }); } finally { setIsSubmitting(false); } @@ -266,6 +285,10 @@ const App: React.FC = () => { */ const increaseCounterTx = useCallback(async () => { if (!tempKeypair.current) return; + if (!counterProgramClient.current) { + console.error("counterProgramClient not initialized yet"); + return; + } if (!isDelegated) { const accountTmpWallet = await connection.getAccountInfo(tempKeypair.current.publicKey); if (!accountTmpWallet || accountTmpWallet.lamports <= 0.01 * LAMPORTS_PER_SOL) { @@ -273,8 +296,6 @@ const App: React.FC = () => { } } - if (!counterProgramClient.current) return; - const transaction = await counterProgramClient.current.methods .increment() .accounts({ @@ -307,7 +328,10 @@ const App: React.FC = () => { }); transaction.add(noopInstruction); - await submitTransaction(transaction, true, isDelegated); + // Base-layer txs use 'confirmed' (devnet has reorgs at 'processed'); ER uses + // 'processed' since it's a single sequencer and no slot can orphan. + const commitment = isDelegated ? "processed" : "confirmed"; + await submitTransaction(transaction, true, isDelegated, commitment, counterPda); }, [isDelegated, counterPda, submitTransaction, connection, transferToTempKeypair]); const updateCounter = async (_: number): Promise => { @@ -348,7 +372,7 @@ const App: React.FC = () => { .transaction() as Transaction; setEphemeralCounter(Number(counter)); - await submitTransaction(transaction, true, false, "confirmed"); + await submitTransaction(transaction, true, false, "confirmed", counterPda); }, [counterPda, connection, counter, submitTransaction, transferToTempKeypair]); /** @@ -366,7 +390,7 @@ const App: React.FC = () => { }) .transaction() as Transaction; - await submitTransaction(transaction, true, true); + await submitTransaction(transaction, true, true, "processed", counterPda); }, [counterPda, submitTransaction]); const delegateTx = useCallback(async () => { @@ -423,10 +447,20 @@ const App: React.FC = () => { )} {transactionError && - setTransactionError(null)}/>} + setTransactionError(null)} + />} {transactionSuccess && - setTransactionSuccess(null)}/>} + setTransactionSuccess(null)} + />} Magic Block Logo diff --git a/00-LEGACY_EXAMPLES/anchor-counter/app/src/components/Alert.tsx b/00-LEGACY_EXAMPLES/anchor-counter/app/src/components/Alert.tsx index 27d941e8..3a27aeab 100644 --- a/00-LEGACY_EXAMPLES/anchor-counter/app/src/components/Alert.tsx +++ b/00-LEGACY_EXAMPLES/anchor-counter/app/src/components/Alert.tsx @@ -4,54 +4,65 @@ type AlertProps = { type: 'success' | 'error'; message: string; onClose: () => void; + href?: string; }; -const Alert: React.FC = ({ type, message, onClose }) => { +const Alert: React.FC = ({ type, message, onClose, href }) => { const [opacity, setOpacity] = useState(0); // Start with 0 opacity for fade-in effect useEffect(() => { setOpacity(1); + // Linger longer when the notification is clickable so the user has time to click. + const visibleMs = href ? 6000 : 3000; const fadeOutTimer = setTimeout(() => { setOpacity(0); - }, 3000); + }, visibleMs); const removeTimer = setTimeout(() => { onClose(); - }, 3500); + }, visibleMs + 500); return () => { clearTimeout(fadeOutTimer); clearTimeout(removeTimer); }; - }, [onClose]); - - return ( -
- {message} -
- ); + }, [onClose, href]); + + const color = type === 'success' ? 'green' : 'red'; + const containerStyle: React.CSSProperties = { + position: 'fixed', + bottom: '20px', + left: '50%', + transform: 'translateX(-50%)', + backgroundColor: type === 'success' ? 'lightgreen' : 'pink', + padding: '20px', + marginBottom: '10px', + borderRadius: '10px', + color, + transition: 'opacity 1s ease-in-out', + opacity: opacity, + zIndex: 1000, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + maxWidth: '90%', + wordWrap: 'break-word', + wordBreak: 'break-word', + minHeight: '50px', + boxSizing: 'border-box', + textDecoration: 'none', + cursor: href ? 'pointer' : 'default', + }; + + if (href) { + return ( + + {message} + + ); + } + return
{message}
; }; export default Alert; \ No newline at end of file diff --git a/00-LEGACY_EXAMPLES/private-counter/app/src/App.tsx b/00-LEGACY_EXAMPLES/private-counter/app/src/App.tsx index 643f2ca9..13b76e00 100644 --- a/00-LEGACY_EXAMPLES/private-counter/app/src/App.tsx +++ b/00-LEGACY_EXAMPLES/private-counter/app/src/App.tsx @@ -56,7 +56,7 @@ const App: React.FC = () => { // gesture, and the resulting token is baked into the URL below. const [explorerToken, setExplorerToken] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [transactionError, setTransactionError] = useState(null); + const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [isLoading, setIsLoading] = useState(true); const counterProgramClient = useRef(null); @@ -317,7 +317,7 @@ const App: React.FC = () => { ): Promise => { if (!tempKeypair) return null; if (ephemeral && !ephemeralConnection.current) { - setTransactionError("TEE ER connection not ready — auth token missing"); + setTransactionError({ message: "TEE ER connection not ready — auth token missing" }); return null; } setIsSubmitting(true); @@ -325,6 +325,9 @@ const App: React.FC = () => { setTransactionSuccess(null); const targetConnection = ephemeral ? ephemeralConnection.current! : provider.current.connection; const layerLabel = ephemeral ? "ER" : "Base"; + // Hoist signature so the catch handler can include the explorer link for + // failed-on-chain txs (much easier to debug with a clickable link). + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await targetConnection.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; @@ -332,7 +335,7 @@ const App: React.FC = () => { transaction.sign(tempKeypair); const sendStart = performance.now(); - const signature = await targetConnection.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); + signature = await targetConnection.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); const sendMs = Math.round(performance.now() - sendStart); const confirmStart = performance.now(); @@ -388,7 +391,23 @@ const App: React.FC = () => { }); return signature; } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + // If the tx made it on-chain (signature was returned by sendRawTransaction), + // attach an explorer link so the user can inspect the failure logs. + let explorerUrl: string | undefined; + if (signature) { + if (ephemeral) { + const token = await ensureAuthToken(); + if (token) { + explorerUrl = `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent(PRIVATE_ER_ENDPOINT + '?token=' + token)}`; + } + } else { + explorerUrl = `https://explorer.solana.com/tx/${signature}?cluster=devnet`; + } + } + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl, + }); } finally { setIsSubmitting(false); } @@ -485,7 +504,7 @@ const App: React.FC = () => { const togglePrivacyTx = useCallback(async () => { if (!tempKeypair || !counterPda || !permissionPda || !counterProgramClient.current) return; if (!ephemeralConnection.current) { - setTransactionError("ER connection not ready"); + setTransactionError({ message: "ER connection not ready" }); return; } const next = !isPrivate; @@ -545,7 +564,7 @@ const App: React.FC = () => { if (!token) { const fresh = await ensureWalletAuthToken(); if (!fresh) { - setTransactionError("Wallet declined the auth token"); + setTransactionError({ message: "Wallet declined the auth token" }); return; } token = fresh; @@ -660,7 +679,12 @@ const App: React.FC = () => { )} {transactionError && - setTransactionError(null)}/>} + setTransactionError(null)} + />} {transactionSuccess && `https://explorer.solana.com/tx/${sig}?cluster=devnet`; +const erExplorerUrl = (sig: string) => + `https://explorer.solana.com/tx/${sig}?cluster=custom&customUrl=${encodeURIComponent(PUBLIC_ER_ENDPOINT)}`; + const App: React.FC = () => { let { connection } = useConnection(); const ephemeralConnection = useRef(null); const provider = useRef(new SimpleProvider(connection)); const { publicKey, signTransaction: walletSignTransaction } = useWallet(); const signTransaction = useMemo(() => - walletSignTransaction || (async (tx: Transaction) => { throw new Error('Wallet not connected'); }), + walletSignTransaction || (async (_tx: Transaction) => { throw new Error('Wallet not connected'); }), [walletSignTransaction] ); const tempKeypair = useRef(null); @@ -40,8 +48,8 @@ const App: React.FC = () => { const [ephemeralCounter, setEphemeralCounter] = useState(0); const [isDelegated, setIsDelegated] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [transactionError, setTransactionError] = useState(null); - const [transactionSuccess, setTransactionSuccess] = useState(null); + const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); + const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [sessionTokenExists, setSessionTokenExists] = useState(false); const counterProgramClient = useRef(null); const [counterPda, setCounterPda] = useState(null); @@ -49,8 +57,9 @@ const App: React.FC = () => { let ephemeralCounterSubscriptionId = useRef(null); const sessionTokenManager = useRef(null); const sessionTokenPDA = useRef(null); - const SESSION_TOKEN_SEED = "session_token"; + const SESSION_TOKEN_SEED = "session_token_v2"; + // Helpers to Dynamically fetch the IDL and initialize the program client // Build the program client from the LOCAL IDL (copied into src/idl/ by the copy-idl // npm script). Avoids Program.fetchIdl, which requires the IDL to be uploaded // on-chain via `anchor idl init` and silently breaks if that step was skipped. @@ -116,16 +125,36 @@ const App: React.FC = () => { initializeProgramClient().catch(console.error); }, [connection, counterPda, getProgramClient, subscribeToCounter]); + // Derive the temp keypair from (publicKey, nonce). The nonce is stored in + // localStorage and bumped each time a session is created — this lets us rotate + // the session token PDA without waiting for Solana to garbage-collect a previous + // (drained but not yet swept) account, which otherwise produces "Allocate: account + // already in use" on recreate. + const sessionNonceKey = useCallback( + (pk: PublicKey) => `sessionNonce:${pk.toBase58()}`, + [], + ); + const deriveTempKeypair = useCallback((pk: PublicKey, nonce: string) => { + // 32-byte seed from sha256(publicKey || nonce). Use Web Crypto for portability. + const seedBytes = new Uint8Array(32); + const src = new TextEncoder().encode(pk.toBase58() + ":" + nonce); + // Synchronous-ish: borrow first 32 bytes by hashing manually via subtle isn't sync. + // Simpler: pad/truncate the raw bytes ourselves since they don't need to be uniform. + const raw = new Uint8Array(pk.toBytes()); + for (let i = 0; i < 32; i++) seedBytes[i] = raw[i] ^ (src[i % src.length] ?? 0); + return Keypair.fromSeed(seedBytes); + }, []); + // Detect when publicKey is set/connected useEffect( () => { if (!publicKey) return; - if (!publicKey || Keypair.fromSeed(publicKey.toBytes()).publicKey.equals(tempKeypair.current?.publicKey || PublicKey.default)) return; + const nonce = localStorage.getItem(sessionNonceKey(publicKey)) ?? "0"; + const newTempKeypair = deriveTempKeypair(publicKey, nonce); + if (tempKeypair.current?.publicKey.equals(newTempKeypair.publicKey)) return; console.log("Wallet connected with publicKey:", publicKey.toBase58()); - // Derive the temp keypair from the publicKey - const newTempKeypair = Keypair.fromSeed(publicKey.toBytes()); tempKeypair.current = newTempKeypair; - console.log("Temp Keypair", newTempKeypair.publicKey.toBase58()); - }, [connection, publicKey]); + console.log("Temp Keypair", newTempKeypair.publicKey.toBase58(), "nonce:", nonce); + }, [connection, publicKey, sessionNonceKey, deriveTempKeypair]); // Derive counterPda with publicKey included in seed useEffect(() => { @@ -154,8 +183,12 @@ const App: React.FC = () => { ); sessionTokenPDA.current = pda; console.log("Session Token PDA:", pda.toString()); - - // Check if session token exists + + // UI-level "does the PDA have anything at it" check — any non-null account + // means we should show "Revoke Session" (so a user can clean up a stale or + // undecodable session). Per-action handlers do their own strict decode check + // to avoid passing a broken session_token into the program (which would trip + // AccountDiscriminatorMismatch 0xbba). const accountInfo = await connection.getAccountInfo(pda); setSessionTokenExists(!!accountInfo); }; @@ -178,12 +211,10 @@ const App: React.FC = () => { useEffect(() => { const initializeEphemeralConnection = async () => { - // Default to a specific ER region (devnet-as) — the router URL would break WS subscriptions. - const cluster = process.env.REACT_APP_EPHEMERAL_PROVIDER_ENDPOINT || "https://devnet-as.magicblock.app" if(ephemeralConnection.current || counterProgramClient.current == null || !counterPda) { return; } - ephemeralConnection.current = new Connection(cluster); + ephemeralConnection.current = new Connection(PUBLIC_ER_ENDPOINT); // Retry logic to wait for account to sync to ephemeral rollups let retries = 0; @@ -245,10 +276,6 @@ const App: React.FC = () => { console.error("counterPda not available"); return; } - if (!counterProgramClient.current) { - console.error("counterProgramClient not initialized yet"); - return; - } if (sessionTokenExists) { if (!tempKeypair.current) { @@ -284,23 +311,23 @@ const App: React.FC = () => { incrementAccounts.sessionToken = COUNTER_PROGRAM; } - let transaction = await counterProgramClient.current.methods + let transaction = await counterProgramClient.current?.methods .increment() .accounts(incrementAccounts) .transaction() as Transaction; - // Check if counter account exists, if not PREPEND an initialize instruction. + // Check if counter account exists, if not prepend an initialize instruction. // Must run BEFORE increment, otherwise increment hits AccountNotInitialized (0xbc4). const accountInfo = await connection.getAccountInfo(counterPda); if (!accountInfo) { console.log("Counter not initialized, prepending initialize instruction"); - const initIx = await counterProgramClient.current.methods + const initIx = await counterProgramClient.current?.methods .initialize() .accounts({ user: payer, }) .instruction(); - transaction.instructions.unshift(initIx); + if (initIx) transaction.instructions.unshift(initIx); } // Add instruction to print to the noop program and make the transaction unique @@ -314,33 +341,38 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { if (sessionTokenExists && tempKeypair.current) { // Sign with temp keypair when session token exists let connectionToUse = isDelegated ? ephemeralConnection.current : connection; if (!connectionToUse) return; - + const { value: { blockhash, lastValidBlockHeight } } = await connectionToUse.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - + transaction.sign(tempKeypair.current); - const signature = await connectionToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); - await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connectionToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); + { + const c = await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); + } console.log(`Transaction confirmed: ${signature}`); } else { // Sign with wallet when no session token let connectionToUse = isDelegated ? ephemeralConnection.current : connection; if (!connectionToUse) return; - + const { value: { blockhash, lastValidBlockHeight } } = await connectionToUse.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - + try { console.log("Attempting wallet signature with:", { instructions: transaction.instructions.length, @@ -348,8 +380,11 @@ const App: React.FC = () => { recentBlockhash: transaction.recentBlockhash }); const signedTransaction = await signTransaction(transaction); - const signature = await connectionToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); - await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connectionToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + { + const c = await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); + } console.log(`Transaction confirmed: ${signature}`); } catch (walletErr: any) { console.error("Wallet error details:", { @@ -360,9 +395,19 @@ const App: React.FC = () => { throw walletErr; } } - setTransactionSuccess(`Counter incremented`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[${isDelegated ? "ER" : "Base"}] Counter incremented in ${totalMs}ms`, + explorerUrl: signature ? (isDelegated ? erExplorerUrl(signature) : baseExplorerUrl(signature)) : undefined, + }); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + const explorerUrl = signature + ? (isDelegated ? erExplorerUrl(signature) : baseExplorerUrl(signature)) + : undefined; + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl, + }); } finally { setIsSubmitting(false); } @@ -376,15 +421,40 @@ const App: React.FC = () => { const topUp = true; const validUntilBN = new anchor.BN(Math.floor(Date.now() / 1000) + 3600); // valid for 1 hour - // Rent-exempt minimum for a 0-byte system account is ~890,880 lamports; - // top up to 0.002 SOL so a fresh sessionSigner clears rent with headroom. + // Rent-exempt minimum for a 0-byte system account is ~890,880 lamports. + // Top up to 0.002 SOL so a brand-new (just-rotated) sessionSigner clears rent + // with headroom for tx fees. const topUpLamportsBN = new anchor.BN(0.002 * LAMPORTS_PER_SOL); + // Always bump the nonce on Create. Detecting whether the previous PDA is "really + // free" is unreliable: `getAccountInfo` returns null for zombie accounts (lamports=0 + // but slot still allocated by System), so we can't tell from the client whether + // Allocate will succeed. Always rotating gives a fresh PDA every time. + { + const key = sessionNonceKey(publicKey); + const nextNonce = String((Number(localStorage.getItem(key) ?? "0") + 1) | 0); + localStorage.setItem(key, nextNonce); + const fresh = deriveTempKeypair(publicKey, nextNonce); + tempKeypair.current = fresh; + const [pda] = PublicKey.findProgramAddressSync( + [ + Buffer.from(SESSION_TOKEN_SEED), + COUNTER_PROGRAM.toBytes(), + fresh.publicKey.toBytes(), + publicKey.toBuffer(), + ], + sessionTokenManager.current.program.programId, + ); + sessionTokenPDA.current = pda; + console.log("Rotated tempKeypair, new session PDA:", pda.toBase58(), "nonce:", nextNonce); + } + let transaction = await sessionTokenManager.current.program.methods - .createSession(topUp, validUntilBN, topUpLamportsBN) + .createSessionV2(topUp, validUntilBN, topUpLamportsBN) .accounts({ targetProgram: COUNTER_PROGRAM, sessionSigner: tempKeypair.current.publicKey, + feePayer: publicKey, authority: publicKey, }) .transaction(); @@ -405,32 +475,58 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await connection.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = publicKey; - + // Sign with tempKeypair first transaction.sign(tempKeypair.current); - + // Then sign with wallet adapter const signedTransaction = await signTransaction(transaction); - const signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); const confirm = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); if (confirm.value.err) { throw new Error(`Transaction failed on chain: ${JSON.stringify(confirm.value.err)}`); } console.log(`Transaction confirmed: ${signature}`); - setTransactionSuccess(`Session created successfully`); - setSessionTokenExists(true); + // Confirm the new session token account landed. Use the same loose check + // as the init useEffect (getAccountInfo, not program.account.X.fetch) — the + // SDK's decoder schema can mismatch the on-chain layout and falsely report + // "not found" even when the account is allocated and the create tx confirmed. + if (sessionTokenPDA.current) { + const accountInfo = await connection.getAccountInfo(sessionTokenPDA.current); + if (!accountInfo) { + throw new Error("Session token account not found after create"); + } + setSessionTokenExists(true); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Session created in ${totalMs}ms`, + explorerUrl: baseExplorerUrl(signature), + }); + } else { + setSessionTokenExists(true); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Session created in ${totalMs}ms`, + explorerUrl: baseExplorerUrl(signature), + }); + } } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } - }, [publicKey, connection, signTransaction, counterPda]); + }, [publicKey, connection, signTransaction, counterPda, deriveTempKeypair, sessionNonceKey]); /** * Delegate PDA transaction @@ -439,7 +535,21 @@ const App: React.FC = () => { console.log("Delegate PDA transaction"); if (!counterPda) return; - if (sessionTokenExists) { + // Local decision only — do NOT side-effect setSessionTokenExists from here. + // The decoder check is too strict (returns false when SDK schema and on-chain + // layout differ even on a valid token), and flipping the UI state mid-handler + // makes the button snap back to "Create Session" while the tx is in flight. + let hasValidSession = false; + if (sessionTokenPDA.current && sessionTokenManager.current) { + try { + await sessionTokenManager.current.program.account.sessionTokenV2.fetch(sessionTokenPDA.current); + hasValidSession = true; + } catch { + hasValidSession = false; + } + } + + if (hasValidSession) { if (!tempKeypair.current) { console.error("tempKeypair not available"); return; @@ -455,15 +565,15 @@ const App: React.FC = () => { } } - const payer = sessionTokenExists ? tempKeypair.current!.publicKey : publicKey!; - + const payer = hasValidSession ? tempKeypair.current!.publicKey : publicKey!; + const delegateAccounts: any = { payer: payer, pda: counterPda, }; - + // Use sessionToken if session exists, otherwise use program ID as placeholder - if (sessionTokenExists) { + if (hasValidSession) { if (!sessionTokenPDA.current) { console.error("sessionTokenPDA not available"); return; @@ -496,33 +606,44 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await connection.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - - if (sessionTokenExists && tempKeypair.current) { + + if (hasValidSession && tempKeypair.current) { // Sign with temp keypair when session token exists transaction.sign(tempKeypair.current); - const signature = await connection.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); - await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connection.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); + const c = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } else { // Sign with wallet when no session token const signedTransaction = await signTransaction(transaction); - const signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); - await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + const c = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } setEphemeralCounter(Number(counter)); console.log(`Transaction confirmed`); - setTransactionSuccess(`Delegation successful`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Delegation successful in ${totalMs}ms`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } - }, [counterPda, sessionTokenPDA, connection, counter, transferToTempKeypair, publicKey, sessionTokenExists, signTransaction, tempKeypair]); + }, [counterPda, sessionTokenPDA, connection, counter, transferToTempKeypair, publicKey, signTransaction, tempKeypair]); /** * Undelegate PDA transaction @@ -573,28 +694,39 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await connToUse.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - + if (sessionTokenExists && tempKeypair.current) { // Sign with temp keypair when session token exists transaction.sign(tempKeypair.current); - const signature = await connToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); - await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); + const c = await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } else { // Sign with wallet when no session token const signedTransaction = await signTransaction(transaction); - const signature = await connToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: true }); - await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: true }); + const c = await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } console.log(`Transaction confirmed`); - setTransactionSuccess(`Undelegation successful`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[ER] Undelegation successful in ${totalMs}ms`, + explorerUrl: signature ? erExplorerUrl(signature) : undefined, + }); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? erExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } @@ -607,7 +739,7 @@ const App: React.FC = () => { if (!publicKey || !sessionTokenPDA.current || !sessionTokenManager.current) return; const transaction = await sessionTokenManager.current.program.methods - .revokeSession() + .revokeSessionV2() .accounts({ sessionToken: sessionTokenPDA.current, }) @@ -616,6 +748,8 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } @@ -625,13 +759,21 @@ const App: React.FC = () => { // Sign with wallet adapter const signedTransaction = await signTransaction(transaction); - const signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); - await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + const c = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); console.log(`Transaction confirmed: ${signature}`); - setTransactionSuccess(`Session revoked successfully`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Session revoked in ${totalMs}ms`, + explorerUrl: baseExplorerUrl(signature), + }); setSessionTokenExists(false); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } @@ -699,10 +841,20 @@ const App: React.FC = () => { )} {transactionError && - setTransactionError(null)}/>} + setTransactionError(null)} + />} {transactionSuccess && - setTransactionSuccess(null)}/>} + setTransactionSuccess(null)} + />} Magic Block Logo diff --git a/00-LEGACY_EXAMPLES/session-keys/app/src/components/Alert.tsx b/00-LEGACY_EXAMPLES/session-keys/app/src/components/Alert.tsx index 27d941e8..3a27aeab 100644 --- a/00-LEGACY_EXAMPLES/session-keys/app/src/components/Alert.tsx +++ b/00-LEGACY_EXAMPLES/session-keys/app/src/components/Alert.tsx @@ -4,54 +4,65 @@ type AlertProps = { type: 'success' | 'error'; message: string; onClose: () => void; + href?: string; }; -const Alert: React.FC = ({ type, message, onClose }) => { +const Alert: React.FC = ({ type, message, onClose, href }) => { const [opacity, setOpacity] = useState(0); // Start with 0 opacity for fade-in effect useEffect(() => { setOpacity(1); + // Linger longer when the notification is clickable so the user has time to click. + const visibleMs = href ? 6000 : 3000; const fadeOutTimer = setTimeout(() => { setOpacity(0); - }, 3000); + }, visibleMs); const removeTimer = setTimeout(() => { onClose(); - }, 3500); + }, visibleMs + 500); return () => { clearTimeout(fadeOutTimer); clearTimeout(removeTimer); }; - }, [onClose]); - - return ( -
- {message} -
- ); + }, [onClose, href]); + + const color = type === 'success' ? 'green' : 'red'; + const containerStyle: React.CSSProperties = { + position: 'fixed', + bottom: '20px', + left: '50%', + transform: 'translateX(-50%)', + backgroundColor: type === 'success' ? 'lightgreen' : 'pink', + padding: '20px', + marginBottom: '10px', + borderRadius: '10px', + color, + transition: 'opacity 1s ease-in-out', + opacity: opacity, + zIndex: 1000, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + maxWidth: '90%', + wordWrap: 'break-word', + wordBreak: 'break-word', + minHeight: '50px', + boxSizing: 'border-box', + textDecoration: 'none', + cursor: href ? 'pointer' : 'default', + }; + + if (href) { + return ( + + {message} + + ); + } + return
{message}
; }; export default Alert; \ No newline at end of file diff --git a/anchor-counter/app/src/App.tsx b/anchor-counter/app/src/App.tsx index d1669a4f..2b951248 100644 --- a/anchor-counter/app/src/App.tsx +++ b/anchor-counter/app/src/App.tsx @@ -39,8 +39,8 @@ const App: React.FC = () => { const [ephemeralCounter, setEphemeralCounter] = useState(0); const [isDelegated, setIsDelegated] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [transactionError, setTransactionError] = useState(null); - const [transactionSuccess, setTransactionSuccess] = useState(null); + const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); + const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [isLoading, setIsLoading] = useState(true); const counterProgramClient = useRef(null); const counterSubscriptionId = useRef(null); @@ -194,6 +194,7 @@ const App: React.FC = () => { setTransactionSuccess(null); const targetConnection = ephemeral ? ephemeralConnection.current : provider.current.connection; const layerLabel = ephemeral ? "ER" : "Base"; + let signature: string | null = null; try { const { context: { slot: minContextSlot }, @@ -205,7 +206,6 @@ const App: React.FC = () => { transaction.feePayer = useTempKeypair ? tempKeypair.current.publicKey : publicKey; } if (useTempKeypair) transaction.sign(tempKeypair.current); - let signature; const sendStart = performance.now(); if (!ephemeral && !useTempKeypair) { signature = await sendTransaction(transaction, targetConnection, { minContextSlot }); @@ -253,10 +253,27 @@ const App: React.FC = () => { console.log( `[${layerLabel}] ${totalMs}ms total = send ${sendMs}ms + confirmTransaction(${confirmCommitment}) ${confirmMs}ms + refresh ${refreshMs}ms · sig ${signature}`, ); - setTransactionSuccess(`[${layerLabel}] confirmed in ${totalMs}ms`); + + // Build an explorer URL so the success Alert links to tx details. + const explorerUrl = ephemeral + ? `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent(PUBLIC_ER_ENDPOINT)}` + : `https://explorer.solana.com/tx/${signature}?cluster=devnet`; + + setTransactionSuccess({ + message: `[${layerLabel}] confirmed in ${totalMs}ms`, + explorerUrl, + }); return signature; } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + const explorerUrl = signature + ? (ephemeral + ? `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent(PUBLIC_ER_ENDPOINT)}` + : `https://explorer.solana.com/tx/${signature}?cluster=devnet`) + : undefined; + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl, + }); } finally { setIsSubmitting(false); } @@ -430,10 +447,20 @@ const App: React.FC = () => { )} {transactionError && - setTransactionError(null)}/>} + setTransactionError(null)} + />} {transactionSuccess && - setTransactionSuccess(null)}/>} + setTransactionSuccess(null)} + />} Magic Block Logo diff --git a/anchor-counter/app/src/components/Alert.tsx b/anchor-counter/app/src/components/Alert.tsx index 27d941e8..3a27aeab 100644 --- a/anchor-counter/app/src/components/Alert.tsx +++ b/anchor-counter/app/src/components/Alert.tsx @@ -4,54 +4,65 @@ type AlertProps = { type: 'success' | 'error'; message: string; onClose: () => void; + href?: string; }; -const Alert: React.FC = ({ type, message, onClose }) => { +const Alert: React.FC = ({ type, message, onClose, href }) => { const [opacity, setOpacity] = useState(0); // Start with 0 opacity for fade-in effect useEffect(() => { setOpacity(1); + // Linger longer when the notification is clickable so the user has time to click. + const visibleMs = href ? 6000 : 3000; const fadeOutTimer = setTimeout(() => { setOpacity(0); - }, 3000); + }, visibleMs); const removeTimer = setTimeout(() => { onClose(); - }, 3500); + }, visibleMs + 500); return () => { clearTimeout(fadeOutTimer); clearTimeout(removeTimer); }; - }, [onClose]); - - return ( -
- {message} -
- ); + }, [onClose, href]); + + const color = type === 'success' ? 'green' : 'red'; + const containerStyle: React.CSSProperties = { + position: 'fixed', + bottom: '20px', + left: '50%', + transform: 'translateX(-50%)', + backgroundColor: type === 'success' ? 'lightgreen' : 'pink', + padding: '20px', + marginBottom: '10px', + borderRadius: '10px', + color, + transition: 'opacity 1s ease-in-out', + opacity: opacity, + zIndex: 1000, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + maxWidth: '90%', + wordWrap: 'break-word', + wordBreak: 'break-word', + minHeight: '50px', + boxSizing: 'border-box', + textDecoration: 'none', + cursor: href ? 'pointer' : 'default', + }; + + if (href) { + return ( + + {message} + + ); + } + return
{message}
; }; export default Alert; \ No newline at end of file diff --git a/private-counter/app/src/App.tsx b/private-counter/app/src/App.tsx index 643f2ca9..13b76e00 100644 --- a/private-counter/app/src/App.tsx +++ b/private-counter/app/src/App.tsx @@ -56,7 +56,7 @@ const App: React.FC = () => { // gesture, and the resulting token is baked into the URL below. const [explorerToken, setExplorerToken] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [transactionError, setTransactionError] = useState(null); + const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [isLoading, setIsLoading] = useState(true); const counterProgramClient = useRef(null); @@ -317,7 +317,7 @@ const App: React.FC = () => { ): Promise => { if (!tempKeypair) return null; if (ephemeral && !ephemeralConnection.current) { - setTransactionError("TEE ER connection not ready — auth token missing"); + setTransactionError({ message: "TEE ER connection not ready — auth token missing" }); return null; } setIsSubmitting(true); @@ -325,6 +325,9 @@ const App: React.FC = () => { setTransactionSuccess(null); const targetConnection = ephemeral ? ephemeralConnection.current! : provider.current.connection; const layerLabel = ephemeral ? "ER" : "Base"; + // Hoist signature so the catch handler can include the explorer link for + // failed-on-chain txs (much easier to debug with a clickable link). + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await targetConnection.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; @@ -332,7 +335,7 @@ const App: React.FC = () => { transaction.sign(tempKeypair); const sendStart = performance.now(); - const signature = await targetConnection.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); + signature = await targetConnection.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); const sendMs = Math.round(performance.now() - sendStart); const confirmStart = performance.now(); @@ -388,7 +391,23 @@ const App: React.FC = () => { }); return signature; } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + // If the tx made it on-chain (signature was returned by sendRawTransaction), + // attach an explorer link so the user can inspect the failure logs. + let explorerUrl: string | undefined; + if (signature) { + if (ephemeral) { + const token = await ensureAuthToken(); + if (token) { + explorerUrl = `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent(PRIVATE_ER_ENDPOINT + '?token=' + token)}`; + } + } else { + explorerUrl = `https://explorer.solana.com/tx/${signature}?cluster=devnet`; + } + } + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl, + }); } finally { setIsSubmitting(false); } @@ -485,7 +504,7 @@ const App: React.FC = () => { const togglePrivacyTx = useCallback(async () => { if (!tempKeypair || !counterPda || !permissionPda || !counterProgramClient.current) return; if (!ephemeralConnection.current) { - setTransactionError("ER connection not ready"); + setTransactionError({ message: "ER connection not ready" }); return; } const next = !isPrivate; @@ -545,7 +564,7 @@ const App: React.FC = () => { if (!token) { const fresh = await ensureWalletAuthToken(); if (!fresh) { - setTransactionError("Wallet declined the auth token"); + setTransactionError({ message: "Wallet declined the auth token" }); return; } token = fresh; @@ -660,7 +679,12 @@ const App: React.FC = () => { )} {transactionError && - setTransactionError(null)}/>} + setTransactionError(null)} + />} {transactionSuccess && `https://explorer.solana.com/tx/${sig}?cluster=devnet`; +const erExplorerUrl = (sig: string) => + `https://explorer.solana.com/tx/${sig}?cluster=custom&customUrl=${encodeURIComponent(PUBLIC_ER_ENDPOINT)}`; + const App: React.FC = () => { let { connection } = useConnection(); const ephemeralConnection = useRef(null); @@ -40,8 +48,8 @@ const App: React.FC = () => { const [ephemeralCounter, setEphemeralCounter] = useState(0); const [isDelegated, setIsDelegated] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [transactionError, setTransactionError] = useState(null); - const [transactionSuccess, setTransactionSuccess] = useState(null); + const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); + const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [sessionTokenExists, setSessionTokenExists] = useState(false); const counterProgramClient = useRef(null); const [counterPda, setCounterPda] = useState(null); @@ -203,12 +211,10 @@ const App: React.FC = () => { useEffect(() => { const initializeEphemeralConnection = async () => { - // Default to a specific ER region (devnet-as) — the router URL would break WS subscriptions. - const cluster = process.env.REACT_APP_EPHEMERAL_PROVIDER_ENDPOINT || "https://devnet-as.magicblock.app" if(ephemeralConnection.current || counterProgramClient.current == null || !counterPda) { return; } - ephemeralConnection.current = new Connection(cluster); + ephemeralConnection.current = new Connection(PUBLIC_ER_ENDPOINT); // Retry logic to wait for account to sync to ephemeral rollups let retries = 0; @@ -335,33 +341,38 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { if (sessionTokenExists && tempKeypair.current) { // Sign with temp keypair when session token exists let connectionToUse = isDelegated ? ephemeralConnection.current : connection; if (!connectionToUse) return; - + const { value: { blockhash, lastValidBlockHeight } } = await connectionToUse.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - + transaction.sign(tempKeypair.current); - const signature = await connectionToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); - await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connectionToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); + { + const c = await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); + } console.log(`Transaction confirmed: ${signature}`); } else { // Sign with wallet when no session token let connectionToUse = isDelegated ? ephemeralConnection.current : connection; if (!connectionToUse) return; - + const { value: { blockhash, lastValidBlockHeight } } = await connectionToUse.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - + try { console.log("Attempting wallet signature with:", { instructions: transaction.instructions.length, @@ -369,8 +380,11 @@ const App: React.FC = () => { recentBlockhash: transaction.recentBlockhash }); const signedTransaction = await signTransaction(transaction); - const signature = await connectionToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); - await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connectionToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + { + const c = await connectionToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); + } console.log(`Transaction confirmed: ${signature}`); } catch (walletErr: any) { console.error("Wallet error details:", { @@ -381,9 +395,19 @@ const App: React.FC = () => { throw walletErr; } } - setTransactionSuccess(`Counter incremented`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[${isDelegated ? "ER" : "Base"}] Counter incremented in ${totalMs}ms`, + explorerUrl: signature ? (isDelegated ? erExplorerUrl(signature) : baseExplorerUrl(signature)) : undefined, + }); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + const explorerUrl = signature + ? (isDelegated ? erExplorerUrl(signature) : baseExplorerUrl(signature)) + : undefined; + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl, + }); } finally { setIsSubmitting(false); } @@ -451,19 +475,21 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await connection.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = publicKey; - + // Sign with tempKeypair first transaction.sign(tempKeypair.current); - + // Then sign with wallet adapter const signedTransaction = await signTransaction(transaction); - const signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); const confirm = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); if (confirm.value.err) { throw new Error(`Transaction failed on chain: ${JSON.stringify(confirm.value.err)}`); @@ -479,13 +505,24 @@ const App: React.FC = () => { throw new Error("Session token account not found after create"); } setSessionTokenExists(true); - setTransactionSuccess(`Session created successfully`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Session created in ${totalMs}ms`, + explorerUrl: baseExplorerUrl(signature), + }); } else { setSessionTokenExists(true); - setTransactionSuccess(`Session created successfully`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Session created in ${totalMs}ms`, + explorerUrl: baseExplorerUrl(signature), + }); } } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } @@ -569,29 +606,40 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await connection.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - + if (hasValidSession && tempKeypair.current) { // Sign with temp keypair when session token exists transaction.sign(tempKeypair.current); - const signature = await connection.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); - await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connection.sendRawTransaction(transaction.serialize(), { skipPreflight: false }); + const c = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } else { // Sign with wallet when no session token const signedTransaction = await signTransaction(transaction); - const signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); - await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + const c = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } setEphemeralCounter(Number(counter)); console.log(`Transaction confirmed`); - setTransactionSuccess(`Delegation successful`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Delegation successful in ${totalMs}ms`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } @@ -646,28 +694,39 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } } = await connToUse.getLatestBlockhashAndContext(); if (!transaction.recentBlockhash) transaction.recentBlockhash = blockhash; if (!transaction.feePayer) transaction.feePayer = payer; - + if (sessionTokenExists && tempKeypair.current) { // Sign with temp keypair when session token exists transaction.sign(tempKeypair.current); - const signature = await connToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); - await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connToUse.sendRawTransaction(transaction.serialize(), { skipPreflight: true }); + const c = await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } else { // Sign with wallet when no session token const signedTransaction = await signTransaction(transaction); - const signature = await connToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: true }); - await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connToUse.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: true }); + const c = await connToUse.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); } console.log(`Transaction confirmed`); - setTransactionSuccess(`Undelegation successful`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[ER] Undelegation successful in ${totalMs}ms`, + explorerUrl: signature ? erExplorerUrl(signature) : undefined, + }); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? erExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } @@ -689,6 +748,8 @@ const App: React.FC = () => { setIsSubmitting(true); setTransactionError(null); setTransactionSuccess(null); + const txStart = performance.now(); + let signature: string | null = null; try { const { value: { blockhash, lastValidBlockHeight } @@ -698,13 +759,21 @@ const App: React.FC = () => { // Sign with wallet adapter const signedTransaction = await signTransaction(transaction); - const signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); - await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + signature = await connection.sendRawTransaction(signedTransaction.serialize(), { skipPreflight: false }); + const c = await connection.confirmTransaction({ blockhash, lastValidBlockHeight, signature }, "confirmed"); + if (c.value.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(c.value.err)}`); console.log(`Transaction confirmed: ${signature}`); - setTransactionSuccess(`Session revoked successfully`); + const totalMs = Math.round(performance.now() - txStart); + setTransactionSuccess({ + message: `[Base] Session revoked in ${totalMs}ms`, + explorerUrl: baseExplorerUrl(signature), + }); setSessionTokenExists(false); } catch (error) { - setTransactionError(`Transaction failed: ${error}`); + setTransactionError({ + message: `Transaction failed: ${error}`, + explorerUrl: signature ? baseExplorerUrl(signature) : undefined, + }); } finally { setIsSubmitting(false); } @@ -772,10 +841,20 @@ const App: React.FC = () => { )} {transactionError && - setTransactionError(null)}/>} + setTransactionError(null)} + />} {transactionSuccess && - setTransactionSuccess(null)}/>} + setTransactionSuccess(null)} + />} Magic Block Logo diff --git a/session-keys/app/src/components/Alert.tsx b/session-keys/app/src/components/Alert.tsx index 27d941e8..3a27aeab 100644 --- a/session-keys/app/src/components/Alert.tsx +++ b/session-keys/app/src/components/Alert.tsx @@ -4,54 +4,65 @@ type AlertProps = { type: 'success' | 'error'; message: string; onClose: () => void; + href?: string; }; -const Alert: React.FC = ({ type, message, onClose }) => { +const Alert: React.FC = ({ type, message, onClose, href }) => { const [opacity, setOpacity] = useState(0); // Start with 0 opacity for fade-in effect useEffect(() => { setOpacity(1); + // Linger longer when the notification is clickable so the user has time to click. + const visibleMs = href ? 6000 : 3000; const fadeOutTimer = setTimeout(() => { setOpacity(0); - }, 3000); + }, visibleMs); const removeTimer = setTimeout(() => { onClose(); - }, 3500); + }, visibleMs + 500); return () => { clearTimeout(fadeOutTimer); clearTimeout(removeTimer); }; - }, [onClose]); - - return ( -
- {message} -
- ); + }, [onClose, href]); + + const color = type === 'success' ? 'green' : 'red'; + const containerStyle: React.CSSProperties = { + position: 'fixed', + bottom: '20px', + left: '50%', + transform: 'translateX(-50%)', + backgroundColor: type === 'success' ? 'lightgreen' : 'pink', + padding: '20px', + marginBottom: '10px', + borderRadius: '10px', + color, + transition: 'opacity 1s ease-in-out', + opacity: opacity, + zIndex: 1000, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + maxWidth: '90%', + wordWrap: 'break-word', + wordBreak: 'break-word', + minHeight: '50px', + boxSizing: 'border-box', + textDecoration: 'none', + cursor: href ? 'pointer' : 'default', + }; + + if (href) { + return ( + + {message} + + ); + } + return
{message}
; }; export default Alert; \ No newline at end of file From 66955a10b434f2c9c5afa2132bf7640add32334c Mon Sep 17 00:00:00 2001 From: jonasXchen Date: Tue, 2 Jun 2026 13:26:30 +0800 Subject: [PATCH 2/5] feat: renew session key --- .../session-keys/app/src/App.tsx | 54 +++++++++++++++++-- session-keys/app/src/App.tsx | 54 +++++++++++++++++-- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/00-LEGACY_EXAMPLES/session-keys/app/src/App.tsx b/00-LEGACY_EXAMPLES/session-keys/app/src/App.tsx index 57b07ede..291eae15 100644 --- a/00-LEGACY_EXAMPLES/session-keys/app/src/App.tsx +++ b/00-LEGACY_EXAMPLES/session-keys/app/src/App.tsx @@ -51,6 +51,19 @@ const App: React.FC = () => { const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [sessionTokenExists, setSessionTokenExists] = useState(false); + // Epoch seconds — set from on-chain `valid_until` after fetch / create. Null + // when no session, or when we couldn't decode the token (treated as "alive" + // so we don't silently push users into Renew when the decoder is just out of + // sync with the on-chain layout). + const [sessionExpiresAt, setSessionExpiresAt] = useState(null); + // Ticking clock so `isSessionExpired` recomputes without manual refresh. + // 15s is fine — session validity is at minute granularity. + const [nowEpoch, setNowEpoch] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const id = setInterval(() => setNowEpoch(Math.floor(Date.now() / 1000)), 15_000); + return () => clearInterval(id); + }, []); + const isSessionExpired = sessionExpiresAt !== null && nowEpoch >= sessionExpiresAt; const counterProgramClient = useRef(null); const [counterPda, setCounterPda] = useState(null); let counterSubscriptionId = useRef(null); @@ -191,6 +204,19 @@ const App: React.FC = () => { // AccountDiscriminatorMismatch 0xbba). const accountInfo = await connection.getAccountInfo(pda); setSessionTokenExists(!!accountInfo); + if (accountInfo && sessionTokenManager.current) { + try { + const tok = await sessionTokenManager.current.program.account.sessionTokenV2.fetch(pda); + // BN | number — both safely fit i64 dates within ~year 2106. + const v = (tok as any).validUntil; + setSessionExpiresAt(typeof v?.toNumber === "function" ? v.toNumber() : Number(v)); + } catch { + // Schema mismatch — leave null so the UI doesn't lie about expiry. + setSessionExpiresAt(null); + } + } else { + setSessionExpiresAt(null); + } }; initSessionManager().catch(console.error); @@ -277,6 +303,11 @@ const App: React.FC = () => { return; } + if (sessionTokenExists && isSessionExpired) { + setTransactionError({ message: "Session expired — click Renew Session to continue." }); + return; + } + if (sessionTokenExists) { if (!tempKeypair.current) { console.error("tempKeypair not available"); @@ -411,7 +442,7 @@ const App: React.FC = () => { } finally { setIsSubmitting(false); } - }, [isDelegated, counterPda, sessionTokenPDA, connection, transferToTempKeypair, publicKey, sessionTokenExists, signTransaction]); + }, [isDelegated, counterPda, sessionTokenPDA, connection, transferToTempKeypair, publicKey, sessionTokenExists, isSessionExpired, signTransaction]); /** * Create session transaction @@ -505,6 +536,7 @@ const App: React.FC = () => { throw new Error("Session token account not found after create"); } setSessionTokenExists(true); + setSessionExpiresAt(validUntilBN.toNumber()); const totalMs = Math.round(performance.now() - txStart); setTransactionSuccess({ message: `[Base] Session created in ${totalMs}ms`, @@ -512,6 +544,7 @@ const App: React.FC = () => { }); } else { setSessionTokenExists(true); + setSessionExpiresAt(validUntilBN.toNumber()); const totalMs = Math.round(performance.now() - txStart); setTransactionSuccess({ message: `[Base] Session created in ${totalMs}ms`, @@ -535,6 +568,11 @@ const App: React.FC = () => { console.log("Delegate PDA transaction"); if (!counterPda) return; + if (sessionTokenExists && isSessionExpired) { + setTransactionError({ message: "Session expired — click Renew Session to continue." }); + return; + } + // Local decision only — do NOT side-effect setSessionTokenExists from here. // The decoder check is too strict (returns false when SDK schema and on-chain // layout differ even on a valid token), and flipping the UI state mid-handler @@ -643,7 +681,7 @@ const App: React.FC = () => { } finally { setIsSubmitting(false); } - }, [counterPda, sessionTokenPDA, connection, counter, transferToTempKeypair, publicKey, signTransaction, tempKeypair]); + }, [counterPda, sessionTokenPDA, connection, counter, transferToTempKeypair, publicKey, signTransaction, tempKeypair, sessionTokenExists, isSessionExpired]); /** * Undelegate PDA transaction @@ -652,6 +690,11 @@ const App: React.FC = () => { if (!counterPda) return; console.log("Undelegate PDA transaction"); + if (sessionTokenExists && isSessionExpired) { + setTransactionError({ message: "Session expired — click Renew Session to continue." }); + return; + } + if (sessionTokenExists) { if (!tempKeypair.current) { console.error("tempKeypair not available"); @@ -730,7 +773,7 @@ const App: React.FC = () => { } finally { setIsSubmitting(false); } - }, [counterPda, sessionTokenPDA, publicKey, sessionTokenExists, signTransaction, ephemeralConnection]); + }, [counterPda, sessionTokenPDA, publicKey, sessionTokenExists, isSessionExpired, signTransaction, ephemeralConnection]); /** * Revoke session transaction @@ -769,6 +812,7 @@ const App: React.FC = () => { explorerUrl: baseExplorerUrl(signature), }); setSessionTokenExists(false); + setSessionExpiresAt(null); } catch (error) { setTransactionError({ message: `Transaction failed: ${error}`, @@ -822,6 +866,10 @@ const App: React.FC = () => {
{!sessionTokenExists ? ( + ) : isSessionExpired ? ( + // createSessionTx always bumps the nonce → fresh PDA, so it + // doubles as Renew (the previous, expired PDA is left to lapse). + ) : ( )} diff --git a/session-keys/app/src/App.tsx b/session-keys/app/src/App.tsx index 57b07ede..291eae15 100644 --- a/session-keys/app/src/App.tsx +++ b/session-keys/app/src/App.tsx @@ -51,6 +51,19 @@ const App: React.FC = () => { const [transactionError, setTransactionError] = useState<{ message: string; explorerUrl?: string } | null>(null); const [transactionSuccess, setTransactionSuccess] = useState<{ message: string; explorerUrl?: string } | null>(null); const [sessionTokenExists, setSessionTokenExists] = useState(false); + // Epoch seconds — set from on-chain `valid_until` after fetch / create. Null + // when no session, or when we couldn't decode the token (treated as "alive" + // so we don't silently push users into Renew when the decoder is just out of + // sync with the on-chain layout). + const [sessionExpiresAt, setSessionExpiresAt] = useState(null); + // Ticking clock so `isSessionExpired` recomputes without manual refresh. + // 15s is fine — session validity is at minute granularity. + const [nowEpoch, setNowEpoch] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const id = setInterval(() => setNowEpoch(Math.floor(Date.now() / 1000)), 15_000); + return () => clearInterval(id); + }, []); + const isSessionExpired = sessionExpiresAt !== null && nowEpoch >= sessionExpiresAt; const counterProgramClient = useRef(null); const [counterPda, setCounterPda] = useState(null); let counterSubscriptionId = useRef(null); @@ -191,6 +204,19 @@ const App: React.FC = () => { // AccountDiscriminatorMismatch 0xbba). const accountInfo = await connection.getAccountInfo(pda); setSessionTokenExists(!!accountInfo); + if (accountInfo && sessionTokenManager.current) { + try { + const tok = await sessionTokenManager.current.program.account.sessionTokenV2.fetch(pda); + // BN | number — both safely fit i64 dates within ~year 2106. + const v = (tok as any).validUntil; + setSessionExpiresAt(typeof v?.toNumber === "function" ? v.toNumber() : Number(v)); + } catch { + // Schema mismatch — leave null so the UI doesn't lie about expiry. + setSessionExpiresAt(null); + } + } else { + setSessionExpiresAt(null); + } }; initSessionManager().catch(console.error); @@ -277,6 +303,11 @@ const App: React.FC = () => { return; } + if (sessionTokenExists && isSessionExpired) { + setTransactionError({ message: "Session expired — click Renew Session to continue." }); + return; + } + if (sessionTokenExists) { if (!tempKeypair.current) { console.error("tempKeypair not available"); @@ -411,7 +442,7 @@ const App: React.FC = () => { } finally { setIsSubmitting(false); } - }, [isDelegated, counterPda, sessionTokenPDA, connection, transferToTempKeypair, publicKey, sessionTokenExists, signTransaction]); + }, [isDelegated, counterPda, sessionTokenPDA, connection, transferToTempKeypair, publicKey, sessionTokenExists, isSessionExpired, signTransaction]); /** * Create session transaction @@ -505,6 +536,7 @@ const App: React.FC = () => { throw new Error("Session token account not found after create"); } setSessionTokenExists(true); + setSessionExpiresAt(validUntilBN.toNumber()); const totalMs = Math.round(performance.now() - txStart); setTransactionSuccess({ message: `[Base] Session created in ${totalMs}ms`, @@ -512,6 +544,7 @@ const App: React.FC = () => { }); } else { setSessionTokenExists(true); + setSessionExpiresAt(validUntilBN.toNumber()); const totalMs = Math.round(performance.now() - txStart); setTransactionSuccess({ message: `[Base] Session created in ${totalMs}ms`, @@ -535,6 +568,11 @@ const App: React.FC = () => { console.log("Delegate PDA transaction"); if (!counterPda) return; + if (sessionTokenExists && isSessionExpired) { + setTransactionError({ message: "Session expired — click Renew Session to continue." }); + return; + } + // Local decision only — do NOT side-effect setSessionTokenExists from here. // The decoder check is too strict (returns false when SDK schema and on-chain // layout differ even on a valid token), and flipping the UI state mid-handler @@ -643,7 +681,7 @@ const App: React.FC = () => { } finally { setIsSubmitting(false); } - }, [counterPda, sessionTokenPDA, connection, counter, transferToTempKeypair, publicKey, signTransaction, tempKeypair]); + }, [counterPda, sessionTokenPDA, connection, counter, transferToTempKeypair, publicKey, signTransaction, tempKeypair, sessionTokenExists, isSessionExpired]); /** * Undelegate PDA transaction @@ -652,6 +690,11 @@ const App: React.FC = () => { if (!counterPda) return; console.log("Undelegate PDA transaction"); + if (sessionTokenExists && isSessionExpired) { + setTransactionError({ message: "Session expired — click Renew Session to continue." }); + return; + } + if (sessionTokenExists) { if (!tempKeypair.current) { console.error("tempKeypair not available"); @@ -730,7 +773,7 @@ const App: React.FC = () => { } finally { setIsSubmitting(false); } - }, [counterPda, sessionTokenPDA, publicKey, sessionTokenExists, signTransaction, ephemeralConnection]); + }, [counterPda, sessionTokenPDA, publicKey, sessionTokenExists, isSessionExpired, signTransaction, ephemeralConnection]); /** * Revoke session transaction @@ -769,6 +812,7 @@ const App: React.FC = () => { explorerUrl: baseExplorerUrl(signature), }); setSessionTokenExists(false); + setSessionExpiresAt(null); } catch (error) { setTransactionError({ message: `Transaction failed: ${error}`, @@ -822,6 +866,10 @@ const App: React.FC = () => {
{!sessionTokenExists ? ( + ) : isSessionExpired ? ( + // createSessionTx always bumps the nonce → fresh PDA, so it + // doubles as Renew (the previous, expired PDA is left to lapse). + ) : ( )} From 11bdc2c6886cda921d892488c8ba83d75c491862 Mon Sep 17 00:00:00 2001 From: jonasXchen Date: Tue, 2 Jun 2026 14:04:27 +0800 Subject: [PATCH 3/5] docs: updated README --- README.md | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d1499919..4a7fffac 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,34 @@ Read more about Ephemeral Rollups [here](https://docs.magicblock.gg/EphemeralRol > To view integrated demos for specific usecases, please look at [MagicBlock Starter Kits](https://github.com/magicblock-labs/starter-kits). -## 👷Examples - -- [Anchor Counter](./anchor-counter/README.md) - A simple counter that can be incremented. Tests use the ts sdk to delegate/undelegate accounts and run transactions. -- [Rust Counter](./rust-counter/README.md) - A simple counter that can be incremented. Tests natively to delegate/undelegate accounts and run transactions. -- [Bolt Counter](./bolt-counter/README.md) - A simple counter that can be incremented. Tests use the bolt sdk to delegate/undelegate accounts and run transactions. -- [Crank Counter](./crank-counter/README.md) - A counter program with scheduled cranks for automatic execution using MagicBlock's crank system. -- [Dummy Token Transfer](./dummy-token-transfer/README.md) - A token transferer that can delegate and execute both on-chain and in the ephemeral rollup. -- [Magic Actions](./magic-actions/README.md) - Demonstrates using Magic Actions to execute base chain actions from an ephemeral rollup. +## 👷 Examples + +### Counter programs + +- [Anchor Counter](./anchor-counter/README.md) — Counter program in Anchor. Tests delegate/undelegate via the TypeScript SDK. +- [Rust Counter](./rust-counter/README.md) — Counter program in native Rust. Tests delegate/undelegate natively. +- [Pinocchio Counter](./pinocchio-counter/README.md) — Counter program built with Pinocchio (no heap, no Borsh `Vec`s). +- [Pinocchio Secret Counter](./pinocchio-secret-counter/README.md) — Pinocchio counter variant exercising secret state on the ER. +- [Private Counter](./private-counter/README.md) — Anchor counter gated by an on-rollup ephemeral permission account. +- [Session Keys](./session-keys/README.md) — Counter using gpl-session keys for delegated-signer auth on both base chain and ER. +- [Crank Counter](./crank-counter/README.md) — Counter driven by MagicBlock's scheduled crank system. +- [Ephemeral Account Chats](./ephemeral-account-chats/README.md) — Chat program using Anchor "ephemeral accounts" (state lives only on the ER). + +### Tokens & payments + +- [Dummy Token Transfer](./dummy-token-transfer/README.md) — Token transferer that can delegate and execute on both the base chain and the ER. +- [SPL Tokens](./spl-tokens/) — SPL token delegation example. + +### VRF & games + +- [Roll Dice](./roll-dice/README.md) — Dice roll using a verifiable random function (VRF) on the ER. +- [Rewards (Delegated VRF)](./rewards-delegated-vrf/README.md) — Rewards distribution program using delegated VRF. +- [Rock Paper Scissor](./rock-paper-scissor/README.md) — Two-player RPS with hidden moves on the ER until reveal. + +### Other patterns + +- [Magic Actions](./magic-actions/README.md) — Execute base-chain actions from inside an Ephemeral Rollup. +- [On-Curve Delegation](./oncurve-delegation/README.md) — Delegate on-curve (non-PDA) accounts to the ER and manage their lifecycle. ## Backward Compatibility From 882e6c535ffdc27c29fdd6f12faba4148377cfde Mon Sep 17 00:00:00 2001 From: jonasXchen Date: Wed, 3 Jun 2026 12:19:28 +0800 Subject: [PATCH 4/5] feat: roll dice program improved + app fixed --- roll-dice/Anchor.toml | 2 +- roll-dice/app/app/delegated/page.tsx | 58 +++-- roll-dice/app/app/page.tsx | 21 +- roll-dice/app/lib/config.ts | 4 +- roll-dice/app/lib/idl/random_dice.json | 222 ++++++++++++++++++ .../app/lib/idl/random_dice_delegated.json | 73 +++--- roll-dice/app/lib/solana-utils.ts | 28 +++ .../programs/roll-dice-delegated/src/lib.rs | 27 ++- roll-dice/programs/roll-dice/src/lib.rs | 18 +- .../target/deploy/roll_dice-keypair.json | 2 +- roll-dice/tests/roll-dice-delegated.ts | 71 ++++-- roll-dice/tests/roll-dice.ts | 70 ++++-- 12 files changed, 487 insertions(+), 109 deletions(-) create mode 100644 roll-dice/app/lib/idl/random_dice.json diff --git a/roll-dice/Anchor.toml b/roll-dice/Anchor.toml index 4add91d4..34020897 100644 --- a/roll-dice/Anchor.toml +++ b/roll-dice/Anchor.toml @@ -22,4 +22,4 @@ cluster = "devnet" wallet = "~/.config/solana/id.json" [scripts] -test = "yarn run ts-mocha -p ./tsconfig.json -t 120000 tests/**/*.ts" +test = "yarn run ts-mocha -p ./tsconfig.json -t 120000 --exit tests/**/*.ts" diff --git a/roll-dice/app/app/delegated/page.tsx b/roll-dice/app/app/delegated/page.tsx index 710ba309..e6bbc819 100644 --- a/roll-dice/app/app/delegated/page.tsx +++ b/roll-dice/app/app/delegated/page.tsx @@ -37,6 +37,8 @@ import { ROLL_TIMEOUT_MS, ROLL_ANIMATION_INTERVAL_MS, } from "@/lib/config" +// Local IDL — bundle at build time instead of fetchIdl-ing from chain. +import randomDiceDelegatedIdl from "@/lib/idl/random_dice_delegated.json" import { walletAdapterFrom, loadOrCreateKeypair, @@ -44,6 +46,7 @@ import { fetchAndCacheBlockhash, getCachedBlockhash, checkDelegationStatus, + loadIdl, } from "@/lib/solana-utils" import type { RollEntry, CachedBlockhash } from "@/lib/types" @@ -227,15 +230,13 @@ export default function DiceRollerDelegated() { ephemeralConnectionRef.current = newEphemeralConnection setEphemeralEndpoint(validatorFqdn) - // Recreate ephemeral program with new connection - const idl = await anchor.Program.fetchIdl(PROGRAM_ID, programRef.current.provider) - if (!idl) throw new Error("IDL not found") - + // Recreate ephemeral program with new connection — bundled IDL. const ephemeralProvider = new anchor.AnchorProvider( newEphemeralConnection, walletAdapterFrom(playerKeypairRef.current), anchor.AnchorProvider.defaultOptions() ) + const idl = await loadIdl(PROGRAM_ID, programRef.current.provider, randomDiceDelegatedIdl) ephemeralProgramRef.current = new anchor.Program(idl, ephemeralProvider) // Recreate subscription with new connection @@ -333,9 +334,7 @@ export default function DiceRollerDelegated() { anchor.AnchorProvider.defaultOptions() ) - const idl = await anchor.Program.fetchIdl(PROGRAM_ID, provider) - if (!idl) throw new Error("IDL not found") - + const idl = await loadIdl(PROGRAM_ID, provider, randomDiceDelegatedIdl) const program = new anchor.Program(idl, provider) programRef.current = program @@ -665,32 +664,30 @@ export default function DiceRollerDelegated() { const validatorFqdn = delegationStatusWithFqdn.fqdn const wsEndpoint = validatorFqdn.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") - // Fetch IDL once - const idl = await anchor.Program.fetchIdl(PROGRAM_ID, program.provider) - if (!idl) throw new Error("IDL not found") - // Create connection to the specific validator we're delegated to const connection = new Connection(validatorFqdn, { wsEndpoint, commitment: "processed", }) - + const provider = new anchor.AnchorProvider( connection, walletAdapterFrom(playerKeypair), anchor.AnchorProvider.defaultOptions() ) - + + const idl = await loadIdl(PROGRAM_ID, program.provider, randomDiceDelegatedIdl) const ephemeralProgram = new anchor.Program(idl, provider) + console.log(ephemeralProgram.programId.toBase58(), "vs", program.programId.toBase58()) - // Send undelegate RPC to the specific validator endpoint + // Send undelegate RPC to the specific validator endpoint. await ephemeralProgram.methods .undelegate() .accounts({ payer: playerKeypair.publicKey, user: playerPda, }) - .rpc() + .rpc({ skipPreflight: true }) console.log(`Undelegation sent to ${validatorFqdn}`) @@ -709,8 +706,35 @@ export default function DiceRollerDelegated() { setIsUndelegating(false) } }, 1000) - } catch (error) { - console.error("Undelegation failed:", error) + } catch (error: any) { + // Aggressive unwrap — SendTransactionError stores everything as + // non-enumerable, and Anchor wraps it with another object whose + // `.message` may itself be an object (printing as "[object Object]"). + // Walk the layers and dump JSON for anything that isn't a string. + const safe = (v: any) => { + if (v == null) return v + if (typeof v === "string") return v + try { return JSON.stringify(v, Object.getOwnPropertyNames(v), 2) } + catch { return String(v) } + } + // One combined string so the Next.js error overlay (which only + // surfaces the first console.error per render) shows everything. + let extraLogs: string | null = null + if (typeof error?.getLogs === "function") { + try { + const logs = await error.getLogs(ephemeralConnectionRef.current ?? undefined) + extraLogs = safe(logs) + } catch { /* getLogs may fail if the tx never landed */ } + } + console.error( + "Undelegation failed:\n" + + ` error: ${safe(error)}\n` + + ` message: ${safe(error?.message)}\n` + + ` cause: ${safe(error?.cause)}\n` + + ` logs: ${safe(error?.logs)}\n` + + ` signature: ${error?.signature ?? error?.txid ?? error?.tx ?? "(none)"}\n` + + ` getLogs(): ${extraLogs ?? "(unavailable)"}`, + ) if (delegationPollIntervalRef.current) { clearInterval(delegationPollIntervalRef.current) delegationPollIntervalRef.current = null diff --git a/roll-dice/app/app/page.tsx b/roll-dice/app/app/page.tsx index 4b74f887..48f65c41 100644 --- a/roll-dice/app/app/page.tsx +++ b/roll-dice/app/app/page.tsx @@ -8,12 +8,16 @@ import * as anchor from "@coral-xyz/anchor" // @ts-ignore import {Connection, Keypair, PublicKey, Transaction, VersionedTransaction} from "@solana/web3.js" import { useToast } from "@/hooks/use-toast" +// Bundled IDL — `loadIdl` tries this first, falls back to on-chain fetchIdl. +import randomDiceIdl from "@/lib/idl/random_dice.json" +import { loadIdl } from "@/lib/solana-utils" // Program ID for the dice game -const PROGRAM_ID = new anchor.web3.PublicKey("8xgZ1hY7TnVZ4Bbh7v552Rs3BZMSq3LisyWckkBsNLP") +const PROGRAM_ID = new anchor.web3.PublicKey("3iSNV84a4hp2AiZpczjeuJEy4PTVCSzZZyU533MR6tEU") export default function DiceRoller() { const [diceValue, setDiceValue] = useState(1) + const [rollnum, setRollnum] = useState(0) const [isRolling, setIsRolling] = useState(false) const [isInitialized, setIsInitialized] = useState(false) const [key, setKey] = useState(0) // Used to force re-render the component @@ -70,11 +74,9 @@ export default function DiceRoller() { // User console.log("User: ", keypair.publicKey.toBase58()) - // Fetch the IDL - const idl = await anchor.Program.fetchIdl(PROGRAM_ID, provider) - if (!idl) throw new Error("IDL not found") - - // Create the program instance + // Local IDL first, fall back to on-chain fetch if the bundled one is + // out of sync with the current PROGRAM_ID. + const idl = await loadIdl(PROGRAM_ID, provider, randomDiceIdl) const program = new anchor.Program(idl, provider) programRef.current = program @@ -90,8 +92,9 @@ export default function DiceRoller() { console.log("User initialized with tx:", tx) }else{ const ply = program.coder.accounts.decode("player", account.data) - console.log("Player account:", playerPk.toBase58(), "lastResult:", ply.lastResult) + console.log("Player account:", playerPk.toBase58(), "lastResult:", ply.lastResult, "rollnum:", ply.rollnum) setDiceValue(ply.lastResult) + setRollnum(Number(ply.rollnum)) } // Subscribe to account changes @@ -106,6 +109,7 @@ export default function DiceRoller() { const player = program.coder.accounts.decode("player", accountInfo.data) console.log("Player account changed:", player) setDiceValue(player.lastResult) + setRollnum(Number(player.rollnum)) setIsRolling(false) clearRollInterval() }, @@ -237,6 +241,9 @@ export default function DiceRoller() { > {isRolling ? "Rolling..." : !isInitialized ? "Initializing..." : "Roll Dice"} +
+ Roll Count: {rollnum} +
) diff --git a/roll-dice/app/lib/config.ts b/roll-dice/app/lib/config.ts index aeedd6e3..a23bb662 100644 --- a/roll-dice/app/lib/config.ts +++ b/roll-dice/app/lib/config.ts @@ -1,7 +1,7 @@ import { PublicKey } from "@solana/web3.js" -export const PROGRAM_ID = new PublicKey("D74Ho1cWBHgZNpVG4FnBBA4JtjX4HFZ5QqqRXXVKA8gM") -export const PROGRAM_ID_STANDARD = new PublicKey("8xgZ1hY7TnVZ4Bbh7v552Rs3BZMSq3LisyWckkBsNLP") +export const PROGRAM_ID = new PublicKey("nkPMzRtKV4feTsDfst5P6sH8Rzf4VNe3p9Y1MzMiAme") +export const PROGRAM_ID_STANDARD = new PublicKey("3iSNV84a4hp2AiZpczjeuJEy4PTVCSzZZyU533MR6tEU") export const PLAYER_SEED = "playerd2" export const ORACLE_QUEUE = new PublicKey("5hBR571xnXppuCPveTrctfTU7tJLSN94nq7kv7FRK5Tc") export const BASE_ENDPOINT = "https://rpc.magicblock.app/devnet" diff --git a/roll-dice/app/lib/idl/random_dice.json b/roll-dice/app/lib/idl/random_dice.json new file mode 100644 index 00000000..ee198e55 --- /dev/null +++ b/roll-dice/app/lib/idl/random_dice.json @@ -0,0 +1,222 @@ +{ + "address": "3iSNV84a4hp2AiZpczjeuJEy4PTVCSzZZyU533MR6tEU", + "metadata": { + "name": "random_dice", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "callback_roll_dice", + "discriminator": [ + 129, + 76, + 217, + 160, + 252, + 234, + 19, + 238 + ], + "accounts": [ + { + "name": "vrf_program_identity", + "docs": [ + "This check ensure that the vrf_program_identity (which is a PDA) is a singer", + "enforcing the callback is executed by the VRF program trough CPI" + ], + "signer": true, + "address": "9irBy75QS2BN81FUgXuHcjqceJJRuc9oDkAe8TKVvvAw" + }, + { + "name": "player", + "writable": true + } + ], + "args": [ + { + "name": "randomness", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "client_seed", + "type": "u8" + } + ] + }, + { + "name": "initialize", + "discriminator": [ + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "player", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 121, + 101, + 114, + 100 + ] + }, + { + "kind": "account", + "path": "payer" + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "roll_dice", + "discriminator": [ + 27, + 140, + 230, + 215, + 37, + 178, + 226, + 114 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "player", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 112, + 108, + 97, + 121, + 101, + 114, + 100 + ] + }, + { + "kind": "account", + "path": "payer" + } + ] + } + }, + { + "name": "oracle_queue", + "writable": true, + "address": "Cuj97ggrhhidhbu39TijNVqE74xvKJ69gDervRUXAxGh" + }, + { + "name": "program_identity", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 105, + 100, + 101, + 110, + 116, + 105, + 116, + 121 + ] + } + ] + } + }, + { + "name": "vrf_program", + "address": "Vrf1RNUjXmQGjmQrQLvJHs9SNkvDJEsRVFPkfSQUwGz" + }, + { + "name": "slot_hashes", + "address": "SysvarS1otHashes111111111111111111111111111" + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "client_seed", + "type": "u8" + } + ] + } + ], + "accounts": [ + { + "name": "Player", + "discriminator": [ + 205, + 222, + 112, + 7, + 165, + 155, + 206, + 218 + ] + } + ], + "types": [ + { + "name": "Player", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_result", + "type": "u8" + }, + { + "name": "rollnum", + "type": "u8" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/roll-dice/app/lib/idl/random_dice_delegated.json b/roll-dice/app/lib/idl/random_dice_delegated.json index c1659255..eab8b9a3 100644 --- a/roll-dice/app/lib/idl/random_dice_delegated.json +++ b/roll-dice/app/lib/idl/random_dice_delegated.json @@ -1,5 +1,5 @@ { - "address": "D74Ho1cWBHgZNpVG4FnBBA4JtjX4HFZ5QqqRXXVKA8gM", + "address": "nkPMzRtKV4feTsDfst5P6sH8Rzf4VNe3p9Y1MzMiAme", "metadata": { "name": "random_dice_delegated", "version": "0.1.0", @@ -43,6 +43,10 @@ 32 ] } + }, + { + "name": "client_seed", + "type": "u8" } ] }, @@ -91,38 +95,38 @@ "program": { "kind": "const", "value": [ - 179, - 217, - 114, - 141, - 12, - 154, - 18, + 11, + 184, + 49, + 78, + 162, + 211, + 162, + 91, + 77, + 67, + 94, + 121, + 20, 123, - 68, - 119, - 180, - 125, - 173, - 111, - 60, - 210, - 43, - 63, - 230, - 240, - 225, - 132, - 125, - 237, - 175, - 38, - 240, - 134, - 203, - 177, - 191, - 110 + 8, + 193, + 223, + 61, + 22, + 164, + 104, + 216, + 170, + 3, + 99, + 93, + 158, + 232, + 243, + 30, + 174, + 169 ] } } @@ -228,7 +232,7 @@ }, { "name": "owner_program", - "address": "D74Ho1cWBHgZNpVG4FnBBA4JtjX4HFZ5QqqRXXVKA8gM" + "address": "nkPMzRtKV4feTsDfst5P6sH8Rzf4VNe3p9Y1MzMiAme" }, { "name": "delegation_program", @@ -372,8 +376,7 @@ }, { "name": "oracle_queue", - "writable": true, - "address": "5hBR571xnXppuCPveTrctfTU7tJLSN94nq7kv7FRK5Tc" + "writable": true }, { "name": "program_identity", diff --git a/roll-dice/app/lib/solana-utils.ts b/roll-dice/app/lib/solana-utils.ts index 82fa6a37..098898cf 100644 --- a/roll-dice/app/lib/solana-utils.ts +++ b/roll-dice/app/lib/solana-utils.ts @@ -1,8 +1,36 @@ +import * as anchor from "@coral-xyz/anchor" import { Keypair, PublicKey, Transaction, VersionedTransaction, Connection, LAMPORTS_PER_SOL } from "@solana/web3.js" import { DELEGATION_PROGRAM_ID } from "@magicblock-labs/ephemeral-rollups-sdk" import { MIN_BALANCE_LAMPORTS, BLOCKHASH_CACHE_MAX_AGE_MS } from "./config" import type { CachedBlockhash } from "./types" +/** + * Resolve a program's IDL by trying the bundled local copy first, then falling + * back to `Program.fetchIdl` against the on-chain IDL account. Useful when: + * - You change `declare_id!` and rebuild but forget to copy the new IDL into + * the app — local lookup returns a mismatched address, we fall through to + * the chain copy (if `anchor idl init` was run). + * - The on-chain IDL account hasn't been uploaded — we still work via local. + * + * Throws only if both sources are missing or address-mismatched. + */ +export async function loadIdl( + programId: PublicKey, + provider: anchor.Provider, + localIdl: any, +): Promise { + if (localIdl?.address === programId.toBase58()) { + return localIdl as anchor.Idl + } + const remote = await anchor.Program.fetchIdl(programId, provider).catch(() => null) + if (remote) return remote as anchor.Idl + // Last-resort: hand back the local IDL anyway. The address mismatch will + // surface as an Anchor error on first ix build, with a clearer message + // ("program id mismatch") than the generic "IDL not found". + if (localIdl) return localIdl as anchor.Idl + throw new Error(`IDL not found locally or on-chain for ${programId.toBase58()}`) +} + export const walletAdapterFrom = (keypair: Keypair) => ({ publicKey: keypair.publicKey, async signTransaction(transaction: T): Promise { diff --git a/roll-dice/programs/roll-dice-delegated/src/lib.rs b/roll-dice/programs/roll-dice-delegated/src/lib.rs index 8787e532..4548d225 100644 --- a/roll-dice/programs/roll-dice-delegated/src/lib.rs +++ b/roll-dice/programs/roll-dice-delegated/src/lib.rs @@ -6,7 +6,7 @@ use ephemeral_vrf_sdk::anchor::vrf; use ephemeral_vrf_sdk::instructions::{create_request_randomness_ix, RequestRandomnessParams}; use ephemeral_vrf_sdk::types::SerializableAccountMeta; -declare_id!("987QBJc2qrL6be9VJKSTtkSMBZCqzT8ACLshiw9JaSpe"); +declare_id!("nkPMzRtKV4feTsDfst5P6sH8Rzf4VNe3p9Y1MzMiAme"); pub const PLAYER_SEED: &[u8] = b"playerd2"; @@ -31,7 +31,7 @@ pub mod random_dice_delegated { ctx: Context, client_seed: u8, ) -> Result<()> { - msg!("Requesting randomness..."); + msg!("Requesting randomness with client_seed={}", client_seed); let ix = create_request_randomness_ix(RequestRandomnessParams { payer: ctx.accounts.payer.key(), oracle_queue: ctx.accounts.oracle_queue.key(), @@ -43,6 +43,10 @@ pub mod random_dice_delegated { is_signer: false, is_writable: true, }]), + // Echo client_seed forward — VRF appends these bytes after the + // `randomness` arg in the callback ix data, so the callback can + // read it back as a trailing u8 and log it for correlation. + callback_args: Some(vec![client_seed]), ..Default::default() }); ctx.accounts @@ -53,13 +57,18 @@ pub mod random_dice_delegated { pub fn callback_roll_dice_simple( ctx: Context, randomness: [u8; 32], + client_seed: u8, ) -> Result<()> { + msg!("client_seed={}", client_seed); + msg!("Randomness bytes: {:?}", randomness); + + // ---- Derive + apply ---- let player = &mut ctx.accounts.player; let rnd_u8 = ephemeral_vrf_sdk::rnd::random_u8_with_range(&randomness, 1, 6); - msg!("Consuming random number: {:?}", rnd_u8); - player.rollnum = player.rollnum.saturating_add(1); - msg!("Roll number: {:?}", player.rollnum); player.last_result = rnd_u8; + player.rollnum = player.rollnum.saturating_add(1); + msg!("Roll result (1-6): {}", rnd_u8); + msg!("Roll number: {}", player.rollnum); Ok(()) } @@ -107,8 +116,8 @@ pub struct DoRollDiceCtx<'info> { pub payer: Signer<'info>, #[account(seeds = [PLAYER_SEED, payer.key().to_bytes().as_slice()], bump)] pub player: Account<'info, Player>, - /// CHECK: The oracle queue - #[account(mut, address = ephemeral_vrf_sdk::consts::DEFAULT_QUEUE)] + /// CHECK: validated by the ephemeral VRF program when it processes the request + #[account(mut)] pub oracle_queue: UncheckedAccount<'info>, } @@ -119,8 +128,8 @@ pub struct DoRollDiceDelegatedCtx<'info> { pub payer: Signer<'info>, #[account(seeds = [PLAYER_SEED, payer.key().to_bytes().as_slice()], bump)] pub player: Account<'info, Player>, - /// CHECK: The oracle queue - #[account(mut, address = ephemeral_vrf_sdk::consts::DEFAULT_EPHEMERAL_QUEUE)] + /// CHECK: validated by the ephemeral VRF program when it processes the request + #[account(mut)] pub oracle_queue: UncheckedAccount<'info>, } diff --git a/roll-dice/programs/roll-dice/src/lib.rs b/roll-dice/programs/roll-dice/src/lib.rs index 04f9bfdb..e7bcff52 100644 --- a/roll-dice/programs/roll-dice/src/lib.rs +++ b/roll-dice/programs/roll-dice/src/lib.rs @@ -3,7 +3,7 @@ use ephemeral_vrf_sdk::anchor::vrf; use ephemeral_vrf_sdk::instructions::{create_request_randomness_ix, RequestRandomnessParams}; use ephemeral_vrf_sdk::types::SerializableAccountMeta; -declare_id!("HmSqeP8YiP7AYXwhKBPnfguAwKRuSkUSyeQeuJkg1nmK"); +declare_id!("3iSNV84a4hp2AiZpczjeuJEy4PTVCSzZZyU533MR6tEU"); pub const PLAYER: &[u8] = b"playerd"; @@ -16,11 +16,14 @@ pub mod random_dice { "Initializing player account: {:?}", ctx.accounts.player.key() ); + let player = &mut ctx.accounts.player; + player.last_result = 0; + player.rollnum = 0; Ok(()) } pub fn roll_dice(ctx: Context, client_seed: u8) -> Result<()> { - msg!("Requesting randomness..."); + msg!("Requesting randomness with client_seed={}", client_seed); let ix = create_request_randomness_ix(RequestRandomnessParams { payer: ctx.accounts.payer.key(), oracle_queue: ctx.accounts.oracle_queue.key(), @@ -33,6 +36,7 @@ pub mod random_dice { is_signer: false, is_writable: true, }]), + callback_args: Some(vec![client_seed]), ..Default::default() }); ctx.accounts @@ -43,11 +47,16 @@ pub mod random_dice { pub fn callback_roll_dice( ctx: Context, randomness: [u8; 32], + client_seed: u8, ) -> Result<()> { + msg!("client_seed={}", client_seed); + msg!("Randomness bytes: {:?}", randomness); let rnd_u8 = ephemeral_vrf_sdk::rnd::random_u8_with_range(&randomness, 1, 6); msg!("Consuming random number: {:?}", rnd_u8); let player = &mut ctx.accounts.player; - player.last_result = rnd_u8; // Update the player's last result + player.last_result = rnd_u8; + player.rollnum = player.rollnum.saturating_add(1); + msg!("Roll number: {}", player.rollnum); Ok(()) } } @@ -56,7 +65,7 @@ pub mod random_dice { pub struct Initialize<'info> { #[account(mut)] pub payer: Signer<'info>, - #[account(init_if_needed, payer = payer, space = 8 + 1, seeds = [PLAYER, payer.key().to_bytes().as_slice()], bump)] + #[account(init_if_needed, payer = payer, space = 8 + 2, seeds = [PLAYER, payer.key().to_bytes().as_slice()], bump)] pub player: Account<'info, Player>, pub system_program: Program<'info, System>, } @@ -86,4 +95,5 @@ pub struct CallbackRollDiceCtx<'info> { #[account] pub struct Player { pub last_result: u8, + pub rollnum: u8, } diff --git a/roll-dice/target/deploy/roll_dice-keypair.json b/roll-dice/target/deploy/roll_dice-keypair.json index 7a965626..983bc4bc 100644 --- a/roll-dice/target/deploy/roll_dice-keypair.json +++ b/roll-dice/target/deploy/roll_dice-keypair.json @@ -1 +1 @@ -[49, 112, 156, 20, 147, 212, 223, 88, 181, 100, 159, 142, 18, 188, 65, 83, 53, 188, 154, 4, 76, 40, 232, 132, 181, 135, 77, 7, 153, 97, 50, 184, 249, 29, 195, 40, 21, 236, 101, 144, 63, 72, 24, 96, 20, 141, 146, 161, 89, 149, 153, 85, 144, 157, 230, 61, 14, 128, 130, 85, 218, 137, 67, 238] \ No newline at end of file +[192, 28, 211, 121, 161, 240, 126, 59, 64, 100, 128, 17, 248, 127, 194, 254, 135, 230, 79, 187, 201, 34, 225, 95, 179, 209, 178, 143, 179, 141, 94, 148, 40, 84, 223, 155, 16, 74, 109, 92, 80, 113, 87, 250, 163, 169, 115, 52, 14, 65, 142, 2, 111, 115, 220, 83, 248, 36, 45, 248, 66, 230, 49, 129] \ No newline at end of file diff --git a/roll-dice/tests/roll-dice-delegated.ts b/roll-dice/tests/roll-dice-delegated.ts index a47b17aa..5777af17 100644 --- a/roll-dice/tests/roll-dice-delegated.ts +++ b/roll-dice/tests/roll-dice-delegated.ts @@ -3,6 +3,11 @@ import {Program} from "@coral-xyz/anchor"; import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; import { RandomDiceDelegated } from "../target/types/random_dice_delegated"; +// Default to the canonical ephemeral queue; override with VRF_EPHEMERAL_QUEUE env var to point at a test queue. +const DEFAULT_EPHEMERAL_QUEUE = new PublicKey( + process.env.VRF_EPHEMERAL_QUEUE || "5hBR571xnXppuCPveTrctfTU7tJLSN94nq7kv7FRK5Tc", +); + describe("roll-dice-delegated", () => { // Configure the client to use the local cluster. const provider = anchor.AnchorProvider.env(); @@ -31,6 +36,12 @@ describe("roll-dice-delegated", () => { console.log("Ephemeral Rollup Connection: ", providerEphemeralRollup.connection.rpcEndpoint); console.log(`Current SOL Public Key: ${anchor.Wallet.local().publicKey}`) console.log("Player PDA: ", playerPda.toString()); + // Annotate the queue source so a wrong queue is obvious from the test log + // (devnet/mainnet should be the SDK default; local needs the test queue). + console.log( + `VRF Ephemeral Queue: ${DEFAULT_EPHEMERAL_QUEUE.toString()}` + + `${process.env.VRF_EPHEMERAL_QUEUE ? " (from VRF_EPHEMERAL_QUEUE env)" : " (SDK default)"}`, + ); before(async function () { const balance = await provider.connection.getBalance(anchor.Wallet.local().publicKey) @@ -69,23 +80,53 @@ describe("roll-dice-delegated", () => { }); it("Do Roll Dice Delegated!", async () => { - const before = await ephemeralProgram.account.player - .fetchNullable(playerPda) - .catch(() => null); - const tx = await ephemeralProgram.methods - .rollDiceDelegated(0) - .rpc({ skipPreflight: true, commitment: "confirmed" }); - console.log("rollDiceDelegated tx:", tx); + // Generate the seed BEFORE subscribing so the handler closes over it. + // The program logs "Callback for client_seed={n}" inside + // callback_roll_dice_simple — we match on that exact substring. + const clientSeed = Math.floor(Math.random() * 256); + const seedTag = `client_seed=${clientSeed}`; + // Pre-arm a one-shot promise that the onLogs handler resolves with the + // matching signature. No polling — we just await it, racing a timeout. + let resolveSig!: (sig: string) => void; + const sigPromise = new Promise((r) => { resolveSig = r; }); + const callbackSubId = providerEphemeralRollup.connection.onLogs( + program.programId, + (info) => { + if ( + !info.err && + info.logs.some((l) => l.includes("CallbackRollDiceSimple")) && + info.logs.some((l) => l.includes(seedTag)) + ) { + resolveSig(info.signature); + } + }, + "processed", + ); + + try { + const tx = await ephemeralProgram.methods + .rollDiceDelegated(clientSeed) + .accounts({ oracleQueue: DEFAULT_EPHEMERAL_QUEUE }) + .rpc({ skipPreflight: true, commitment: "confirmed" }); + console.log(`client_seed: ${clientSeed}`); + console.log("rollDiceDelegated tx:", tx); + + const start = Date.now(); + const sig = await Promise.race([ + sigPromise, + new Promise((r) => setTimeout(() => r(null), 1_000)), + ]); + if (sig) { + console.log(`callbackRollDiceSimple tx: ${sig} (after ${Date.now() - start}ms)`); + } else { + console.warn(`callbackRollDiceSimple not observed within 1s.`); + } - // Wait for the VRF callback to land on the ER and rewrite the player state. - const start = Date.now(); - let player = await ephemeralProgram.account.player.fetch(playerPda, "processed"); - while (Date.now() - start < 10_000) { - player = await ephemeralProgram.account.player.fetch(playerPda, "processed"); - if (!before || JSON.stringify(player) !== JSON.stringify(before)) break; - await new Promise((r) => setTimeout(r, 500)); + const player = await ephemeralProgram.account.player.fetch(playerPda, "processed"); + console.log("player:", player); + } finally { + await providerEphemeralRollup.connection.removeOnLogsListener(callbackSubId); } - console.log(`player (ER, after ${Date.now() - start}ms):`, player); }); it("Undelegate Roll Dice!", async () => { diff --git a/roll-dice/tests/roll-dice.ts b/roll-dice/tests/roll-dice.ts index 6027befa..c47b141f 100644 --- a/roll-dice/tests/roll-dice.ts +++ b/roll-dice/tests/roll-dice.ts @@ -5,14 +5,19 @@ import { RandomDice } from "../target/types/random_dice"; describe("roll-dice", () => { // Configure the client to use the local cluster. anchor.setProvider(anchor.AnchorProvider.env()); + const provider = anchor.getProvider() as anchor.AnchorProvider; const program = anchor.workspace.RandomDice as Program; const playerPda = web3.PublicKey.findProgramAddressSync( - [Buffer.from("playerd"), anchor.getProvider().publicKey!.toBytes()], + [Buffer.from("playerd"), provider.publicKey!.toBytes()], program.programId, )[0]; + console.log("Base Layer Connection: ", provider.connection.rpcEndpoint); + console.log(`Current SOL Public Key: ${provider.publicKey}`); + console.log("Player PDA: ", playerPda.toString()); + it("Initialized player!", async () => { const tx = await program.methods .initialize() @@ -21,24 +26,53 @@ describe("roll-dice", () => { }); it("Do Roll Dice!", async () => { - const before = await program.account.player.fetchNullable(playerPda).catch(() => null); - const tx = await program.methods - .rollDice(0) - .rpc({ skipPreflight: true, commitment: "confirmed" }); - console.log("rollDice tx:", tx); - - // VRF is asynchronous — the rollDice ix requests randomness; the oracle - // callback writes the result back to the player PDA in a separate tx. Poll - // up to ~10s until the player state actually changes. - const start = Date.now(); - let player = await program.account.player.fetch(playerPda, "processed"); - while (Date.now() - start < 10_000) { - player = await program.account.player.fetch(playerPda, "processed"); - if (!before || JSON.stringify(player) !== JSON.stringify(before)) break; - await new Promise((r) => setTimeout(r, 500)); + // Generate the seed BEFORE subscribing so the handler closes over it. + // The program logs "client_seed=N" inside callback_roll_dice — we match + // on that exact substring to pin the callback to our specific request. + const clientSeed = Math.floor(Math.random() * 256); + const seedTag = `client_seed=${clientSeed}`; + // Pre-arm a one-shot promise that the onLogs handler resolves with the + // matching signature. No polling — we just await it, racing a timeout. + let resolveSig!: (sig: string) => void; + const sigPromise = new Promise((r) => { resolveSig = r; }); + const callbackSubId = provider.connection.onLogs( + program.programId, + (info) => { + if ( + !info.err && + info.logs.some((l) => l.includes("CallbackRollDice")) && + info.logs.some((l) => l.includes(seedTag)) + ) { + resolveSig(info.signature); + } + }, + "confirmed", + ); + + try { + const tx = await program.methods + .rollDice(clientSeed) + .rpc({ skipPreflight: true, commitment: "confirmed" }); + console.log(`client_seed: ${clientSeed}`); + console.log("rollDice tx:", tx); + + // Base-chain VRF response is slower than ER (~1-5s typical) so 10s timeout. + const start = Date.now(); + const sig = await Promise.race([ + sigPromise, + new Promise((r) => setTimeout(() => r(null), 10_000)), + ]); + if (sig) { + console.log(`callbackRollDice tx: ${sig} (after ${Date.now() - start}ms)`); + } else { + console.warn(`callbackRollDice not observed within 10s.`); + } + + const player = await program.account.player.fetch(playerPda, "processed"); + console.log("player:", player); + } finally { + await provider.connection.removeOnLogsListener(callbackSubId); } - console.log(`Player PDA: ${playerPda.toBase58()} (after ${Date.now() - start}ms)`); - console.log("player:", player); }); }); From 3317fe9775191977a1aef3f8cc452d39a7f67127 Mon Sep 17 00:00:00 2001 From: jonasXchen Date: Thu, 4 Jun 2026 14:59:56 +0800 Subject: [PATCH 5/5] feat: rename to pinocchio-private-counter --- README.md | 2 +- .../.env.example | 0 .../.gitignore | 0 .../Cargo.toml | 4 +- .../LICENSE | 0 .../README.md | 4 +- .../package.json | 6 +- .../src/entrypoint.rs | 49 +- .../src/lib.rs | 0 pinocchio-private-counter/src/processor.rs | 501 ++++++++++++++++++ pinocchio-private-counter/src/state.rs | 43 ++ .../tests-rs/delegate_counter.rs | 0 .../tests-rs/fixtures/dlp.so | Bin .../tests-rs/increase_counter.rs | 0 .../tests-rs/initialize_counter.rs | 0 .../tests-rs/utils.rs | 0 .../tests/initializeKeypair.ts | 0 .../tests/kit/initializeKeypair.ts | 0 .../kit/pinocchio-private-counter.test.ts | 4 +- .../tests/kit/schema.ts | 0 .../tests/schema.ts | 0 .../tests/web3js/initializeKeypair.ts | 2 - .../web3js/pinocchio-private-counter.test.ts | 9 +- .../tests/web3js/schema.ts | 0 .../tsconfig.json | 0 .../vitest.config.ts | 0 .../yarn.lock | 0 pinocchio-secret-counter/src/processor.rs | 444 ---------------- pinocchio-secret-counter/src/state.rs | 24 - session-keys/package.json | 2 +- session-keys/tests/anchor-counter-session.ts | 20 +- test-locally.sh | 35 +- 32 files changed, 642 insertions(+), 507 deletions(-) rename {pinocchio-secret-counter => pinocchio-private-counter}/.env.example (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/.gitignore (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/Cargo.toml (93%) rename {pinocchio-secret-counter => pinocchio-private-counter}/LICENSE (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/README.md (93%) rename {pinocchio-secret-counter => pinocchio-private-counter}/package.json (83%) rename {pinocchio-secret-counter => pinocchio-private-counter}/src/entrypoint.rs (76%) rename {pinocchio-secret-counter => pinocchio-private-counter}/src/lib.rs (100%) create mode 100644 pinocchio-private-counter/src/processor.rs create mode 100644 pinocchio-private-counter/src/state.rs rename {pinocchio-secret-counter => pinocchio-private-counter}/tests-rs/delegate_counter.rs (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests-rs/fixtures/dlp.so (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests-rs/increase_counter.rs (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests-rs/initialize_counter.rs (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests-rs/utils.rs (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests/initializeKeypair.ts (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests/kit/initializeKeypair.ts (100%) rename pinocchio-secret-counter/tests/kit/pinocchio-secret-counter.test.ts => pinocchio-private-counter/tests/kit/pinocchio-private-counter.test.ts (98%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests/kit/schema.ts (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests/schema.ts (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests/web3js/initializeKeypair.ts (90%) rename pinocchio-secret-counter/tests/web3js/pinocchio-secret-counter.test.ts => pinocchio-private-counter/tests/web3js/pinocchio-private-counter.test.ts (98%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tests/web3js/schema.ts (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/tsconfig.json (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/vitest.config.ts (100%) rename {pinocchio-secret-counter => pinocchio-private-counter}/yarn.lock (100%) delete mode 100644 pinocchio-secret-counter/src/processor.rs delete mode 100644 pinocchio-secret-counter/src/state.rs diff --git a/README.md b/README.md index 4a7fffac..c6805e04 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Read more about Ephemeral Rollups [here](https://docs.magicblock.gg/EphemeralRol - [Anchor Counter](./anchor-counter/README.md) — Counter program in Anchor. Tests delegate/undelegate via the TypeScript SDK. - [Rust Counter](./rust-counter/README.md) — Counter program in native Rust. Tests delegate/undelegate natively. - [Pinocchio Counter](./pinocchio-counter/README.md) — Counter program built with Pinocchio (no heap, no Borsh `Vec`s). -- [Pinocchio Secret Counter](./pinocchio-secret-counter/README.md) — Pinocchio counter variant exercising secret state on the ER. +- [Pinocchio Private Counter](./pinocchio-private-counter/README.md) — Pinocchio counter variant exercising private state on the ER. - [Private Counter](./private-counter/README.md) — Anchor counter gated by an on-rollup ephemeral permission account. - [Session Keys](./session-keys/README.md) — Counter using gpl-session keys for delegated-signer auth on both base chain and ER. - [Crank Counter](./crank-counter/README.md) — Counter driven by MagicBlock's scheduled crank system. diff --git a/pinocchio-secret-counter/.env.example b/pinocchio-private-counter/.env.example similarity index 100% rename from pinocchio-secret-counter/.env.example rename to pinocchio-private-counter/.env.example diff --git a/pinocchio-secret-counter/.gitignore b/pinocchio-private-counter/.gitignore similarity index 100% rename from pinocchio-secret-counter/.gitignore rename to pinocchio-private-counter/.gitignore diff --git a/pinocchio-secret-counter/Cargo.toml b/pinocchio-private-counter/Cargo.toml similarity index 93% rename from pinocchio-secret-counter/Cargo.toml rename to pinocchio-private-counter/Cargo.toml index 4959c2c9..6d4bd870 100644 --- a/pinocchio-secret-counter/Cargo.toml +++ b/pinocchio-private-counter/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "pinocchio-secret-counter" +name = "pinocchio-private-counter" version = "0.1.0" edition = "2021" @@ -25,7 +25,7 @@ solana-transaction = "4.1.1" solana-system-interface = { version = "3.2.0", features = ["bincode"] } [lib] -name = "pinocchio_secret_counter" +name = "pinocchio_private_counter" crate-type = ["cdylib"] [[test]] diff --git a/pinocchio-secret-counter/LICENSE b/pinocchio-private-counter/LICENSE similarity index 100% rename from pinocchio-secret-counter/LICENSE rename to pinocchio-private-counter/LICENSE diff --git a/pinocchio-secret-counter/README.md b/pinocchio-private-counter/README.md similarity index 93% rename from pinocchio-secret-counter/README.md rename to pinocchio-private-counter/README.md index 90f864f4..ee3e791e 100644 --- a/pinocchio-secret-counter/README.md +++ b/pinocchio-private-counter/README.md @@ -1,6 +1,6 @@ -# ➕ Pinocchio Counter +# 🔒 Pinocchio Private Counter -Simple counter program using Pinocchio and Ephemeral Rollups. +Pinocchio counter variant that exercises private state on the Ephemeral Rollup. This is a port of the Rust Counter program to use Pinocchio instead of Borsh for serialization, eliminating the need for Vec types. diff --git a/pinocchio-secret-counter/package.json b/pinocchio-private-counter/package.json similarity index 83% rename from pinocchio-secret-counter/package.json rename to pinocchio-private-counter/package.json index eb8ca396..9127aa04 100644 --- a/pinocchio-secret-counter/package.json +++ b/pinocchio-private-counter/package.json @@ -1,10 +1,12 @@ { - "name": "pinocchio-counter-client", + "name": "pinocchio-private-counter-client", "version": "0.1.0", - "description": "Client tests for pinocchio-counter program", + "description": "Client tests for pinocchio-private-counter program", "main": "index.js", "scripts": { "test": "vitest run tests/", + "test:kit": "vitest run tests/kit/", + "test:web3js": "vitest run tests/web3js/", "test:watch": "vitest watch tests/", "test:legacy": "ts-node tests/client.test.ts", "build": "cargo build --release", diff --git a/pinocchio-secret-counter/src/entrypoint.rs b/pinocchio-private-counter/src/entrypoint.rs similarity index 76% rename from pinocchio-secret-counter/src/entrypoint.rs rename to pinocchio-private-counter/src/entrypoint.rs index 484cebfb..e2799414 100644 --- a/pinocchio-secret-counter/src/entrypoint.rs +++ b/pinocchio-private-counter/src/entrypoint.rs @@ -1,6 +1,7 @@ use crate::processor::{ - process_commit, process_commit_and_undelegate, process_delegate, process_increase_counter, - process_increment_commit, process_increment_undelegate, process_initialize_counter, + process_close_permission, process_commit, process_commit_and_undelegate, process_delegate, + process_increase_counter, process_increment_commit, process_increment_undelegate, + process_init_permission, process_initialize_counter, process_set_privacy, process_undelegation_callback, }; use core::{mem::MaybeUninit, slice::from_raw_parts}; @@ -18,6 +19,9 @@ enum InstructionDiscriminator { Commit, IncrementAndCommit, IncrementAndUndelegate, + InitPermission, + SetPrivacy, + ClosePermission, UndelegationCallback, } @@ -29,6 +33,10 @@ impl InstructionDiscriminator { const COMMIT: [u8; 8] = [4, 0, 0, 0, 0, 0, 0, 0]; const INCREMENT_AND_COMMIT: [u8; 8] = [5, 0, 0, 0, 0, 0, 0, 0]; const INCREMENT_AND_UNDELEGATE: [u8; 8] = [6, 0, 0, 0, 0, 0, 0, 0]; + // EphemeralPermission flow — created/updated/closed directly on the ER. + const INIT_PERMISSION: [u8; 8] = [7, 0, 0, 0, 0, 0, 0, 0]; + const SET_PRIVACY: [u8; 8] = [8, 0, 0, 0, 0, 0, 0, 0]; + const CLOSE_PERMISSION: [u8; 8] = [9, 0, 0, 0, 0, 0, 0, 0]; // Undelegation callback called by the delegation program const UNDELEGATION_CALLBACK: [u8; 8] = [196, 28, 41, 206, 48, 37, 51, 167]; @@ -41,6 +49,9 @@ impl InstructionDiscriminator { Self::COMMIT => Ok(Self::Commit), Self::INCREMENT_AND_COMMIT => Ok(Self::IncrementAndCommit), Self::INCREMENT_AND_UNDELEGATE => Ok(Self::IncrementAndUndelegate), + Self::INIT_PERMISSION => Ok(Self::InitPermission), + Self::SET_PRIVACY => Ok(Self::SetPrivacy), + Self::CLOSE_PERMISSION => Ok(Self::ClosePermission), Self::UNDELEGATION_CALLBACK => Ok(Self::UndelegationCallback), _ => Err(ProgramError::InvalidInstructionData), } @@ -133,6 +144,18 @@ pub(crate) fn inner_process_instruction( let (bump, increase_by) = read_bump_and_u64(payload)?; process_increment_undelegate(program_id, accounts, bump, increase_by) } + InstructionDiscriminator::InitPermission => { + let bump = read_u8(payload)?; + process_init_permission(program_id, accounts, bump) + } + InstructionDiscriminator::SetPrivacy => { + let (bump, is_private) = read_bump_and_bool(payload)?; + process_set_privacy(program_id, accounts, bump, is_private) + } + InstructionDiscriminator::ClosePermission => { + let bump = read_u8(payload)?; + process_close_permission(program_id, accounts, bump) + } InstructionDiscriminator::UndelegationCallback => { process_undelegation_callback(program_id, accounts, payload) } @@ -164,6 +187,19 @@ fn read_bump_and_u64(input: &[u8]) -> Result<(u8, u64), ProgramError> { Ok((bump, value)) } +fn read_bump_and_bool(input: &[u8]) -> Result<(u8, bool), ProgramError> { + if input.len() < 2 { + return Err(ProgramError::InvalidInstructionData); + } + let bump = input[0]; + let is_private = match input[1] { + 0 => false, + 1 => true, + _ => return Err(ProgramError::InvalidInstructionData), + }; + Ok((bump, is_private)) +} + #[allow(unused_variables)] fn log_instruction(discriminator: InstructionDiscriminator) { #[cfg(feature = "logging")] @@ -190,6 +226,15 @@ fn log_instruction(discriminator: InstructionDiscriminator) { InstructionDiscriminator::IncrementAndUndelegate => { pinocchio_log::log!("IncrementAndUndelegate"); } + InstructionDiscriminator::InitPermission => { + pinocchio_log::log!("InitPermission"); + } + InstructionDiscriminator::SetPrivacy => { + pinocchio_log::log!("SetPrivacy"); + } + InstructionDiscriminator::ClosePermission => { + pinocchio_log::log!("ClosePermission"); + } InstructionDiscriminator::UndelegationCallback => { pinocchio_log::log!("UndelegationCallback"); } diff --git a/pinocchio-secret-counter/src/lib.rs b/pinocchio-private-counter/src/lib.rs similarity index 100% rename from pinocchio-secret-counter/src/lib.rs rename to pinocchio-private-counter/src/lib.rs diff --git a/pinocchio-private-counter/src/processor.rs b/pinocchio-private-counter/src/processor.rs new file mode 100644 index 00000000..84b2dd0b --- /dev/null +++ b/pinocchio-private-counter/src/processor.rs @@ -0,0 +1,501 @@ +//! Private-counter logic ported from the Anchor `private-counter` example to +//! Pinocchio, using the **EphemeralPermission** flow: +//! +//! 1. `initialize` (base): allocate the counter PDA, set `authority`, and +//! pre-fund it with enough lamports to cover the ephemeral-permission rent +//! that will be paid on the ER post-delegation. +//! 2. `delegate` (base): delegate the counter PDA to the ER. No permission +//! delegation step — ephemeral permissions live entirely on the ER. +//! 3. `init_permission` (ER): PDA-signed `CreateEphemeralPermission`. Idempotent. +//! 4. `set_privacy` (ER): toggle privacy via `UpdateEphemeralPermission`. When +//! private, the counter's `authority` is the sole member with logs/message/ +//! balances visibility. +//! 5. `close_permission` (ER): `CloseEphemeralPermission`, refunds rent to the +//! counter PDA. +//! 6. `commit` / `commit_and_undelegate` / `increment_and_*` use the +//! `MagicIntentBundleBuilder` — no extra permission CPI needed. + +use crate::state::Counter; +use ephemeral_rollups_pinocchio::acl::{ + CloseEphemeralPermission, CreateEphemeralPermission, EphemeralMembersArgs, EphemeralPermission, + Member, MemberFlags, UpdateEphemeralPermission, +}; +use ephemeral_rollups_pinocchio::instruction::{delegate_account, undelegate}; +use ephemeral_rollups_pinocchio::intent_bundle::MagicIntentBundleBuilder; +use ephemeral_rollups_pinocchio::types::DelegateConfig; +use pinocchio::{ + account::AccountView, + cpi::{Seed, Signer}, + error::ProgramError, + Address, ProgramResult, +}; +use pinocchio_log::log; +use pinocchio_system::instructions::CreateAccount; + +const INTENT_BUNDLE_DATA_BUF_SIZE: usize = 512; + +/// Buffer size for EphemeralPermission CPI data: discriminator (8) + +/// `EphemeralMembersArgs` body (1 + members * 33). For up to 1 member that's +/// 8 + 1 + 33 = 42 bytes; round up to 64 for slack on Update calls that may +/// transition between 0 and 1 members. +const PERMISSION_CPI_BUF: usize = 64; + +/// Mirrors `ephemeral_rollups_sdk::ephemeral_accounts::rent`: ephemeral +/// accounts cost 32 lamports/byte with a 60-byte overhead. For +/// `EphemeralPermission::size_of(1)` ≈ 101 bytes → ~5152 lamports of rent. +/// The Pinocchio SDK doesn't expose this helper; we inline it. +const EPHEMERAL_RENT_PER_BYTE: u64 = 32; +const EPHEMERAL_ACCOUNT_OVERHEAD: u32 = 60; + +#[inline] +const fn ephemeral_rent(data_len: u32) -> u64 { + (data_len as u64 + EPHEMERAL_ACCOUNT_OVERHEAD as u64) * EPHEMERAL_RENT_PER_BYTE +} + +/// Derive the counter PDA from the caller-provided bump. +fn counter_address_from_bump( + program_id: &Address, + authority: &Address, + bump: u8, +) -> Result { + let bump_seed = [bump]; + #[cfg(any(target_os = "solana", target_arch = "bpf"))] + { + Address::create_program_address( + &[b"counter", authority.as_ref(), &bump_seed], + program_id, + ) + .map_err(|_| ProgramError::InvalidArgument) + } + #[cfg(not(any(target_os = "solana", target_arch = "bpf")))] + { + use solana_pubkey::Pubkey; + let program_pubkey = Pubkey::new_from_array(*program_id.as_array()); + let authority_pubkey = Pubkey::new_from_array(*authority.as_array()); + let pda = Pubkey::create_program_address( + &[b"counter", authority_pubkey.as_ref(), &bump_seed], + &program_pubkey, + ) + .map_err(|_| ProgramError::InvalidArgument)?; + Ok(Address::new_from_array(pda.to_bytes())) + } +} + +/// Create and initialize the counter PDA, pre-funded for its ephemeral permission. +/// +/// Pre-funding here means the counter PDA, after delegation, carries enough +/// lamports onto the ER to cover the rent of the `EphemeralPermission` account +/// when `init_permission` is called on the ER. +pub fn process_initialize_counter( + program_id: &Address, + accounts: &[AccountView], + bump: u8, +) -> ProgramResult { + // Trailing `..` so the test can pass extra accounts (perm/delegation + // accounts left over from the old base-layer-permission flow) without + // forcing a strict re-wire. + let [authority_account, counter_account, _system_program, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let bump_seed = [bump]; + let counter_pda = counter_address_from_bump(program_id, authority_account.address(), bump)?; + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + let seeds_array: [Seed; 3] = [ + Seed::from(b"counter"), + Seed::from(authority_account.address().as_ref()), + Seed::from(&bump_seed), + ]; + let signer = Signer::from(&seeds_array); + + if counter_account.lamports() == 0 { + // Base-layer rent for the counter PDA itself, plus the ER-side rent + // for one EphemeralPermission account that will be created post-delegation. + // Hardcoded base rent is generous; the ER prefund is computed exactly. + let base_rent_exempt: u64 = 1_000_000; + let ephemeral_permission_rent = ephemeral_rent(EphemeralPermission::size_of(1) as u32); + let total_lamports = base_rent_exempt + .checked_add(ephemeral_permission_rent) + .ok_or(ProgramError::ArithmeticOverflow)?; + + log!("Creating counter with prefund"); + let create_account_ix = CreateAccount { + from: authority_account, + to: counter_account, + lamports: total_lamports, + space: Counter::SIZE as u64, + owner: program_id, + }; + create_account_ix + .invoke_signed(&[signer.clone()]) + .map_err(|_| { + log!("Counter creation failed"); + ProgramError::Custom(100) + })?; + } + + // Initialize fields. + { + let mut data = counter_account.try_borrow_mut()?; + let counter_data = Counter::load_mut(&mut data)?; + counter_data.count = 0; + counter_data.authority = *authority_account.address(); + } + + log!("Counter initialized"); + Ok(()) +} + +/// Increase the counter PDA by the requested amount. +pub fn process_increase_counter( + program_id: &Address, + accounts: &[AccountView], + bump: u8, + increase_by: u64, +) -> ProgramResult { + let [authority_account, counter_account, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let counter_pda = counter_address_from_bump(program_id, authority_account.address(), bump)?; + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + let mut data = counter_account.try_borrow_mut()?; + let counter_data = Counter::load_mut(&mut data)?; + counter_data.count = counter_data + .count + .checked_add(increase_by) + .ok_or(ProgramError::ArithmeticOverflow)?; + Ok(()) +} + +/// Delegate the counter PDA to the ER. No permission delegation needed — +/// ephemeral permissions are created directly on the ER post-delegation. +pub fn process_delegate( + _program_id: &Address, + accounts: &[AccountView], + bump: u8, +) -> ProgramResult { + let [authority, pda_to_delegate, owner_program, delegation_buffer, delegation_record, delegation_metadata, _delegation_program, system_program, rest @ ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + let validator = rest.first().map(|account| *account.address()); + + let counter_pda = counter_address_from_bump(owner_program.address(), authority.address(), bump)?; + if counter_pda != *pda_to_delegate.address() { + return Err(ProgramError::InvalidArgument); + } + + let seeds: &[&[u8]] = &[b"counter", authority.address().as_ref()]; + delegate_account( + &[ + authority, + pda_to_delegate, + owner_program, + delegation_buffer, + delegation_record, + delegation_metadata, + system_program, + ], + seeds, + bump, + DelegateConfig { + validator, + ..Default::default() + }, + )?; + Ok(()) +} + +/// Create the ephemeral permission on the ER. +/// +/// Idempotent: skips if the permission account already has lamports. The +/// counter PDA (delegated, with prefunded lamports) is the payer and signs +/// via its seeds. +pub fn process_init_permission( + program_id: &Address, + accounts: &[AccountView], + bump: u8, +) -> ProgramResult { + let [authority, counter_account, permission, vault, magic_program, permission_program, ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let counter_pda = counter_address_from_bump(program_id, authority.address(), bump)?; + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + if permission.lamports() > 0 { + log!("Permission already exists, skipping creation"); + return Ok(()); + } + + let bump_seed = [bump]; + let seeds_array: [Seed; 3] = [ + Seed::from(b"counter"), + Seed::from(authority.address().as_ref()), + Seed::from(&bump_seed), + ]; + let signer = Signer::from(&seeds_array); + + // Empty members + is_private=false → public permission to start. + let members: [Member; 0] = []; + CreateEphemeralPermission { + payer: counter_account, + permissioned_account: counter_account, + permission, + vault, + magic_program, + permission_program, + args: EphemeralMembersArgs { + is_private: false, + members: &members, + }, + } + .invoke_signed::(&[signer])?; + log!("Ephemeral permission created"); + Ok(()) +} + +/// Toggle the privacy flag on the ephemeral permission. +/// +/// When `is_private` is true, only the counter's authority is allowed to read +/// state via the TEE (logs, messages, balances). When false, the permission +/// is public. +pub fn process_set_privacy( + program_id: &Address, + accounts: &[AccountView], + bump: u8, + is_private: bool, +) -> ProgramResult { + let [authority, counter_account, permission, vault, magic_program, permission_program, ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let counter_pda = counter_address_from_bump(program_id, authority.address(), bump)?; + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + let bump_seed = [bump]; + let seeds_array: [Seed; 3] = [ + Seed::from(b"counter"), + Seed::from(authority.address().as_ref()), + Seed::from(&bump_seed), + ]; + let signer = Signer::from(&seeds_array); + + // Read the counter's stored authority — that's the only member when private. + let counter_authority = { + let data = counter_account.try_borrow()?; + Counter::load(&data)?.authority + }; + + let single_member = [Member { + flags: MemberFlags::from_acl_flag_byte( + MemberFlags::TX_LOGS | MemberFlags::TX_MESSAGE | MemberFlags::TX_BALANCES, + ), + pubkey: counter_authority, + }]; + let members: &[Member] = if is_private { &single_member } else { &[] }; + + log!("Toggling privacy"); + UpdateEphemeralPermission { + payer: counter_account, + permissioned_account: counter_account, + permission, + vault, + magic_program, + permission_program, + authority: counter_account, + authority_is_signer: false, // PDA signs via the seeds above + args: EphemeralMembersArgs { + is_private, + members, + }, + } + .invoke_signed::(&[signer])?; + Ok(()) +} + +/// Close the ephemeral permission, refunding rent to the counter PDA. +pub fn process_close_permission( + program_id: &Address, + accounts: &[AccountView], + bump: u8, +) -> ProgramResult { + let [authority, counter_account, permission, vault, magic_program, permission_program, ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let counter_pda = counter_address_from_bump(program_id, authority.address(), bump)?; + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + let bump_seed = [bump]; + let seeds_array: [Seed; 3] = [ + Seed::from(b"counter"), + Seed::from(authority.address().as_ref()), + Seed::from(&bump_seed), + ]; + let signer = Signer::from(&seeds_array); + + CloseEphemeralPermission { + payer: counter_account, + permissioned_account: counter_account, + permission, + vault, + magic_program, + permission_program, + authority: counter_account, + authority_is_signer: false, + } + .invoke_signed(&[signer])?; + log!("Ephemeral permission closed"); + Ok(()) +} + +/// Commit the counter PDA state to the base layer. +pub fn process_commit(_program_id: &Address, accounts: &[AccountView]) -> ProgramResult { + let [authority, counter_account, magic_program, magic_context, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; + MagicIntentBundleBuilder::new(*authority, *magic_context, *magic_program) + .commit(&[*counter_account]) + .build_and_invoke(&mut intent_bundle_data)?; + Ok(()) +} + +/// Commit the counter PDA state and undelegate it. No separate permission +/// undelegation step — ephemeral permissions are confined to the ER and can be +/// closed independently via `close_permission` before this call. +pub fn process_commit_and_undelegate( + program_id: &Address, + accounts: &[AccountView], +) -> ProgramResult { + let [authority, counter_account, magic_program, magic_context, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + let (counter_pda, _bump_seed) = + Address::find_program_address(&[b"counter", authority.address().as_ref()], program_id); + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; + MagicIntentBundleBuilder::new(*authority, *magic_context, *magic_program) + .commit_and_undelegate(&[*counter_account]) + .build_and_invoke(&mut intent_bundle_data)?; + Ok(()) +} + +/// Increment + commit in one instruction. +pub fn process_increment_commit( + program_id: &Address, + accounts: &[AccountView], + bump: u8, + increase_by: u64, +) -> ProgramResult { + let [authority, counter_account, magic_program, magic_context, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let counter_pda = counter_address_from_bump(program_id, authority.address(), bump)?; + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + { + let mut data = counter_account.try_borrow_mut()?; + let counter_data = Counter::load_mut(&mut data)?; + counter_data.count = counter_data + .count + .checked_add(increase_by) + .ok_or(ProgramError::ArithmeticOverflow)?; + } + + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; + MagicIntentBundleBuilder::new(*authority, *magic_context, *magic_program) + .commit(&[*counter_account]) + .build_and_invoke(&mut intent_bundle_data)?; + Ok(()) +} + +/// Increment + commit-and-undelegate in one instruction. +pub fn process_increment_undelegate( + program_id: &Address, + accounts: &[AccountView], + bump: u8, + increase_by: u64, +) -> ProgramResult { + let [authority, counter_account, magic_program, magic_context, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let counter_pda = counter_address_from_bump(program_id, authority.address(), bump)?; + if counter_pda != *counter_account.address() { + return Err(ProgramError::InvalidArgument); + } + + { + let mut data = counter_account.try_borrow_mut()?; + let counter_data = Counter::load_mut(&mut data)?; + counter_data.count = counter_data + .count + .checked_add(increase_by) + .ok_or(ProgramError::ArithmeticOverflow)?; + } + + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; + MagicIntentBundleBuilder::new(*authority, *magic_context, *magic_program) + .commit_and_undelegate(&[*counter_account]) + .build_and_invoke(&mut intent_bundle_data)?; + Ok(()) +} + +/// Handle the callback emitted by the delegation program on undelegation. +pub fn process_undelegation_callback( + program_id: &Address, + accounts: &[AccountView], + ix_data: &[u8], +) -> ProgramResult { + let [delegated_acc, buffer_acc, payer, _system_program, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + undelegate(delegated_acc, program_id, buffer_acc, payer, ix_data)?; + Ok(()) +} diff --git a/pinocchio-private-counter/src/state.rs b/pinocchio-private-counter/src/state.rs new file mode 100644 index 00000000..5100dd63 --- /dev/null +++ b/pinocchio-private-counter/src/state.rs @@ -0,0 +1,43 @@ +use pinocchio::{error::ProgramError, Address}; + +/// State structure for the counter. +/// +/// Mirrors `private-counter`'s Anchor `Counter` so we can use the same +/// EphemeralPermission flow on the ER: the counter PDA pays for its own +/// ephemeral permission via PDA-signed CPI, and `authority` is the sole +/// "private" member when privacy is on. +#[repr(C)] +pub struct Counter { + pub count: u64, + pub authority: Address, +} + +impl Counter { + pub const SIZE: usize = 8 + 32; // count + authority + + pub fn load_mut(data: &mut [u8]) -> Result<&mut Self, ProgramError> { + if data.len() < Self::SIZE { + return Err(ProgramError::InvalidArgument); + } + let ptr = data.as_mut_ptr() as *mut Self; + #[allow(clippy::manual_is_multiple_of)] + if (ptr as usize) % core::mem::align_of::() != 0 { + return Err(ProgramError::InvalidAccountData); + } + // Safety: caller ensures the account data is valid for Counter. + Ok(unsafe { &mut *ptr }) + } + + pub fn load(data: &[u8]) -> Result<&Self, ProgramError> { + if data.len() < Self::SIZE { + return Err(ProgramError::InvalidArgument); + } + let ptr = data.as_ptr() as *const Self; + #[allow(clippy::manual_is_multiple_of)] + if (ptr as usize) % core::mem::align_of::() != 0 { + return Err(ProgramError::InvalidAccountData); + } + // Safety: caller ensures the account data is valid for Counter. + Ok(unsafe { &*ptr }) + } +} diff --git a/pinocchio-secret-counter/tests-rs/delegate_counter.rs b/pinocchio-private-counter/tests-rs/delegate_counter.rs similarity index 100% rename from pinocchio-secret-counter/tests-rs/delegate_counter.rs rename to pinocchio-private-counter/tests-rs/delegate_counter.rs diff --git a/pinocchio-secret-counter/tests-rs/fixtures/dlp.so b/pinocchio-private-counter/tests-rs/fixtures/dlp.so similarity index 100% rename from pinocchio-secret-counter/tests-rs/fixtures/dlp.so rename to pinocchio-private-counter/tests-rs/fixtures/dlp.so diff --git a/pinocchio-secret-counter/tests-rs/increase_counter.rs b/pinocchio-private-counter/tests-rs/increase_counter.rs similarity index 100% rename from pinocchio-secret-counter/tests-rs/increase_counter.rs rename to pinocchio-private-counter/tests-rs/increase_counter.rs diff --git a/pinocchio-secret-counter/tests-rs/initialize_counter.rs b/pinocchio-private-counter/tests-rs/initialize_counter.rs similarity index 100% rename from pinocchio-secret-counter/tests-rs/initialize_counter.rs rename to pinocchio-private-counter/tests-rs/initialize_counter.rs diff --git a/pinocchio-secret-counter/tests-rs/utils.rs b/pinocchio-private-counter/tests-rs/utils.rs similarity index 100% rename from pinocchio-secret-counter/tests-rs/utils.rs rename to pinocchio-private-counter/tests-rs/utils.rs diff --git a/pinocchio-secret-counter/tests/initializeKeypair.ts b/pinocchio-private-counter/tests/initializeKeypair.ts similarity index 100% rename from pinocchio-secret-counter/tests/initializeKeypair.ts rename to pinocchio-private-counter/tests/initializeKeypair.ts diff --git a/pinocchio-secret-counter/tests/kit/initializeKeypair.ts b/pinocchio-private-counter/tests/kit/initializeKeypair.ts similarity index 100% rename from pinocchio-secret-counter/tests/kit/initializeKeypair.ts rename to pinocchio-private-counter/tests/kit/initializeKeypair.ts diff --git a/pinocchio-secret-counter/tests/kit/pinocchio-secret-counter.test.ts b/pinocchio-private-counter/tests/kit/pinocchio-private-counter.test.ts similarity index 98% rename from pinocchio-secret-counter/tests/kit/pinocchio-secret-counter.test.ts rename to pinocchio-private-counter/tests/kit/pinocchio-private-counter.test.ts index 3ee3d912..4931d078 100644 --- a/pinocchio-secret-counter/tests/kit/pinocchio-secret-counter.test.ts +++ b/pinocchio-private-counter/tests/kit/pinocchio-private-counter.test.ts @@ -46,7 +46,7 @@ describe("basic-test", async () => { console.log("🧪 Running pinocchio-counter.ts test suite..."); // Load the deployed program keypair and get Proram ID - const keypairPath = "target/deploy/pinocchio_secret_counter-keypair.json"; + const keypairPath = "target/deploy/pinocchio_private_counter-keypair.json"; const secretKeyArray = Uint8Array.from( JSON.parse(fs.readFileSync(keypairPath, "utf8")) ); @@ -114,7 +114,7 @@ describe("basic-test", async () => { ] : [ { - address: address("FnE6VJT5QNZdedZPnCoLsARgBwoE6DeJNjBs2H1gySXA"), + address: address("MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo"), role: AccountRole.READONLY } ]; diff --git a/pinocchio-secret-counter/tests/kit/schema.ts b/pinocchio-private-counter/tests/kit/schema.ts similarity index 100% rename from pinocchio-secret-counter/tests/kit/schema.ts rename to pinocchio-private-counter/tests/kit/schema.ts diff --git a/pinocchio-secret-counter/tests/schema.ts b/pinocchio-private-counter/tests/schema.ts similarity index 100% rename from pinocchio-secret-counter/tests/schema.ts rename to pinocchio-private-counter/tests/schema.ts diff --git a/pinocchio-secret-counter/tests/web3js/initializeKeypair.ts b/pinocchio-private-counter/tests/web3js/initializeKeypair.ts similarity index 90% rename from pinocchio-secret-counter/tests/web3js/initializeKeypair.ts rename to pinocchio-private-counter/tests/web3js/initializeKeypair.ts index f72fd17c..1d207321 100644 --- a/pinocchio-secret-counter/tests/web3js/initializeKeypair.ts +++ b/pinocchio-private-counter/tests/web3js/initializeKeypair.ts @@ -33,8 +33,6 @@ export async function airdropSolIfNeeded(connection: web3.Connection, pubkey: we if (balance < threshold * web3.LAMPORTS_PER_SOL) { console.log(`Selected cluster: ${connection.rpcEndpoint}`) console.log(`Airdropping ${amount} SOL...`) - await connection.requestAirdrop(pubkey, amount * web3.LAMPORTS_PER_SOL ) - console.log(`\rAirdrop of ${amount} SOL was successful.`) } } diff --git a/pinocchio-secret-counter/tests/web3js/pinocchio-secret-counter.test.ts b/pinocchio-private-counter/tests/web3js/pinocchio-private-counter.test.ts similarity index 98% rename from pinocchio-secret-counter/tests/web3js/pinocchio-secret-counter.test.ts rename to pinocchio-private-counter/tests/web3js/pinocchio-private-counter.test.ts index 3e23ecac..3e1bcf24 100644 --- a/pinocchio-secret-counter/tests/web3js/pinocchio-secret-counter.test.ts +++ b/pinocchio-private-counter/tests/web3js/pinocchio-private-counter.test.ts @@ -35,7 +35,7 @@ describe("basic-test", async () => { console.log("pinocchio-counter.ts") // Get programId from target folder - const keypairPath = "target/deploy/pinocchio_secret_counter-keypair.json"; + const keypairPath = "target/deploy/pinocchio_private_counter-keypair.json"; const secretKeyArray = Uint8Array.from(JSON.parse(fs.readFileSync(keypairPath, "utf8"))); const keypair = Keypair.fromSecretKey(secretKeyArray); const PROGRAM_ID = keypair.publicKey; @@ -100,7 +100,7 @@ describe("basic-test", async () => { ] : [ { - pubkey: new PublicKey("FnE6VJT5QNZdedZPnCoLsARgBwoE6DeJNjBs2H1gySXA"), + pubkey: new PublicKey("MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo"), isSigner: false, isWritable: false, }, @@ -193,12 +193,13 @@ describe("basic-test", async () => { skipPreflight: true, commitment: "confirmed" } - ); + ); const duration = Date.now() - start; console.log(`${duration}ms (Base Layer) Initialize txHash: ${txHash}`); expect(txHash).toBeDefined(); } catch (error: any) { - console.error("Initialize error:", error); + console.error("Initialize error:", error?.message ?? error); + if (error?.logs) console.error(" logs:", error.logs); throw error; } diff --git a/pinocchio-secret-counter/tests/web3js/schema.ts b/pinocchio-private-counter/tests/web3js/schema.ts similarity index 100% rename from pinocchio-secret-counter/tests/web3js/schema.ts rename to pinocchio-private-counter/tests/web3js/schema.ts diff --git a/pinocchio-secret-counter/tsconfig.json b/pinocchio-private-counter/tsconfig.json similarity index 100% rename from pinocchio-secret-counter/tsconfig.json rename to pinocchio-private-counter/tsconfig.json diff --git a/pinocchio-secret-counter/vitest.config.ts b/pinocchio-private-counter/vitest.config.ts similarity index 100% rename from pinocchio-secret-counter/vitest.config.ts rename to pinocchio-private-counter/vitest.config.ts diff --git a/pinocchio-secret-counter/yarn.lock b/pinocchio-private-counter/yarn.lock similarity index 100% rename from pinocchio-secret-counter/yarn.lock rename to pinocchio-private-counter/yarn.lock diff --git a/pinocchio-secret-counter/src/processor.rs b/pinocchio-secret-counter/src/processor.rs deleted file mode 100644 index 6ac582e1..00000000 --- a/pinocchio-secret-counter/src/processor.rs +++ /dev/null @@ -1,444 +0,0 @@ -use crate::state::Counter; -use ephemeral_rollups_pinocchio::acl::{ - commit_and_undelegate_permission, CreatePermissionCpiBuilder, DelegatePermissionCpiBuilder, - Member, MemberFlags, MembersArgs, -}; -use ephemeral_rollups_pinocchio::instruction::{delegate_account, undelegate}; -use ephemeral_rollups_pinocchio::intent_bundle::MagicIntentBundleBuilder; -use ephemeral_rollups_pinocchio::types::DelegateConfig; -use pinocchio::{ - account::AccountView, - cpi::{Seed, Signer}, - error::ProgramError, - Address, ProgramResult, -}; -use pinocchio_log::log; -use pinocchio_system::instructions::CreateAccount; - -const INTENT_BUNDLE_DATA_BUF_SIZE: usize = 512; - -/// Derive the counter PDA from the caller-provided bump. -fn counter_address_from_bump( - program_id: &Address, - initializer: &AccountView, - bump: u8, -) -> Result { - let bump_seed = [bump]; - #[cfg(any(target_os = "solana", target_arch = "bpf"))] - { - Address::create_program_address( - &[b"counter", initializer.address().as_ref(), &bump_seed], - program_id, - ) - .map_err(|_| ProgramError::InvalidArgument) - } - #[cfg(not(any(target_os = "solana", target_arch = "bpf")))] - { - use solana_pubkey::Pubkey; - let program_pubkey = Pubkey::new_from_array(*program_id.as_array()); - let initializer_pubkey = Pubkey::new_from_array(*initializer.address().as_array()); - let pda = Pubkey::create_program_address( - &[b"counter", initializer_pubkey.as_ref(), &bump_seed], - &program_pubkey, - ) - .map_err(|_| ProgramError::InvalidArgument)?; - Ok(Address::new_from_array(pda.to_bytes())) - } -} - -/// Create and initialize the counter PDA for the initializer. -pub fn process_initialize_counter( - program_id: &Address, - accounts: &[AccountView], - bump: u8, -) -> ProgramResult { - let [initializer_account, counter_account, system_program, permission_program, permission, delegation_buffer, delegation_record, delegation_metadata, _delegation_program, validator] = - accounts - else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - let bump_seed = [bump]; - let counter_pda = counter_address_from_bump(program_id, initializer_account, bump)?; - - if counter_pda != *counter_account.address() { - return Err(ProgramError::InvalidArgument); - } - - // Counter signer seeds - let seeds_array: [Seed; 3] = [ - Seed::from(b"counter"), - Seed::from(initializer_account.address().as_ref()), - Seed::from(&bump_seed), - ]; - - // Signer with bump - let signer = Signer::from(&seeds_array); - - // Create counter account if it doesn't exist. - if counter_account.lamports() == 0 { - log!("Creating counter ..."); - let rent_exempt_lamports = 1_000_000; - - let create_account_ix = CreateAccount { - from: initializer_account, - to: counter_account, - lamports: rent_exempt_lamports, - space: Counter::SIZE as u64, - owner: program_id, - }; - create_account_ix - .invoke_signed(&[signer.clone()]) - .map_err(|_| { - log!("Counter creation failed with error"); - ProgramError::Custom(100) - })?; - log!("Counter created successfully"); - } - - // Initialize counter to 0. - { - let mut data = counter_account.try_borrow_mut()?; - let counter_data = Counter::load_mut(&mut data)?; - counter_data.count = 0; - } // Explicitly drop borrow before CPI - - // Create permission for the counter account if it doesn't already exist - if permission.lamports() == 0 { - log!("Creating permission ..."); - let members_array = [Member { - flags: MemberFlags::default(), - pubkey: *initializer_account.address(), - }]; - let members_args = MembersArgs { - members: Some(&members_array), - }; - let result = CreatePermissionCpiBuilder::new( - counter_account, - permission, - initializer_account, - system_program, - &permission_program.address(), - ) - .members(members_args) - .seeds(&[b"counter", initializer_account.address().as_ref()]) - .bump(bump) - .invoke(); - match result { - Ok(_) => { - log!("Permission created successfully"); - } - Err(e) => { - log!("Permission creation failed"); - // Try to log error code if available - match e { - ProgramError::Custom(_code) => { - log!("Custom error code"); - } - ProgramError::InvalidArgument => { - log!("InvalidArgument error"); - } - ProgramError::InvalidAccountData => { - log!("InvalidAccountData error"); - } - ProgramError::NotEnoughAccountKeys => { - log!("NotEnoughAccountKeys error"); - } - _ => { - log!("Other error type"); - } - } - return Err(e); - } - } - } else { - log!("Permission account already exists, skipping creation"); - } - - // Delegate permisison if not delegated - if unsafe { permission.owner() } == permission_program.address() { - log!("Delegating permission"); - let result = DelegatePermissionCpiBuilder::new( - &initializer_account, - &initializer_account, - &counter_account, - &permission, - &system_program, - &permission_program, - &delegation_buffer, - &delegation_record, - &delegation_metadata, - &_delegation_program, - validator, - permission_program.address(), - ) - .signer_seeds(signer.clone()) - .invoke(); - - match result { - Ok(_) => { - log!("Permission delegated successfully"); - } - Err(e) => { - log!("Permission delegation failed"); - // Try to log error code if available - match e { - ProgramError::Custom(_code) => { - log!("Custom error code"); - } - ProgramError::InvalidArgument => { - log!("InvalidArgument error"); - } - ProgramError::InvalidAccountData => { - log!("InvalidAccountData error"); - } - ProgramError::NotEnoughAccountKeys => { - log!("NotEnoughAccountKeys error"); - } - _ => { - log!("Other error type"); - } - } - return Err(e); - } - } - } else { - log!("Permission already delegated"); - } - Ok(()) -} - -/// Increase the counter PDA by the requested amount. -pub fn process_increase_counter( - program_id: &Address, - accounts: &[AccountView], - bump: u8, - increase_by: u64, -) -> ProgramResult { - let [initializer_account, counter_account] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - let counter_pda = counter_address_from_bump(program_id, initializer_account, bump)?; - - if counter_pda != *counter_account.address() { - return Err(ProgramError::InvalidArgument); - } - - { - let mut data = counter_account.try_borrow_mut()?; - let counter_data = Counter::load_mut(&mut data)?; - counter_data.count = counter_data - .count - .checked_add(increase_by) - .ok_or(ProgramError::ArithmeticOverflow)?; - } - - Ok(()) -} - -/// Delegate the counter PDA to the delegation program. -pub fn process_delegate( - _program_id: &Address, - accounts: &[AccountView], - bump: u8, -) -> ProgramResult { - let [initializer, pda_to_delegate, owner_program, delegation_buffer, delegation_record, delegation_metadata, _delegation_program, system_program, rest @ ..] = - accounts - else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - let validator = rest.first().map(|account| *account.address()); - let permission = rest.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; - let permission_program = rest.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; - - let seed_1 = b"counter"; - let seed_2 = initializer.address().as_ref(); - let seeds: &[&[u8]] = &[seed_1, seed_2]; - let counter_pda = counter_address_from_bump(owner_program.address(), initializer, bump)?; - - let delegate_config = DelegateConfig { - validator, - ..Default::default() - }; - - if counter_pda != *pda_to_delegate.address() { - return Err(ProgramError::InvalidArgument); - } - - // Verify permission was created and delegated before delegating counter - log!("Checking permission delegation status"); - if unsafe { permission.owner() } == permission_program.address() { - log!("Permission not delegated, cannot delegate counter"); - return Err(ProgramError::Custom(4)); - } - log!("Permission verified as delegated, proceeding with counter delegation"); - - delegate_account( - &[ - initializer, - pda_to_delegate, - owner_program, - delegation_buffer, - delegation_record, - delegation_metadata, - system_program, - ], - seeds, - bump, - delegate_config, - )?; - - Ok(()) -} - -/// Commit the counter PDA state to the base layer. -pub fn process_commit(_program_id: &Address, accounts: &[AccountView]) -> ProgramResult { - let [initializer, counter_account, magic_program, magic_context] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - if !initializer.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - - let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; - MagicIntentBundleBuilder::new(*initializer, *magic_context, *magic_program) - .commit(&[*counter_account]) - .build_and_invoke(&mut intent_bundle_data)?; - - Ok(()) -} - -/// Commit the counter PDA state and undelegate it. -pub fn process_commit_and_undelegate( - _program_id: &Address, - accounts: &[AccountView], -) -> ProgramResult { - let [initializer, counter_account, permission_program, permission, magic_program, magic_context] = - accounts - else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - if !initializer.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - - let (counter_pda, bump_seed) = - Address::find_program_address(&[b"counter", initializer.address().as_ref()], _program_id); - - if counter_pda != *counter_account.address() { - return Err(ProgramError::InvalidArgument); - } - - // Prepare signer seeds - let seed_array: [Seed; 3] = [ - Seed::from(b"counter"), - Seed::from(initializer.address().as_ref()), - Seed::from(core::slice::from_ref(&bump_seed)), - ]; - let signer_seeds = Signer::from(&seed_array); - - let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; - MagicIntentBundleBuilder::new(*initializer, *magic_context, *magic_program) - .commit_and_undelegate(&[*counter_account]) - .build_and_invoke(&mut intent_bundle_data)?; - - commit_and_undelegate_permission( - &[ - initializer, - counter_account, - permission, - magic_program, - magic_context, - ], - permission_program.address(), - true, - true, - Some(signer_seeds.clone()), - )?; - - Ok(()) -} - -/// Increment the counter PDA and commit in a single instruction. -pub fn process_increment_commit( - program_id: &Address, - accounts: &[AccountView], - bump: u8, - increase_by: u64, -) -> ProgramResult { - let [initializer, counter_account, magic_program, magic_context] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - let counter_pda = counter_address_from_bump(program_id, initializer, bump)?; - - if counter_pda != *counter_account.address() { - return Err(ProgramError::InvalidArgument); - } - - { - let mut data = counter_account.try_borrow_mut()?; - let counter_data = Counter::load_mut(&mut data)?; - counter_data.count += increase_by; - } - - if !initializer.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - - let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; - MagicIntentBundleBuilder::new(*initializer, *magic_context, *magic_program) - .commit(&[*counter_account]) - .build_and_invoke(&mut intent_bundle_data)?; - - Ok(()) -} - -/// Increment the counter PDA and commit+undelegate in a single instruction. -pub fn process_increment_undelegate( - program_id: &Address, - accounts: &[AccountView], - bump: u8, - increase_by: u64, -) -> ProgramResult { - let [initializer, counter_account, magic_program, magic_context] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - let counter_pda = counter_address_from_bump(program_id, initializer, bump)?; - - if counter_pda != *counter_account.address() { - return Err(ProgramError::InvalidArgument); - } - - let mut data = counter_account.try_borrow_mut()?; - let counter_data = Counter::load_mut(&mut data)?; - counter_data.count += increase_by; - - if !initializer.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - - let mut intent_bundle_data = [0u8; INTENT_BUNDLE_DATA_BUF_SIZE]; - MagicIntentBundleBuilder::new(*initializer, *magic_context, *magic_program) - .commit_and_undelegate(&[*counter_account]) - .build_and_invoke(&mut intent_bundle_data)?; - - Ok(()) -} - -/// Handle the callback emitted by the delegation program on undelegation. -pub fn process_undelegation_callback( - program_id: &Address, - accounts: &[AccountView], - ix_data: &[u8], -) -> ProgramResult { - let [delegated_acc, buffer_acc, payer, _system_program, ..] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - undelegate(delegated_acc, program_id, buffer_acc, payer, ix_data)?; - Ok(()) -} diff --git a/pinocchio-secret-counter/src/state.rs b/pinocchio-secret-counter/src/state.rs deleted file mode 100644 index e909270f..00000000 --- a/pinocchio-secret-counter/src/state.rs +++ /dev/null @@ -1,24 +0,0 @@ -use pinocchio::error::ProgramError; - -// State structure for the counter -#[repr(C)] -pub struct Counter { - pub count: u64, -} - -impl Counter { - pub const SIZE: usize = 8; - - pub fn load_mut(data: &mut [u8]) -> Result<&mut Self, ProgramError> { - if data.len() < Self::SIZE { - return Err(ProgramError::InvalidArgument); - } - let ptr = data.as_mut_ptr() as *mut Self; - #[allow(clippy::manual_is_multiple_of)] - if (ptr as usize) % core::mem::align_of::() != 0 { - return Err(ProgramError::InvalidAccountData); - } - // Safety: caller ensures the account data is valid for Counter. - Ok(unsafe { &mut *ptr }) - } -} diff --git a/session-keys/package.json b/session-keys/package.json index d5167d81..540402a6 100644 --- a/session-keys/package.json +++ b/session-keys/package.json @@ -7,7 +7,7 @@ "dependencies": { "@coral-xyz/anchor": "0.32.1", "@magicblock-labs/ephemeral-rollups-sdk": "0.14.3", - "@magicblock-labs/gum-sdk": "^3.1.0" + "@magicblock-labs/gum-sdk": "^3.0.10" }, "devDependencies": { "@types/bn.js": "^5.1.0", diff --git a/session-keys/tests/anchor-counter-session.ts b/session-keys/tests/anchor-counter-session.ts index 8c8195ef..e2024eb3 100644 --- a/session-keys/tests/anchor-counter-session.ts +++ b/session-keys/tests/anchor-counter-session.ts @@ -8,7 +8,7 @@ import { initializeSessionSignerKeypair } from "../utils/initializeKeypair"; const COUNTER_SEED = "counter"; -describe("anchor-counter-session", () => { +describe.only("anchor-counter-session", () => { console.log("anchor-counter-session.ts"); // Configure the client to use the local cluster. @@ -70,7 +70,11 @@ describe("anchor-counter-session", () => { const topUp = true const validUntilBN = new anchor.BN(Math.floor(Date.now() / 1000) + 3600); // valid for 1 hour - const topUpLamportsBN = new anchor.BN(0.0005 * LAMPORTS_PER_SOL); + // The sessionKeypair pays for: (a) rent-exempt minimum on its own system + // account (~890,880 lamports), (b) tx fees on every session-signed tx, and + // (c) the on-chain delegation buffer rent (~3M lamports) when it's the + // delegate ix's payer. 0.005 SOL covers all three with headroom. + const topUpLamportsBN = new anchor.BN(0.005 * LAMPORTS_PER_SOL); const tx = await sessionTokenManager.program.methods.createSessionV2( topUp, @@ -84,9 +88,9 @@ describe("anchor-counter-session", () => { authority: provider.wallet.publicKey, }) .transaction(); + tx.feePayer = provider.wallet.publicKey; - const txHash = await sendAndConfirmTransaction(provider.connection, tx, [sessionKeypair, provider.wallet.payer], { - skipPreflight: true, + const txHash = await sendAndConfirmTransaction(provider.connection, tx, [provider.wallet.payer!, sessionKeypair], { commitment: "confirmed" }); const duration = Date.now() - start; @@ -283,9 +287,15 @@ describe("anchor-counter-session", () => { .revokeSessionV2() .accounts({ sessionToken: sessionTokenPDA, + feePayer: provider.wallet.publicKey, + authority: provider.wallet.publicKey, }) .transaction() - const txHash = await sendAndConfirmTransaction(provider.connection, tx, [sessionKeypair], + // While the session is still within `valid_until`, the program requires + // `authority` to be a signer (see SessionError::InvalidAuthority). Sign + // with the wallet (authority), not the session signer. + tx.feePayer = provider.wallet.publicKey; + const txHash = await sendAndConfirmTransaction(provider.connection, tx, [provider.wallet.payer!], { skipPreflight: true, commitment: "confirmed", diff --git a/test-locally.sh b/test-locally.sh index 115a8609..ccd90435 100755 --- a/test-locally.sh +++ b/test-locally.sh @@ -397,7 +397,7 @@ for i in {1..60}; do done echo "Ephemeral validator is ready." -# Start the VRF oracle (needed by rewards-delegated-vrf). +# Start the VRF oracle. echo "Starting VRF oracle..." VRF_ORACLE_BIN=$(command -v vrf-oracle 2>/dev/null) if [ -z "$VRF_ORACLE_BIN" ]; then @@ -456,49 +456,52 @@ export VALIDATOR=mAGicPQYBMvcYveUZA5F5UNNwyHvfYh5xkLS2Fr1mev # anchor-counter has 3 test files: public-counter (local), private-counter (TEE), advanced-magic (router). # Locally we run only public-counter.ts. The other two run from the TEE/devnet block below. -run_test "anchor-counter" "cd anchor-counter && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/public-counter.ts; cd .." +run_test "anchor-counter" "cd anchor-counter && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/public-counter.ts && cd .." # private-counter is TEE-only — runs in the TEE/devnet block below. # crank-counter: bypass `anchor test` — Anchor.toml has cluster=devnet so anchor would # re-set ANCHOR_PROVIDER_URL to devnet, overriding our local export. -run_test "crank-counter" "cd crank-counter && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts'; cd .." +run_test "crank-counter" "cd crank-counter && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts' && cd .." # dummy-token-transfer + magic-actions: have router-based tests (devnet-router) plus # local *-local.ts variants. We run only the local variants here. -run_test "dummy-token-transfer" "cd dummy-token-transfer && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/dummy-transfer-local.ts; cd .." +run_test "dummy-token-transfer" "cd dummy-token-transfer && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/dummy-transfer-local.ts && cd .." # ephemeral-account-chats: bypass `anchor test` — Anchor.toml has cluster=devnet so # anchor would re-set ANCHOR_PROVIDER_URL to devnet, overriding our local export. -run_test "ephemeral-account-chats" "cd ephemeral-account-chats && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts'; cd .." +run_test "ephemeral-account-chats" "cd ephemeral-account-chats && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts' && cd .." -run_test "magic-actions" "cd magic-actions && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/magic-actions-local.ts; cd .." +run_test "magic-actions" "cd magic-actions && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/magic-actions-local.ts && cd .." -run_test "oncurve-delegation" "cd oncurve-delegation && yarn install && yarn test && yarn test-web3js; cd .." +run_test "oncurve-delegation" "cd oncurve-delegation && yarn install && yarn test && yarn test-web3js && cd .." -run_test "pinocchio-counter" "cd pinocchio-counter && cargo build-sbf && solana program deploy --program-id target/deploy/pinocchio_counter-keypair.json target/deploy/pinocchio_counter.so && yarn install && yarn test; cd .." +run_test "pinocchio-counter" "cd pinocchio-counter && cargo build-sbf && solana program deploy --program-id target/deploy/pinocchio_counter-keypair.json target/deploy/pinocchio_counter.so && yarn install && yarn test && cd .." -run_test "pinocchio-secret-counter" "cd pinocchio-secret-counter && cargo build-sbf && solana program deploy --program-id target/deploy/pinocchio_secret_counter-keypair.json target/deploy/pinocchio_secret_counter.so && yarn install && yarn test; cd .." +run_test "pinocchio-private-counter" "cd pinocchio-private-counter && cargo build-sbf && solana program deploy --program-id target/deploy/pinocchio_private_counter-keypair.json target/deploy/pinocchio_private_counter.so && yarn install && yarn test && cd .." # rewards-delegated-vrf: bypass `anchor test` — Anchor.toml has cluster=devnet so anchor # would re-set ANCHOR_PROVIDER_URL to devnet, overriding our local export. -run_test "rewards-delegated-vrf" "cd rewards-delegated-vrf && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts'; cd .." +run_test "rewards-delegated-vrf" "cd rewards-delegated-vrf && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts' && cd .." # roll-dice + roll-dice-delegated: VRF integration. roll-dice-delegated reads # VALIDATOR env var → defaults to the local-ER validator since EPHEMERAL_PROVIDER_ENDPOINT # is localhost. Same Anchor.toml glob picks up both test files. -run_test "roll-dice" "cd roll-dice && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts'; cd .." +run_test "roll-dice" "cd roll-dice && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 'tests/**/*.ts' && cd .." # rust-counter: skip ./tests/kit/advanced-magic.test.ts — it's router-based (devnet-router). -run_test "rust-counter" "cd rust-counter && yarn install && npx vitest run ./tests/kit/rust-counter.test.ts; cd .." +# Build + deploy the native Rust program before running vitest — the test loads +# the program keypair from target/deploy/ and expects the program live on the +# local validator (matches the pinocchio-* pattern above). +run_test "rust-counter" "cd rust-counter && cargo build-sbf && solana program deploy --program-id target/deploy/rust_counter-keypair.json target/deploy/rust_counter.so && yarn install && npx vitest run ./tests/kit/rust-counter.test.ts && cd .." # session-keys: skip ./tests/advanced-magic.ts — it's router-based (devnet-router). -run_test "session-keys" "cd session-keys && anchor build && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/anchor-counter-session.ts; cd .." +run_test "session-keys" "cd session-keys && anchor build && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/anchor-counter-session.ts && cd .." # spl-tokens: bypass `anchor test` (which calls fullstack-test.sh — that script # branches on Anchor.toml's cluster=devnet and overrides ANCHOR_PROVIDER_URL, # fighting our locally-exported env). Invoke ts-mocha directly. -run_test "spl-tokens" "cd spl-tokens && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/spl-tokens.ts; cd .." +run_test "spl-tokens" "cd spl-tokens && anchor build && anchor deploy --provider.cluster localnet && yarn install && npx ts-mocha -p ./tsconfig.json -t 1000000 tests/spl-tokens.ts && cd .." # ---- TEE tests: base layer = Solana devnet; ER = MagicBlock TEE devnet ---- # TEE attestation isn't available locally, so these examples are tested against @@ -513,9 +516,9 @@ if [ "${SKIP_TEE_TESTS:-0}" != "1" ]; then TEE_ENV="PROVIDER_ENDPOINT=$DEVNET_RPC WS_ENDPOINT=$DEVNET_WS EPHEMERAL_PROVIDER_ENDPOINT=https://devnet-tee.magicblock.app EPHEMERAL_WS_ENDPOINT=wss://devnet-tee.magicblock.app TEE_PROVIDER_ENDPOINT=https://devnet-tee.magicblock.app TEE_WS_ENDPOINT=wss://devnet-tee.magicblock.app ROUTER_ENDPOINT=https://devnet-router.magicblock.app ROUTER_WS_ENDPOINT=wss://devnet-router.magicblock.app VALIDATOR=MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo" - run_test "private-counter (devnet TEE)" "cd private-counter && anchor build && anchor deploy --provider.cluster devnet && yarn install && $TEE_ENV anchor test --skip-build --skip-deploy --skip-local-validator --provider.cluster devnet; cd .." + run_test "private-counter (devnet TEE)" "cd private-counter && anchor build && anchor deploy --provider.cluster devnet && yarn install && $TEE_ENV anchor test --skip-build --skip-deploy --skip-local-validator --provider.cluster devnet && cd .." - run_test "rock-paper-scissor (devnet TEE)" "cd rock-paper-scissor && anchor build && anchor deploy --provider.cluster devnet && yarn install && $TEE_ENV anchor test --skip-build --skip-deploy --skip-local-validator --provider.cluster devnet; cd .." + run_test "rock-paper-scissor (devnet TEE)" "cd rock-paper-scissor && anchor build && anchor deploy --provider.cluster devnet && yarn install && $TEE_ENV anchor test --skip-build --skip-deploy --skip-local-validator --provider.cluster devnet && cd .." fi # Print summary report