Skip to content

Commit d913fc4

Browse files
committed
Add payWithToken functionality for token-based payments
1 parent 69484ab commit d913fc4

File tree

29 files changed

+2792
-167
lines changed

29 files changed

+2792
-167
lines changed
Lines changed: 165 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
1-
import { createBaseAccountSDK } from '@base-org/account';
2-
import { Box, Button } from '@chakra-ui/react';
1+
import type { TokenPaymentSuccess } from '@base-org/account';
2+
import { createBaseAccountSDK, payWithToken } from '@base-org/account';
3+
import { CheckCircleIcon, ExternalLinkIcon } from '@chakra-ui/icons';
4+
import { Box, Button, Flex, HStack, Icon, Link, Text, VStack } from '@chakra-ui/react';
35
import { useCallback, useState } from 'react';
4-
import { numberToHex } from 'viem';
5-
import { baseSepolia } from 'viem/chains';
6+
import { formatUnits } from 'viem';
7+
8+
// Common token symbols and decimals
9+
const TOKEN_CONFIG: Record<string, { symbol: string; decimals: number }> = {
10+
'0xac1bd2486aaf3b5c0fc3fd868558b082a531b2b4': { symbol: 'USDC', decimals: 6 }, // Base USDC
11+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { symbol: 'USDC', decimals: 6 }, // Base mainnet USDC
12+
};
13+
14+
function getTokenInfo(tokenAddress: string) {
15+
const lowerAddress = tokenAddress.toLowerCase();
16+
return TOKEN_CONFIG[lowerAddress] || { symbol: 'Token', decimals: 18 };
17+
}
18+
19+
function stripChainPrefix(txHash: string): string {
20+
// Remove chain prefix if present (e.g., "base:0x..." -> "0x...")
21+
return txHash.includes(':') ? txHash.split(':')[1] : txHash;
22+
}
23+
24+
function getBlockExplorerUrl(chainId: number, txHash: string): string {
25+
const hash = stripChainPrefix(txHash);
26+
27+
const explorers: Record<number, string> = {
28+
8453: 'https://basescan.org/tx', // Base mainnet
29+
84532: 'https://sepolia.basescan.org/tx', // Base Sepolia
30+
};
31+
32+
return `${explorers[chainId] || 'https://basescan.org/tx'}/${hash}`;
33+
}
634

