diff --git a/examples/nextjs/.env.local.example b/examples/nextjs/.env.local.example new file mode 100644 index 0000000..d798f42 --- /dev/null +++ b/examples/nextjs/.env.local.example @@ -0,0 +1,6 @@ +# Rename `.env.local.example` to `.env.local` and update the following: +# Check gitignore to ensure your `.env.local` file is not committed or push to repo + +# keypair +# Replace "[777,-----------12]" with your actual full Solana wallet private key +NEXT_PUBLIC_GAME_WALLET_PRIVATE_KEY=[777,-----------,12] diff --git a/examples/nextjs/package-lock.json b/examples/nextjs/package-lock.json index cc48de0..336be88 100644 --- a/examples/nextjs/package-lock.json +++ b/examples/nextjs/package-lock.json @@ -8,8 +8,8 @@ "name": "nextjs", "version": "0.1.0", "dependencies": { - "@solana/actions": "^1.4.0", - "@solana/web3.js": "^1.95.2", + "@solana/actions": "^1.6.6", + "@solana/web3.js": "^1.95.5", "@tabler/icons": "^3.11.0", "@tabler/icons-react": "^3.11.0", "next": "14.2.5", @@ -456,11 +456,11 @@ "dev": true }, "node_modules/@solana/actions": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@solana/actions/-/actions-1.6.2.tgz", - "integrity": "sha512-E0YKakNo65mHBKNbauEV7wBwVdIO4e5YX67O0sTbplEDdsptGtvDRPdWSwZ3DZVFahiRsbFd1e95VrotehhoXQ==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@solana/actions/-/actions-1.6.6.tgz", + "integrity": "sha512-LkXsXr2NFVgo74ZO8WS1Vli68oJhFBvr7pwh6u6Zb//HJKoG8eHIz24E7Q9gBumWRkduLcLrqtpIgL5SbfO59g==", "dependencies": { - "@solana/actions-spec": "^2.2.1", + "@solana/actions-spec": "^2.4.1", "@solana/qr-code-styling": "^1.6.0", "@solana/web3.js": "^1.61.0", "bs58": "^5.0.0", @@ -473,9 +473,9 @@ } }, "node_modules/@solana/actions-spec": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@solana/actions-spec/-/actions-spec-2.2.1.tgz", - "integrity": "sha512-LWHKehKebHIQSprp7HBdhzNoAEx/Sjl9HcpyJSZKQxi36WmgfGNBsY9B677PJsPMNSL+o2G5JcoN+hoxObNTkg==" + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@solana/actions-spec/-/actions-spec-2.4.2.tgz", + "integrity": "sha512-phdlA+JqIrsWw3rmhDynddv9/ZQONPqzYCpPoxN3VMR/2bECsdPWyBgLQpjqk4hgdn7VWQXWzEJtdeFMKxn78g==" }, "node_modules/@solana/buffer-layout": { "version": "4.0.1", @@ -497,11 +497,11 @@ } }, "node_modules/@solana/web3.js": { - "version": "1.95.2", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.95.2.tgz", - "integrity": "sha512-SjlHp0G4qhuhkQQc+YXdGkI8EerCqwxvgytMgBpzMUQTafrkNant3e7pgilBGgjy/iM40ICvWBLgASTPMrQU7w==", + "version": "1.95.5", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.95.5.tgz", + "integrity": "sha512-hU9cBrbg1z6gEjLH9vwIckGBVB78Ijm0iZFNk4ocm5OD82piPwuk3MeQ1rfiKD9YQtr95krrcaopb49EmQJlRg==", "dependencies": { - "@babel/runtime": "^7.24.8", + "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", "@noble/hashes": "^1.4.0", "@solana/buffer-layout": "^4.0.1", diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index fcfa9fb..45fca7a 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -9,8 +9,8 @@ "lint": "next lint" }, "dependencies": { - "@solana/actions": "^1.4.0", - "@solana/web3.js": "^1.95.2", + "@solana/actions": "^1.6.6", + "@solana/web3.js": "^1.95.5", "@tabler/icons": "^3.11.0", "@tabler/icons-react": "^3.11.0", "next": "14.2.5", diff --git a/examples/nextjs/public/RPS-game-image-001.jpeg b/examples/nextjs/public/RPS-game-image-001.jpeg new file mode 100644 index 0000000..4909b85 Binary files /dev/null and b/examples/nextjs/public/RPS-game-image-001.jpeg differ diff --git a/examples/nextjs/src/app/api/actions/rps-game-chaining-post/README.md b/examples/nextjs/src/app/api/actions/rps-game-chaining-post/README.md new file mode 100644 index 0000000..d259d53 --- /dev/null +++ b/examples/nextjs/src/app/api/actions/rps-game-chaining-post/README.md @@ -0,0 +1,3 @@ +# Learn to build Solana Blinks Games - Rock Paper Scissors RPS Game Blinks + +Full guide and video at [Solana Blinks Development Ep2: Ultimate Guide To Build Blinks Games (Rock Paper Scissors (RPS) Game Blinks)](https://dprogramminguniversity.com/solana-blinks-guides/ep2-ultimate-guide-to-build-blinks-games-rps-game-blinks/) diff --git a/examples/nextjs/src/app/api/actions/rps-game-chaining-post/play/route.ts b/examples/nextjs/src/app/api/actions/rps-game-chaining-post/play/route.ts new file mode 100644 index 0000000..0e8c31d --- /dev/null +++ b/examples/nextjs/src/app/api/actions/rps-game-chaining-post/play/route.ts @@ -0,0 +1,230 @@ +// /api/actions/rps-game-chaining-post/play/route.ts +import { + ActionGetResponse, + ActionPostRequest, + ActionPostResponse, + createActionHeaders, + createPostResponse, + ActionError, + MEMO_PROGRAM_ID, +} from "@solana/actions"; +import { + clusterApiUrl, + Connection, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; + +// create the standard headers for this route (including CORS) +const headers = createActionHeaders({ + chainId: 'devnet', + actionVersion: '2.2.1', + }); + +// Game wallet to receive/send SOL +const GAME_WALLET = new PublicKey('FuRxfPnmfQ7RjKobbXdm7bs4VFT4DXXR3t7wC8dc4zb2'); + + +// Helper function to determine winner +function determineWinner(playerMove: string, botMove: string): 'win' | 'lose' | 'draw' { + if (playerMove === botMove) return 'draw'; + + if ( + (playerMove === 'R' && botMove === 'S') || + (playerMove === 'P' && botMove === 'R') || + (playerMove === 'S' && botMove === 'P') + ) { + return 'win'; + } + + return 'lose'; +} + +// Generate bot move +function generateBotMove(): string { + const moves = ['R', 'P', 'S']; + return moves[Math.floor(Math.random() * moves.length)]; +} + +// GET Request Code +export const GET = async (req: Request) => { + const payload: ActionGetResponse = { + title: "Rock Paper Scissors", + icon: new URL("/RPS-game-image-001.jpeg", new URL(req.url).origin).toString(), + description: "Let's play Rock Paper Scissors! If you win you get DOUBLE your betted SOL, if it's a tie you get your betted SOL back, and if you lose you lose your betted SOL.", + label: "Play RPS", + links: { + actions: [ + { + label: "Play!", + href: `${req.url}?amount={amount}&choice={choice}&opponent={opponent}`, + type: 'transaction', + parameters: [ + { + type: "select", + name: "amount", + label: "Play Amount in SOL", + required: true, + options: [ + { label: "0.01 SOL", value: "0.01" }, + { label: "0.1 SOL", value: "0.1" }, + { label: "1 SOL", value: "1" } + ] + }, + { + type: "radio", + name: "choice", + label: "Choose your move", + required: true, + options: [ + { label: "Rock", value: "R" }, + { label: "Paper", value: "P" }, + { label: "Scissors", value: "S" } + ] + }, + { + type: "radio", + name: "opponent", + label: "Choose your opponent", + required: true, + options: [ + { label: "Bot (Instant prize)", value: "bot" }, + { label: "Friend (Multiplayer- NotAvailableNow)", value: "friend" } + ] + } + ] + } + ] + } + }; + + return Response.json(payload, { headers }); +}; + + +// OPTIONS Code +// DO NOT FORGET TO INCLUDE THE `OPTIONS` HTTP METHOD +// THIS WILL ENSURE CORS WORKS FOR BLINKS +export const OPTIONS = async () => { + return new Response(null, { headers }); +}; + + +// POST Request Code +export const POST = async (req: Request) => { + try { + const url = new URL(req.url); + const amount = parseFloat(url.searchParams.get('amount') || '0'); + const choice = url.searchParams.get('choice'); + const opponent = url.searchParams.get('opponent'); + const body: ActionPostRequest = await req.json(); + + // Validate inputs + if (!amount || amount <= 0) { + return Response.json({ error: 'Invalid play amount' }, { + status: 400, + headers + }); + } + + if (!choice || !['R', 'P', 'S'].includes(choice)) { + return Response.json({ error: 'Invalid move choice' }, { + status: 400, + headers + }); + } + + if (!opponent || !['bot', 'friend'].includes(opponent)) { + return Response.json({ error: 'Invalid opponent choice' }, { + status: 400, + headers + }); + } + + // Validate account + let account: PublicKey; + try { + account = new PublicKey(body.account); + } catch (err) { + console.error(err); + return Response.json({ error: 'Invalid account' }, { + status: 400, + headers + }); + } + + //Establish connection with the Solana Blockchain + const connection = new Connection( + process.env.SOLANA_RPC || clusterApiUrl('devnet') + ); + + // Generate bot move and determine result + const botMove = generateBotMove(); + const result = determineWinner(choice, botMove); + + // Create memo instruction with game details to record onchain + const memoInstruction = new TransactionInstruction({ + keys: [], + programId: new PublicKey(MEMO_PROGRAM_ID), + data: Buffer.from( + `RPS Game | Player: ${choice} | Bot: ${botMove} | Result: ${result} | Amount: ${amount} SOL`, + 'utf-8' + ), + }); + + // Create payment instruction + const paymentInstruction = SystemProgram.transfer({ + fromPubkey: account, + toPubkey: GAME_WALLET, + lamports: amount * LAMPORTS_PER_SOL, + }); + + // Get latest blockhash + const { blockhash } = await connection.getLatestBlockhash(); + + // Create transaction + const transaction = new Transaction() + .add(memoInstruction) // Add memo to transaction to record game play onchain + .add(paymentInstruction); // Actual transaction + + transaction.feePayer = account; + transaction.recentBlockhash = blockhash; + + // Create response using createPostResponse helper + // Chain to reward route if win/draw + const payload: ActionPostResponse = await createPostResponse({ + fields: { + type: 'transaction', + transaction, + message: `Game played! Your move: ${choice}, Bot's move: ${botMove}, Result: ${result}`, + links: { + // /** + // * this `href` will receive a POST request (callback) + // * with the confirmed `signature` + // * + // * you could also use query params to track whatever step you are on + // */ + next: { + type: "post", + href: "/api/actions/rps-game-chaining-post/reward", + }, + }, + }, + }); + + return Response.json(payload, { headers }); + + } catch (err) { + console.error(err); + const actionError: ActionError = { + message: typeof err === 'string' ? err : 'Internal server error' + }; + return Response.json(actionError, { + status: 500, + headers + }); + } +}; \ No newline at end of file diff --git a/examples/nextjs/src/app/api/actions/rps-game-chaining-post/reward/route.ts b/examples/nextjs/src/app/api/actions/rps-game-chaining-post/reward/route.ts new file mode 100644 index 0000000..081efbe --- /dev/null +++ b/examples/nextjs/src/app/api/actions/rps-game-chaining-post/reward/route.ts @@ -0,0 +1,168 @@ +// /api/actions/rps-game-chaining-post/reward/route.ts +import { + createActionHeaders, + NextActionPostRequest, + ActionError, + CompletedAction, + MEMO_PROGRAM_ID +} from "@solana/actions"; +import { + clusterApiUrl, + Connection, + PublicKey, + SystemProgram, + Transaction, + TransactionInstruction, + LAMPORTS_PER_SOL, + Keypair, +} from "@solana/web3.js"; + +// Create headers for this route (including CORS) +const headers = createActionHeaders({ + chainId: 'devnet', + actionVersion: '2.2.1', +}); + +// Load and initialize game wallet +let gameWallet: Keypair; +try { + const privateKeyString = process.env.NEXT_PUBLIC_GAME_WALLET_PRIVATE_KEY; + if (!privateKeyString) { + throw new Error('GAME_WALLET_PRIVATE_KEY not found in environment'); + } + const privateKeyArray = JSON.parse(privateKeyString); + const secretKey = Uint8Array.from(privateKeyArray); + gameWallet = Keypair.fromSecretKey(secretKey); + console.log('Game wallet initialized with public key:', gameWallet.publicKey.toBase58()); +} catch (error) { + console.error('Failed to initialize game wallet:', error); + throw new Error('Game wallet initialization failed'); +} + +// GET Request Code +// Since this is a next action endpoint, GET is not supported +export const GET = async () => { + return Response.json({ message: "Method not supported" } as ActionError, { + status: 403, + headers, + }); +}; + +// OPTIONS Code +export const OPTIONS = async () => Response.json(null, { headers }); + +// POST Request Code +export const POST = async (req: Request) => { + try { + const body: NextActionPostRequest = await req.json(); + + // Validate account + let account: PublicKey; + try { + account = new PublicKey(body.account); + } catch (err) { + console.error(err); + return Response.json({ message: 'Invalid account' } as ActionError, { + status: 400, + headers + }); + } + + const connection = new Connection( + process.env.SOLANA_RPC || clusterApiUrl("devnet") + ); + + // Confirm the previous transaction + const signature = body.signature; + if (!signature) { + throw 'Invalid "signature" provided'; + } + + const status = await connection.getSignatureStatus(signature); + if ( + !status || + !status.value || + !status.value.confirmationStatus || + !['confirmed', 'finalized'].includes(status.value.confirmationStatus) + ) { + throw "Unable to confirm the transaction"; + } + + // Get transaction details to determine game result + const transaction = await connection.getParsedTransaction(signature, "confirmed"); + if (!transaction?.meta?.logMessages) { + throw "Unable to fetch transaction details"; + } + + // Parse game result from memo + const memoLog = transaction.meta.logMessages.find(log => log.includes('RPS Game')); + if (!memoLog) throw "Invalid game transaction"; + + const result = memoLog.includes('Result: win') ? 'win' : memoLog.includes('Result: draw') ? 'draw' : 'lose'; + const amount = parseFloat(memoLog.match(/Amount: ([\d.]+) SOL/)?.[1] || '0'); + + // Return completed action for losses + if (result === 'lose') { + const payload: CompletedAction = { + type: "completed", + title: "Game Over!", + icon: new URL("/RPS-game-image-001.jpeg", new URL(req.url).origin).toString(), + label: "Better luck next time!", + description: "You lost this round. Try again!", + }; + return Response.json(payload, { headers }); + } + + // Process reward for wins/draws + const reward = result === 'win' ? amount * 2 : amount; + const rewardTx = new Transaction().add( + new TransactionInstruction({ + keys: [], + programId: new PublicKey(MEMO_PROGRAM_ID), + data: Buffer.from(`RPS Reward | ${result.toUpperCase()} | Sent ${reward} SOL`, 'utf-8'), + }), + SystemProgram.transfer({ + fromPubkey: gameWallet.publicKey, + toPubkey: account, + lamports: reward * LAMPORTS_PER_SOL, + }) + ); + + const { blockhash } = await connection.getLatestBlockhash(); + rewardTx.feePayer = gameWallet.publicKey; + rewardTx.recentBlockhash = blockhash; + rewardTx.sign(gameWallet); + + // Send the transaction + const rawTransaction = rewardTx.serialize(); + const txId = await connection.sendRawTransaction(rawTransaction, { + skipPreflight: false, + preflightCommitment: "confirmed", + }); + + // Use the new transaction confirmation strategy + await connection.confirmTransaction({ + signature: txId, + blockhash, + lastValidBlockHeight: (await connection.getLatestBlockhash()).lastValidBlockHeight + }, "confirmed"); + + const payload: CompletedAction = { + type: "completed", + title: result === 'win' ? "Congratulations!" : "It's a Draw!", + icon: new URL("/RPS-game-image-001.jpeg", new URL(req.url).origin).toString(), + label: "Reward Sent!", + description: `${result === 'win' ? 'You won! ' : 'Game drawn! '}${reward} SOL has been sent to your wallet.`, + }; + + return Response.json(payload, { headers }); + } catch (err) { + console.error(err); + const actionError: ActionError = { message: "An unknown error occurred" }; + if (typeof err == "string") actionError.message = err; + return Response.json(actionError, { + status: 400, + headers, + }); + } +}; \ No newline at end of file