Skip to content

Commit 41b2713

Browse files
authored
feat: add tx hooks (#226)
* add remark tx form * good add useTx * fix build * add tests * good add estimated fee * good improvements * add useTxFee * simplify logic * refactoring * add unit tests * good improvements * enable on in bestblock * add more examples
1 parent fbd9056 commit 41b2713

10 files changed

Lines changed: 1798 additions & 38 deletions

File tree

examples/dapp-general/src/App.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import { Box, Flex, Heading } from '@chakra-ui/react';
1+
import { Box, Flex, VStack, Divider } from '@chakra-ui/react';
22
import BalanceInsufficientAlert from '@/components/shared/BalanceInsufficientAlert.tsx';
33
import MainFooter from '@/components/shared/MainFooter';
44
import MainHeader from '@/components/shared/MainHeader';
5+
import RemarkTransactionExample from '@/components/RemarkTransactionExample';
6+
import TransferKeepAliveExample from '@/components/TransferKeepAliveExample';
57

68
function App() {
79
return (
810
<Flex direction='column' minHeight='100vh'>
911
<MainHeader />
1012
<Box maxWidth='760px' mx='auto' my={4} px={4} flex={1} w='full'>
1113
<BalanceInsufficientAlert />
12-
<Heading>Hello World</Heading>
14+
<VStack spacing={8} align='stretch'>
15+
<RemarkTransactionExample />
16+
<Divider />
17+
<TransferKeepAliveExample />
18+
</VStack>
1319
</Box>
1420
<MainFooter />
1521
</Flex>
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import {
2+
Box,
3+
Input,
4+
Menu,
5+
MenuButton,
6+
MenuItem,
7+
MenuList,
8+
Text,
9+
Flex,
10+
VStack,
11+
RadioGroup,
12+
Radio,
13+
Stack
14+
} from '@chakra-ui/react';
15+
import { useState, useMemo } from 'react';
16+
import { ChevronDownIcon } from '@chakra-ui/icons';
17+
import { useTypink, useBalances, formatBalance } from 'typink';
18+
import { shortenAddress } from '@/utils/string.ts';
19+
import { PolkadotApi } from '@dedot/chaintypes';
20+
21+
interface RecipientSelectorProps {
22+
value: string;
23+
onChange: (address: string) => void;
24+
isDisabled?: boolean;
25+
isInvalid?: boolean;
26+
}
27+
28+
// Simple address validation helper
29+
const isValidPolkadotAddress = (address: string): boolean => {
30+
// Basic validation - should be 47-48 characters and start with '1' for Polkadot
31+
return /^[1-9A-HJ-NP-Za-km-z]{47,48}$/.test(address.trim());
32+
};
33+
34+
type SelectionMode = 'accounts' | 'custom';
35+
36+
export default function RecipientSelector({
37+
value,
38+
onChange,
39+
isDisabled = false,
40+
isInvalid = false
41+
}: RecipientSelectorProps) {
42+
const { accounts, connectedAccount, network } = useTypink<PolkadotApi>();
43+
44+
// Get all accounts except the connected one
45+
const availableAccounts = useMemo(() => {
46+
return accounts.filter(acc => acc.address !== connectedAccount?.address);
47+
}, [accounts, connectedAccount]);
48+
49+
// Get balances for all available accounts
50+
const accountAddresses = useMemo(() => availableAccounts.map(acc => acc.address), [availableAccounts]);
51+
const balances = useBalances(accountAddresses);
52+
53+
// Find if current value matches an available account
54+
const selectedAccount = useMemo(() => {
55+
return availableAccounts.find(acc => acc.address === value);
56+
}, [availableAccounts, value]);
57+
58+
// Initialize mode - default to 'accounts' if available accounts exist, otherwise 'custom'
59+
const [mode, setMode] = useState<SelectionMode>('accounts');
60+
61+
const handleAccountSelect = (account: typeof availableAccounts[0]) => {
62+
onChange(account.address);
63+
};
64+
65+
const handleCustomInput = (inputValue: string) => {
66+
onChange(inputValue);
67+
};
68+
69+
const handleModeChange = (newMode: SelectionMode) => {
70+
setMode(newMode);
71+
if (newMode === 'accounts' && availableAccounts.length > 0) {
72+
// If switching to accounts mode and we have a selected account, keep it
73+
// Otherwise, select the first available account
74+
if (!selectedAccount) {
75+
onChange(availableAccounts[0].address);
76+
}
77+
} else if (newMode === 'custom') {
78+
// Clear the value when switching to custom mode unless it's already a custom address
79+
if (selectedAccount) {
80+
onChange('');
81+
}
82+
}
83+
};
84+
85+
return (
86+
<VStack align='stretch' spacing={3}>
87+
{/* Radio buttons for mode selection */}
88+
{availableAccounts.length > 0 && (
89+
<RadioGroup value={mode} onChange={handleModeChange}>
90+
<Stack direction='row'>
91+
<Radio value='accounts' size='sm' isDisabled={isDisabled}>
92+
Select from connected accounts
93+
</Radio>
94+
<Radio value='custom' size='sm' isDisabled={isDisabled}>
95+
Enter custom address
96+
</Radio>
97+
</Stack>
98+
</RadioGroup>
99+
)}
100+
101+
{/* Conditional rendering based on mode */}
102+
{mode === 'custom' || availableAccounts.length === 0 ? (
103+
<Box>
104+
<Input
105+
placeholder={availableAccounts.length === 0
106+
? 'Enter recipient address (no other accounts available)'
107+
: 'Enter recipient address (1abc...)'
108+
}
109+
value={value}
110+
onChange={(e) => handleCustomInput(e.target.value)}
111+
isDisabled={isDisabled}
112+
isInvalid={isInvalid || (value.length > 0 && !isValidPolkadotAddress(value))}
113+
/>
114+
{value.length > 0 && !isValidPolkadotAddress(value) && (
115+
<Text fontSize='xs' color='red.500' mt={1}>
116+
Please enter a valid Polkadot address
117+
</Text>
118+
)}
119+
</Box>
120+
) : (
121+
<Menu>
122+
<MenuButton
123+
as={Box}
124+
cursor='pointer'
125+
p={3}
126+
border='1px solid'
127+
borderColor={isInvalid ? 'red.500' : 'gray.200'}
128+
borderRadius='md'
129+
_hover={{ borderColor: 'gray.300' }}
130+
_disabled={{ opacity: 0.6, cursor: 'not-allowed' }}
131+
aria-disabled={isDisabled}
132+
>
133+
{selectedAccount ? (
134+
<Flex align='center' justify='space-between' width='100%'>
135+
<Box>
136+
<Text fontSize='sm' fontWeight='medium'>
137+
{selectedAccount.name}
138+
</Text>
139+
<Text fontSize='xs' color='gray.600'>
140+
{shortenAddress(selectedAccount.address)}
141+
</Text>
142+
</Box>
143+
<Flex align='center' gap={2}>
144+
<Text fontSize='xs' color='gray.500'>
145+
{formatBalance(balances[selectedAccount.address]?.free, network)}
146+
</Text>
147+
<ChevronDownIcon />
148+
</Flex>
149+
</Flex>
150+
) : (
151+
<Flex align='center' justify='space-between' width='100%'>
152+
<Text color='gray.500'>Select recipient account</Text>
153+
<ChevronDownIcon />
154+
</Flex>
155+
)}
156+
</MenuButton>
157+
158+
<MenuList maxH='300px' overflowY='auto'>
159+
{availableAccounts.map((account) => (
160+
<MenuItem
161+
key={account.address}
162+
onClick={() => handleAccountSelect(account)}
163+
bg={selectedAccount?.address === account.address ? 'gray.100' : undefined}
164+
isDisabled={isDisabled}
165+
>
166+
<Flex direction='column' width='100%'>
167+
<Flex justify='space-between' align='center' width='100%'>
168+
<Text fontWeight='medium' fontSize='sm'>
169+
{account.name}
170+
</Text>
171+
<Text fontSize='xs' color='gray.500'>
172+
{formatBalance(balances[account.address]?.free, network)}
173+
</Text>
174+
</Flex>
175+
<Text fontSize='xs' color='gray.600'>
176+
{shortenAddress(account.address)}
177+
</Text>
178+
</Flex>
179+
</MenuItem>
180+
))}
181+
</MenuList>
182+
</Menu>
183+
)}
184+
</VStack>
185+
);
186+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { Button, VStack, Input, Heading, Text, Spinner, Box, Flex } from '@chakra-ui/react';
2+
import { useState } from 'react';
3+
import { useDebounce } from 'react-use';
4+
import { useTypink, useTx, useTxFee, formatBalance } from 'typink';
5+
import { txToaster } from '@/utils/txToaster.tsx';
6+
import { PolkadotApi } from '@dedot/chaintypes';
7+
8+
export default function RemarkTransactionExample() {
9+
const { client, connectedAccount, network } = useTypink<PolkadotApi>();
10+
const [message, setMessage] = useState('Hello from Typink!');
11+
12+
// Debounce message changes to avoid excessive fee calculations
13+
const [debouncedMessage, setDebouncedMessage] = useState(message);
14+
useDebounce(() => setDebouncedMessage(message), 500, [message]);
15+
16+
// Create remarkTx for signing and sending
17+
const remarkTx = useTx((tx) => tx.system.remark);
18+
19+
const {
20+
fee: estimatedFee,
21+
isLoading: feeLoading,
22+
error: feeError
23+
} = useTxFee({
24+
tx: remarkTx,
25+
args: [debouncedMessage],
26+
enabled: debouncedMessage.trim().length > 0
27+
});
28+
29+
const handleSendRemark = async () => {
30+
const toaster = txToaster('Submitting remark transaction...');
31+
32+
try {
33+
await remarkTx.signAndSend({
34+
args: [debouncedMessage],
35+
callback: (result) => {
36+
toaster.onTxProgress(result);
37+
38+
const { status } = result;
39+
40+
if (status.type === 'BestChainBlockIncluded' || status.type === 'Finalized') {
41+
setMessage('Hello from Typink!');
42+
}
43+
},
44+
});
45+
} catch (error: any) {
46+
console.error('Error sending remark:', error);
47+
toaster.onTxError(error);
48+
}
49+
};
50+
51+
return (
52+
<VStack spacing={4} align='stretch' maxW='400px' mx='auto'>
53+
<Heading size='md'>Send System Remark</Heading>
54+
<Text fontSize='sm' color='gray.600'>
55+
Submit a remark transaction to the blockchain with your custom message.
56+
</Text>
57+
58+
<Input
59+
placeholder='Enter your remark message...'
60+
value={message}
61+
onChange={(e) => setMessage(e.target.value)}
62+
isDisabled={!connectedAccount || remarkTx.inBestBlockProgress}
63+
/>
64+
65+
{/* Estimated Fee Display */}
66+
{connectedAccount && message.trim() && (
67+
<Flex
68+
p={3}
69+
alignItems='center'
70+
justifyContent='space-between'
71+
bg='gray.50'
72+
borderRadius='md'
73+
border='1px solid'
74+
borderColor='gray.200'>
75+
<Text fontSize='sm' fontWeight='medium' color='gray.700'>
76+
Estimated Fee:
77+
</Text>
78+
{feeLoading ? (
79+
<Box display='flex' alignItems='center' mt={1}>
80+
<Spinner size='xs' mr={2} />
81+
<Text fontSize='sm' color='gray.600'>
82+
Calculating...
83+
</Text>
84+
</Box>
85+
) : feeError ? (
86+
<Text fontSize='sm' color='red.500' mt={1}>
87+
{feeError}
88+
</Text>
89+
) : estimatedFee ? (
90+
<Text fontSize='sm' color='blue.600' fontWeight='bold' mt={1}>
91+
{formatBalance(estimatedFee, network)}
92+
</Text>
93+
) : null}
94+
</Flex>
95+
)}
96+
97+
<Button
98+
colorScheme='blue'
99+
onClick={handleSendRemark}
100+
isLoading={remarkTx.inBestBlockProgress}
101+
isDisabled={!client || !connectedAccount || !message.trim() || remarkTx.inBestBlockProgress}
102+
loadingText='Sending...'>
103+
Send Remark Transaction
104+
</Button>
105+
106+
{!connectedAccount && (
107+
<Text fontSize='sm' color='orange.600' textAlign='center'>
108+
Please connect your wallet to send transactions
109+
</Text>
110+
)}
111+
</VStack>
112+
);
113+
}

0 commit comments

Comments
 (0)