735
export function SendCalls({
836
sdk,
@@ -11,48 +39,45 @@ export function SendCalls({
1139
sdk: ReturnType<typeof createBaseAccountSDK>;
1240
subAccountAddress: string;
1341
}) {
14-
const [state, setState] = useState<string>();
15-
const handleSendCalls = useCallback(async () => {
16-
if (!sdk) {
17-
return;
18-
}
42+
const [paymentResult, setPaymentResult] = useState<TokenPaymentSuccess | null>(null);
43+
const [isLoading, setIsLoading] = useState(false);
44+
const [error, setError] = useState<string | null>(null);
45+
46+
const handlePayWithToken = useCallback(async () => {
47+
setIsLoading(true);
48+
setError(null);
49+
setPaymentResult(null);
1950

20-
const provider = sdk.getProvider();
2151
try {
22-
const response = await provider.request({
23-
method: 'wallet_sendCalls',
24-
params: [
25-
{
26-
chainId: numberToHex(baseSepolia.id),
27-
from: subAccountAddress,
28-
calls: [
29-
{
30-
to: '0x000000000000000000000000000000000000dead',
31-
data: '0x',
32-
value: '0x0',
33-
},
34-
],
35-
version: '1',
36-
capabilities: {
37-
paymasterService: {
38-
url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O',
39-
},
40-
},
41-
},
42-
],
52+
// Example payment with token
53+
const result = await payWithToken({
54+
amount: '100000000000000000000', // 100 tokens (18 decimals)
55+
to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
56+
token: '0xAC1Bd2486aAf3B5C0fc3Fd868558b082a531B2B4', // USDC on Base Sepolia
57+
chainId: 8453,
58+
paymaster: {
59+
url: 'https://api.developer.coinbase.com/rpc/v1/base/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O',
60+
},
4361
});
44-
console.info('response', response);
45-
setState(response as string);
62+
63+
if (result.success) {
64+
setPaymentResult(result);
65+
}
4666
} catch (e) {
47-
console.error('error', e);
67+
console.error('Payment error:', e);
68+
setError(e instanceof Error ? e.message : 'Payment failed');
69+
} finally {
70+
setIsLoading(false);
4871
}
49-
}, [sdk, subAccountAddress]);
72+
}, []);
5073

5174
return (
52-
<>
75+
<VStack spacing={4} align="stretch" w="full">
5376
<Button
5477
w="full"
55-
onClick={handleSendCalls}
78+
onClick={handlePayWithToken}
79+
isLoading={isLoading}
80+
loadingText="Processing..."
5681
bg="blue.500"
5782
color="white"
5883
border="1px solid"
@@ -64,25 +89,115 @@ export function SendCalls({
6489
_hover: { bg: 'blue.700', borderColor: 'blue.700' },
6590
}}
6691
>
67-
Send Calls
92+
Pay with Token
6893
</Button>
69-
{state && (
94+
95+
{error && (
7096
<Box
71-
as="pre"
7297
w="full"
73-
p={2}
74-
bg="gray.50"
75-
borderRadius="md"
98+
p={4}
99+
bg="red.50"
100+
borderRadius="lg"
76101
border="1px solid"
77-
borderColor="gray.300"
78-
overflow="auto"
79-
whiteSpace="pre-wrap"
80-
color="gray.800"
81-
_dark={{ bg: 'gray.900', borderColor: 'gray.700', color: 'gray.200' }}
102+
borderColor="red.200"
103+
_dark={{ bg: 'red.900', borderColor: 'red.700' }}
82104
>
83-
{JSON.stringify(state, null, 2)}
105+
<HStack spacing={2}>
106+
<Text color="red.800" _dark={{ color: 'red.200' }} fontWeight="medium">
107+
❌ Payment Failed
108+
</Text>
109+
</HStack>
110+
<Text color="red.700" _dark={{ color: 'red.300' }} fontSize="sm" mt={2}>
111+
{error}
112+
</Text>
113+
</Box>
114+
)}
115+
116+
{paymentResult && (
117+
<Box
118+
w="full"
119+
p={6}
120+
bg="green.50"
121+
borderRadius="lg"
122+
border="1px solid"
123+
borderColor="green.200"
124+
_dark={{ bg: 'green.900', borderColor: 'green.700' }}
125+
>
126+
<HStack spacing={3} mb={6}>
127+
<Icon as={CheckCircleIcon} boxSize={6} color="green.600" _dark={{ color: 'green.400' }} />
128+
<Text fontSize="xl" fontWeight="bold" color="green.800" _dark={{ color: 'green.200' }}>
129+
Payment Successful!
130+
</Text>
131+
</HStack>
132+
133+
<VStack spacing={4} align="stretch">
134+
{/* Amount */}
135+
<Box>
136+
<Text fontSize="sm" color="gray.600" _dark={{ color: 'gray.400' }} mb={1}>
137+
Amount
138+
</Text>
139+
<Text fontSize="lg" fontWeight="semibold" color="gray.900" _dark={{ color: 'white' }}>
140+
{formatUnits(
141+
BigInt(paymentResult.tokenAmount),
142+
getTokenInfo(paymentResult.tokenAddress).decimals
143+
)}{' '}
144+
{paymentResult.token || getTokenInfo(paymentResult.tokenAddress).symbol}
145+
</Text>
146+
</Box>
147+
148+
{/* Recipient */}
149+
<Box>
150+
<Text fontSize="sm" color="gray.600" _dark={{ color: 'gray.400' }} mb={1}>
151+
Recipient
152+
</Text>
153+
<Flex align="center" gap={2}>
154+
<Text
155+
fontSize="md"
156+
fontFamily="mono"
157+
color="gray.700"
158+
_dark={{ color: 'gray.300' }}
159+
title={paymentResult.to}
160+
>
161+
{paymentResult.to}
162+
</Text>
163+
</Flex>
164+
</Box>
165+
166+
{/* Transaction ID */}
167+
<Box>
168+
<Text fontSize="sm" color="gray.600" _dark={{ color: 'gray.400' }} mb={1}>
169+
Transaction ID
170+
</Text>
171+
<HStack spacing={2}>
172+
<Text
173+
fontSize="sm"
174+
fontFamily="mono"
175+
color="gray.700"
176+
_dark={{ color: 'gray.300' }}
177+
wordBreak="break-all"
178+
>
179+
{stripChainPrefix(paymentResult.id)}
180+
</Text>
181+
</HStack>
182+
<Link
183+
href={getBlockExplorerUrl(paymentResult.chainId, paymentResult.id)}
184+
isExternal
185+
color="blue.600"
186+
_dark={{ color: 'blue.400' }}
187+
fontSize="sm"
188+
mt={2}
189+
display="inline-flex"
190+
alignItems="center"
191+
gap={1}
192+
_hover={{ textDecoration: 'underline' }}
193+
>
194+
View on Block Explorer
195+
<ExternalLinkIcon />
196+
</Link>
197+
</Box>
198+
</VStack>
84199
</Box>
85200
)}
86-
</>
201+
</VStack>
87202
);
88203
}

examples/testapp/src/pages/pay-playground/components/CodeEditor.module.css

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,58 @@
3232
color: #64748b;
3333
}
3434

35+
.presetContainer {
36+
padding: 1rem 1.5rem;
37+
border-bottom: 1px solid #e2e8f0;
38+
background: #f8fafc;
39+
display: flex;
40+
align-items: center;
41+
gap: 0.75rem;
42+
}
43+
44+
.presetLabel {
45+
display: flex;
46+
align-items: center;
47+
gap: 0.5rem;
48+
font-size: 0.875rem;
49+
font-weight: 500;
50+
color: #475569;
51+
white-space: nowrap;
52+
}
53+
54+
.presetIcon {
55+
width: 16px;
56+
height: 16px;
57+
color: #64748b;
58+
}
59+
60+
.presetSelect {
61+
flex: 1;
62+
padding: 0.5rem 0.75rem;
63+
font-size: 0.875rem;
64+
color: #0f172a;
65+
background: white;
66+
border: 1px solid #cbd5e1;
67+
border-radius: 6px;
68+
cursor: pointer;
69+
transition: all 0.2s;
70+
}
71+
72+
.presetSelect:hover:not(:disabled) {
73+
border-color: #94a3b8;
74+
}
75+
76+
.presetSelect:focus {
77+
outline: none;
78+
border-color: #0052ff;
79+
box-shadow: 0 0 0 3px rgba(0, 82, 255, 0.1);
80+
}
81+
82+
.presetSelect:disabled {
83+
opacity: 0.5;
84+
cursor: not-allowed;
85+
}
86+
3587
.checkboxContainer {
3688
padding: 1rem 1.5rem;
3789
border-bottom: 1px solid #e2e8f0;

0 commit comments

Comments
 (0)