Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 63 additions & 27 deletions src/components/pages/game/game.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { usePrivy } from '@privy-io/react-auth';
import { useFundWallet } from '@privy-io/react-auth/solana';
import { useCreateWallet, useFundWallet, useWallets } from '@privy-io/react-auth/solana';
import { useWallet } from '@solana/wallet-adapter-react';
import { useEffect, useRef, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
Expand All @@ -16,6 +16,7 @@
import usePaymentPrices from '@/hooks/api/use-payment-prices';
import useStatus from '@/hooks/api/use-status';
import useMakePrediction from '@/hooks/contracts/write/use-make-prediction';
import usePrivyMakePrediction from '@/hooks/contracts/write/use-privy-make-prediction';
import useSend from '@/hooks/contracts/write/use-send';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { cn, showTxToast } from '@/lib/utils';
Expand Down Expand Up @@ -70,12 +71,19 @@
export const GameSection = () => {
const { publicKey } = useWallet();
const { fundWallet } = useFundWallet();
const { createWallet } = useCreateWallet();
const { wallets, ready: walletsReady } = useWallets();
const { authenticated, login } = usePrivy();
const pendingFund = useRef(false);

const privyWallet = walletsReady
? (wallets.find((w) => 'isPrivyWallet' in w.standardWallet && (w.standardWallet as { isPrivyWallet: boolean }).isPrivyWallet) ?? null)

Check failure on line 80 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `(w)·=>·'isPrivyWallet'·in·w.standardWallet·&&·(w.standardWallet·as·{·isPrivyWallet:·boolean·}).isPrivyWallet` with `⏎········(w)·=>·'isPrivyWallet'·in·w.standardWallet·&&·(w.standardWallet·as·{·isPrivyWallet:·boolean·}).isPrivyWallet,⏎······`
: null;

const isMd = useBreakpoint('md');
const { setIsOpen } = useWalletModalStore();
const { mutateAsync: transfer, isPending } = useMakePrediction();
const { mutateAsync: privyTransfer, isPending: isPrivyPending } = usePrivyMakePrediction();
const { mutateAsync: transferCurrency, isPending: isSolPending, isSuccess: isTipSuccess } = useSend();
const { data: status } = useStatus();
const { data: paymentPrices } = usePaymentPrices();
Expand Down Expand Up @@ -214,6 +222,27 @@
};
}, [showTip, dontReload]);

const onPrivySubmit: SubmitHandler<TarotRequestSchemaType> = async (data, e) => {
e?.preventDefault();
if (isDemoPending) return;
if (predictionResponseTimer.current) clearTimeout(predictionResponseTimer.current);
setDemoReading(null);
setDisplayedTarots(null);
setShowTip(false);
setRetry(false);
const trimmedQuestion = data.question.trim();
const amount = paymentPrices?.[currencyName] ?? currencies[currencyName].defaultPrice;
const result = await privyTransfer({ question: trimmedQuestion, tokenName: currencyName, amount, onStep: setLoadingStep });

Check failure on line 235 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `·question:·trimmedQuestion,·tokenName:·currencyName,·amount,·onStep:·setLoadingStep` with `⏎······question:·trimmedQuestion,⏎······tokenName:·currencyName,⏎······amount,⏎······onStep:·setLoadingStep,⏎···`
if (!result) return;
setDisplayedTarots(result.tarots);
predictionResponseTimer.current = setTimeout(() => {
const formatted = result.answer.replaceAll('*', '');
setValue('question', formatReadingResponse({ answer: formatted, question: trimmedQuestion }));
setShowTip(true);
setRetry(true);
}, 3200);
};

