diff --git a/app/ts/components/App.tsx b/app/ts/components/App.tsx index f0eae4b5..3b8e9f9a 100644 --- a/app/ts/components/App.tsx +++ b/app/ts/components/App.tsx @@ -5,6 +5,7 @@ import { TransferPage } from './TransferPage/index.js' import { EthereumProvider } from '../context/Ethereum.js' import { WalletProvider } from '../context/Wallet.js' import { NotificationProvider } from '../context/Notification.js' +import { TemplatesProvider } from '../context/TransferTemplates.js' export function App() { return ( @@ -12,14 +13,19 @@ export function App() { - - - - - - - - + + + + + + + + + + + + + diff --git a/app/ts/components/HashRouter.tsx b/app/ts/components/HashRouter.tsx index 4549ad45..6e20a672 100644 --- a/app/ts/components/HashRouter.tsx +++ b/app/ts/components/HashRouter.tsx @@ -45,7 +45,7 @@ export const Router = ({ children }: { children: unknown | unknown[] }) => { return <>{router.value.activeRoute}> } -export function useRouter() { +export function useRouter>() { return useComputed(() => ({ activeRoute: router.value.activeRoute, params: router.value.params as T })) } diff --git a/app/ts/components/SetupTransfer.tsx b/app/ts/components/SetupTransfer.tsx index 95d4d6fd..042955e6 100644 --- a/app/ts/components/SetupTransfer.tsx +++ b/app/ts/components/SetupTransfer.tsx @@ -14,6 +14,7 @@ import { useNotice } from '../store/notice.js' import { TokenPicker } from './TokenPicker.js' import { TokenAdd } from './TokenAdd.js' import { TransferResult } from './TransferResult.js' +import { TemplateFeeder } from './TemplateFeeder.js' export function SetupTransfer() { return ( @@ -29,6 +30,7 @@ export function SetupTransfer() { + ) @@ -82,6 +84,5 @@ const TransferForm = ({ children }: { children: ComponentChildren }) => { useSignalEffect(listenForWalletsChainChange) useSignalEffect(listenForQueryChanges) - return {children} } diff --git a/app/ts/components/TemplateFeeder.tsx b/app/ts/components/TemplateFeeder.tsx new file mode 100644 index 00000000..9917d446 --- /dev/null +++ b/app/ts/components/TemplateFeeder.tsx @@ -0,0 +1,38 @@ +import { useComputed, useSignalEffect } from "@preact/signals" +import { formatUnits } from "ethers" +import { useTokenManager } from "../context/TokenManager.js" +import { useTransfer } from "../context/Transfer.js" +import { useTemplates } from "../context/TransferTemplates.js" +import { useRouter } from "./HashRouter.js" + +export const TemplateFeeder = () => { + const templates = useTemplates() + const tokens = useTokenManager() + const router = useRouter<{ template_id: string | undefined }>() + const transfer = useTransfer() + + const activeTemplate = useComputed(() => { + const templateIdFromParams = router.value.params.template_id + if (templateIdFromParams === undefined) return + const templateId = parseInt(templateIdFromParams) + return templates.cache.peek().data.at(templateId) + }) + + const selectedToken = useComputed(() => { + const tokensCache = tokens.cache.value.data + if (activeTemplate.value === undefined) return + const templateContractAddress = activeTemplate.value.contractAddress + return tokensCache.find(token => token.address === templateContractAddress) + }) + + const feedTemplateToTransferInput = () => { + const tmpl = activeTemplate.value + if (tmpl === undefined) return + const amount = formatUnits(tmpl.quantity, selectedToken.value?.decimals) + transfer.input.value = { to: tmpl.to, amount, token: selectedToken.value } + } + + useSignalEffect(feedTemplateToTransferInput) + + return <>> +} diff --git a/app/ts/components/TemplateRecorder.tsx b/app/ts/components/TemplateRecorder.tsx new file mode 100644 index 00000000..e4c3618d --- /dev/null +++ b/app/ts/components/TemplateRecorder.tsx @@ -0,0 +1,112 @@ +import { Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' +import { toQuantity } from 'ethers' +import { JSX } from 'preact/jsx-runtime' +import { useTemplates } from '../context/TransferTemplates.js' +import { extractERC20TransferRequest } from '../library/ethereum.js' +import { serialize, TransferRequest, TransferTemplate } from '../schema.js' +import { useTransaction } from './TransactionProvider.js' + +export const TemplateRecorder = () => { + const { response, receipt } = useTransaction() + const { add } = useTemplates() + const isSaved = useSignal(false) + const templateDraft = useSignal(undefined) + + const erc20TransferTemplate = useComputed(() => { + if (receipt.value.state !== 'resolved' || receipt.value.value === null) return + const erc20TransferRequest = extractERC20TransferRequest(receipt.value.value) + if (erc20TransferRequest === undefined) return + const parsed = TransferRequest.safeParse(erc20TransferRequest) + const label = templateDraft.peek()?.label + return parsed.success ? { ...parsed.value, label } : undefined + }) + + const ethTransferTemplate = useComputed(() => { + if (response.value.state !== 'resolved' || response.value.value === null) return + const { to, from, value } = response.value.value + const parsed = TransferRequest.safeParse({ to, from, quantity: toQuantity(value) }) + return parsed.success ? { label: templateDraft.peek()?.label, ...parsed.value } : undefined + }) + + useSignalEffect(() => { + // Update draft with values coming from transaction + templateDraft.value = erc20TransferTemplate.value || ethTransferTemplate.value + }) + + const saveTemplate = () => { + if (!templateDraft.value) return + const serialized = serialize(TransferTemplate, templateDraft.value) + const template = TransferTemplate.parse(serialized) + add(template) + isSaved.value = true + } + + // Activate form only after the transaction receipt is resolved + if (receipt.value.state !== 'resolved') return <>> + + if (isSaved.value === true) return + + return +} + +type AddTemplateFormProps = { + onSubmit: () => void + formData: Signal +} + +const AddTemplateForm = ({ formData, onSubmit }: AddTemplateFormProps) => { + const submitForm = (e: Event) => { + e.preventDefault() + onSubmit() + } + + const updateLabel = (event: JSX.TargetedEvent) => { + event.preventDefault() + const templateData = formData.peek() + if (!templateData) return + formData.value = { ...templateData, label: event.currentTarget.value } + } + + return ( + + Save Transfer + + + + Prevent accidental inputs by saving this transfer so you can quickly do this again later. Add a label to this transfer and hit save to continue. + + + + + + Save + + + + + + + ) +} + +const TemplateAddConfirmation = () => { + return ( + + + + + + + + + + + + Transfer Saved! + This transfer was added to the sidebar so you can use it as a starting point for your next transfer. + + + + + ) +} diff --git a/app/ts/components/Favorites.tsx b/app/ts/components/Templates.tsx similarity index 51% rename from app/ts/components/Favorites.tsx rename to app/ts/components/Templates.tsx index 0713fbbe..f63ac594 100644 --- a/app/ts/components/Favorites.tsx +++ b/app/ts/components/Templates.tsx @@ -1,13 +1,20 @@ -import { useSignal } from '@preact/signals' +import { useComputed, useSignal } from '@preact/signals' +import { formatEther, formatUnits } from 'ethers' +import { useTokenManager } from '../context/TokenManager.js' +import { useTemplates } from '../context/TransferTemplates.js' import { removeNonStringsAndTrim } from '../library/utilities.js' -import { FavoriteModel, useFavorites } from '../store/favorites.js' +import { TransferTemplate } from '../schema.js' import * as Icon from './Icon/index.js' -export const Favorites = () => { +export const Templates = () => { const manage = useSignal(false) - const { favorites } = useFavorites() + const { cache: templatesCache } = useTemplates() + const { cache: tokensCache } = useTokenManager() - if (favorites.value.length < 1) + const templates = useComputed(() => templatesCache.value.data) + const getCachedToken = (contractAddress: string) => tokensCache.value.data.find(token => token.address === contractAddress) + + if (templates.value.length < 1) return ( @@ -28,16 +35,19 @@ export const Favorites = () => { - {favorites.value.map((favorite, index) => { + {templates.value.map((template, index) => { + const token = template.contractAddress && getCachedToken(template.contractAddress) + const amount = token ? formatUnits(template.quantity, token.decimals) : formatEther(template.quantity) + return ( - + - {favorite.token ? : } + {token ? : } - {favorite.label} + {template.label} - {favorite.amount} {favorite.token ? favorite.token.symbol : 'ETH'} to {favorite.recipientAddress} + {amount} {token ? token.symbol : 'ETH'} to {template.to} @@ -52,12 +62,26 @@ export const Favorites = () => { type PromoteButtonProps = { show: boolean - favorite: FavoriteModel + template: TransferTemplate index: number } const MoveUpButton = (props: PromoteButtonProps) => { - const { swapIndex } = useFavorites() + const { cache } = useTemplates() + const templates = useComputed(() => cache.value.data) + + const swapIndex = (indexA: number, indexB: number) => { + // ignore same indices swap + if (indexA === indexB) return + + const orderedTemplates = [...templates.value] + + const tempA = orderedTemplates[indexA] + orderedTemplates[indexA] = orderedTemplates[indexB] + orderedTemplates[indexB] = tempA + + cache.value = { ...cache.peek(), data: orderedTemplates } + } if (!props.show) return <>> if (props.index === 0) return @@ -75,7 +99,12 @@ type RemoveButtonProps = { } const RemoveButton = (props: RemoveButtonProps) => { - const { remove } = useFavorites() + const { cache } = useTemplates() + + const remove = (index: number) => { + const newData = [...cache.value.data.slice(0, index), ...cache.value.data.slice(index + 1)] + cache.value = { ...cache.peek(), data: newData } + } if (!props.show) return <>> diff --git a/app/ts/components/TransactionPage/SaveTransfer.tsx b/app/ts/components/TransactionPage/SaveTransfer.tsx deleted file mode 100644 index c8737ee3..00000000 --- a/app/ts/components/TransactionPage/SaveTransfer.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Signal, useSignal } from '@preact/signals' -import { useEffect } from 'preact/hooks' -import { FavoriteModel, isFavorite, useFavorites } from '../../store/favorites.js' - -type Props = { - show?: boolean - addFavoriteStore: Signal | undefined> -} - -export const SaveTransfer = ({ show, addFavoriteStore }: Props) => { - const isSaved = useSignal(false) - const { add } = useFavorites() - - const saveTransfer = () => { - if (addFavoriteStore.value === undefined || !isFavorite(addFavoriteStore.value)) return - - add(addFavoriteStore.value) - isSaved.value = true - } - - useEffect(() => { - if (!show) return - addFavoriteStore.value = { ...addFavoriteStore.peek(), label: '' } - }, [show]) - - if (!isFavorite(addFavoriteStore.value)) return <>> - - if (isSaved.value === true) return - - return -} - -type SaveFormProps = { - onSubmit: () => void - addFavoriteStore: Signal | undefined> -} - -const AddFavoriteForm = ({ addFavoriteStore, onSubmit }: SaveFormProps) => { - if (addFavoriteStore.value === undefined) return <>> - - const submitForm = (e: Event) => { - e.preventDefault() - onSubmit() - } - - return ( - - Save Transfer - - - - Prevent accidental inputs by saving this transfer so you can quickly do this again later. Add a label to this transfer and hit save to continue. - - - - (addFavoriteStore.value = { ...addFavoriteStore.peek(), label: event.currentTarget.value })} placeholder='Add a label (optional)' /> - - Save - - - - - - - ) -} - -const AcknowledgeFavoriteAdd = () => { - return ( - - - - - - - - - - - - Transfer Saved! - This transfer was added to the sidebar so you can use it as a starting point for your next transfer. - - - - - ) -} diff --git a/app/ts/components/TransactionPage/TransactionDetails.tsx b/app/ts/components/TransactionPage/TransactionDetails.tsx index 9f90d39a..8c395636 100644 --- a/app/ts/components/TransactionPage/TransactionDetails.tsx +++ b/app/ts/components/TransactionPage/TransactionDetails.tsx @@ -1,77 +1,65 @@ -import { Signal, useComputed, useSignal } from '@preact/signals' +import { useSignalEffect } from '@preact/signals' +import { formatEther, formatUnits } from 'ethers' +import SVGBlockie from '../SVGBlockie.js' import { useRouter } from '../HashRouter.js' -import { formatEther, formatUnits, TransactionReceipt, TransactionResponse } from 'ethers' -import { AsyncProperty } from '../../library/preact-utilities.js' +import { TemplateRecorder } from '../TemplateRecorder.js' import { Info, InfoError, InfoPending } from './Info.js' -import { useTransaction } from '../../store/transaction.js' -import { calculateGasFee, extractArgValue, extractTransferLogFromSender } from '../../library/ethereum.js' -import { SaveTransfer } from './SaveTransfer.js' -import { FavoriteModel } from '../../store/favorites.js' -import SVGBlockie from '../SVGBlockie.js' +import { useTransaction } from '../TransactionProvider.js' +import { EthereumAddress, TransferRequest } from '../../schema.js' +import { extractERC20TransferRequest } from '../../library/ethereum.js' import { useTokenManager } from '../../context/TokenManager.js' export const TransactionDetails = () => { - const favorite = useSignal | undefined>(undefined) const router = useRouter<{ transaction_hash: string }>() - const { transactionResponse, transactionReceipt } = useTransaction(router.value.params.transaction_hash) + const { transactionHash } = useTransaction() - const isDoneFetching = useComputed(() => transactionResponse.value.state === 'resolved' && transactionReceipt.value.state === 'resolved') + useSignalEffect(() => { + transactionHash.value = router.value.params.transaction_hash + }) return ( - - - + + + + + + ) } -type DataFromResponseProps = { - response: Signal> - addFavoriteStore: Signal | undefined> +const TransactionHash = () => { + const { transactionHash } = useTransaction() + if (!transactionHash.value) return <>> + return } -const DataFromResponse = ({ response, addFavoriteStore }: DataFromResponseProps) => { +const TransferFrom = () => { + const { response } = useTransaction() + switch (response.value.state) { case 'inactive': return <>> case 'pending': - return ( - <> - - - - - > - ) + return case 'rejected': return case 'resolved': - const source = response.value.value.from - addFavoriteStore.value = { ...addFavoriteStore.peek(), source } - + const from = response.value.value?.from + if (from === undefined) return <>> const blockieIcon = () => ( - + ) - return ( - <> - - - - - > - ) + return } } -type DataFromReceiptProps = { - receipt: Signal> - addFavoriteStore: Signal | undefined> -} +const TransferTo = () => { + const { receipt } = useTransaction() -const DataFromReceipt = ({ receipt, addFavoriteStore }: DataFromReceiptProps) => { switch (receipt.value.state) { case 'inactive': return <>> @@ -80,96 +68,109 @@ const DataFromReceipt = ({ receipt, addFavoriteStore }: DataFromReceiptProps) => case 'rejected': return case 'resolved': - if (receipt.value.value === null) return <>> + const txReceipt = receipt.value.value + if (txReceipt === null) return <>> - const transactionFee = calculateGasFee(receipt.value.value.gasPrice, receipt.value.value.gasUsed) - return ( - <> - - - - > - ) - } -} + const extractedRequest = extractERC20TransferRequest(txReceipt) + if (extractedRequest) return -type EthAmountProps = { - response: TransactionResponse - addFavoriteStore: Signal | undefined> + return + } } -const EthAmount = ({ response, addFavoriteStore }: EthAmountProps) => { - if (response.value === 0n) return <>> - const ethAmount = formatEther(response.value) +const TokenRecipient = ({ transferRequest }: { transferRequest: ReturnType }) => { + // loading states are handled by the parent component + const parsedERC20Request = TransferRequest.safeParse(transferRequest) + if (!parsedERC20Request.success) return - addFavoriteStore.value = { ...addFavoriteStore.peek(), amount: ethAmount } - - return + const Blockie = () => + return } -type EthRecipientProps = { - response: TransactionResponse - addFavoriteStore: Signal | undefined> -} -const EthRecipient = ({ response, addFavoriteStore }: EthRecipientProps) => { - if (response.value === 0n || response.to === null) return <>> +const EthRecipient = () => { + const { response } = useTransaction() - const recipientAddress = response.to - addFavoriteStore.value = { ...addFavoriteStore.peek(), recipientAddress } + // loading states are handled by the parent component + if (response.value.state !== 'resolved') return <>> - const blockieIcon = () => ( - - - - ) + const txResponse = response.value.value + if (!txResponse) return <>> - return -} + const parsedTo = EthereumAddress.safeParse(txResponse.to) + if (!parsedTo.success) return -type TokenRecipientProps = { - receipt: TransactionReceipt - addFavoriteStore: Signal | undefined> + const blockieIcon = () => + return } -const TokenRecipient = ({ receipt, addFavoriteStore }: TokenRecipientProps) => { - const txLog = extractTransferLogFromSender(receipt) - if (txLog === null) return <>> +const TransferAmount = () => { + const { receipt } = useTransaction() - const recipientAddress = extractArgValue(txLog, 'to') - if (recipientAddress === null) return <>> + switch (receipt.value.state) { + case 'inactive': + return <>> + case 'pending': + return + case 'rejected': + return + case 'resolved': + const txReceipt = receipt.value.value + if (txReceipt === null) return <>> - addFavoriteStore.value = { ...addFavoriteStore.peek(), recipientAddress } + const extractedRequest = extractERC20TransferRequest(txReceipt) + if (extractedRequest) return - const blockieIcon = () => ( - - - - ) - - return + return + } } -type TokenAmountProps = { - receipt: TransactionReceipt - addFavoriteStore: Signal | undefined> -} +const TokenAmount = ({ transferRequest }: { transferRequest: ReturnType }) => { + const { cache } = useTokenManager() -const TokenAmount = ({ receipt }: TokenAmountProps) => { - const { cache:tokensCache } = useTokenManager() + const parsedERC20Request = TransferRequest.safeParse(transferRequest) + if (!parsedERC20Request.success) return - if (receipt.to === null) return <>> + const getTokenMetaFromCache = (address: EthereumAddress) => cache.value.data.find(token => token.address === address) - const txLog = extractTransferLogFromSender(receipt) - if (txLog === null) return <>> + const token = parsedERC20Request.value.contractAddress ? getTokenMetaFromCache(parsedERC20Request.value.contractAddress) : undefined + if (!token) return - const transferredTokenValue = extractArgValue(txLog, 'value') - if (transferredTokenValue === null) return <>> + const displayAmount = `${formatUnits(parsedERC20Request.value.quantity, token.decimals)} ${token.symbol}` + return +} - const token = useComputed(() => tokensCache.value.data.find(token => token.address === receipt.to)) +const EthAmount = () => { + const { response } = useTransaction() + + switch (response.value.state) { + case 'inactive': + return <>> + case 'pending': + return + case 'rejected': + return + case 'resolved': + const txResponse = response.value.value + if (txResponse === null) return <>> + const displayValue = `${formatEther(txResponse.value)} ETH` + return + } +} - if (!token.value) return <>> - const amount = formatUnits(transferredTokenValue, token.value.decimals) +const TransferFee = () => { + const { receipt } = useTransaction() - return + switch (receipt.value.state) { + case 'inactive': + return <>> + case 'pending': + return + case 'rejected': + return + case 'resolved': + if (receipt.value.value === null) return <>> + const transactionFee = `${formatEther(receipt.value.value.fee)} ETH` + return + } } diff --git a/app/ts/components/TransactionPage/index.tsx b/app/ts/components/TransactionPage/index.tsx index 28f97a12..dc534983 100644 --- a/app/ts/components/TransactionPage/index.tsx +++ b/app/ts/components/TransactionPage/index.tsx @@ -2,7 +2,7 @@ import { LAYOUT_SCROLL_OPTIONS } from '../../library/constants.js' import { Header, HeaderNav, Main, Navigation, Root, usePanels } from '../DefaultLayout/index.js' import { ConnectAccount } from '../ConnectAccount.js' import { DiscordInvite } from '../DiscordInvite.js' -import { Favorites } from '../Favorites.js' +import { Templates } from '../Templates.js' import { MainFooter } from '../MainFooter.js' import { TransferHistoryProvider } from '../../context/TransferHistory.js' import { TransferHistory } from '../TransferHistory.js' @@ -10,6 +10,7 @@ import { TransactionDetails } from './TransactionDetails.js' import { AccountReconnect } from '../AccountReconnect.js' import * as Icon from '../Icon/index.js' import { TokenManagerProvider } from '../../context/TokenManager.js' +import { TransactionProvider } from '../TransactionProvider.js' export const TransactionPage = () => { return ( @@ -47,7 +48,9 @@ const MainPanel = () => { - + + + @@ -88,7 +91,7 @@ const LeftPanel = () => { - + diff --git a/app/ts/components/TransactionProvider.tsx b/app/ts/components/TransactionProvider.tsx new file mode 100644 index 00000000..cec0a530 --- /dev/null +++ b/app/ts/components/TransactionProvider.tsx @@ -0,0 +1,61 @@ +import { ComponentChildren, createContext } from 'preact' +import { useContext } from 'preact/hooks' +import { Signal, useSignal, useSignalEffect } from '@preact/signals' +import { TransactionReceipt, TransactionResponse } from 'ethers' +import { useEthereumProvider } from '../context/Ethereum.js' +import { AsyncProperty, useAsyncState } from '../library/preact-utilities.js' + +type TransactionContext = { + transactionHash: Signal + response: Signal> + receipt: Signal> +} + +const TransactionContext = createContext(undefined) + +export const TransactionProvider = ({ children }: { children: ComponentChildren }) => { + const { browserProvider } = useEthereumProvider() + const { value: response, waitFor: waitForResponse, reset: resetResponse } = useAsyncState() + const { value: receipt, waitFor: waitForReceipt, reset: resetReceipt } = useAsyncState() + const transactionHash = useSignal<`0x{string}` | undefined>(undefined) + + const getTransactionResponse = (transactionHash: string) => { + if (!browserProvider.value) return + const provider = browserProvider.value + waitForResponse(async () => { + return await provider.getTransaction(transactionHash) + }) + } + + const getTransactionReceipt = (txResponse: TransactionResponse) => { + waitForReceipt(async () => { + return await txResponse.wait() + }) + } + + // automatically get transaction receipt + useSignalEffect(() => { + if (response.value.state !== 'resolved') return + if (response.value.value === null) return + getTransactionReceipt(response.value.value) + }) + + useSignalEffect(() => { + if (transactionHash.value === undefined) return + resetResponse() + resetReceipt() + getTransactionResponse(transactionHash.value) + }) + + return ( + + {children} + + ) +} + +export function useTransaction() { + const context = useContext(TransactionContext) + if (context === undefined) throw ('use useTransaction within children of TransactionProvider') + return context +} diff --git a/app/ts/components/TransferPage/index.tsx b/app/ts/components/TransferPage/index.tsx index 33e66f3d..bd77fac7 100644 --- a/app/ts/components/TransferPage/index.tsx +++ b/app/ts/components/TransferPage/index.tsx @@ -4,7 +4,7 @@ import { Header, HeaderNav, Main, Navigation, Root, usePanels } from '../Default import { ConnectAccount } from '../ConnectAccount.js' import { TransferHistory } from '../TransferHistory.js' import { DiscordInvite } from '../DiscordInvite.js' -import { Favorites } from '../Favorites.js' +import { Templates } from '../Templates.js' import { MainFooter } from '../MainFooter.js' import { TransferProvider, useTransfer } from '../../context/Transfer.js' import { TransferHistoryProvider } from '../../context/TransferHistory.js' @@ -104,7 +104,7 @@ const LeftPanel = () => { - + diff --git a/app/ts/components/TransferTokenField.tsx b/app/ts/components/TransferTokenField.tsx index a1c6b064..e2b46fb2 100644 --- a/app/ts/components/TransferTokenField.tsx +++ b/app/ts/components/TransferTokenField.tsx @@ -24,7 +24,7 @@ export const TransferTokenSelectField = () => { } } - const selectedAsset = useComputed(() => input.value.token) + const selectedAsset = useComputed(() => input.value.token || { name: 'Ether', symbol: 'ETH' }) const focusButtonOnClearStage = () => { if (!buttonRef.value) return @@ -39,10 +39,10 @@ export const TransferTokenSelectField = () => { Asset - {selectedAsset.value?.name || 'Ether'} + {selectedAsset.value.name} - {selectedAsset.value?.symbol || 'ETH'} + {selectedAsset.value.symbol} diff --git a/app/ts/context/TransferTemplates.tsx b/app/ts/context/TransferTemplates.tsx new file mode 100644 index 00000000..8e911fef --- /dev/null +++ b/app/ts/context/TransferTemplates.tsx @@ -0,0 +1,36 @@ +import { Signal, useSignal } from '@preact/signals' +import { ComponentChildren, createContext } from 'preact' +import { useContext } from 'preact/hooks' +import { TEMPLATES_CACHE_KEY } from '../library/constants.js' +import { persistSignalEffect } from '../library/persistent-signal.js' +import { createCacheParser, TemplatesCache, TemplatesCacheSchema, TransferTemplate } from '../schema.js' + +export type TemplatesContext = { + cache: Signal +} + +export const TemplatesContext = createContext(undefined) +export const TemplatesProvider = ({ children }: { children: ComponentChildren }) => { + const cache = useSignal({ data: [], version: '1.0.0' }) + + persistSignalEffect(TEMPLATES_CACHE_KEY, cache, createCacheParser(TemplatesCacheSchema)) + + return ( + + {children} + + ) +} + +export function useTemplates() { + const context = useContext(TemplatesContext) + if (!context) throw new Error('useTransferTemplates can only be used within children of TransferTemplatesProfider') + + const { cache } = context + + const add = (newTemplate: TransferTemplate) => { + cache.value = { ...cache.peek(), data: [...cache.peek().data, newTemplate] } + } + + return { cache, add } +} diff --git a/app/ts/library/constants.ts b/app/ts/library/constants.ts index b34a55b4..bb68d785 100644 --- a/app/ts/library/constants.ts +++ b/app/ts/library/constants.ts @@ -56,3 +56,4 @@ export const STORAGE_KEY_RECENTS = 'txns' export const KNOWN_TOKENS_CACHE_KEY = 'tokens' export const SETTINGS_CACHE_KEY = 'settings' export const RECENT_TRANSFERS_CACHE_KEY = 'transfers' +export const TEMPLATES_CACHE_KEY = 'templates' diff --git a/app/ts/library/ethereum.ts b/app/ts/library/ethereum.ts index d22d394f..41d1268d 100644 --- a/app/ts/library/ethereum.ts +++ b/app/ts/library/ethereum.ts @@ -1,4 +1,4 @@ -import { TransactionResponse, Interface, id, TransactionReceipt, Log, Eip1193Provider, formatEther, EventEmitterable } from 'ethers' +import { Interface, id, TransactionReceipt, Eip1193Provider, EventEmitterable, Log, toQuantity } from 'ethers' import { ERC20ABI } from './ERC20ABI.js' export interface WithEip1193Provider { @@ -16,39 +16,39 @@ export function isEthereumProvider(ethereum: unknown): ethereum is EthereumProvi return ethereum !== null && typeof ethereum === 'object' && 'on' in ethereum && typeof ethereum.on === 'function' && 'removeListener' in ethereum && typeof ethereum.removeListener === 'function' } -export const calculateGasFee = (effectiveGasPrice: bigint, gasUsed: bigint) => { - const gasFeeBigNum = effectiveGasPrice * gasUsed - const gasFee = formatEther(gasFeeBigNum) - return gasFee -} +export const erc20Interface = new Interface(ERC20ABI) +export const transferSignature = id('Transfer(address,address,uint256)') -export type TransferTransactionResponse = TransactionResponse & { - to: string -} +export const isTransferTopic = (topic: string) => topic === transferSignature -export function isTransferTransaction(txResponse: TransactionResponse): txResponse is TransferTransactionResponse { - return txResponse.data.toLowerCase().startsWith('0xa9059cbb') -} +export const ERC20Interface = new Interface(ERC20ABI) -export const erc20Interface = new Interface(ERC20ABI) -export const transferTopic = id('Transfer(address,address,uint256)') - -export function parseERC20Log({ topics: [...topics], data }: Log) { - // topics is spread to conform to parseLog parameters - try { - return erc20Interface.parseLog({ topics, data }) - } catch (error) { - return null +export const parseERC20ReceiptLog = ({ topics, data }: Log) => ERC20Interface.parseLog({ topics: [...topics], data }) + +export function extractERC20TransferRequest(receipt: TransactionReceipt) { + // receipt should have a recipient + if (!receipt.to) return + + for (const log of receipt.logs) { + const parsedLog = parseERC20ReceiptLog(log) + + // log is a "Transfer" method + if (parsedLog === null || parsedLog.name !== 'Transfer') return + + // if an arg was not defined, fail the next conditions + const logFrom = parsedLog.args["from"] + const logTo = parsedLog.args["to"] + const logValue = parsedLog.args["value"] + + // a transfer that originates from which the receipt was initiated + if (BigInt(logFrom) !== BigInt(receipt.from)) return + + // recipient is the contract address + if (BigInt(receipt.to) !== BigInt(log.address)) return + + return { contractAddress: log.address, from: receipt.from, to: logTo, quantity: toQuantity(logValue) } } -} -export function extractArgValue(log: Log, argKey: string): T | null { - const parsedLog = parseERC20Log(log) - return parsedLog ? parsedLog.args.getValue(argKey) : null + return } -export function extractTransferLogFromSender(receipt: TransactionReceipt) { - const hasTransferTopic = (log: Log) => log.topics.some(topic => topic === transferTopic) - const isAddressFromSender = (log: Log) => extractArgValue(log, 'from') === receipt.from - return receipt.logs.filter(hasTransferTopic).find(isAddressFromSender) || null -} diff --git a/app/ts/schema.ts b/app/ts/schema.ts index 1c7fb6af..4dee7377 100644 --- a/app/ts/schema.ts +++ b/app/ts/schema.ts @@ -1,4 +1,4 @@ -import { getAddress, isAddress, isHexString, parseUnits } from 'ethers' +import { getAddress, isHexString, parseUnits } from 'ethers' import * as funtypes from 'funtypes' export function createCacheParser(funType: funtypes.Codec) { @@ -50,8 +50,16 @@ export function createUnitParser(decimals?: bigint): funtypes.ParsedValue['config'] = { parse: value => { - if (!isAddress(value)) return { success: false, message: `${value} is not a valid address string.` } - else return { success: true, value: getAddress(value) } + if (!/^0x[0-9a-fA-F]+$/.test(value)) return { success: false, message: `${value} is not a hex string.` } + if (BigInt(value) >= 2n**160n) return { success: false, message: `${value} is not within a valid address range.` } + + // remove padded zeros for addresses like logs + const noPadAddress = `0x${BigInt(value).toString(16).padStart(40, '0')}` + + // get checksummed address + const checksummedAddress = getAddress(noPadAddress.toLowerCase()) + + return { success: true, value: checksummedAddress } }, serialize: funtypes.String.safeParse, } @@ -134,12 +142,12 @@ export type ToWireType = T extends funtypes.Intersect ? Record, ToWireType> : T extends funtypes.Partial ? V extends true - ? { readonly [K in keyof U]?: ToWireType } - : { [K in keyof U]?: ToWireType } + ? { readonly [K in keyof U]?: ToWireType } + : { [K in keyof U]?: ToWireType } : T extends funtypes.Object ? V extends true - ? { readonly [K in keyof U]: ToWireType } - : { [K in keyof U]: ToWireType } + ? { readonly [K in keyof U]: ToWireType } + : { [K in keyof U]: ToWireType } : T extends funtypes.Readonly> ? { readonly [P in keyof U]: ToWireType } : T extends funtypes.Tuple @@ -153,3 +161,24 @@ export type ToWireType = T extends funtypes.Intersect : T extends funtypes.Codec ? U : never + +export const TransferRequest = funtypes.Object({ + contractAddress: EthereumAddress.Or(funtypes.Undefined), + from: EthereumAddress, + to: EthereumAddress, + quantity: BigIntHex +}) + +export type TransferRequest = funtypes.Static + +export const TransferTemplate = funtypes.Intersect(TransferRequest, funtypes.Object({ label: funtypes.String.Or(funtypes.Undefined) })) +export type TransferTemplate = funtypes.Static + +export const TemplatesCacheSchema = funtypes.Union( + funtypes.Object({ + data: funtypes.Array(TransferTemplate), + version: funtypes.Literal('1.0.0'), + }) +) + +export type TemplatesCache = funtypes.Static diff --git a/app/ts/store/favorites.ts b/app/ts/store/favorites.ts deleted file mode 100644 index 3485f05f..00000000 --- a/app/ts/store/favorites.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { effect, signal } from '@preact/signals' -import { useEffect } from 'preact/hooks' -import { JSONStringify } from '../library/utilities.js' -import { ERC20Token } from '../schema.js' - -export type FavoriteModel = { - label?: string - source: string - recipientAddress: string - token?: ERC20Token - amount: string -} - -const FAVORITES_CACHE_ID = 'favorites' - -const getCachedFavorites = () => { - const cached = localStorage.getItem(FAVORITES_CACHE_ID) - if (cached === null) return [] - try { - const parsed = JSON.parse(cached) as unknown - // exit already if not iterable - if (!Array.isArray(parsed)) throw new Error('Favorites cache is malformed.') - // check if each item in array is correct model - if (!parsed.every(isFavorite)) throw new Error('Some cached favorite is malformed') - return parsed - } catch (error) { - throw new Error('Cache is corrupted') - } -} - -export function isFavorite(data: unknown): data is FavoriteModel { - return data !== null && typeof data === 'object' && 'label' in data && typeof data.label === 'string' && 'source' in data && typeof data.source === 'string' && 'recipientAddress' in data && typeof data.recipientAddress === 'string' -} - -const favorites = signal(getCachedFavorites()) - -effect(() => { - const uniqueTxns = Array.from(new Set(favorites.value)) - localStorage.setItem(FAVORITES_CACHE_ID, JSONStringify(uniqueTxns)) -}) - -export function useFavorites() { - const syncCacheChange = (event: StorageEvent) => { - if (event.key !== FAVORITES_CACHE_ID) return - const newValue = event.newValue !== null ? (JSON.parse(event.newValue) as FavoriteModel[]) : [] - favorites.value = newValue - } - - const add = (data: Omit) => { - const current = favorites.peek() - favorites.value = [...current, data] - } - - const remove = (index: number) => { - favorites.value = [...favorites.peek().slice(0, index), ...favorites.peek().slice(index + 1)] - } - - const swapIndex = (indexA: number, indexB: number) => { - // ignore same indices swap - if (indexA === indexB) return - - const orderedFavorites = [...favorites.peek()] - - const tempA = orderedFavorites[indexA] - orderedFavorites[indexA] = orderedFavorites[indexB] - orderedFavorites[indexB] = tempA - - favorites.value = orderedFavorites - } - - useEffect(() => { - window.addEventListener('storage', syncCacheChange) - return () => window.removeEventListener('storage', syncCacheChange) - }, []) - - return { favorites, add, remove, swapIndex } -}
Prevent accidental inputs by saving this transfer so you can quickly do this again later. Add a label to this transfer and hit save to continue.