diff --git a/.github/workflows/joyboy-community.yml b/.github/workflows/joyboy-community.yml index 727e5fe0..14b5bbce 100644 --- a/.github/workflows/joyboy-community.yml +++ b/.github/workflows/joyboy-community.yml @@ -42,6 +42,3 @@ jobs: - name: TypeScript Check run: yarn ts:check - - - name: Build app bundle - run: yarn expo export --platform android diff --git a/JoyboyCommunity/src/constants/tokens.ts b/JoyboyCommunity/src/constants/tokens.ts index e09cbc92..c28e5dd8 100644 --- a/JoyboyCommunity/src/constants/tokens.ts +++ b/JoyboyCommunity/src/constants/tokens.ts @@ -11,8 +11,7 @@ export type MultiChainTokens = Record; export enum TokenSymbol { ETH = 'ETH', - /* STRK = 'STRK', - JBY = 'JBY', */ + STRK = 'STRK', } export const ETH: MultiChainToken = { @@ -34,7 +33,7 @@ export const ETH: MultiChainToken = { }, }; -/* export const STRK: MultiChainToken = { +export const STRK: MultiChainToken = { [constants.StarknetChainId.SN_MAIN]: { name: 'Stark', symbol: TokenSymbol.STRK, @@ -53,29 +52,9 @@ export const ETH: MultiChainToken = { }, }; -export const JBY: MultiChainToken = { - [constants.StarknetChainId.SN_MAIN]: { - name: 'Joyboy', - symbol: TokenSymbol.JBY, - decimals: 18, - address: getChecksumAddress( - '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - ), - }, - [constants.StarknetChainId.SN_SEPOLIA]: { - name: 'Joyboy', - symbol: TokenSymbol.JBY, - decimals: 18, - address: getChecksumAddress( - '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - ), - }, -}; */ - export const TOKENS: MultiChainTokens = { [TokenSymbol.ETH]: ETH, - /* [TokenSymbol.STRK]: STRK, - [TokenSymbol.JBY]: JBY, */ + [TokenSymbol.STRK]: STRK, }; export const TOKEN_ADDRESSES: Record< diff --git a/JoyboyCommunity/src/context/NostrContext.tsx b/JoyboyCommunity/src/context/NostrContext.tsx index 221f6edc..cfb8faf6 100644 --- a/JoyboyCommunity/src/context/NostrContext.tsx +++ b/JoyboyCommunity/src/context/NostrContext.tsx @@ -1,12 +1,11 @@ import NDK, {NDKPrivateKeySigner} from '@nostr-dev-kit/ndk'; -import {createContext, useContext, useMemo} from 'react'; +import {createContext, useContext, useEffect, useState} from 'react'; import {useAuth} from '../store/auth'; import {JOYBOY_RELAYS} from '../utils/relay'; export type NostrContextType = { ndk: NDK; - relays: string[]; }; export const NostrContext = createContext(null); @@ -14,19 +13,24 @@ export const NostrContext = createContext(null); export const NostrProvider: React.FC = ({children}) => { const privateKey = useAuth((state) => state.privateKey); - const relays = JOYBOY_RELAYS; - const ndk = useMemo(() => { - const ndk = new NDK({ - explicitRelayUrls: relays, + const [ndk, setNdk] = useState( + new NDK({ + explicitRelayUrls: JOYBOY_RELAYS, + }), + ); + + useEffect(() => { + const newNdk = new NDK({ + explicitRelayUrls: JOYBOY_RELAYS, signer: privateKey ? new NDKPrivateKeySigner(privateKey) : undefined, }); - ndk.connect(); - - return ndk; - }, [relays, privateKey]); + newNdk.connect().then(() => { + setNdk(newNdk); + }); + }, [privateKey]); - return {children}; + return {children}; }; export const useNostrContext = () => { diff --git a/JoyboyCommunity/src/hooks/nostr/useContacts.ts b/JoyboyCommunity/src/hooks/nostr/useContacts.ts index 4c0e47cd..edd8aa29 100644 --- a/JoyboyCommunity/src/hooks/nostr/useContacts.ts +++ b/JoyboyCommunity/src/hooks/nostr/useContacts.ts @@ -12,7 +12,7 @@ export const useContacts = (options?: UseContactsOptions) => { const {ndk} = useNostrContext(); return useQuery({ - queryKey: ['contacts', options?.authors, options?.search], + queryKey: ['contacts', ndk, options?.authors, options?.search], queryFn: async () => { const contacts = await ndk.fetchEvent({ kinds: [NDKKind.Contacts], diff --git a/JoyboyCommunity/src/hooks/nostr/useEditContacts.ts b/JoyboyCommunity/src/hooks/nostr/useEditContacts.ts index 24f4ec13..03418572 100644 --- a/JoyboyCommunity/src/hooks/nostr/useEditContacts.ts +++ b/JoyboyCommunity/src/hooks/nostr/useEditContacts.ts @@ -9,7 +9,7 @@ export const useEditContacts = () => { const {publicKey} = useAuth(); return useMutation({ - mutationKey: ['editContacts'], + mutationKey: ['editContacts', ndk], mutationFn: async (data: {pubkey: string; type: 'add' | 'remove'}) => { let contacts = await ndk.fetchEvent({ kinds: [NDKKind.Contacts], diff --git a/JoyboyCommunity/src/hooks/nostr/useEditProfile.ts b/JoyboyCommunity/src/hooks/nostr/useEditProfile.ts index b6f04ee8..2fdb9110 100644 --- a/JoyboyCommunity/src/hooks/nostr/useEditProfile.ts +++ b/JoyboyCommunity/src/hooks/nostr/useEditProfile.ts @@ -9,7 +9,7 @@ export const useEditProfile = () => { const {publicKey} = useAuth(); return useMutation({ - mutationKey: ['editProfile'], + mutationKey: ['editProfile', ndk], mutationFn: async (data: NDKUserProfile) => { try { const user = ndk.getUser({pubkey: publicKey}); diff --git a/JoyboyCommunity/src/hooks/nostr/useNote.ts b/JoyboyCommunity/src/hooks/nostr/useNote.ts index 0261d604..b5272761 100644 --- a/JoyboyCommunity/src/hooks/nostr/useNote.ts +++ b/JoyboyCommunity/src/hooks/nostr/useNote.ts @@ -11,7 +11,7 @@ export const useNote = (options: UseNoteOptions) => { const {ndk} = useNostrContext(); return useQuery({ - queryKey: ['note', options.noteId], + queryKey: ['note', ndk, options.noteId], queryFn: async () => { const note = await ndk.fetchEvent({ kinds: [NDKKind.Text], diff --git a/JoyboyCommunity/src/hooks/nostr/useProfile.ts b/JoyboyCommunity/src/hooks/nostr/useProfile.ts index 8bd9849a..3df26734 100644 --- a/JoyboyCommunity/src/hooks/nostr/useProfile.ts +++ b/JoyboyCommunity/src/hooks/nostr/useProfile.ts @@ -10,7 +10,7 @@ export const useProfile = (options: UseProfileOptions) => { const {ndk} = useNostrContext(); return useQuery({ - queryKey: ['profile', options.publicKey], + queryKey: ['profile', ndk, options.publicKey], queryFn: async () => { const user = ndk.getUser({pubkey: options.publicKey}); diff --git a/JoyboyCommunity/src/hooks/nostr/useReact.ts b/JoyboyCommunity/src/hooks/nostr/useReact.ts index e86cf080..2a21214b 100644 --- a/JoyboyCommunity/src/hooks/nostr/useReact.ts +++ b/JoyboyCommunity/src/hooks/nostr/useReact.ts @@ -7,7 +7,7 @@ export const useReact = () => { const {ndk} = useNostrContext(); return useMutation({ - mutationKey: ['react'], + mutationKey: ['react', ndk], mutationFn: async (data: {event: NDKEvent; type: 'like' | 'dislike'}) => { const event = new NDKEvent(ndk); event.kind = NDKKind.Reaction; diff --git a/JoyboyCommunity/src/hooks/nostr/useReactions.ts b/JoyboyCommunity/src/hooks/nostr/useReactions.ts index 29aff5c2..c5d1c5ba 100644 --- a/JoyboyCommunity/src/hooks/nostr/useReactions.ts +++ b/JoyboyCommunity/src/hooks/nostr/useReactions.ts @@ -13,7 +13,7 @@ export const useReactions = (options?: UseReactionsOptions) => { const {ndk} = useNostrContext(); return useQuery({ - queryKey: ['reactions', options?.noteId, options?.authors, options?.search], + queryKey: ['reactions', ndk, options?.noteId, options?.authors, options?.search], queryFn: async () => { const notes = await ndk.fetchEvents({ kinds: [NDKKind.Reaction], diff --git a/JoyboyCommunity/src/hooks/nostr/useReplyNotes.ts b/JoyboyCommunity/src/hooks/nostr/useReplyNotes.ts index 8538a96f..a3195fa3 100644 --- a/JoyboyCommunity/src/hooks/nostr/useReplyNotes.ts +++ b/JoyboyCommunity/src/hooks/nostr/useReplyNotes.ts @@ -14,7 +14,7 @@ export const useReplyNotes = (options?: UseReplyNotesOptions) => { return useInfiniteQuery({ initialPageParam: 0, - queryKey: ['replyNotes', options?.noteId, options?.authors, options?.search], + queryKey: ['replyNotes', ndk, options?.noteId, options?.authors, options?.search], getNextPageParam: (lastPage: any, allPages, lastPageParam) => { if (!lastPage?.length) return undefined; diff --git a/JoyboyCommunity/src/hooks/nostr/useReposts.ts b/JoyboyCommunity/src/hooks/nostr/useReposts.ts index 9b912f2c..b11b2c0e 100644 --- a/JoyboyCommunity/src/hooks/nostr/useReposts.ts +++ b/JoyboyCommunity/src/hooks/nostr/useReposts.ts @@ -13,7 +13,7 @@ export const useReposts = (options?: UseRepostsOptions) => { return useInfiniteQuery({ initialPageParam: 0, - queryKey: ['reposts', options?.authors, options?.search], + queryKey: ['reposts', ndk, options?.authors, options?.search], getNextPageParam: (lastPage: any, allPages, lastPageParam) => { if (!lastPage?.length) return undefined; diff --git a/JoyboyCommunity/src/hooks/nostr/useRootNotes.ts b/JoyboyCommunity/src/hooks/nostr/useRootNotes.ts index b88f29c7..1e687b53 100644 --- a/JoyboyCommunity/src/hooks/nostr/useRootNotes.ts +++ b/JoyboyCommunity/src/hooks/nostr/useRootNotes.ts @@ -13,7 +13,7 @@ export const useRootNotes = (options?: UseRootNotesOptions) => { return useInfiniteQuery({ initialPageParam: 0, - queryKey: ['rootNotes', options?.authors, options?.search], + queryKey: ['rootNotes', ndk, options?.authors, options?.search], getNextPageParam: (lastPage: any, allPages, lastPageParam) => { if (!lastPage?.length) return undefined; diff --git a/JoyboyCommunity/src/hooks/nostr/useSendNote.ts b/JoyboyCommunity/src/hooks/nostr/useSendNote.ts index 59cf8daa..49cd18f6 100644 --- a/JoyboyCommunity/src/hooks/nostr/useSendNote.ts +++ b/JoyboyCommunity/src/hooks/nostr/useSendNote.ts @@ -7,7 +7,7 @@ export const useSendNote = () => { const {ndk} = useNostrContext(); return useMutation({ - mutationKey: ['sendNote'], + mutationKey: ['sendNote', ndk], mutationFn: async (data: {content: string; tags?: string[][]}) => { const event = new NDKEvent(ndk); event.kind = NDKKind.Text; diff --git a/JoyboyCommunity/src/hooks/useWindowDimensions.ts b/JoyboyCommunity/src/hooks/useWindowDimensions.ts index dc72a67f..b89e436c 100644 --- a/JoyboyCommunity/src/hooks/useWindowDimensions.ts +++ b/JoyboyCommunity/src/hooks/useWindowDimensions.ts @@ -5,7 +5,7 @@ import {WEB_MAX_WIDTH} from '../constants/misc'; export const useWindowDimensions = () => { const dimensions = useRNWindowDimensions(); - if (Platform.OS === 'web') dimensions.width = WEB_MAX_WIDTH; + if (Platform.OS === 'web') dimensions.width = Math.min(dimensions.width, WEB_MAX_WIDTH); return dimensions; }; diff --git a/JoyboyCommunity/src/modules/Post/index.tsx b/JoyboyCommunity/src/modules/Post/index.tsx index 2572b8e2..56ac98bf 100644 --- a/JoyboyCommunity/src/modules/Post/index.tsx +++ b/JoyboyCommunity/src/modules/Post/index.tsx @@ -18,6 +18,7 @@ import {useProfile, useReact, useReactions, useReplyNotes, useStyles, useTheme} import {useTipModal} from '../../hooks/modals'; import {useAuth} from '../../store/auth'; import {MainStackNavigationProps} from '../../types'; +import {shortenPubkey} from '../../utils/helpers'; import {getElapsedTimeStringFull} from '../../utils/timestamp'; import stylesheet from './styles'; @@ -123,14 +124,23 @@ export const Post: React.FC = ({asComment, event}) => { numberOfLines={1} ellipsizeMode="middle" > - {profile?.displayName ?? profile?.name ?? event?.pubkey} + {profile?.displayName ?? + profile?.name ?? + profile?.nip05 ?? + shortenPubkey(event?.pubkey)} - {profile?.nip05 && ( + {(profile?.nip05 || profile?.name) && ( <> - - @{profile?.nip05} + + @{profile?.nip05 ?? profile.name} diff --git a/JoyboyCommunity/src/modules/TipModal/index.tsx b/JoyboyCommunity/src/modules/TipModal/index.tsx index 002698ba..fd559111 100644 --- a/JoyboyCommunity/src/modules/TipModal/index.tsx +++ b/JoyboyCommunity/src/modules/TipModal/index.tsx @@ -12,6 +12,7 @@ import {CHAIN_ID} from '../../constants/env'; import {DEFAULT_TIMELOCK, Entrypoint} from '../../constants/misc'; import {TOKENS, TokenSymbol} from '../../constants/tokens'; import {useProfile, useStyles, useWaitConnection} from '../../hooks'; +import {useTransactionModal} from '../../hooks/modals'; import {useDialog} from '../../hooks/modals/useDialog'; import {useTransaction} from '../../hooks/modals/useTransaction'; import {useWalletModal} from '../../hooks/modals/useWalletModal'; @@ -42,6 +43,7 @@ export const TipModal = forwardRef( const account = useAccount(); const walletModal = useWalletModal(); const sendTransaction = useTransaction(); + const {hide: hideTransactionModal} = useTransactionModal(); const waitConnection = useWaitConnection(); const {showDialog, hideDialog} = useDialog(); @@ -91,6 +93,7 @@ export const TipModal = forwardRef( if (receipt?.isSuccess()) { hideTipModal(); + hideTransactionModal(); showSuccess({ amount: Number(amount), symbol: token, diff --git a/JoyboyCommunity/src/modules/TransactionModal/index.tsx b/JoyboyCommunity/src/modules/TransactionModal/index.tsx index 7b378062..6636ebc0 100644 --- a/JoyboyCommunity/src/modules/TransactionModal/index.tsx +++ b/JoyboyCommunity/src/modules/TransactionModal/index.tsx @@ -23,12 +23,28 @@ export const TransactionModal: React.FC = ({ const [status, setStatus] = useState<'confirmation' | 'processing' | 'success' | 'failure'>( 'confirmation', ); + const [trial, setTrial] = useState(0); + + const { + data: transactionReceipt, + error: transactionError, + isLoading, + refetch, + } = useWaitForTransaction({hash: transactionHash}); useEffect(() => { if (transactionHash) setStatus('processing'); }, [transactionHash]); - const {data: transactionReceipt, isLoading} = useWaitForTransaction({hash: transactionHash}); + useEffect(() => { + if (transactionError) { + if (trial < 3) { + refetch().then(() => setTrial((prev) => prev + 1)); + } else { + setStatus('failure'); + } + } + }, [transactionError, refetch, trial]); useEffect(() => { if (transactionReceipt && !isLoading) { diff --git a/JoyboyCommunity/src/screens/Auth/CreateAccount.tsx b/JoyboyCommunity/src/screens/Auth/CreateAccount.tsx index c4cbce79..758fb667 100644 --- a/JoyboyCommunity/src/screens/Auth/CreateAccount.tsx +++ b/JoyboyCommunity/src/screens/Auth/CreateAccount.tsx @@ -1,9 +1,11 @@ +import {NDKPrivateKeySigner} from '@nostr-dev-kit/ndk'; import {canUseBiometricAuthentication} from 'expo-secure-store'; import {useState} from 'react'; import {Platform} from 'react-native'; import {LockIcon} from '../../assets/icons'; import {Button, Input} from '../../components'; +import {useNostrContext} from '../../context/NostrContext'; import {useTheme} from '../../hooks'; import {useDialog, useToast} from '../../hooks/modals'; import {Auth} from '../../modules/Auth'; @@ -17,10 +19,16 @@ export const CreateAccount: React.FC = ({navigatio const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const {ndk} = useNostrContext(); const {showToast} = useToast(); const {showDialog, hideDialog} = useDialog(); const handleCreateAccount = async () => { + if (!username) { + showToast({type: 'error', title: 'Username is required'}); + return; + } + if (!password) { showToast({type: 'error', title: 'Password is required'}); return; @@ -31,6 +39,11 @@ export const CreateAccount: React.FC = ({navigatio await storePrivateKey(privateKey, password); await storePublicKey(publicKey); + ndk.signer = new NDKPrivateKeySigner(privateKey); + const ndkUser = ndk.getUser({pubkey: publicKey}); + ndkUser.profile = {nip05: username}; + await ndkUser.publish(); + const biometySupported = Platform.OS !== 'web' && canUseBiometricAuthentication(); if (biometySupported) { showDialog({ diff --git a/JoyboyCommunity/src/screens/Auth/Login.tsx b/JoyboyCommunity/src/screens/Auth/Login.tsx index b6644d59..81b2041e 100644 --- a/JoyboyCommunity/src/screens/Auth/Login.tsx +++ b/JoyboyCommunity/src/screens/Auth/Login.tsx @@ -66,7 +66,6 @@ export const Login: React.FC = ({navigation}) => { description: 'Creating a new account will delete your current account. Are you sure you want to continue?', buttons: [ - {type: 'default', label: 'Cancel', onPress: hideDialog}, { type: 'primary', label: 'Continue', @@ -75,6 +74,7 @@ export const Login: React.FC = ({navigation}) => { hideDialog(); }, }, + {type: 'default', label: 'Cancel', onPress: hideDialog}, ], }); }; diff --git a/JoyboyCommunity/src/screens/Tips/index.tsx b/JoyboyCommunity/src/screens/Tips/index.tsx index c0f33f73..c9645024 100644 --- a/JoyboyCommunity/src/screens/Tips/index.tsx +++ b/JoyboyCommunity/src/screens/Tips/index.tsx @@ -1,24 +1,28 @@ import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useAccount, useProvider} from '@starknet-react/core'; import {Fraction} from '@uniswap/sdk-core'; -import {FlatList, RefreshControl, View} from 'react-native'; -import {byteArray, cairo, CallData, uint256} from 'starknet'; +import {useState} from 'react'; +import {ActivityIndicator, FlatList, RefreshControl, View} from 'react-native'; +import {byteArray, cairo, CallData, getChecksumAddress, uint256} from 'starknet'; import {Button, Divider, Header, Text} from '../../components'; import {ESCROW_ADDRESSES} from '../../constants/contracts'; import {CHAIN_ID} from '../../constants/env'; import {Entrypoint} from '../../constants/misc'; -import {ETH} from '../../constants/tokens'; +import {ETH, STRK} from '../../constants/tokens'; import {useNostrContext} from '../../context/NostrContext'; -import {useStyles, useTips, useWaitConnection} from '../../hooks'; +import {useStyles, useTheme, useTips, useWaitConnection} from '../../hooks'; import {useClaim, useEstimateClaim} from '../../hooks/api'; import {useToast, useTransaction, useTransactionModal, useWalletModal} from '../../hooks/modals'; import {decimalsScale} from '../../utils/helpers'; import stylesheet from './styles'; export const Tips: React.FC = () => { + const theme = useTheme(); const styles = useStyles(stylesheet); + const [loading, setLoading] = useState(false); + const tips = useTips(); const {ndk} = useNostrContext(); @@ -40,12 +44,31 @@ export const Tips: React.FC = () => { const connectedAccount = await waitConnection(); if (!connectedAccount || !connectedAccount.address) return; + setLoading(depositId); + + const deposit = await provider.callContract({ + contractAddress: ESCROW_ADDRESSES[CHAIN_ID], + entrypoint: Entrypoint.GET_DEPOSIT, + calldata: [depositId], + }); + + if (deposit[0] === '0x0') { + showToast({ + type: 'error', + title: 'This tip is not available anymore', + }); + setLoading(false); + return; + } + + const tokenAddress = getChecksumAddress(deposit[3]); + const getNostrEvent = async (gasAmount: bigint) => { const event = new NDKEvent(ndk); event.kind = NDKKind.Text; event.content = `claim: ${cairo.felt(depositId)},${cairo.felt( connectedAccount.address!, - )},${cairo.felt(ETH[CHAIN_ID].address)},${gasAmount.toString()}`; + )},${cairo.felt(tokenAddress)},${gasAmount.toString()}`; event.tags = []; await event.sign(); @@ -53,19 +76,21 @@ export const Tips: React.FC = () => { }; const feeResult = await estimateClaim.mutateAsync(await getNostrEvent(BigInt(1))); - const fee = BigInt(feeResult.data.fee); + const gasFee = BigInt(feeResult.data.gasFee); + const tokenFee = BigInt(feeResult.data.tokenFee); const [balanceLow, balanceHigh] = await provider.callContract({ - contractAddress: ETH[CHAIN_ID].address, + contractAddress: + tokenAddress === STRK[CHAIN_ID].address ? STRK[CHAIN_ID].address : ETH[CHAIN_ID].address, entrypoint: Entrypoint.BALANCE_OF, calldata: [connectedAccount.address], }); const balance = uint256.uint256ToBN({low: balanceLow, high: balanceHigh}); - if (balance < fee) { + if (balance < gasFee) { // Send the claim through backend - const claimResult = await claim.mutateAsync(await getNostrEvent(fee)); + const claimResult = await claim.mutateAsync(await getNostrEvent(tokenFee)); const txHash = claimResult.data.transaction_hash; showTransactionModal(txHash, async (receipt) => { @@ -80,6 +105,8 @@ export const Tips: React.FC = () => { showToast({type: 'error', title: `Failed to claim the tip. ${description}`}); } + + setLoading(false); }); } else { // Send the claim through the wallet @@ -129,6 +156,8 @@ export const Tips: React.FC = () => { showToast({type: 'error', title: `Failed to claim the tip. ${description}`}); } + + setLoading(false); } }; @@ -168,12 +197,25 @@ export const Tips: React.FC = () => { small variant="primary" onPress={() => onClaimPress(item.depositId)} + left={ + loading === item.depositId ? ( + + ) : undefined + } > Claim )} - ) : null} + ) : ( + + )} diff --git a/JoyboyCommunity/src/screens/Tips/styles.ts b/JoyboyCommunity/src/screens/Tips/styles.ts index 83cc84ec..69fd1f60 100644 --- a/JoyboyCommunity/src/screens/Tips/styles.ts +++ b/JoyboyCommunity/src/screens/Tips/styles.ts @@ -41,10 +41,13 @@ export default ThemedStyleSheet((theme) => ({ sender: { flex: 1, }, + buttonIndicator: { + marginRight: Spacing.xsmall, + }, statusDisabledButton: { backgroundColor: theme.colors.buttonDisabledBackground, }, statusButton: { backgroundColor: theme.colors.buttonBackground, - }, + } })); diff --git a/JoyboyCommunity/src/utils/helpers.ts b/JoyboyCommunity/src/utils/helpers.ts index 305ff3d9..d81d30b2 100644 --- a/JoyboyCommunity/src/utils/helpers.ts +++ b/JoyboyCommunity/src/utils/helpers.ts @@ -40,3 +40,6 @@ export const getArgentAppStoreURL = () => { }; export const decimalsScale = (decimals: number) => `1${Array(decimals).fill('0').join('')}`; + +export const shortenPubkey = (pubkey?: string, length = 6) => + pubkey ? `${pubkey.slice(0, length)}...${pubkey.slice(-length)}` : undefined; diff --git a/website/.env.example b/website/.env.example index 29354e87..314988a7 100644 --- a/website/.env.example +++ b/website/.env.example @@ -1,4 +1,5 @@ ACCOUNT_ADDRESS="0x" ACCOUNT_PRIVATE_KEY="0x" -PROVIDER_URL="https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_7/your_api_key" \ No newline at end of file +PROVIDER_URL="https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_7/your_api_key" +NETWORK_NAME="SN_SEPOLIA" # SN_SEPOLIA, SN_MAIN diff --git a/website/package.json b/website/package.json index f730dffa..53c31729 100644 --- a/website/package.json +++ b/website/package.json @@ -9,9 +9,12 @@ "lint": "next lint" }, "dependencies": { + "@avnu/avnu-sdk": "^2.0.0", + "ethers": "^6.13.1", "framer-motion": "^11.2.4", "next": "^14.2.3", "nostr-tools": "^2.7.0", + "qs": "^6.12.3", "react": "^18.3.1", "react-dom": "^18.3.1", "starknet": "6.9.0", diff --git a/website/src/app/api/deposit/calldata.ts b/website/src/app/api/deposit/calldata.ts index 70d0a00b..6fe920de 100644 --- a/website/src/app/api/deposit/calldata.ts +++ b/website/src/app/api/deposit/calldata.ts @@ -1,8 +1,15 @@ import {verifyEvent} from 'nostr-tools'; -import {byteArray, cairo, CallData, uint256, validateAndParseAddress} from 'starknet'; +import { + byteArray, + cairo, + CallData, + getChecksumAddress, + uint256, + validateAndParseAddress, +} from 'starknet'; import {ESCROW_ADDRESSES} from '@/constants/contracts'; -import {Entrypoint} from '@/constants/misc'; +import {CHAIN_ID, Entrypoint} from '@/constants/misc'; import {provider} from '@/services/provider'; import {ErrorCode} from '@/utils/errors'; import {ClaimSchema} from '@/utils/validation'; @@ -22,8 +29,8 @@ export const getClaimCallData = async (data: (typeof ClaimSchema)['_output']) => } const depositId = cairo.felt(content[0]); - const recipientAddress = `0x${BigInt(content[1]).toString(16)}`; - const tokenAddress = `0x${BigInt(content[2]).toString(16)}`; + const recipientAddress = getChecksumAddress(`0x${BigInt(content[1]).toString(16)}`); + const tokenAddress = getChecksumAddress(`0x${BigInt(content[2]).toString(16)}`); const gasAmount = BigInt(content[3]); try { @@ -34,7 +41,7 @@ export const getClaimCallData = async (data: (typeof ClaimSchema)['_output']) => } const deposit = await provider.callContract({ - contractAddress: ESCROW_ADDRESSES[await provider.getChainId()], + contractAddress: ESCROW_ADDRESSES[CHAIN_ID], entrypoint: Entrypoint.GET_DEPOSIT, calldata: [depositId], }); @@ -74,5 +81,5 @@ export const getClaimCallData = async (data: (typeof ClaimSchema)['_output']) => uint256.bnToUint256(gasAmount), ]); - return {calldata, gasAmount}; + return {calldata, gasAmount, tokenAddress}; }; diff --git a/website/src/app/api/deposit/claim/route.ts b/website/src/app/api/deposit/claim/route.ts index 9bc4005b..2aa31ef0 100644 --- a/website/src/app/api/deposit/claim/route.ts +++ b/website/src/app/api/deposit/claim/route.ts @@ -1,10 +1,10 @@ +import {fetchBuildExecuteTransaction, fetchQuotes} from '@avnu/avnu-sdk'; import {NextRequest, NextResponse} from 'next/server'; import {Calldata} from 'starknet'; -import {ESCROW_ADDRESSES} from '@/constants/contracts'; -import {Entrypoint} from '@/constants/misc'; +import {ESCROW_ADDRESSES, ETH_ADDRESSES, STRK_ADDRESSES} from '@/constants/contracts'; +import {AVNU_URL, CHAIN_ID, Entrypoint} from '@/constants/misc'; import {account} from '@/services/account'; -import {provider} from '@/services/provider'; import {ErrorCode} from '@/utils/errors'; import {HTTPStatus} from '@/utils/http'; import {ClaimSchema} from '@/utils/validation'; @@ -24,10 +24,12 @@ export async function POST(request: NextRequest) { let claimCallData: Calldata; let gasAmount: bigint; + let gasTokenAddress: string; try { const result = await getClaimCallData(body.data); claimCallData = result.calldata; gasAmount = result.gasAmount; + gasTokenAddress = result.tokenAddress; } catch (error) { if (error instanceof Error) { return NextResponse.json({code: error.message}, {status: HTTPStatus.BadRequest}); @@ -37,19 +39,85 @@ export async function POST(request: NextRequest) { } try { - const {transaction_hash} = await account.execute( - [ + if ( + gasTokenAddress === ETH_ADDRESSES[CHAIN_ID] || + gasTokenAddress === STRK_ADDRESSES[CHAIN_ID] + ) { + // ETH | STRK transaction + + const {transaction_hash} = await account.execute( + [ + { + contractAddress: ESCROW_ADDRESSES[CHAIN_ID], + entrypoint: Entrypoint.CLAIM, + calldata: claimCallData, + }, + ], + { + version: gasTokenAddress === ETH_ADDRESSES[CHAIN_ID] ? 1 : 3, + maxFee: gasAmount, + }, + ); + + return NextResponse.json({transaction_hash}, {status: HTTPStatus.OK}); + } else { + // ERC20 transaction + + const result = await account.estimateInvokeFee([ { - contractAddress: ESCROW_ADDRESSES[await provider.getChainId()], + contractAddress: ESCROW_ADDRESSES[CHAIN_ID], entrypoint: Entrypoint.CLAIM, calldata: claimCallData, }, - ], - {maxFee: gasAmount}, - ); + ]); + + const gasFeeQuotes = await fetchQuotes( + { + buyTokenAddress: ETH_ADDRESSES[CHAIN_ID], + sellTokenAddress: gasTokenAddress, + sellAmount: gasAmount, + }, + {baseUrl: AVNU_URL}, + ); + const gasFeeQuote = gasFeeQuotes[0]; + + if (!gasFeeQuote) { + return NextResponse.json({code: ErrorCode.NO_ROUTE_FOUND}, {status: HTTPStatus.BadRequest}); + } + + if (result.overall_fee > gasFeeQuote.buyAmount) { + return NextResponse.json( + {code: ErrorCode.INVALID_GAS_AMOUNT}, + {status: HTTPStatus.BadRequest}, + ); + } + + const {calls: swapCalls} = await fetchBuildExecuteTransaction( + gasFeeQuote.quoteId, + account.address, + undefined, + undefined, + {baseUrl: AVNU_URL}, + ); - return NextResponse.json({transaction_hash}, {status: HTTPStatus.OK}); + const {transaction_hash} = await account.execute( + [ + { + contractAddress: ESCROW_ADDRESSES[CHAIN_ID], + entrypoint: Entrypoint.CLAIM, + calldata: claimCallData, + }, + ...swapCalls, + ], + { + maxFee: gasFeeQuote.buyAmount, + }, + ); + + return NextResponse.json({transaction_hash}, {status: HTTPStatus.OK}); + } } catch (error) { + console.log(error); return NextResponse.json( {code: ErrorCode.TRANSACTION_ERROR, error}, {status: HTTPStatus.InternalServerError}, diff --git a/website/src/app/api/deposit/estimate-claim/route.ts b/website/src/app/api/deposit/estimate-claim/route.ts index 55f682ee..343e5039 100644 --- a/website/src/app/api/deposit/estimate-claim/route.ts +++ b/website/src/app/api/deposit/estimate-claim/route.ts @@ -1,10 +1,10 @@ +import {fetchBuildExecuteTransaction, fetchQuotes} from '@avnu/avnu-sdk'; import {NextRequest, NextResponse} from 'next/server'; import {Calldata} from 'starknet'; -import {ESCROW_ADDRESSES} from '@/constants/contracts'; -import {Entrypoint} from '@/constants/misc'; +import {ESCROW_ADDRESSES, ETH_ADDRESSES, STRK_ADDRESSES} from '@/constants/contracts'; +import {AVNU_URL, CHAIN_ID, Entrypoint} from '@/constants/misc'; import {account} from '@/services/account'; -import {provider} from '@/services/provider'; import {ErrorCode} from '@/utils/errors'; import {HTTPStatus} from '@/utils/http'; import {ClaimSchema} from '@/utils/validation'; @@ -23,9 +23,11 @@ export async function POST(request: NextRequest) { } let claimCallData: Calldata; + let gasTokenAddress: string; try { - const {calldata} = await getClaimCallData(body.data); + const {calldata, tokenAddress} = await getClaimCallData(body.data); claimCallData = calldata; + gasTokenAddress = tokenAddress; } catch (error) { if (error instanceof Error) { return NextResponse.json({code: error.message}, {status: HTTPStatus.BadRequest}); @@ -35,18 +37,92 @@ export async function POST(request: NextRequest) { } try { - const result = await account.estimateInvokeFee([ - { - contractAddress: ESCROW_ADDRESSES[await provider.getChainId()], - entrypoint: Entrypoint.CLAIM, - calldata: claimCallData, - }, - ]); - - // Using 1.1 as a multiplier to ensure the fee is enough - const fee = ((result.overall_fee * BigInt(11)) / BigInt(10)).toString(); - - return NextResponse.json({fee}, {status: HTTPStatus.OK}); + if ( + gasTokenAddress === ETH_ADDRESSES[CHAIN_ID] || + gasTokenAddress === STRK_ADDRESSES[CHAIN_ID] + ) { + // ETH | STRK fee estimation + + const result = await account.estimateInvokeFee( + [ + { + contractAddress: ESCROW_ADDRESSES[CHAIN_ID], + entrypoint: Entrypoint.CLAIM, + calldata: claimCallData, + }, + ], + { + version: gasTokenAddress === ETH_ADDRESSES[CHAIN_ID] ? 1 : 3, + }, + ); + + // Using 1.1 as a multiplier to ensure the fee is enough + const fee = ((result.overall_fee * BigInt(11)) / BigInt(10)).toString(); + + return NextResponse.json({gasFee: fee, tokenFee: fee}, {status: HTTPStatus.OK}); + } else { + // ERC20 fee estimation + + const quotes = await fetchQuotes( + { + sellTokenAddress: ETH_ADDRESSES[CHAIN_ID], + buyTokenAddress: gasTokenAddress, + sellAmount: BigInt(1), + takerAddress: account.address, + }, + {baseUrl: AVNU_URL}, + ); + const quote = quotes[0]; + + if (!quote) { + return NextResponse.json({code: ErrorCode.NO_ROUTE_FOUND}, {status: HTTPStatus.BadRequest}); + } + + const {calls: swapCalls} = await fetchBuildExecuteTransaction( + quote.quoteId, + account.address, + undefined, + undefined, + {baseUrl: AVNU_URL}, + ); + + const result = await account.estimateInvokeFee( + [ + { + contractAddress: ESCROW_ADDRESSES[CHAIN_ID], + entrypoint: Entrypoint.CLAIM, + calldata: claimCallData, + }, + ...swapCalls, + ], + { + version: 1, + }, + ); + + // Using 1.1 as a multiplier to ensure the fee is enough + const ethFee = (result.overall_fee * BigInt(11)) / BigInt(10); + + const feeQuotes = await fetchQuotes( + { + sellTokenAddress: ETH_ADDRESSES[CHAIN_ID], + buyTokenAddress: gasTokenAddress, + sellAmount: ethFee, + takerAddress: account.address, + }, + {baseUrl: AVNU_URL}, + ); + const feeQuote = feeQuotes[0]; + + if (!feeQuote) { + return NextResponse.json({code: ErrorCode.NO_ROUTE_FOUND}, {status: HTTPStatus.BadRequest}); + } + + return NextResponse.json( + {gasFee: ethFee, tokenFee: feeQuote.buyAmount}, + {status: HTTPStatus.OK}, + ); + } } catch (error) { return NextResponse.json( {code: ErrorCode.ESTIMATION_ERROR, error}, diff --git a/website/src/constants/contracts.ts b/website/src/constants/contracts.ts index 1846905e..71023c90 100644 --- a/website/src/constants/contracts.ts +++ b/website/src/constants/contracts.ts @@ -1,7 +1,26 @@ -import {constants} from 'starknet'; +import {constants, getChecksumAddress} from 'starknet'; export const ESCROW_ADDRESSES = { [constants.StarknetChainId.SN_MAIN]: '', // TODO: Add mainnet escrow address - [constants.StarknetChainId.SN_SEPOLIA]: + [constants.StarknetChainId.SN_SEPOLIA]: getChecksumAddress( '0x078a022e6906c83e049a30f7464b939b831ecbe47029480d7e89684f20c8d263', + ), +}; + +export const ETH_ADDRESSES = { + [constants.StarknetChainId.SN_MAIN]: getChecksumAddress( + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + ), + [constants.StarknetChainId.SN_SEPOLIA]: getChecksumAddress( + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + ), +}; + +export const STRK_ADDRESSES = { + [constants.StarknetChainId.SN_MAIN]: getChecksumAddress( + '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d', + ), + [constants.StarknetChainId.SN_SEPOLIA]: getChecksumAddress( + '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d', + ), }; diff --git a/website/src/constants/misc.ts b/website/src/constants/misc.ts index 4c35fc00..7dd89ca0 100644 --- a/website/src/constants/misc.ts +++ b/website/src/constants/misc.ts @@ -1,3 +1,6 @@ +import {BASE_URL as AVNU_BASE_URL, SEPOLIA_BASE_URL as AVNU_SEPOLIA_BASE_URL} from '@avnu/avnu-sdk'; +import {constants} from 'starknet'; + export enum Entrypoint { // ERC-20 NAME = 'name', @@ -10,3 +13,10 @@ export enum Entrypoint { CLAIM = 'claim', GET_DEPOSIT = 'get_deposit', } + +export const NETWORK_NAME = process.env.NETWORK_NAME as constants.NetworkName; +if (!NETWORK_NAME) throw new Error('NETWORK_NAME is not set'); + +export const CHAIN_ID = constants.StarknetChainId[NETWORK_NAME]; + +export const AVNU_URL = NETWORK_NAME === 'SN_SEPOLIA' ? AVNU_SEPOLIA_BASE_URL : AVNU_BASE_URL; diff --git a/website/src/services/provider.ts b/website/src/services/provider.ts index dbb31dbb..51353a98 100644 --- a/website/src/services/provider.ts +++ b/website/src/services/provider.ts @@ -1,8 +1,10 @@ import {constants, RpcProvider} from 'starknet'; +import {NETWORK_NAME} from '@/constants/misc'; + if (!process.env.PROVIDER_URL) throw new Error('PROVIDER_URL is not set'); export const provider = new RpcProvider({ nodeUrl: process.env.PROVIDER_URL, - chainId: constants.StarknetChainId.SN_SEPOLIA, + chainId: constants.StarknetChainId[NETWORK_NAME], }); diff --git a/website/src/utils/errors.ts b/website/src/utils/errors.ts index 39abbe16..38e474b1 100644 --- a/website/src/utils/errors.ts +++ b/website/src/utils/errors.ts @@ -8,6 +8,7 @@ const ErrorCodesArray = [ 'INVALID_GAS_AMOUNT', 'TRANSACTION_ERROR', 'ESTIMATION_ERROR', + 'NO_ROUTE_FOUND', ] as const; export type ErrorCode = (typeof ErrorCodesArray)[number]; diff --git a/website/yarn.lock b/website/yarn.lock index 39e6817a..0c0a2f06 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2,11 +2,21 @@ # yarn lockfile v1 +"@adraffy/ens-normalize@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz#63430d04bd8c5e74f8d7d049338f1cd9d4f02069" + integrity sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw== + "@alloc/quick-lru@^5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== +"@avnu/avnu-sdk@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@avnu/avnu-sdk/-/avnu-sdk-2.0.0.tgz#c86f3c6432e265e46d6d20524d27fecba682220a" + integrity sha512-mNRxvOa3VYt1+HM5LWSLmpQBOIrw6hfVjZkf4rbpC44H/Vem16PqoGpv0rgnoedVom6q8iLgjiI8Lr2NI9g+GA== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -306,6 +316,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/node@18.15.13": + version "18.15.13" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.13.tgz#f64277c341150c979e42b00e4ac289290c9df469" + integrity sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q== + "@types/node@^20": version "20.14.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.9.tgz#12e8e765ab27f8c421a1820c99f5f313a933b420" @@ -513,6 +528,11 @@ acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c" integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== +aes-js@4.0.0-beta.5: + version "4.0.0-beta.5" + resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-4.0.0-beta.5.tgz#8d2452c52adedebc3a3e28465d858c11ca315873" + integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q== + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -1443,6 +1463,19 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +ethers@^6.13.1: + version "6.13.1" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-6.13.1.tgz#2b9f9c7455cde9d38b30fe6589972eb083652961" + integrity sha512-hdJ2HOxg/xx97Lm9HdCWk949BfYqYWpyw4//78SiwOLgASyfrNszfMUNB2joKjvGUdwhHfaiMMFFwacVVoLR9A== + dependencies: + "@adraffy/ens-normalize" "1.10.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.2" + "@types/node" "18.15.13" + aes-js "4.0.0-beta.5" + tslib "2.4.0" + ws "8.17.1" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -2602,6 +2635,13 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qs@^6.12.3: + version "6.12.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.3.tgz#e43ce03c8521b9c7fd7f1f13e514e5ca37727754" + integrity sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ== + dependencies: + side-channel "^1.0.6" + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -3096,6 +3136,11 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -3340,6 +3385,11 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"