diff --git a/examples/ore-react/package.json b/examples/ore-react/package.json index 0298d8ef..db250335 100644 --- a/examples/ore-react/package.json +++ b/examples/ore-react/package.json @@ -9,8 +9,15 @@ "preview": "vite preview" }, "dependencies": { - "hyperstack-react": "^0.5.0", - "hyperstack-stacks": "^0.5.0", + "@solana/wallet-adapter-phantom": "^0.9.24", + "@solana/wallet-adapter-react": "^0.15.39", + "@solana/wallet-adapter-react-ui": "^0.9.39", + "@solana/wallet-adapter-wallets": "^0.19.37", + "@solana/web3.js": "^1.98.4", + "buffer": "^6.0.3", + "hyperstack-react": "file:../../typescript/react", + "hyperstack-stacks": "file:../../stacks/sdk/typescript", + "hyperstack-typescript": "file:../../typescript/core", "react": "^19.0.0", "react-dom": "^19.0.0", "zod": "^3.23.0", @@ -25,6 +32,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "typescript": "^5.3.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vite-plugin-node-polyfills": "^0.25.0" } } diff --git a/examples/ore-react/src/App.tsx b/examples/ore-react/src/App.tsx index e9d4df54..09b71de5 100644 --- a/examples/ore-react/src/App.tsx +++ b/examples/ore-react/src/App.tsx @@ -1,13 +1,34 @@ import { OreDashboard } from './components'; import { HyperstackProvider } from 'hyperstack-react'; import { ThemeProvider } from './hooks/useTheme'; +import { useMemo } from 'react'; +import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react'; +import { WalletModalProvider } from '@solana/wallet-adapter-react-ui'; +import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets'; +import '@solana/wallet-adapter-react-ui/styles.css'; export default function App() { + const endpoint = import.meta.env.VITE_RPC_URL; // add your own RPC URL in a .env file + + // Setup wallet adapters + const wallets = useMemo( + () => [ + new PhantomWalletAdapter(), + ], + [] + ); + return ( - - - - - + + + + + + + + + + + ); } diff --git a/examples/ore-react/src/components/BlockGrid.tsx b/examples/ore-react/src/components/BlockGrid.tsx index 2f1bc212..45ffaa51 100644 --- a/examples/ore-react/src/components/BlockGrid.tsx +++ b/examples/ore-react/src/components/BlockGrid.tsx @@ -3,21 +3,25 @@ import { MinerIcon, SolanaIcon } from './icons'; interface BlockGridProps { round: ValidatedOreRound | undefined; + selectedSquares: number[]; + onSquareClick: (squareId: number) => void; + winnerSquare?: number | null; + myDeployedSquares?: number[]; } -export function BlockGrid({ round }: BlockGridProps) { +export function BlockGrid({ round, selectedSquares, onSquareClick, winnerSquare, myDeployedSquares = [] }: BlockGridProps) { const blocks = round ? round.state.deployed_per_square_ui.map((deployedUi, i) => ({ id: i + 1, + index: i, minerCount: round.state.count_per_square[i], deployedUi, - isWinner: round.results?.winning_square === i, })) : Array.from({ length: 25 }, (_, i) => ({ id: i + 1, + index: i, minerCount: 0, deployedUi: 0, - isWinner: false, })); return ( @@ -28,32 +32,54 @@ export function BlockGrid({ round }: BlockGridProps) { width: 'calc((100vh - 120px - 4 * 0.5rem) / 5 * 5 + 4 * 0.5rem)' }} > - {blocks.map((block) => ( -
-
- {block.id} -
- {block.minerCount} - + {blocks.map((block) => { + const isWinner = winnerSquare === block.index; + const isDeployed = myDeployedSquares.includes(block.index); + const isSelected = selectedSquares.includes(block.id); + + const squareClass = isDeployed + ? 'bg-emerald-50 dark:bg-emerald-900/30 ring-2 ring-emerald-500 shadow-lg' + : isSelected + ? 'bg-blue-50 dark:bg-blue-900/30 ring-2 ring-blue-500 shadow-lg' + : isWinner + ? 'bg-amber-50/70 dark:bg-amber-900/20 shadow-sm dark:shadow-none dark:ring-1 dark:ring-amber-800/50' + : 'shadow-sm dark:shadow-none dark:ring-1 dark:ring-stone-700'; + + return ( +
-
- - {Number(block.deployedUi).toFixed(4)} -
-
- ))} + {isWinner && ( +
👑
+ )} + {isDeployed && ( +
✓
+ )} + {!isDeployed && isSelected && ( +
✓
+ )} + + ); + })}
); } diff --git a/examples/ore-react/src/components/DeployButton.tsx b/examples/ore-react/src/components/DeployButton.tsx new file mode 100644 index 00000000..ccc03e87 --- /dev/null +++ b/examples/ore-react/src/components/DeployButton.tsx @@ -0,0 +1,331 @@ +import { useState } from 'react'; +import { useWallet, useConnection } from '@solana/wallet-adapter-react'; +import type { UseMutationResult } from 'hyperstack-react'; +import { type OreMiner, type OreRound } from 'hyperstack-stacks/ore'; +import { Transaction, PublicKey } from '@solana/web3.js'; + +const LAMPORTS_PER_SOL = 1_000_000_000n; + +function parseSolToLamports(value: string): bigint | null { + const normalized = value.trim(); + if (!/^(?:\d+|\d*\.\d{1,9})$/.test(normalized)) { + return null; + } + + const [wholePart, fractionalPart = ''] = normalized.split('.'); + const whole = BigInt(wholePart || '0'); + const fractional = BigInt(fractionalPart.padEnd(9, '0')); + const lamports = whole * LAMPORTS_PER_SOL + fractional; + + return lamports > 0n ? lamports : null; +} + +type DeployButtonProps = { + currentRound?: OreRound; + minerData?: OreMiner; + recentRounds?: OreRound[]; + selectedSquares: number[]; + onClearSquares: () => void; + checkpoint?: UseMutationResult; + deploy?: UseMutationResult; +}; + +export function DeployButton({ currentRound, minerData, recentRounds, selectedSquares, onClearSquares, checkpoint, deploy }: DeployButtonProps) { + const wallet = useWallet(); + const { connection } = useConnection(); + const [amount, setAmount] = useState('0.000001'); + const [isProcessing, setIsProcessing] = useState(false); + const [processingStep, setProcessingStep] = useState<'checkpoint' | 'deploy' | null>(null); + const [result, setResult] = useState<{ + status: 'success' | 'error'; + checkpointSignature?: string; + deploySignature?: string; + error?: string; + } | null>(null); + + const minerRoundId = minerData?.state?.round_id; + const checkpointId = minerData?.state?.checkpoint_id; + const currentRoundId = currentRound?.id?.round_id; + + const hasUncheckpointedRound = + minerRoundId != null && + (checkpointId == null || checkpointId < minerRoundId); + + const canCheckpointNow = + hasUncheckpointedRound && + currentRoundId != null && + minerRoundId < currentRoundId; + + const waitingForRoundToEnd = + hasUncheckpointedRound && + currentRoundId != null && + minerRoundId === currentRoundId; + + const parsedAmountLamports = parseSolToLamports(amount); + const isAmountValid = parsedAmountLamports !== null; + + const buildWalletAdapter = () => ({ + publicKey: wallet.publicKey!.toBase58(), + signAndSend: async (transaction: any) => { + const tx = new Transaction(); + for (const ix of transaction.instructions) { + tx.add({ + programId: new PublicKey(ix.programId), + keys: ix.keys.map((key: any) => ({ + pubkey: new PublicKey(key.pubkey), + isSigner: key.isSigner, + isWritable: key.isWritable, + })), + data: Buffer.from(ix.data), + }); + } + return await wallet.sendTransaction!(tx, connection); + } + }); + + const runCheckpoint = async (walletAdapter: ReturnType) => { + if (!checkpoint) { + throw new Error('Checkpoint instruction is not ready yet.'); + } + + const oldRound = recentRounds?.find(r => r.id?.round_id === minerRoundId); + if (!oldRound?.id?.round_address) { + throw new Error(`Round ${minerRoundId} not available. Cannot checkpoint.`); + } + + return checkpoint.submit( + {}, + { + wallet: walletAdapter, + accounts: { round: oldRound.id.round_address }, + } + ); + }; + + const handleCheckpointNow = async () => { + if (!wallet.connected || !wallet.publicKey || !wallet.sendTransaction || !canCheckpointNow) { + return; + } + + setIsProcessing(true); + setProcessingStep('checkpoint'); + setResult(null); + + try { + const walletAdapter = buildWalletAdapter(); + const checkpointResult = await runCheckpoint(walletAdapter); + + setResult({ + status: 'success', + checkpointSignature: checkpointResult.signature, + }); + } catch (err: any) { + console.error('Checkpoint failed:', err); + setResult({ status: 'error', error: err?.message || String(err) }); + } finally { + setIsProcessing(false); + setProcessingStep(null); + } + }; + + const handleDeploy = async () => { + if (!wallet.connected || !wallet.publicKey || !wallet.sendTransaction) { + return; + } + + if (!deploy) { + throw new Error('Deploy instruction is not ready yet.'); + } + + if (!currentRound?.id?.round_address || !currentRound?.entropy?.entropy_var_address || selectedSquares.length === 0) { + return; + } + + if (!isAmountValid || parsedAmountLamports === null) { + setResult({ status: 'error', error: 'Enter a valid amount greater than 0 (up to 9 decimals).' }); + return; + } + + setIsProcessing(true); + setProcessingStep(canCheckpointNow ? 'checkpoint' : 'deploy'); + setResult(null); + + try { + const walletAdapter = buildWalletAdapter(); + + let checkpointSig: string | undefined; + + // Call checkpoint first only when the miner round is already completed + if (canCheckpointNow) { + const checkpointResult = await runCheckpoint(walletAdapter); + checkpointSig = checkpointResult.signature; + setProcessingStep('deploy'); + } + + // Then deploy + // Build a bitmask: each selectedSquare is 1-indexed, so square N → bit (N-1). + // The on-chain program treats `squares` as a u32 bitmask where bit i = square i+1. + const squareMask = selectedSquares.reduce((mask, squareId) => mask | (1 << (squareId - 1)), 0); + const deployResult = await deploy.submit( + { + amount: parsedAmountLamports, + squares: squareMask, + }, + { + wallet: walletAdapter, + accounts: { + round: currentRound.id.round_address, + entropyVar: currentRound.entropy.entropy_var_address!, + }, + } + ); + + setResult({ + status: 'success', + checkpointSignature: checkpointSig, + deploySignature: deployResult.signature + }); + onClearSquares(); + + } catch (err: any) { + console.error('Deploy failed:', err); + setResult({ status: 'error', error: err?.message || String(err) }); + } finally { + setIsProcessing(false); + setProcessingStep(null); + } + }; + + return ( +
+

Deploy

+ + {canCheckpointNow && ( +
+ â„šī¸ Round {minerRoundId} is completed and uncheckpointed. It will be checkpointed before deploy. + +
+ )} + + {waitingForRoundToEnd && ( +
+ âŗ You have uncheckpointed positions in active round {minerRoundId}. Checkpoint becomes available after this round ends. +
+ )} + +
+
+ + setAmount(e.target.value)} + disabled={isProcessing} + className="w-full px-4 py-2.5 bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-700 rounded-xl + text-stone-800 dark:text-stone-100 placeholder-stone-400 dark:placeholder-stone-500 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + disabled:opacity-50 disabled:cursor-not-allowed transition-all" + placeholder="0.001" + /> +
+ +
+ Selected: {selectedSquares.length} square{selectedSquares.length !== 1 ? 's' : ''} + {selectedSquares.length > 0 && ( + + ({selectedSquares.slice(0, 5).join(', ')}{selectedSquares.length > 5 ? '...' : ''}) + + )} +
+ + + + {isProcessing && ( +
+ âŗ {processingStep === 'checkpoint' ? 'Checkpointing' : 'Deploying'}... +
+ )} + + {result?.status === 'success' && (result.deploySignature || result.checkpointSignature) && ( +
+
+ ✅ {result.checkpointSignature && result.deploySignature + ? 'Checkpoint + Deploy' + : result.checkpointSignature + ? 'Checkpoint' + : 'Deploy'} successful! +
+ {result.checkpointSignature && ( +
+
Checkpoint:
+ + {result.checkpointSignature.slice(0, 8)}...{result.checkpointSignature.slice(-8)} → + +
+ )} + {result.deploySignature && ( +
+
Deploy:
+ + {result.deploySignature.slice(0, 8)}...{result.deploySignature.slice(-8)} → + +
+ )} + +
+ )} + + {result?.status === 'error' && result.error && ( +
+
❌ Deploy failed
+
{result.error}
+ +
+ )} +
+
+ ); +} diff --git a/examples/ore-react/src/components/OreDashboard.tsx b/examples/ore-react/src/components/OreDashboard.tsx index 769a5f1d..853b9ea4 100644 --- a/examples/ore-react/src/components/OreDashboard.tsx +++ b/examples/ore-react/src/components/OreDashboard.tsx @@ -1,16 +1,61 @@ -import { useHyperstack } from 'hyperstack-react'; +import { useState, useEffect } from 'react'; +import { useHyperstack, type UseMutationResult } from 'hyperstack-react'; import { ORE_STREAM_STACK } from 'hyperstack-stacks/ore'; import { ValidatedOreRoundSchema } from '../schemas/ore-round-validated'; import { BlockGrid } from './BlockGrid'; import { StatsPanel } from './StatsPanel'; import { ConnectionBadge } from './ConnectionBadge'; import { ThemeToggle } from './ThemeToggle'; +import { DeployButton } from './DeployButton'; +import { getStatsWinner } from './winner-utils'; +import { WalletMultiButton } from '@solana/wallet-adapter-react-ui'; +import { useWallet } from '@solana/wallet-adapter-react'; +import type { Miner } from 'hyperstack-stacks/ore'; + +// The miner_snapshot field arrives at runtime as an account wrapper, but the +// package TypeScript type incorrectly types it as `Miner` directly. +type MinerSnapshot = { data: Miner; account_address: string; signature: string; slot: number; timestamp: number }; export function OreDashboard() { - const { views, isConnected } = useHyperstack(ORE_STREAM_STACK); + const wallet = useWallet(); + const { views, isConnected, instructions } = useHyperstack(ORE_STREAM_STACK); + const checkpoint: UseMutationResult | undefined = instructions?.checkpoint?.useMutation(); + const deploy: UseMutationResult | undefined = instructions?.deploy?.useMutation(); const { data: latestRound } = views.OreRound.latest.useOne({ schema: ValidatedOreRoundSchema }); const { data: treasuryData } = views.OreTreasury.list.useOne(); + const { data: minerData } = views.OreMiner.state.use( + { authority: wallet.publicKey?.toBase58() ?? "" }, + { enabled: !!wallet.publicKey } + ); + + const snapshot = (minerData?.miner_snapshot as MinerSnapshot | null | undefined)?.data; + + const currentRoundId = snapshot?.round_id; + const myDeployed: number[] | undefined = + currentRoundId != null && currentRoundId === latestRound?.id?.round_id + ? snapshot?.deployed + : undefined; + + const { data: recentRounds } = views.OreRound.list.use({ take: 10 }); + const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const interval = setInterval(() => setNowUnix(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(interval); + }, []); + const { winnerSquare: statsWinnerSquare, winnerRoundId: statsWinnerRoundId } = + getStatsWinner(latestRound, recentRounds, nowUnix); + + const [selectedSquares, setSelectedSquares] = useState([]); + + const handleSquareClick = (squareId: number) => { + setSelectedSquares(prev => + prev.includes(squareId) + ? prev.filter(id => id !== squareId) + : [...prev, squareId] + ); + }; + return (
@@ -18,19 +63,48 @@ export function OreDashboard() {

Ore Mining

Live ORE rounds powered by Hyperstack

- +
+ + +
-
+
- + (v > 0 ? i : -1)) + .filter((i): i is number => i >= 0) + } + />
-
+
a + b, 0) / 1_000_000_000 + : undefined + } + /> + setSelectedSquares([])} + checkpoint={checkpoint} + deploy={deploy} />
diff --git a/examples/ore-react/src/components/StatsPanel.tsx b/examples/ore-react/src/components/StatsPanel.tsx index 43e9bffa..5753e9a1 100644 --- a/examples/ore-react/src/components/StatsPanel.tsx +++ b/examples/ore-react/src/components/StatsPanel.tsx @@ -6,30 +6,33 @@ interface StatsPanelProps { round: ValidatedOreRound | undefined; treasuryMotherlode: number | null | undefined; isConnected: boolean; + winnerSquare?: number | null; + winnerRoundId?: number | null; + minerDeployedThisRoundSol?: number; } -export function StatsPanel({ round, treasuryMotherlode, isConnected }: StatsPanelProps) { - const [timeRemaining, setTimeRemaining] = useState('00:00'); +export function StatsPanel({ round, treasuryMotherlode, isConnected, winnerSquare, winnerRoundId, minerDeployedThisRoundSol }: StatsPanelProps) { + const [timeDisplay, setTimeDisplay] = useState('–'); useEffect(() => { const expiresAtUnix = round?.state.estimated_expires_at_unix; if (!expiresAtUnix) { - setTimeRemaining('00:00'); + setTimeDisplay('-'); return; } const updateTimer = () => { const now = Math.floor(Date.now() / 1000); - const remaining = Math.max(0, expiresAtUnix - now); + const remaining = expiresAtUnix - now; - if (remaining > 300) { - setTimeRemaining('00:00'); + if (remaining <= 0) { + setTimeDisplay('Round expired'); return; } const minutes = Math.floor(remaining / 60); const seconds = remaining % 60; - setTimeRemaining(`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`); + setTimeDisplay(`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`); }; updateTimer(); @@ -48,7 +51,7 @@ export function StatsPanel({ round, treasuryMotherlode, isConnected }: StatsPane
-
{timeRemaining}
+
{timeDisplay}
Time remaining
@@ -63,13 +66,23 @@ export function StatsPanel({ round, treasuryMotherlode, isConnected }: StatsPane
- 0 + {minerDeployedThisRoundSol != null ? minerDeployedThisRoundSol.toFixed(9).replace(/\.?0+$/, '') : '–'}
You deployed
-
+
+
Current / Last winner square
+
+ {winnerSquare != null ? `#${winnerSquare + 1}` : '—'} +
+
+ {winnerRoundId != null ? `Round ${winnerRoundId}` : 'Winner pending'} +
+
+ +
Round {round?.id.round_id ?? '–'} {round && ( <> diff --git a/examples/ore-react/src/components/index.ts b/examples/ore-react/src/components/index.ts index 91858a2b..9e7778f1 100644 --- a/examples/ore-react/src/components/index.ts +++ b/examples/ore-react/src/components/index.ts @@ -2,4 +2,5 @@ export { OreDashboard } from './OreDashboard'; export { BlockGrid } from './BlockGrid'; export { StatsPanel } from './StatsPanel'; export { ConnectionBadge } from './ConnectionBadge'; +export { DeployButton } from './DeployButton'; export * from './icons'; diff --git a/examples/ore-react/src/components/winner-utils.ts b/examples/ore-react/src/components/winner-utils.ts new file mode 100644 index 00000000..f111b36e --- /dev/null +++ b/examples/ore-react/src/components/winner-utils.ts @@ -0,0 +1,72 @@ +import type { OreRound } from 'hyperstack-stacks/ore'; +import type { ValidatedOreRound } from '../schemas/ore-round-validated'; + +export type WinnerInfo = { + winnerSquare: number | null; + winnerRoundId: number | null; +}; + +export function getCurrentRoundWinner(round: ValidatedOreRound | undefined, nowUnix: number): WinnerInfo { + const roundId = round?.id?.round_id; + const finished = + typeof round?.state?.estimated_expires_at_unix === 'number' && + round.state.estimated_expires_at_unix <= nowUnix; + + if (!finished || typeof round?.results?.winning_square !== 'number') { + return { winnerSquare: null, winnerRoundId: null }; + } + + return { + winnerSquare: round.results.winning_square, + winnerRoundId: roundId ?? null, + }; +} + +export function getLastCompletedWinner(recentRounds: OreRound[] | undefined, currentRoundId: number | null): WinnerInfo { + const bestRound = recentRounds?.reduce((best, round) => { + const roundId = round?.id?.round_id; + const winner = round?.results?.winning_square; + + if (typeof roundId !== 'number' || typeof winner !== 'number') { + return best; + } + + if (currentRoundId != null && roundId >= currentRoundId) { + return best; + } + + if (!best) { + return round; + } + + const bestRoundId = best.id?.round_id; + if (typeof bestRoundId !== 'number' || roundId > bestRoundId) { + return round; + } + + return best; + }, undefined); + + return { + winnerSquare: + typeof bestRound?.results?.winning_square === 'number' + ? bestRound.results.winning_square + : null, + winnerRoundId: bestRound?.id?.round_id ?? null, + }; +} + +export function getStatsWinner( + currentRound: ValidatedOreRound | undefined, + recentRounds: OreRound[] | undefined, + nowUnix: number +): WinnerInfo { + const currentRoundId = currentRound?.id?.round_id ?? null; + const currentWinner = getCurrentRoundWinner(currentRound, nowUnix); + + if (currentWinner.winnerSquare != null) { + return currentWinner; + } + + return getLastCompletedWinner(recentRounds, currentRoundId); +} diff --git a/examples/ore-react/vite.config.ts b/examples/ore-react/vite.config.ts index 9ffcc675..956cd048 100644 --- a/examples/ore-react/vite.config.ts +++ b/examples/ore-react/vite.config.ts @@ -1,6 +1,20 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { nodePolyfills } from 'vite-plugin-node-polyfills' export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + nodePolyfills({ + include: ['buffer', 'process'], + globals: { + Buffer: true, + process: true, + }, + }), + ], + optimizeDeps: { + // exclude local hyperstack packages from pre-bundling + exclude: ['hyperstack-typescript', 'hyperstack-react', 'hyperstack-stacks'], + }, }) diff --git a/stacks/sdk/typescript/src/ore/index.ts b/stacks/sdk/typescript/src/ore/index.ts index 46975eca..09ad110c 100644 --- a/stacks/sdk/typescript/src/ore/index.ts +++ b/stacks/sdk/typescript/src/ore/index.ts @@ -1,4 +1,10 @@ import { z } from 'zod'; +import type { + InstructionHandler, + AccountMeta, + ArgSchema +} from 'hyperstack-typescript'; +import { serializeInstructionData } from 'hyperstack-typescript'; export interface OreRoundEntropy { entropy_end_at?: number | null; @@ -357,6 +363,170 @@ export const OreMinerCompletedSchema = z.object({ automation_snapshot: AutomationSchema, }); +// ============================================================================ +// Instruction Handlers +// ============================================================================ + +const ORE_PROGRAM_ID = "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv"; +const ENTROPY_PROGRAM_ID = "3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X"; +const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111"; +const DEPLOY_DISCRIMINATOR = new Uint8Array([6]); +const CHECKPOINT_DISCRIMINATOR = new Uint8Array([2]); + +/* +* Deploy Instruction +*/ +const deployAccounts: AccountMeta[] = [ + { name: "signer", category: "signer", isSigner: true, isWritable: true }, + { name: "authority", category: "signer", isSigner: false, isWritable: true }, + { + name: "automation", + category: "pda", + pdaConfig: { + programId: ORE_PROGRAM_ID, + seeds: [ + { type: "literal", value: "automation" }, + { type: "accountRef", accountName: "authority" }, + ], + }, + isSigner: false, + isWritable: true, + }, + { + name: "board", + category: "pda", + pdaConfig: { + programId: ORE_PROGRAM_ID, + seeds: [{ type: "literal", value: "board" }], + }, + isSigner: false, + isWritable: true, + }, + { + name: "config", + category: "pda", + pdaConfig: { + programId: ORE_PROGRAM_ID, + seeds: [{ type: "literal", value: "config" }], + }, + isSigner: false, + isWritable: true, + }, + { + name: "miner", + category: "pda", + pdaConfig: { + programId: ORE_PROGRAM_ID, + seeds: [ + { type: "literal", value: "miner" }, + { type: "accountRef", accountName: "authority" }, + ], + }, + isSigner: false, + isWritable: true, + }, + { name: "round", category: "userProvided", isSigner: false, isWritable: true }, + { name: "systemProgram", category: "known", knownAddress: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false }, + { name: "oreProgram", category: "known", knownAddress: ORE_PROGRAM_ID, isSigner: false, isWritable: false }, + { name: "entropyVar", category: "userProvided", isSigner: false, isWritable: true }, + { name: "entropyProgram", category: "known", knownAddress: ENTROPY_PROGRAM_ID, isSigner: false, isWritable: false }, +]; + +const deployArgsSchema: ArgSchema[] = [ + { name: "amount", type: "u64" }, + { name: "squares", type: "u32" }, +]; + +const deployInstruction: InstructionHandler = { + programId: ORE_PROGRAM_ID, + accounts: deployAccounts, + errors: [ + { code: 0, name: "AmountTooSmall", msg: "Amount too small" }, + { code: 1, name: "NotAuthorized", msg: "Not authorized" }, + ], + build: (args, resolvedAccounts) => { + if (typeof args.amount !== 'bigint' && typeof args.amount !== 'number') { + throw new Error('amount must be a number or bigint'); + } + if (typeof args.squares !== 'number') { + throw new Error('squares must be a number'); + } + + const buffer = serializeInstructionData(DEPLOY_DISCRIMINATOR, args, deployArgsSchema); + const data = new Uint8Array(buffer); + + const keys = deployAccounts.map(meta => ({ + pubkey: resolvedAccounts[meta.name]!, + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); + + return { programId: ORE_PROGRAM_ID, keys, data }; + }, +}; + +/* +* Checkpoint Instruction +*/ +const checkpointAccounts: AccountMeta[] = [ + { name: "signer", category: "signer", isSigner: true, isWritable: true }, + { + name: "board", + category: "pda", + pdaConfig: { + programId: ORE_PROGRAM_ID, + seeds: [{ type: "literal", value: "board" }], + }, + isSigner: false, + isWritable: false, + }, + { + name: "miner", + category: "pda", + pdaConfig: { + programId: ORE_PROGRAM_ID, + seeds: [ + { type: "literal", value: "miner" }, + { type: "accountRef", accountName: "signer" }, + ], + }, + isSigner: false, + isWritable: true, + }, + { name: "round", category: "userProvided", isSigner: false, isWritable: true }, + { + name: "treasury", + category: "pda", + pdaConfig: { + programId: ORE_PROGRAM_ID, + seeds: [{ type: "literal", value: "treasury" }], + }, + isSigner: false, + isWritable: true, + }, + { name: "systemProgram", category: "known", knownAddress: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false }, +]; + +const checkpointInstruction: InstructionHandler = { + programId: ORE_PROGRAM_ID, + accounts: checkpointAccounts, + errors: [ + { code: 0, name: "AmountTooSmall", msg: "Amount too small" }, + { code: 1, name: "NotAuthorized", msg: "Not authorized" }, + ], + build: (args, resolvedAccounts) => { + const data = CHECKPOINT_DISCRIMINATOR; + + const keys = checkpointAccounts.map(meta => ({ + pubkey: resolvedAccounts[meta.name]!, + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })); + + return { programId: ORE_PROGRAM_ID, keys, data }; + }, +}; + // ============================================================================ // View Definition Types (framework-agnostic) // ============================================================================ @@ -402,6 +572,10 @@ export const ORE_STREAM_STACK = { list: listView('OreMiner/list'), }, }, + instructions: { + checkpoint: checkpointInstruction, + deploy: deployInstruction, + } as const, schemas: { Automation: AutomationSchema, Miner: MinerSchema, diff --git a/typescript/core/package-lock.json b/typescript/core/package-lock.json index fdfc723f..50590d5e 100644 --- a/typescript/core/package-lock.json +++ b/typescript/core/package-lock.json @@ -9,6 +9,8 @@ "version": "0.5.2", "license": "MIT", "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "pako": "^2.1.0", "zod": "^3.24.1" }, @@ -616,6 +618,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/typescript/core/package.json b/typescript/core/package.json index 97bcd1d6..238e7e02 100644 --- a/typescript/core/package.json +++ b/typescript/core/package.json @@ -47,13 +47,15 @@ "node": ">=16.0.0" }, "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "pako": "^2.1.0", "zod": "^3.24.1" }, "devDependencies": { - "@types/pako": "^2.0.3", "@rollup/plugin-typescript": "^11.0.0", "@types/node": "^20.0.0", + "@types/pako": "^2.0.3", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.0.0", diff --git a/typescript/core/src/instructions/pda.ts b/typescript/core/src/instructions/pda.ts index ff4f81d9..c947f3a1 100644 --- a/typescript/core/src/instructions/pda.ts +++ b/typescript/core/src/instructions/pda.ts @@ -4,6 +4,9 @@ * Implements Solana's PDA derivation algorithm without depending on @solana/web3.js. */ +import { sha256 } from '@noble/hashes/sha2.js'; +import { ed25519 } from '@noble/curves/ed25519.js'; + // Base58 alphabet (Bitcoin/Solana style) const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; @@ -77,12 +80,10 @@ export function encodeBase58(bytes: Uint8Array): string { } /** - * SHA-256 hash function (synchronous, Node.js). + * SHA-256 hash function (synchronous). */ function sha256Sync(data: Uint8Array): Uint8Array { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { createHash } = require('crypto'); - return new Uint8Array(createHash('sha256').update(Buffer.from(data)).digest()); + return sha256(data); } /** @@ -100,18 +101,17 @@ async function sha256Async(data: Uint8Array): Promise { /** * Check if a point is on the ed25519 curve. - * A valid PDA must be OFF the curve. - * - * This is a simplified implementation. - * In practice, most PDAs are valid on first try with bump=255. + * A valid PDA must be OFF the curve to ensure it has no corresponding private key. + * Uses @noble/curves for browser-compatible ed25519 validation. */ -function isOnCurve(_publicKey: Uint8Array): boolean { - // Simplified heuristic: actual curve check requires ed25519 math - // For Solana PDAs, we try bumps from 255 down - // The first bump (255) almost always produces a valid off-curve point - // We return false here to accept the first result - // In production with @solana/web3.js, use PublicKey.isOnCurve() - return false; +function isOnCurve(publicKey: Uint8Array): boolean { + try { + // Try to decode as an ed25519 point - if successful, it's on the curve + ed25519.Point.fromBytes(publicKey); + return true; // Point is on curve - invalid for PDA + } catch { + return false; // Point is off curve - valid for PDA + } } /** @@ -200,7 +200,8 @@ export async function findProgramAddress( const hash = await sha256Async(buffer); if (!isOnCurve(hash)) { - return [encodeBase58(hash), bump]; + const result = encodeBase58(hash); + return [result, bump]; } } @@ -228,7 +229,8 @@ export function findProgramAddressSync( const hash = sha256Sync(buffer); if (!isOnCurve(hash)) { - return [encodeBase58(hash), bump]; + const result = encodeBase58(hash); + return [result, bump]; } } diff --git a/typescript/react/jest.config.js b/typescript/react/jest.config.js index e11d9d68..8ecbf332 100644 --- a/typescript/react/jest.config.js +++ b/typescript/react/jest.config.js @@ -12,6 +12,16 @@ export default { useESM: true, }, ], + '^.+\\.js$': [ + 'ts-jest', + { + useESM: true, + }, + ], }, + // Allow Jest to transform ESM-only packages that can't run in CommonJS + transformIgnorePatterns: [ + '/node_modules/(?!(@noble/hashes|@noble/curves)/)', + ], testMatch: ['**/*.test.ts'], }; diff --git a/typescript/react/src/stack.ts b/typescript/react/src/stack.ts index 0d1e996b..7a3b95df 100644 --- a/typescript/react/src/stack.ts +++ b/typescript/react/src/stack.ts @@ -159,10 +159,20 @@ export function useHyperstack( useMutation: () => useInstructionMutation(executeFn as InstructionExecutor) }; } + } else if (stack.instructions) { + for (const instructionName of Object.keys(stack.instructions)) { + const placeholderExecutor = () => { + throw new Error(`Cannot execute ${instructionName}: client not connected`); + }; + result[instructionName] = { + execute: placeholderExecutor, + useMutation: () => useInstructionMutation(placeholderExecutor) + }; + } } return result; - }, [client]); + }, [client, stack.instructions]); return { views: views as BuildViewInterface,