create-sbc-app 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -12
- package/bin/cli.js +63 -9
- package/package.json +1 -2
- package/templates/README.md +11 -4
- package/templates/react/.env.template +2 -1
- package/templates/react/package.json.template +3 -5
- package/templates/react/src/App.tsx.template +94 -10
- package/templates/react-dynamic/.env.template +7 -0
- package/templates/react-dynamic/README.md.template +24 -0
- package/templates/react-dynamic/eslint.config.js.template +34 -0
- package/templates/react-dynamic/index.html.template +14 -0
- package/templates/react-dynamic/package.json.template +33 -0
- package/templates/react-dynamic/postcss.config.js.template +8 -0
- package/templates/react-dynamic/public/sbc-logo.png +0 -0
- package/templates/react-dynamic/src/App.css.template +5 -0
- package/templates/react-dynamic/src/App.tsx.template +344 -0
- package/templates/react-dynamic/src/env.d.ts.template +14 -0
- package/templates/react-dynamic/src/index.css.template +15 -0
- package/templates/react-dynamic/src/main.tsx.template +12 -0
- package/templates/react-dynamic/tailwind.config.js.template +13 -0
- package/templates/react-dynamic/tsconfig.json.template +18 -0
- package/templates/react-dynamic/vite.config.ts.template +11 -0
- package/templates/react-para/.env.template +7 -0
- package/templates/react-para/README.md.template +24 -0
- package/templates/react-para/eslint.config.js.template +34 -0
- package/templates/react-para/index.html.template +14 -0
- package/templates/react-para/package.json.template +35 -0
- package/templates/react-para/postcss.config.js.template +8 -0
- package/templates/react-para/public/sbc-logo.png +0 -0
- package/templates/react-para/src/App.tsx.template +361 -0
- package/templates/react-para/src/components/ConnectButton.tsx.template +99 -0
- package/templates/react-para/src/env.d.ts.template +14 -0
- package/templates/react-para/src/hooks/usePara.ts.template +34 -0
- package/templates/react-para/src/hooks/useParaViem.ts.template +61 -0
- package/templates/react-para/src/index.css.template +5 -0
- package/templates/react-para/src/main.tsx.template +12 -0
- package/templates/react-para/src/providers.tsx.template +39 -0
- package/templates/react-para/src/utils/permit.ts.template +217 -0
- package/templates/react-para/tailwind.config.js.template +13 -0
- package/templates/react-para/tsconfig.json.template +18 -0
- package/templates/react-para/vite.config.ts.template +73 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { Environment, ParaProvider } from '@getpara/react-sdk';
|
|
3
|
+
import '@getpara/react-sdk/styles.css';
|
|
4
|
+
import { useSbcPara } from '@stablecoin.xyz/react';
|
|
5
|
+
import { radiusTestnet, TestSBC_CONTRACT_ADDRESS } from '@stablecoin.xyz/core';
|
|
6
|
+
import { useAccount, useWallet, useSignMessage } from '@getpara/react-sdk';
|
|
7
|
+
import { baseSepolia, base, type Chain } from 'viem/chains';
|
|
8
|
+
import { createPublicClient, http, getAddress, parseUnits, encodeFunctionData, erc20Abi } from 'viem';
|
|
9
|
+
import { hexToBytes } from 'viem/utils';
|
|
10
|
+
import { useMemo, useState, useEffect } from 'react';
|
|
11
|
+
import { ConnectButton } from './components/ConnectButton';
|
|
12
|
+
import { buildPermitTypedData, hashPermitTypedData, hex32ToBase64, normalizeSignatureToRSV, deriveVForRS } from './utils/permit';
|
|
13
|
+
import { usePara } from './hooks/usePara';
|
|
14
|
+
|
|
15
|
+
const queryClient = new QueryClient();
|
|
16
|
+
|
|
17
|
+
const getChain = () => {
|
|
18
|
+
const chainEnv = import.meta.env.VITE_CHAIN;
|
|
19
|
+
if (chainEnv === 'base') return base;
|
|
20
|
+
if (chainEnv === 'radiusTestnet') return radiusTestnet;
|
|
21
|
+
return baseSepolia;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const chain = getChain();
|
|
25
|
+
const rpcUrl = import.meta.env.VITE_RPC_URL;
|
|
26
|
+
|
|
27
|
+
// Radius testnet uses TestSBC (a test token for development)
|
|
28
|
+
const TEST_SBC_DECIMALS = 6;
|
|
29
|
+
|
|
30
|
+
const SBC_TOKEN_ADDRESS = (chain: Chain) => {
|
|
31
|
+
if (chain.id === baseSepolia.id) return '0xf9FB20B8E097904f0aB7d12e9DbeE88f2dcd0F16';
|
|
32
|
+
if (chain.id === base.id) return '0xfdcC3dd6671eaB0709A4C0f3F53De9a333d80798';
|
|
33
|
+
if (chain.id === radiusTestnet.id) return TestSBC_CONTRACT_ADDRESS;
|
|
34
|
+
throw new Error('Unsupported chain');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const SBC_DECIMALS = (chain: Chain) => {
|
|
38
|
+
if (chain.id === baseSepolia.id) return 6;
|
|
39
|
+
if (chain.id === base.id) return 18;
|
|
40
|
+
if (chain.id === radiusTestnet.id) return TEST_SBC_DECIMALS;
|
|
41
|
+
throw new Error('Unsupported chain');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getTokenSymbol = (chain: Chain) => {
|
|
45
|
+
if (chain.id === radiusTestnet.id) return 'TestSBC';
|
|
46
|
+
return 'SBC';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const publicClient = createPublicClient({ chain, transport: http(rpcUrl) });
|
|
50
|
+
|
|
51
|
+
// ERC20 + EIP-2612 nonces helper ABI
|
|
52
|
+
const erc20PermitAbi = [
|
|
53
|
+
...erc20Abi,
|
|
54
|
+
{
|
|
55
|
+
"inputs": [
|
|
56
|
+
{ "internalType": "address", "name": "owner", "type": "address" }
|
|
57
|
+
],
|
|
58
|
+
"name": "nonces",
|
|
59
|
+
"outputs": [
|
|
60
|
+
{ "internalType": "uint256", "name": "", "type": "uint256" }
|
|
61
|
+
],
|
|
62
|
+
"stateMutability": "view",
|
|
63
|
+
"type": "function"
|
|
64
|
+
}
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
// Minimal permit ABI for encoding the permit call
|
|
68
|
+
const permitAbi = [
|
|
69
|
+
{
|
|
70
|
+
"inputs": [
|
|
71
|
+
{ "internalType": "address", "name": "owner", "type": "address" },
|
|
72
|
+
{ "internalType": "address", "name": "spender", "type": "address" },
|
|
73
|
+
{ "internalType": "uint256", "name": "value", "type": "uint256" },
|
|
74
|
+
{ "internalType": "uint256", "name": "deadline", "type": "uint256" },
|
|
75
|
+
{ "internalType": "uint8", "name": "v", "type": "uint8" },
|
|
76
|
+
{ "internalType": "bytes32", "name": "r", "type": "bytes32" },
|
|
77
|
+
{ "internalType": "bytes32", "name": "s", "type": "bytes32" }
|
|
78
|
+
],
|
|
79
|
+
"name": "permit",
|
|
80
|
+
"outputs": [],
|
|
81
|
+
"stateMutability": "nonpayable",
|
|
82
|
+
"type": "function"
|
|
83
|
+
}
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
const chainExplorer = (c: Chain) => {
|
|
87
|
+
if (c.id === baseSepolia.id) return 'https://sepolia.basescan.org';
|
|
88
|
+
if (c.id === base.id) return 'https://basescan.org';
|
|
89
|
+
if (c.id === radiusTestnet.id) return 'https://testnet.radiustech.xyz/testnet/explorer';
|
|
90
|
+
return '';
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function SmartAccountInfo({ account, refreshAccount, isLoadingAccount, accountError }: any) {
|
|
94
|
+
const [sbcBalance, setSbcBalance] = useState<string | null>(null);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!account?.address) return;
|
|
97
|
+
(async () => {
|
|
98
|
+
try {
|
|
99
|
+
const bal = await publicClient.readContract({
|
|
100
|
+
address: SBC_TOKEN_ADDRESS(chain) as `0x${string}`,
|
|
101
|
+
abi: erc20Abi,
|
|
102
|
+
functionName: 'balanceOf',
|
|
103
|
+
args: [account.address as `0x${string}`],
|
|
104
|
+
});
|
|
105
|
+
setSbcBalance((bal as bigint).toString());
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Failed to fetch smart account SBC balance:', error);
|
|
108
|
+
setSbcBalance('0');
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
}, [account?.address]);
|
|
112
|
+
|
|
113
|
+
if (!account) return null;
|
|
114
|
+
const fmtEth = (v: string | null) => v ? (Number(v) / 1e18).toFixed(6) : '0.000000';
|
|
115
|
+
const fmtSbc = (v: string | null) => v ? (Number(v) / Math.pow(10, SBC_DECIMALS(chain))).toFixed(2) : '0.00';
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="mb-6 p-4 bg-purple-50 border border-purple-200 rounded-lg">
|
|
119
|
+
<div className="flex justify-between items-center mb-2">
|
|
120
|
+
<h3 className="font-semibold text-purple-800">🔐 Smart Account Status</h3>
|
|
121
|
+
<button onClick={refreshAccount} disabled={isLoadingAccount} className="text-xs bg-purple-600 text-white px-3 py-1 rounded hover:bg-purple-700 disabled:opacity-50">{isLoadingAccount ? '🔄 Refreshing...' : '🔄 Refresh'}</button>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="space-y-2 text-sm">
|
|
124
|
+
<div className="flex justify-between"><span className="text-purple-700">Smart Account Address:</span><span className="font-mono text-xs text-purple-600 break-all">{account.address}</span></div>
|
|
125
|
+
<div className="flex justify-between"><span className="text-purple-700">Deployed:</span><span className="text-purple-600">{account.isDeployed ? '✅ Yes' : '⏳ On first transaction'}</span></div>
|
|
126
|
+
<div className="flex justify-between"><span className="text-purple-700">Nonce:</span><span className="text-purple-600">{account.nonce}</span></div>
|
|
127
|
+
<div className="flex justify-between"><span className="text-purple-700">ETH Balance:</span><span className="text-purple-600 font-mono text-xs">{fmtEth(account.balance)} ETH</span></div>
|
|
128
|
+
<div className="flex justify-between"><span className="text-purple-700">{getTokenSymbol(chain)} Balance:</span><span className="text-purple-600 font-mono text-xs">{fmtSbc(sbcBalance)} {getTokenSymbol(chain)}</span></div>
|
|
129
|
+
</div>
|
|
130
|
+
{accountError && <p className="mt-2 text-xs text-red-600">{String(accountError)}</p>}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function TransactionForm({ account, sbcAppKit }: { account: any; sbcAppKit: any }) {
|
|
136
|
+
const paraAccount = useAccount();
|
|
137
|
+
const { data: wallet } = useWallet();
|
|
138
|
+
const signMessage = useSignMessage();
|
|
139
|
+
const [recipient, setRecipient] = useState('');
|
|
140
|
+
const [amount, setAmount] = useState('1');
|
|
141
|
+
const [status, setStatus] = useState<'idle'|'loading'|'success'|'error'>('idle');
|
|
142
|
+
const [error, setError] = useState<string | null>(null);
|
|
143
|
+
const [result, setResult] = useState<any>(null);
|
|
144
|
+
const isValid = recipient && /^0x[a-fA-F0-9]{40}$/.test(recipient) && parseFloat(amount) > 0;
|
|
145
|
+
|
|
146
|
+
const sendTx = async () => {
|
|
147
|
+
if (!isValid || !account || !sbcAppKit || !wallet?.id) return;
|
|
148
|
+
try {
|
|
149
|
+
setStatus('loading'); setError(null); setResult(null);
|
|
150
|
+
// Determine Para owner address (external or embedded)
|
|
151
|
+
const isExternalWallet = paraAccount.isConnected && paraAccount.external?.evm?.address;
|
|
152
|
+
const isEmbeddedWallet = paraAccount.isConnected && paraAccount.embedded?.wallets && paraAccount.embedded.wallets.length > 0;
|
|
153
|
+
const walletAddress = isExternalWallet
|
|
154
|
+
? paraAccount.external?.evm?.address
|
|
155
|
+
: isEmbeddedWallet
|
|
156
|
+
? paraAccount.embedded.wallets?.[0]?.address
|
|
157
|
+
: null;
|
|
158
|
+
if (!walletAddress) throw new Error('No Para wallet address');
|
|
159
|
+
|
|
160
|
+
const owner = getAddress(walletAddress as `0x${string}`);
|
|
161
|
+
const spender = getAddress(account.address);
|
|
162
|
+
const value = parseUnits(amount, SBC_DECIMALS(chain));
|
|
163
|
+
const deadline = Math.floor(Date.now() / 1000) + 60 * 30;
|
|
164
|
+
const [nonce, tokenName] = await Promise.all([
|
|
165
|
+
publicClient.readContract({ address: SBC_TOKEN_ADDRESS(chain) as `0x${string}`, abi: erc20PermitAbi, functionName: 'nonces', args: [owner] }),
|
|
166
|
+
publicClient.readContract({ address: SBC_TOKEN_ADDRESS(chain) as `0x${string}`, abi: erc20Abi, functionName: 'name' })
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const typed = buildPermitTypedData({
|
|
170
|
+
tokenName: tokenName as string,
|
|
171
|
+
chainId: chain.id,
|
|
172
|
+
tokenAddress: SBC_TOKEN_ADDRESS(chain) as `0x${string}`,
|
|
173
|
+
owner,
|
|
174
|
+
spender,
|
|
175
|
+
value,
|
|
176
|
+
nonce: nonce as bigint,
|
|
177
|
+
deadline: BigInt(deadline)
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const digest = hashPermitTypedData(typed);
|
|
181
|
+
const digestBase64 = hex32ToBase64(digest);
|
|
182
|
+
const sigRes = await signMessage.signMessageAsync({ walletId: wallet.id, messageBase64: digestBase64 });
|
|
183
|
+
const paraSig = (sigRes as any)?.signatureBase64 || (sigRes as any)?.signature || (typeof sigRes === 'string' ? sigRes : '');
|
|
184
|
+
let { r, s, v } = normalizeSignatureToRSV(paraSig);
|
|
185
|
+
if (!v || v === 0) {
|
|
186
|
+
v = await deriveVForRS({ digest, r, s, owner });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const permitData = encodeFunctionData({
|
|
190
|
+
abi: permitAbi,
|
|
191
|
+
functionName: 'permit',
|
|
192
|
+
args: [owner, spender, value, BigInt(deadline), v, r, s]
|
|
193
|
+
});
|
|
194
|
+
const transferFromData = encodeFunctionData({
|
|
195
|
+
abi: erc20Abi,
|
|
196
|
+
functionName: 'transferFrom',
|
|
197
|
+
args: [owner, recipient as `0x${string}`, value]
|
|
198
|
+
});
|
|
199
|
+
const res = await sbcAppKit.sendUserOperation({
|
|
200
|
+
calls: [
|
|
201
|
+
{ to: SBC_TOKEN_ADDRESS(chain) as `0x${string}`, data: permitData },
|
|
202
|
+
{ to: SBC_TOKEN_ADDRESS(chain) as `0x${string}`, data: transferFromData }
|
|
203
|
+
]
|
|
204
|
+
});
|
|
205
|
+
setResult(res);
|
|
206
|
+
setStatus('success');
|
|
207
|
+
} catch (e: any) {
|
|
208
|
+
setError(e?.message || 'Transaction failed');
|
|
209
|
+
setStatus('error');
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
if (!account) return null;
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div className="p-4 bg-white border border-gray-200 rounded-lg shadow-sm">
|
|
217
|
+
<h3 className="font-semibold text-gray-800 mb-4">💸 Send {getTokenSymbol(chain)} Tokens</h3>
|
|
218
|
+
<div className="space-y-4">
|
|
219
|
+
<div>
|
|
220
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">Recipient Address</label>
|
|
221
|
+
<input type="text" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="0x..." className="w-full px-3 py-2 text-xs font-mono border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 border-gray-300" />
|
|
222
|
+
</div>
|
|
223
|
+
<div>
|
|
224
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">Amount ({getTokenSymbol(chain)})</label>
|
|
225
|
+
<input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="1.0" step="0.000001" min="0" className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 border-gray-300" />
|
|
226
|
+
</div>
|
|
227
|
+
<div className="p-3 bg-gray-50 rounded">
|
|
228
|
+
<div className="flex justify-between text-sm"><span>Amount:</span><span className="font-medium">{amount} {getTokenSymbol(chain)}</span></div>
|
|
229
|
+
<div className="flex justify-between text-xs text-gray-600"><span>Gas fees:</span><span>Covered by SBC Paymaster ✨</span></div>
|
|
230
|
+
<div className="flex justify-between text-xs text-gray-600"><span>Signing:</span><span>Your wallet will prompt to sign 🖊️</span></div>
|
|
231
|
+
</div>
|
|
232
|
+
<button onClick={sendTx} disabled={!isValid || status==='loading' || !account} className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
233
|
+
{status==='loading' ? 'Waiting for signature...' : `Send ${amount} ${getTokenSymbol(chain)}`}
|
|
234
|
+
</button>
|
|
235
|
+
{status==='success' && result && (
|
|
236
|
+
<div className="p-3 bg-green-50 border border-green-200 rounded">
|
|
237
|
+
<p className="text-sm text-green-800 font-medium">✅ Transaction Submitted</p>
|
|
238
|
+
<p className="text-xs text-green-600 font-mono break-all mt-1">
|
|
239
|
+
<a href={`${chainExplorer(chain)}/tx/${result.transactionHash}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">View transaction: {result.transactionHash}</a>
|
|
240
|
+
</p>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
{status==='error' && error && (
|
|
244
|
+
<div className="p-3 bg-red-50 border border-red-200 rounded">
|
|
245
|
+
<p className="text-sm text-red-800 font-medium">❌ Transaction Failed</p>
|
|
246
|
+
<p className="text-xs text-red-600 mt-1">{error}</p>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function ParaApp() {
|
|
255
|
+
const paraAccount = useAccount();
|
|
256
|
+
// Get Para viem clients (walletClient/account) to pass into SBC hook
|
|
257
|
+
const { publicClient: paraPublicClient, walletClient: paraWalletClient, account: paraViemAccount } = usePara();
|
|
258
|
+
const { data: wallet } = useWallet();
|
|
259
|
+
const signMsg = useSignMessage();
|
|
260
|
+
|
|
261
|
+
// Wrap walletClient.signMessage to route through Para signMessage (base64) and return 65-byte hex
|
|
262
|
+
const wrappedWalletClient = useMemo(() => {
|
|
263
|
+
if (!paraWalletClient || !paraViemAccount || !wallet?.id) return null;
|
|
264
|
+
const base = paraWalletClient as any;
|
|
265
|
+
const account = {
|
|
266
|
+
...paraViemAccount,
|
|
267
|
+
async signMessage({ message }: any) {
|
|
268
|
+
const toBytes = (m: any): Uint8Array => {
|
|
269
|
+
if (!m) throw new Error('signMessage: missing message');
|
|
270
|
+
if (typeof m === 'string') {
|
|
271
|
+
if (m.startsWith('0x')) return hexToBytes(m as `0x${string}`);
|
|
272
|
+
return new TextEncoder().encode(m);
|
|
273
|
+
}
|
|
274
|
+
const raw = m.raw ?? m.bytes ?? m.data ?? m;
|
|
275
|
+
if (typeof raw === 'string') {
|
|
276
|
+
if (raw.startsWith('0x')) return hexToBytes(raw as `0x${string}`);
|
|
277
|
+
return new TextEncoder().encode(raw);
|
|
278
|
+
}
|
|
279
|
+
if (raw instanceof Uint8Array) return raw;
|
|
280
|
+
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
|
281
|
+
if (Array.isArray(raw)) return new Uint8Array(raw);
|
|
282
|
+
throw new Error('signMessage: unsupported message format');
|
|
283
|
+
};
|
|
284
|
+
const rawBytes = toBytes(message);
|
|
285
|
+
const b64 = btoa(String.fromCharCode(...rawBytes));
|
|
286
|
+
const res = await signMsg.signMessageAsync({ walletId: wallet.id, messageBase64: b64 });
|
|
287
|
+
const sig = (res as any)?.signatureBase64 || (res as any)?.signature || (typeof res === 'string' ? res : '');
|
|
288
|
+
const { r, s, v } = normalizeSignatureToRSV(sig);
|
|
289
|
+
const vHex = (v < 27 ? v + 27 : v).toString(16).padStart(2, '0');
|
|
290
|
+
return (r + s.slice(2) + vHex) as `0x${string}`;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
return { ...base, account, async signMessage(args: any) { return account.signMessage(args); } };
|
|
294
|
+
}, [paraWalletClient, paraViemAccount, wallet?.id]);
|
|
295
|
+
|
|
296
|
+
const paraViemClients = useMemo(() => ({
|
|
297
|
+
publicClient: paraPublicClient,
|
|
298
|
+
walletClient: wrappedWalletClient || paraWalletClient,
|
|
299
|
+
account: paraViemAccount,
|
|
300
|
+
}), [paraPublicClient, wrappedWalletClient, paraWalletClient, paraViemAccount]);
|
|
301
|
+
|
|
302
|
+
const { sbcAppKit, isInitialized, error, account, isLoadingAccount, accountError, refreshAccount } = useSbcPara({
|
|
303
|
+
apiKey: import.meta.env.VITE_SBC_API_KEY,
|
|
304
|
+
chain,
|
|
305
|
+
paraAccount,
|
|
306
|
+
rpcUrl,
|
|
307
|
+
debug: true,
|
|
308
|
+
paraViemClients,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const isConnected = paraAccount.isConnected && (paraAccount.external?.evm?.address || paraAccount.embedded?.wallets?.length);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<>
|
|
315
|
+
<ConnectButton />
|
|
316
|
+
{isConnected && isInitialized && (
|
|
317
|
+
<>
|
|
318
|
+
<SmartAccountInfo account={account} refreshAccount={refreshAccount} isLoadingAccount={isLoadingAccount} accountError={accountError} />
|
|
319
|
+
<TransactionForm account={account} sbcAppKit={sbcAppKit} />
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
{error && <div className="error">{error.message}</div>}
|
|
323
|
+
</>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default function App() {
|
|
328
|
+
return (
|
|
329
|
+
<QueryClientProvider client={queryClient}>
|
|
330
|
+
<ParaProvider
|
|
331
|
+
paraClientConfig={{ apiKey: import.meta.env.VITE_PARA_API_KEY, env: Environment.PROD }}
|
|
332
|
+
config={{ appName: '{{projectName}}' }}
|
|
333
|
+
externalWalletConfig={{ wallets: ['METAMASK', 'COINBASE'] }}
|
|
334
|
+
>
|
|
335
|
+
<div className="min-h-screen bg-gray-50 py-8">
|
|
336
|
+
<div className="max-w-2xl mx-auto px-4">
|
|
337
|
+
<div className="text-center mb-8">
|
|
338
|
+
<h1 className="text-3xl font-bold text-gray-900 mb-2 flex items-center justify-center gap-3">
|
|
339
|
+
<img src="/sbc-logo.png" alt="SBC Logo" width={36} height={36} />
|
|
340
|
+
SBC (Para) Integration
|
|
341
|
+
</h1>
|
|
342
|
+
<p className="text-gray-600">Gasless transactions with Para Wallet</p>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<ParaApp />
|
|
346
|
+
|
|
347
|
+
<div className="mt-8 text-center text-xs text-gray-500">
|
|
348
|
+
<p>
|
|
349
|
+
Powered by{' '}
|
|
350
|
+
<a href="https://github.com/stablecoinxyz/app-kit" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">SBC AppKit</a>
|
|
351
|
+
{' '}• Para SDK integration
|
|
352
|
+
</p>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</ParaProvider>
|
|
357
|
+
</QueryClientProvider>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useModal, useAccount } from "@getpara/react-sdk";
|
|
3
|
+
import { createPublicClient, http } from 'viem';
|
|
4
|
+
import { erc20Abi } from 'viem';
|
|
5
|
+
import { baseSepolia, base } from 'viem/chains';
|
|
6
|
+
|
|
7
|
+
const chain = (import.meta.env.VITE_CHAIN === 'base') ? base : baseSepolia;
|
|
8
|
+
const rpcUrl = import.meta.env.VITE_RPC_URL;
|
|
9
|
+
|
|
10
|
+
const SBC_TOKEN_ADDRESS = (chain: any) => {
|
|
11
|
+
if (chain.id === baseSepolia.id) return '0xf9FB20B8E097904f0aB7d12e9DbeE88f2dcd0F16';
|
|
12
|
+
if (chain.id === base.id) return '0xfdcC3dd6671eaB0709A4C0f3F53De9a333d80798';
|
|
13
|
+
throw new Error('Unsupported chain');
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const SBC_DECIMALS = (chain: any) => chain.id === baseSepolia.id ? 6 : 18;
|
|
17
|
+
|
|
18
|
+
const publicClient = createPublicClient({ chain, transport: http(rpcUrl) });
|
|
19
|
+
|
|
20
|
+
export function ConnectButton() {
|
|
21
|
+
const { openModal } = useModal();
|
|
22
|
+
const account = useAccount();
|
|
23
|
+
const [balances, setBalances] = useState<{ eth: string | null; sbc: string | null }>({ eth: null, sbc: null });
|
|
24
|
+
const [isLoadingBalances, setIsLoadingBalances] = useState(false);
|
|
25
|
+
|
|
26
|
+
const isExternalWallet = account.isConnected && account.external?.evm?.address;
|
|
27
|
+
const isEmbeddedWallet = account.isConnected && account.embedded?.wallets && account.embedded.wallets.length > 0;
|
|
28
|
+
const walletAddress = isExternalWallet ? account.external?.evm?.address : isEmbeddedWallet ? account.embedded.wallets?.[0]?.address : null;
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!account.isConnected || !walletAddress) {
|
|
32
|
+
setBalances({ eth: null, sbc: null });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
(async () => {
|
|
36
|
+
setIsLoadingBalances(true);
|
|
37
|
+
try {
|
|
38
|
+
const [ethBalance, sbcBalance] = await Promise.all([
|
|
39
|
+
publicClient.getBalance({ address: walletAddress as `0x${string}` }),
|
|
40
|
+
publicClient.readContract({ address: SBC_TOKEN_ADDRESS(chain) as `0x${string}`, abi: erc20Abi, functionName: 'balanceOf', args: [walletAddress as `0x${string}`] }),
|
|
41
|
+
]);
|
|
42
|
+
setBalances({ eth: ethBalance.toString(), sbc: (sbcBalance as bigint).toString() });
|
|
43
|
+
} catch {
|
|
44
|
+
setBalances({ eth: null, sbc: null });
|
|
45
|
+
} finally {
|
|
46
|
+
setIsLoadingBalances(false);
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
}, [account.isConnected, walletAddress]);
|
|
50
|
+
|
|
51
|
+
const formatEthBalance = (balance: string | null): string => {
|
|
52
|
+
if (!balance) return '0.0000';
|
|
53
|
+
const ethValue = Number(balance) / 1e18;
|
|
54
|
+
return ethValue.toFixed(4);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const formatSbcBalance = (balance: string | null, decimals: number): string => {
|
|
58
|
+
if (!balance) return '0.0000';
|
|
59
|
+
const sbcValue = Number(balance) / Math.pow(10, decimals);
|
|
60
|
+
return sbcValue.toFixed(4);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
65
|
+
{account.isConnected && walletAddress ? (
|
|
66
|
+
<div>
|
|
67
|
+
<h3 className="font-semibold text-green-800 mb-1">
|
|
68
|
+
✅ {isExternalWallet ? 'External Wallet Connected' : 'Para Wallet Connected'}
|
|
69
|
+
</h3>
|
|
70
|
+
<p className="text-xs text-green-600 font-mono break-all mb-2">EOA: {walletAddress}</p>
|
|
71
|
+
<p className="text-xs text-green-600 mb-2">
|
|
72
|
+
{isExternalWallet ? 'External wallet via MetaMask/Coinbase' : 'Embedded Wallet via Para SDK'}
|
|
73
|
+
</p>
|
|
74
|
+
<p className="text-xs text-green-600 mb-2"><strong>Chain:</strong> {chain.name} (ID: {chain.id})</p>
|
|
75
|
+
<div className="mt-2 pt-2 border-t border-green-200">
|
|
76
|
+
<p className="text-xs font-medium text-green-700 mb-1">Wallet Balances:</p>
|
|
77
|
+
{isLoadingBalances ? (
|
|
78
|
+
<p className="text-xs text-green-600">Loading balances...</p>
|
|
79
|
+
) : (
|
|
80
|
+
<div className="flex gap-4">
|
|
81
|
+
<span className="text-xs text-green-600"><strong>ETH:</strong> {formatEthBalance(balances.eth)}</span>
|
|
82
|
+
<span className="text-xs text-green-600"><strong>SBC:</strong> {formatSbcBalance(balances.sbc, SBC_DECIMALS(chain))}</span>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
<button onClick={() => openModal()} className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 text-sm mr-2 mt-3">Manage Wallet</button>
|
|
87
|
+
</div>
|
|
88
|
+
) : (
|
|
89
|
+
<div>
|
|
90
|
+
<h3 className="font-semibold text-blue-800 mb-2">🔗 Connect to Para</h3>
|
|
91
|
+
<p className="text-sm text-blue-600 mb-3">Connect to a Para Embedded Wallet to start the demo.</p>
|
|
92
|
+
<button onClick={() => openModal()} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm">Connect to Para</button>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
interface ImportMetaEnv {
|
|
4
|
+
readonly VITE_SBC_API_KEY: string
|
|
5
|
+
readonly VITE_PARA_API_KEY: string
|
|
6
|
+
readonly VITE_CHAIN?: string
|
|
7
|
+
readonly VITE_RPC_URL?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ImportMeta {
|
|
11
|
+
readonly env: ImportMetaEnv
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useAccount, useWallet } from "@getpara/react-sdk";
|
|
2
|
+
import { useParaViem } from "./useParaViem";
|
|
3
|
+
import { baseSepolia, base } from 'viem/chains';
|
|
4
|
+
|
|
5
|
+
// default to baseSepolia, but can be overridden with VITE_CHAIN=base
|
|
6
|
+
const chain = (import.meta.env.VITE_CHAIN === 'base') ? base : baseSepolia;
|
|
7
|
+
const rpcUrl = import.meta.env.VITE_RPC_URL || 'https://sepolia.base.org';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Return Para wallet + viem clients for signing
|
|
11
|
+
*/
|
|
12
|
+
export function usePara() {
|
|
13
|
+
const { isConnected } = useAccount();
|
|
14
|
+
const { data: wallet } = useWallet();
|
|
15
|
+
const { clients, isLoading, error } = useParaViem(chain, rpcUrl);
|
|
16
|
+
|
|
17
|
+
const address = wallet?.address as `0x${string}` | undefined;
|
|
18
|
+
const walletId = wallet?.id;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
isConnected,
|
|
22
|
+
address,
|
|
23
|
+
walletId,
|
|
24
|
+
publicClient: clients?.publicClient || null,
|
|
25
|
+
walletClient: clients?.walletClient || null,
|
|
26
|
+
account: clients?.account || null,
|
|
27
|
+
isLoading,
|
|
28
|
+
error,
|
|
29
|
+
wallet: { isConnected, address, walletId },
|
|
30
|
+
viem: clients,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useAccount, useClient } from "@getpara/react-sdk";
|
|
2
|
+
import { createPublicClient, http, type WalletClient, type PublicClient, type LocalAccount } from "viem";
|
|
3
|
+
import { createParaAccount, createParaViemClient } from "@getpara/viem-v2-integration";
|
|
4
|
+
import { Chain } from "viem/chains";
|
|
5
|
+
import { useState, useEffect, useMemo } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hook to create viem clients for Para wallet integration
|
|
9
|
+
* Provides publicClient, walletClient, and account information for signing
|
|
10
|
+
*/
|
|
11
|
+
export function useParaViem(chain: Chain, rpcUrl: string) {
|
|
12
|
+
const { isConnected } = useAccount();
|
|
13
|
+
const para = useClient();
|
|
14
|
+
|
|
15
|
+
// Stable public client across renders unless chain/rpc changes
|
|
16
|
+
const publicClient = useMemo(() => createPublicClient({
|
|
17
|
+
chain,
|
|
18
|
+
transport: http(rpcUrl)
|
|
19
|
+
}), [chain.id, rpcUrl]);
|
|
20
|
+
|
|
21
|
+
const [clients, setClients] = useState<{
|
|
22
|
+
publicClient: PublicClient;
|
|
23
|
+
walletClient: WalletClient | null;
|
|
24
|
+
account: LocalAccount | null;
|
|
25
|
+
} | null>(null);
|
|
26
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
27
|
+
const [error, setError] = useState<Error | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let isMounted = true;
|
|
31
|
+
const setup = async () => {
|
|
32
|
+
setIsLoading(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
try {
|
|
35
|
+
if (isConnected && para) {
|
|
36
|
+
const viemAccount = createParaAccount(para);
|
|
37
|
+
const walletClient = createParaViemClient(para, {
|
|
38
|
+
account: viemAccount,
|
|
39
|
+
chain,
|
|
40
|
+
transport: http(rpcUrl)
|
|
41
|
+
});
|
|
42
|
+
if (isMounted) {
|
|
43
|
+
setClients({ publicClient, walletClient, account: viemAccount });
|
|
44
|
+
}
|
|
45
|
+
} else if (isMounted) {
|
|
46
|
+
setClients({ publicClient, walletClient: null, account: null });
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (isMounted) setError(err instanceof Error ? err : new Error('Failed to setup Para viem clients'));
|
|
50
|
+
} finally {
|
|
51
|
+
if (isMounted) setIsLoading(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
setup();
|
|
55
|
+
return () => { isMounted = false; };
|
|
56
|
+
}, [chain.id, rpcUrl, isConnected]);
|
|
57
|
+
|
|
58
|
+
return { clients, isLoading, error };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
2
|
+
import { Environment, ParaProvider } from "@getpara/react-sdk";
|
|
3
|
+
import "@getpara/react-sdk/styles.css";
|
|
4
|
+
|
|
5
|
+
const queryClient = new QueryClient();
|
|
6
|
+
|
|
7
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
8
|
+
return (
|
|
9
|
+
<QueryClientProvider client={queryClient}>
|
|
10
|
+
<ParaProvider
|
|
11
|
+
paraClientConfig={{
|
|
12
|
+
apiKey: import.meta.env.VITE_PARA_API_KEY,
|
|
13
|
+
env: Environment.PROD,
|
|
14
|
+
}}
|
|
15
|
+
config={{
|
|
16
|
+
appName: "{{projectName}}",
|
|
17
|
+
disableAutoSessionKeepAlive: false,
|
|
18
|
+
}}
|
|
19
|
+
externalWalletConfig={{
|
|
20
|
+
wallets: ["METAMASK", "COINBASE"],
|
|
21
|
+
walletConnect: { projectId: import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "" },
|
|
22
|
+
}}
|
|
23
|
+
paraModalConfig={{
|
|
24
|
+
logo: "/sbc-logo.png",
|
|
25
|
+
theme: { "borderRadius": "md" },
|
|
26
|
+
authLayout: ["EXTERNAL:FULL", "AUTH:FULL"],
|
|
27
|
+
oAuthMethods: [],
|
|
28
|
+
disablePhoneLogin: true,
|
|
29
|
+
recoverySecretStepEnabled: false,
|
|
30
|
+
onRampTestMode: false
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
</ParaProvider>
|
|
35
|
+
</QueryClientProvider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|