const onSubmit: SubmitHandler<TarotRequestSchemaType> = async (data, e) => {
e?.preventDefault();

Expand Down Expand Up @@ -272,8 +301,8 @@
setLoadingStep('shuffling');

stepTimers.current.push(
setTimeout(() => { setLoadingStep('drawing'); }, 1600),

Check failure on line 304 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `·setLoadingStep('drawing');` with `⏎········setLoadingStep('drawing');⏎·····`
setTimeout(() => { setLoadingStep('consulting'); }, 3200),

Check failure on line 305 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `·setLoadingStep('consulting');` with `⏎········setLoadingStep('consulting');⏎·····`
);

demoTimer.current = setTimeout(() => {
Expand Down Expand Up @@ -341,35 +370,35 @@
};

useEffect(() => {
if (!pendingFund.current || !authenticated || !publicKey) {
return;
}
if (!authenticated || !walletsReady || privyWallet) return;
createWallet().catch(() => {});

Check failure on line 374 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Unexpected empty arrow function
}, [authenticated, walletsReady, privyWallet, createWallet]);

useEffect(() => {
if (!pendingFund.current || !authenticated || !walletsReady) return;
const target = privyWallet?.address ?? publicKey?.toBase58();
if (!target) return;
pendingFund.current = false;
fundWallet({ address: publicKey.toBase58(), options: { defaultFundingMethod: 'card' } }).catch((error: unknown) => {
fundWallet({ address: target, options: { defaultFundingMethod: 'card', card: { preferredProvider: 'moonpay' } } }).catch((error: unknown) => {

Check failure on line 382 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `·address:·target,·options:·{·defaultFundingMethod:·'card',·card:·{·preferredProvider:·'moonpay'·}·}` with `⏎······address:·target,⏎······options:·{·defaultFundingMethod:·'card',·card:·{·preferredProvider:·'moonpay'·}·},⏎···`
console.error('Fund wallet error:', error);
toast.error('Could not open top-up modal. Please try again.');
});
}, [authenticated, publicKey, fundWallet]);
}, [authenticated, walletsReady, privyWallet, publicKey, fundWallet]);

const handleTopUpWithCard = async () => {
if (!publicKey) {
toast.error('Connect a wallet first');
return;
}

if (!authenticated) {
pendingFund.current = true;
login();
return;
}

const target = privyWallet?.address ?? publicKey?.toBase58();
if (!target) {
pendingFund.current = true;
return;
}
try {
await fundWallet({
address: publicKey.toBase58(),
options: { defaultFundingMethod: 'card' }
});
} catch (error) {
await fundWallet({ address: target, options: { defaultFundingMethod: 'card', card: { preferredProvider: 'moonpay' } } });

Check failure on line 400 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `·address:·target,·options:·{·defaultFundingMethod:·'card',·card:·{·preferredProvider:·'moonpay'·}·}` with `⏎········address:·target,⏎········options:·{·defaultFundingMethod:·'card',·card:·{·preferredProvider:·'moonpay'·}·},⏎·····`
} catch (error: unknown) {
console.error('Fund wallet error:', error);
toast.error('Could not open top-up modal. Please try again.');
}
Expand All @@ -388,7 +417,7 @@
{activeTarots.map((e, idx) => (
<img
key={e.id}
className={cn(

Check failure on line 420 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `⏎····················'mx-auto·rounded-[8px]·max-md:h-[485px]·max-md:w-[280px]',⏎····················e.reverted·&&·'rotate-180',⏎··················` with `'mx-auto·rounded-[8px]·max-md:h-[485px]·max-md:w-[280px]',·e.reverted·&&·'rotate-180'`
'mx-auto rounded-[8px] max-md:h-[485px] max-md:w-[280px]',
e.reverted && 'rotate-180',
)}
Expand Down Expand Up @@ -418,7 +447,7 @@
</div>

{loadingStep && (
<p key={loadingStep} className="animate-in fade-in duration-500 text-center font-inknut text-[18px] italic text-[#621421]">

Check failure on line 450 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `·key={loadingStep}·className="animate-in·fade-in·duration-500·text-center·font-inknut·text-[18px]·italic·text-[#621421]"` with `⏎··········key={loadingStep}⏎··········className="animate-in·fade-in·text-center·font-inknut·text-[18px]·italic·text-[#621421]·duration-500"⏎········`
{STEP_MESSAGES[loadingStep]}
</p>
)}
Expand Down Expand Up @@ -497,27 +526,34 @@
variant="outline"
onClick={
isRetry
? () => {
setDontReload(true);
setTimeout(() => {
window.location.reload();
}, 10);
}
? () => { setDontReload(true); setTimeout(() => { window.location.reload(); }, 10); }

Check failure on line 529 in src/components/pages/game/game.tsx

View workflow job for this annotation

GitHub Actions / main

Replace `·setDontReload(true);·setTimeout(()·=>·{·window.location.reload();·},·10);` with `⏎······················setDontReload(true);⏎······················setTimeout(()·=>·{⏎························window.location.reload();⏎······················},·10);⏎···················`
: handleSubmit(onSubmit)
}
disabled={isPending || isDemoPending || status?.isShutDown || !paymentPrices}
disabled={isPending || isPrivyPending || isDemoPending || status?.isShutDown || !paymentPrices}
className="h-full w-full bg-[#9DA990] text-[22px]"
>
{isRetry ? 'Make a new Forecast' : 'Make a Forecast'}
</Button>
</BaseTooltip>
) : privyWallet ? (
<Button
size="responsive"
variant="outline"
onClick={
isRetry
? () => { setDontReload(true); setTimeout(() => { window.location.reload(); }, 10); }
: handleSubmit(onPrivySubmit)
}
disabled={isPrivyPending || isPending || isDemoPending}
className="h-full w-full bg-[#9DA990] text-[22px]"
>
{isRetry ? 'Make a new Forecast' : 'Make a Forecast'}
</Button>
) : (
<Button
size="responsive"
variant="outline"
onClick={() => {
setIsOpen(true);
}}
onClick={() => { setIsOpen(true); }}
className="bg-[#9DA990] text-[22px]"
>
Connect Wallet
Expand Down
100 changes: 100 additions & 0 deletions src/hooks/contracts/write/use-privy-make-prediction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useFundWallet, useWallets } from '@privy-io/react-auth/solana';

import { currencies, Currencies } from '@/constants/addresses';
import { post } from '@/hooks/api/utils';
import { authClient } from '@/lib/fetcher';
import { getRandomTarotCards } from '@/lib/utils';
import { Status, useStatusModalStore } from '@/store/status-modal';
import usePrivySend from './use-privy-send';

let toastId: string | number | null = null;

const notify = () => {
toastId = toast('Connecting with the Oracle...', {
autoClose: false,
closeOnClick: false,
draggable: false,
isLoading: true,
type: 'default',
});
};

const isPrivyWallet = (w: { standardWallet: object }) =>
'isPrivyWallet' in w.standardWallet && (w.standardWallet as { isPrivyWallet: boolean }).isPrivyWallet;

type PrivyMakePrediction = {
question: string;
tokenName: Currencies;
amount: number;
onStep?: (step: string) => void;
};

const usePrivyMakePrediction = () => {
const { wallets } = useWallets();
const { mutateAsync: sendPrivy } = usePrivySend();
const { fundWallet } = useFundWallet();
const { setStatus } = useStatusModalStore();

return useMutation({
async mutationFn({ question, tokenName, amount, onStep }: PrivyMakePrediction) {
const privyWallet = wallets.find(isPrivyWallet);
if (!privyWallet) throw new Error('No Privy wallet found');

notify();
onStep?.('shuffling');

let txHash: string | undefined;

try {
txHash = await sendPrivy({ amount, tokenName, wallet: privyWallet });
} catch (error) {
if (error instanceof Error && error.message === 'Insufficient funds') {
await fundWallet({ address: privyWallet.address, options: { defaultFundingMethod: 'card', card: { preferredProvider: 'moonpay' } } });
onStep?.('shuffling');
txHash = await sendPrivy({ amount, tokenName, wallet: privyWallet });
} else {
throw error;
}
}

if (!txHash) return;

onStep?.('drawing');
const tarots = getRandomTarotCards(txHash + privyWallet.address);

onStep?.('consulting');
const client = authClient();
const result = await post<{ response: string }>(client, 'tarot/generate-response', {
tarots,
hash: txHash,
question,
address: currencies[tokenName].address.toString(),
});

if (toastId) toast.dismiss(toastId);

return { tarots, answer: result?.response ?? '' };
},

onError(error) {
if (toastId) toast.dismiss(toastId);

if (error instanceof Error) {
if (error.message === 'User rejected the request.') {
setStatus(Status.Canceled);
return;
}
if (error.message === 'Insufficient funds') {
setStatus(Status.InsufficientFunds);
return;
}
}

setStatus(Status.Failed);
},
});
};

export default usePrivyMakePrediction;
120 changes: 120 additions & 0 deletions src/hooks/contracts/write/use-privy-send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import bs58 from 'bs58';
import { createTransferInstruction, getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import { ConnectedStandardSolanaWallet, useSignAndSendTransaction } from '@privy-io/react-auth/solana';
import { useMutation } from '@tanstack/react-query';

import { currencies, Currencies, OwnerAddress } from '@/constants/addresses';
import { connection, network } from '@/constants/solana';
import { generateAssociatedTokenAccountInstruction } from '@/lib/utils';

const recipient = new PublicKey(OwnerAddress[network]);
const solanaChain =
network === WalletAdapterNetwork.Mainnet ? ('solana:mainnet' as const) : ('solana:devnet' as const);

type PrivySend = {
amount: number;
tokenName: Currencies;
wallet: ConnectedStandardSolanaWallet;
};

const usePrivySend = () => {
const { signAndSendTransaction } = useSignAndSendTransaction();

return useMutation({
async mutationFn({ amount, tokenName, wallet }: PrivySend) {
const walletPubkey = new PublicKey(wallet.address);
const solBalance = await connection.getBalance(walletPubkey);

if (tokenName === 'wSolMint') {
const rawTx = new Transaction();
rawTx.add(
SystemProgram.transfer({
fromPubkey: walletPubkey,
toPubkey: OwnerAddress[network],
lamports: Math.round(amount * LAMPORTS_PER_SOL),
}),
);

const { blockhash } = await connection.getLatestBlockhash({ commitment: 'finalized' });
rawTx.recentBlockhash = blockhash;
rawTx.feePayer = walletPubkey;

const fee = await rawTx.getEstimatedFee(connection);
if (fee && solBalance < fee + Math.round(amount * LAMPORTS_PER_SOL)) {
throw new Error('Insufficient funds');
}

const { signature } = await signAndSendTransaction({
transaction: rawTx.serialize({ requireAllSignatures: false, verifySignatures: false }),
wallet,
chain: solanaChain,
options: { skipPreflight: true, commitment: 'finalized' },
});

return bs58.default.encode(signature);
}

const { address: mint, decimals } = currencies[tokenName];

const tokenAccounts = await connection.getParsedTokenAccountsByOwner(walletPubkey, {
programId: TOKEN_PROGRAM_ID,
});
const tokenAccount = tokenAccounts.value.find(
(a) => a.account.data.parsed.info.mint === mint.toBase58(),
);
const tokenAmount = tokenAccount
? Number(tokenAccount.account.data.parsed.info.tokenAmount.amount)
: 0;

if (tokenAmount < amount * 10 ** decimals) {
throw new Error('Insufficient funds');
}

const rawTx = new Transaction();
const senderTokenAddress = await getAssociatedTokenAddress(mint, walletPubkey);
const recipientTokenAddress = await getAssociatedTokenAddress(mint, recipient);

const ataInstruction = await generateAssociatedTokenAccountInstruction({
owner: recipient,
payer: walletPubkey,
mint,
});
if (ataInstruction) rawTx.add(ataInstruction);

rawTx.add(
createTransferInstruction(
senderTokenAddress,
recipientTokenAddress,
walletPubkey,
Math.round(amount * 10 ** decimals),
),
);

const { blockhash } = await connection.getLatestBlockhash({ commitment: 'finalized' });
rawTx.recentBlockhash = blockhash;
rawTx.feePayer = walletPubkey;

const fee = await rawTx.getEstimatedFee(connection);
if (fee && solBalance < fee) {
throw new Error('Insufficient funds');
}

const { signature } = await signAndSendTransaction({
transaction: rawTx.serialize({ requireAllSignatures: false, verifySignatures: false }),
wallet,
chain: solanaChain,
options: { skipPreflight: true, commitment: 'finalized' },
});

return bs58.default.encode(signature);
},

onError(error) {
console.trace(error);
},
});
};

export default usePrivySend;
Loading