@abstraxn/signer-react 3.1.4 → 3.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/CHANGELOG.md +32 -0
- package/README.md +79 -0
- package/dist/src/WalletModal.css +46 -0
- package/dist/src/WalletModal.js +23 -4
- package/dist/src/components/AbstraxnProvider/AbstraxnProvider.js +4 -1
- package/dist/src/components/AbstraxnProvider/AbstraxnProviderInner.js +91 -15
- package/dist/src/components/WalletModal/hooks/useSendTransaction.js +1 -1
- package/dist/src/components/WalletModal/utils/formatUtils.d.ts +1 -1
- package/dist/src/components/WalletModal/utils/formatUtils.js +3 -4
- package/dist/src/hooks.d.ts +80 -8
- package/dist/src/hooks.js +479 -1
- package/dist/src/index.d.ts +3 -3
- package/dist/src/index.js +1 -1
- package/dist/src/types.d.ts +38 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.3.0] - 2026-04-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Solana gasless relayer hooks** – Added `useSolanaGasless()` and `useSignAndSendSolanaGaslessTxn()` to support build/sign/send gasless flow from React apps.
|
|
13
|
+
- **Solana gasless provider config** – Added `solanaGasless` in `AbstraxnProviderConfig` for relayer settings (`enabled`, `relayerBaseUrl`, `apiKey`, `networkChainId`, and optional polling defaults).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Gasless signing path uses existing signer-react backend signing** – Gasless transaction signing now reuses `signSolanaTransactionViaAPI` (+ MFA step-up retry) so signatures continue to come from the existing Turnkey-backed signer flow instead of client-side signing.
|
|
18
|
+
- **Public exports extended** – Exported new Solana gasless hooks and related types from `@abstraxn/signer-react`.
|
|
19
|
+
|
|
20
|
+
## [3.2.0] - 2026-04-22
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Account Abstraction owner hook** – Added `useSmartAccountOwner()` to expose a viem-compatible custom local account for AA integrations (e.g. permissionless). The hook returns `{ smartAccountOwner, isReady, address }`, supports optional address override, and is exported from `@abstraxn/signer-react`.
|
|
25
|
+
- **EIP-7702 authorization signing support** – `smartAccountOwner` now implements `signAuthorization(...)` so 7702 flows can sign delegation authorizations through the SDK backend path.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **AA signing now uses backend signing APIs** – AA owner signing routes through existing authenticated backend signing methods (`signTransactionViaAPI` / `signTypedTxViaAPI`) instead of requiring app-level Turnkey account creation.
|
|
30
|
+
- **UserOperation message signing compatibility** – `signMessage` in AA owner flow now follows viem EIP-191 semantics (`hashMessage(...)` then sign digest) to match permissionless/simple account UserOperation signature expectations.
|
|
31
|
+
- **7702 payload encoding alignment** – 7702 authorization signing uses Turnkey-specific parameters:
|
|
32
|
+
- `encoding: PAYLOAD_ENCODING_EIP7702_AUTHORIZATION`
|
|
33
|
+
- `hashFunction: HASH_FUNCTION_NOT_APPLICABLE`
|
|
34
|
+
|
|
35
|
+
### Documentation
|
|
36
|
+
|
|
37
|
+
- **AA docs update** – Added `useSmartAccountOwner()` to hook list and expanded AA integration example in README.
|
|
38
|
+
- **Abstraxn infra example** – Updated README AA sample to use Abstraxn bundler/paymaster endpoints and dynamic chain-based URL construction.
|
|
39
|
+
|
|
8
40
|
## [3.1.4] - 2026-04-06
|
|
9
41
|
|
|
10
42
|
### Changed
|
package/README.md
CHANGED
|
@@ -27,6 +27,12 @@ import App from './App';
|
|
|
27
27
|
|
|
28
28
|
const providerConfig: AbstraxnProviderConfig = {
|
|
29
29
|
apiKey: 'your-api-key-here',
|
|
30
|
+
solanaGasless: {
|
|
31
|
+
enabled: true,
|
|
32
|
+
relayerBaseUrl: 'https://solana-hub.abstraxn.com',
|
|
33
|
+
apiKey: 'your-solana-relayer-api-key',
|
|
34
|
+
networkChainId: 101, // Solana mainnet
|
|
35
|
+
},
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
createRoot(document.getElementById('root')!).render(
|
|
@@ -105,6 +111,79 @@ function HookConnectButton() {
|
|
|
105
111
|
- `useExportWallet()` - Export wallet (EVM or Solana, based on current chain)
|
|
106
112
|
- `usePublicClient()` / `usePrepareRawTxn()` / `useSignTxn()` / `useSignAndSendTxn()` / `useWaitForTxnReceipt()` - EVM transaction flow (prepare → sign → send → confirm)
|
|
107
113
|
- `useSolanaConnection()` / `useSolanaPublicKey()` / `usePrepareSolanaTxn()` / `useSignSolanaTxn()` / `useSignAndSendSolanaTxn()` / `useWaitForSolanaConfirmation()` - Solana transaction flow (prepare → sign → send → confirm)
|
|
114
|
+
- `useSolanaGasless()` / `useSignAndSendSolanaGaslessTxn()` - Solana gasless flow using relayer build/send with Turnkey signing through signer-react
|
|
115
|
+
- `useSmartAccountOwner()` - Get an AA-compatible owner account for permissionless-style account abstraction flows
|
|
116
|
+
|
|
117
|
+
### Example: Account Abstraction owner (permissionless-compatible)
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { useMemo } from 'react';
|
|
121
|
+
import { useSmartAccountOwner } from '@abstraxn/signer-react';
|
|
122
|
+
import { createPublicClient, createWalletClient, http } from 'viem';
|
|
123
|
+
import { sepolia } from 'viem/chains';
|
|
124
|
+
import { toSimpleSmartAccount } from 'permissionless/accounts';
|
|
125
|
+
import {
|
|
126
|
+
createBundlerClient,
|
|
127
|
+
createPaymasterClient,
|
|
128
|
+
entryPoint07Address,
|
|
129
|
+
} from 'viem/account-abstraction';
|
|
130
|
+
|
|
131
|
+
export function useAaClient({
|
|
132
|
+
chain = sepolia,
|
|
133
|
+
rpcUrl = 'https://sepolia.rpc.thirdweb.com',
|
|
134
|
+
abstraxnApiKey,
|
|
135
|
+
}: {
|
|
136
|
+
chain?: Chain;
|
|
137
|
+
rpcUrl?: string;
|
|
138
|
+
abstraxnApiKey: string;
|
|
139
|
+
}) {
|
|
140
|
+
const { smartAccountOwner, isReady } = useSmartAccountOwner();
|
|
141
|
+
|
|
142
|
+
return useMemo(async () => {
|
|
143
|
+
if (!isReady || !smartAccountOwner) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const bundlerUrl = `https://bundler.abstraxn.com/api/v1/${chainId}/?apikey=${abstraxnApiKey}`;
|
|
148
|
+
const paymasterUrl = `https://paymaster.abstraxn.com/api/v2/${chainId}/?apikey=${abstraxnApiKey}`;
|
|
149
|
+
|
|
150
|
+
const publicClient = createPublicClient({
|
|
151
|
+
chain,
|
|
152
|
+
transport: http(rpcUrl),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// permissionless expects a wallet client owner
|
|
156
|
+
const ownerWalletClient = createWalletClient({
|
|
157
|
+
account: smartAccountOwner,
|
|
158
|
+
chain,
|
|
159
|
+
transport: http(rpcUrl),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const paymasterClient = createPaymasterClient({
|
|
163
|
+
transport: http(paymasterUrl),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const simpleSmartAccount = await toSimpleSmartAccount({
|
|
167
|
+
owner: ownerWalletClient,
|
|
168
|
+
client: publicClient,
|
|
169
|
+
entryPoint: {
|
|
170
|
+
address: entryPoint07Address,
|
|
171
|
+
version: '0.7',
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return createBundlerClient({
|
|
176
|
+
client: publicClient,
|
|
177
|
+
transport: http(bundlerUrl),
|
|
178
|
+
});
|
|
179
|
+
}, [isReady, smartAccountOwner, chain, rpcUrl, abstraxnApiKey]);
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Notes:
|
|
184
|
+
- `permissionless` and AA provider clients are app-level dependencies (not bundled in `@abstraxn/signer-react`).
|
|
185
|
+
- The owner returned by `useSmartAccountOwner()` is signer-only; you control chain, bundler, paymaster, and entry point in your app.
|
|
186
|
+
- After creating the `bundlerClient`, call `sendUserOperation({ account: simpleSmartAccount, calls: [...], paymaster: paymasterClient })`.
|
|
108
187
|
|
|
109
188
|
### Example: EVM → Solana flow after social/email login
|
|
110
189
|
|
package/dist/src/WalletModal.css
CHANGED
|
@@ -531,6 +531,52 @@
|
|
|
531
531
|
color: #6b7280;
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
.wallet-modal-asset-balance-skeleton {
|
|
535
|
+
display: inline-block;
|
|
536
|
+
width: 140px;
|
|
537
|
+
max-width: 100%;
|
|
538
|
+
height: 14px;
|
|
539
|
+
border-radius: 999px;
|
|
540
|
+
background: linear-gradient(90deg, rgba(156, 163, 175, 0.22), rgba(156, 163, 175, 0.44), rgba(156, 163, 175, 0.22));
|
|
541
|
+
background-size: 200% 100%;
|
|
542
|
+
animation: wallet-modal-balance-shimmer 1.2s ease-in-out infinite;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.wallet-modal-theme-dark .wallet-modal-asset-balance-skeleton {
|
|
546
|
+
background: linear-gradient(90deg, rgba(75, 85, 99, 0.3), rgba(107, 114, 128, 0.55), rgba(75, 85, 99, 0.3));
|
|
547
|
+
background-size: 200% 100%;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.wallet-modal-asset-explorer-link {
|
|
551
|
+
display: inline-flex;
|
|
552
|
+
width: fit-content;
|
|
553
|
+
margin-top: 2px;
|
|
554
|
+
font-size: 12px;
|
|
555
|
+
font-weight: 500;
|
|
556
|
+
text-decoration: none;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.wallet-modal-theme-dark .wallet-modal-asset-explorer-link {
|
|
560
|
+
color: #a855f7;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.wallet-modal-theme-light .wallet-modal-asset-explorer-link {
|
|
564
|
+
color: #9333ea;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.wallet-modal-asset-explorer-link:hover {
|
|
568
|
+
text-decoration: underline;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
@keyframes wallet-modal-balance-shimmer {
|
|
572
|
+
0% {
|
|
573
|
+
background-position: 100% 0;
|
|
574
|
+
}
|
|
575
|
+
100% {
|
|
576
|
+
background-position: -100% 0;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
534
580
|
.wallet-modal-asset-status {
|
|
535
581
|
position: absolute;
|
|
536
582
|
bottom: 0;
|
package/dist/src/WalletModal.js
CHANGED
|
@@ -36,6 +36,7 @@ export function WalletModal({ isOpen, onClose, onRampUrl }) {
|
|
|
36
36
|
const [showSendModal, setShowSendModal] = useState(false);
|
|
37
37
|
const [showManageModal, setShowManageModal] = useState(false);
|
|
38
38
|
const [showChainSelector, setShowChainSelector] = useState(false);
|
|
39
|
+
const [isBalanceRefreshing, setIsBalanceRefreshing] = useState(false);
|
|
39
40
|
const [selectedChainType, setSelectedChainType] = useState("evm");
|
|
40
41
|
// Export states
|
|
41
42
|
const [showExportWarning, setShowExportWarning] = useState(false);
|
|
@@ -59,19 +60,30 @@ export function WalletModal({ isOpen, onClose, onRampUrl }) {
|
|
|
59
60
|
// If no chain selected, show default
|
|
60
61
|
if (!balance || balance === 0n)
|
|
61
62
|
return "0";
|
|
62
|
-
return formatBalance(balance, 18, "ETH");
|
|
63
|
+
return formatBalance(balance, 18, "ETH", 4);
|
|
63
64
|
}
|
|
64
65
|
const symbol = currentChain.nativeCurrency?.symbol || "ETH";
|
|
65
66
|
const decimals = currentChain.nativeCurrency?.decimals || 18;
|
|
66
67
|
if (!balance || balance === 0n) {
|
|
67
68
|
return `0 ${symbol}`;
|
|
68
69
|
}
|
|
69
|
-
return formatBalance(balance, decimals, symbol);
|
|
70
|
+
return formatBalance(balance, decimals, symbol, 4);
|
|
70
71
|
}, [currentChain, externalWalletBalance, walletBalance]);
|
|
72
|
+
const chainExplorer = useMemo(() => {
|
|
73
|
+
if (!currentChain)
|
|
74
|
+
return null;
|
|
75
|
+
const url = currentChain.blockExplorer?.url || currentChain.explorerUrl;
|
|
76
|
+
if (!url)
|
|
77
|
+
return null;
|
|
78
|
+
return {
|
|
79
|
+
url,
|
|
80
|
+
label: currentChain.blockExplorer?.name || "Explorer",
|
|
81
|
+
};
|
|
82
|
+
}, [currentChain]);
|
|
71
83
|
// Refetch balance from RPC when UI screen changes (back button, modal open/close, etc.)
|
|
72
84
|
useEffect(() => {
|
|
73
85
|
if (isOpen && refetchBalance) {
|
|
74
|
-
refetchBalance();
|
|
86
|
+
void refetchBalance();
|
|
75
87
|
}
|
|
76
88
|
}, [isOpen, showReceiveModal, showSendModal, showManageModal, showChainSelector, refetchBalance]);
|
|
77
89
|
/** Load MFA status once per session (in-memory) when this wallet modal opens; not persisted. */
|
|
@@ -300,7 +312,7 @@ export function WalletModal({ isOpen, onClose, onRampUrl }) {
|
|
|
300
312
|
return (_jsx(ManageWalletModal, { isOpen: true, onClose: () => setShowManageModal(false), displayAddress: displayAddress, userEmail: user?.email, organizationName: whoami?.organizationName, isExternalWalletConnected: isExternalWalletConnected || false, onExportPrivateKey: handleExportPrivateKey, theme: theme }));
|
|
301
313
|
}
|
|
302
314
|
// Main Modal
|
|
303
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { "data-abstraxn-sdk-root": true, className: `wallet-modal-overlay wallet-modal-theme-${theme}`, onClick: showChainSelector ? undefined : onClose, style: showChainSelector ? { pointerEvents: "none" } : undefined, children: _jsxs("div", { className: `wallet-modal-content wallet-modal-theme-${theme} ${showChainSelector ? "selector-open" : ""}`, onClick: (e) => e.stopPropagation(), style: showChainSelector ? { pointerEvents: "auto" } : undefined, children: [_jsxs("div", { className: "wallet-modal-header", children: [_jsx("div", { className: "wallet-modal-header-left", children: _jsxs("div", { className: "wallet-modal-user-info", children: [_jsx("div", { className: "wallet-modal-avatar", children: _jsx(UserAvatar, { user: user, isExternalWalletConnected: isExternalWalletConnected || false }) }), _jsxs("div", { className: "wallet-modal-user-details", children: [_jsxs("div", { className: "wallet-modal-address", onClick: handleCopyAddress, children: [formatAddress(displayAddress || address), copied ? (_jsx("span", { className: "wallet-modal-copy-notification", children: "Copied!" })) : (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", className: "wallet-modal-copy-icon", children: [_jsx("path", { d: "M5.5 3.5H3.5C2.67157 3.5 2 4.17157 2 5V12.5C2 13.3284 2.67157 14 3.5 14H11C11.8284 14 12.5 13.3284 12.5 12.5V10.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("path", { d: "M5.5 3.5C5.5 2.67157 6.17157 2 7 2H12.5C13.3284 2 14 2.67157 14 3.5V9C14 9.82843 13.3284 10.5 12.5 10.5H7C6.17157 10.5 5.5 9.82843 5.5 9V3.5Z", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })] }))] }), user?.email && (_jsx("div", { className: "wallet-modal-email", children: user.email }))] })] }) }), _jsx("div", { className: "wallet-modal-header-right", children: _jsx("button", { className: "wallet-modal-close", onClick: onClose, "aria-label": "Close", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M18 6L6 18M6 6L18 18", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) }) })] }), !isExternalWalletConnected && (_jsxs("div", { className: "wallet-modal-actions", children: [_jsxs("button", { className: "wallet-modal-action-btn", onClick: handleSend, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "22", y1: "2", x2: "11", y2: "13" }), _jsx("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })] }), _jsx("span", { children: "Send" })] }), _jsxs("button", { className: "wallet-modal-action-btn", onClick: handleReceive, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), _jsx("polyline", { points: "7 10 12 15 17 10" }), _jsx("line", { x1: "12", y1: "15", x2: "12", y2: "3" })] }), _jsx("span", { children: "Receive" })] }), _jsxs("button", { className: "wallet-modal-action-btn", onClick: handleBuy, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "12", y1: "5", x2: "12", y2: "19" }), _jsx("line", { x1: "5", y1: "12", x2: "19", y2: "12" })] }), _jsx("span", { children: "Buy" })] })] })), !isExternalWalletConnected && currentChain && (_jsxs("div", { className: "wallet-modal-asset", onClick: () => setShowChainSelector(true), children: [_jsxs("div", { className: "wallet-modal-asset-info", children: [_jsxs("div", { className: "wallet-modal-asset-icon-wrapper", children: [currentChain.iconUrl ? (_jsx("img", { src: currentChain.iconUrl, alt: currentChain.displayName, className: "wallet-modal-asset-icon-img" })) : (_jsx("div", { className: "wallet-modal-asset-icon", children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("path", { d: "M12 2v20M2 12h20" })] }) })), _jsx("span", { className: "wallet-modal-asset-status" })] }), _jsxs("div", { className: "wallet-modal-asset-details", children: [_jsx("div", { className: "wallet-modal-asset-name", children: currentChain.displayName }), _jsx("div", { className: "wallet-modal-asset-balance", children: balanceDisplay })] })] }), _jsx("div", { className: "wallet-modal-asset-arrow", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "9 18 15 12 9 6" }) }) })] })), _jsxs("div", { className: "wallet-modal-menu", children: [_jsxs("button", { className: "wallet-modal-menu-item", onClick: handleManageWallet, children: [_jsx(LuWallet, { size: 20 }), _jsx("span", { children: "Manage Wallet" })] }), _jsxs("button", { className: "wallet-modal-menu-item wallet-modal-menu-item-disconnect", onClick: handleDisconnect, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", children: [_jsx("path", { d: "M7 3H4C3.44772 3 3 3.44772 3 4V16C3 16.5523 3.44772 17 4 17H7", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("path", { d: "M13 7L17 10L13 13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("path", { d: "M17 10H9", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] }), _jsx("span", { children: "Disconnect" })] })] })] }) }), showChainSelector && (_jsx("div", { "data-abstraxn-sdk-root": true, className: `wallet-modal-overlay wallet-modal-theme-${theme}`, style: { zIndex: 10006, pointerEvents: "auto" }, onClick: (e) => {
|
|
315
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { "data-abstraxn-sdk-root": true, className: `wallet-modal-overlay wallet-modal-theme-${theme}`, onClick: showChainSelector ? undefined : onClose, style: showChainSelector ? { pointerEvents: "none" } : undefined, children: _jsxs("div", { className: `wallet-modal-content wallet-modal-theme-${theme} ${showChainSelector ? "selector-open" : ""}`, onClick: (e) => e.stopPropagation(), style: showChainSelector ? { pointerEvents: "auto" } : undefined, children: [_jsxs("div", { className: "wallet-modal-header", children: [_jsx("div", { className: "wallet-modal-header-left", children: _jsxs("div", { className: "wallet-modal-user-info", children: [_jsx("div", { className: "wallet-modal-avatar", children: _jsx(UserAvatar, { user: user, isExternalWalletConnected: isExternalWalletConnected || false }) }), _jsxs("div", { className: "wallet-modal-user-details", children: [_jsxs("div", { className: "wallet-modal-address", onClick: handleCopyAddress, children: [formatAddress(displayAddress || address), copied ? (_jsx("span", { className: "wallet-modal-copy-notification", children: "Copied!" })) : (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", className: "wallet-modal-copy-icon", children: [_jsx("path", { d: "M5.5 3.5H3.5C2.67157 3.5 2 4.17157 2 5V12.5C2 13.3284 2.67157 14 3.5 14H11C11.8284 14 12.5 13.3284 12.5 12.5V10.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("path", { d: "M5.5 3.5C5.5 2.67157 6.17157 2 7 2H12.5C13.3284 2 14 2.67157 14 3.5V9C14 9.82843 13.3284 10.5 12.5 10.5H7C6.17157 10.5 5.5 9.82843 5.5 9V3.5Z", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })] }))] }), user?.email && (_jsx("div", { className: "wallet-modal-email", children: user.email }))] })] }) }), _jsx("div", { className: "wallet-modal-header-right", children: _jsx("button", { className: "wallet-modal-close", onClick: onClose, "aria-label": "Close", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M18 6L6 18M6 6L18 18", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) }) })] }), !isExternalWalletConnected && (_jsxs("div", { className: "wallet-modal-actions", children: [_jsxs("button", { className: "wallet-modal-action-btn", onClick: handleSend, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "22", y1: "2", x2: "11", y2: "13" }), _jsx("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })] }), _jsx("span", { children: "Send" })] }), _jsxs("button", { className: "wallet-modal-action-btn", onClick: handleReceive, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), _jsx("polyline", { points: "7 10 12 15 17 10" }), _jsx("line", { x1: "12", y1: "15", x2: "12", y2: "3" })] }), _jsx("span", { children: "Receive" })] }), _jsxs("button", { className: "wallet-modal-action-btn", onClick: handleBuy, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "12", y1: "5", x2: "12", y2: "19" }), _jsx("line", { x1: "5", y1: "12", x2: "19", y2: "12" })] }), _jsx("span", { children: "Buy" })] })] })), !isExternalWalletConnected && currentChain && (_jsxs("div", { className: "wallet-modal-asset", onClick: () => setShowChainSelector(true), children: [_jsxs("div", { className: "wallet-modal-asset-info", children: [_jsxs("div", { className: "wallet-modal-asset-icon-wrapper", children: [currentChain.iconUrl ? (_jsx("img", { src: currentChain.iconUrl, alt: currentChain.displayName, className: "wallet-modal-asset-icon-img" })) : (_jsx("div", { className: "wallet-modal-asset-icon", children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("path", { d: "M12 2v20M2 12h20" })] }) })), _jsx("span", { className: "wallet-modal-asset-status" })] }), _jsxs("div", { className: "wallet-modal-asset-details", children: [_jsx("div", { className: "wallet-modal-asset-name", children: currentChain.displayName }), _jsx("div", { className: "wallet-modal-asset-balance", children: isBalanceRefreshing ? (_jsx("span", { className: "wallet-modal-asset-balance-skeleton", "aria-label": "Loading balance" })) : (balanceDisplay) }), chainExplorer?.url && (_jsx("a", { href: chainExplorer.url, target: "_blank", rel: "noopener noreferrer", className: "wallet-modal-asset-explorer-link", onClick: (e) => e.stopPropagation(), children: chainExplorer.label }))] })] }), _jsx("div", { className: "wallet-modal-asset-arrow", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "9 18 15 12 9 6" }) }) })] })), _jsxs("div", { className: "wallet-modal-menu", children: [_jsxs("button", { className: "wallet-modal-menu-item", onClick: handleManageWallet, children: [_jsx(LuWallet, { size: 20 }), _jsx("span", { children: "Manage Wallet" })] }), _jsxs("button", { className: "wallet-modal-menu-item wallet-modal-menu-item-disconnect", onClick: handleDisconnect, children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", children: [_jsx("path", { d: "M7 3H4C3.44772 3 3 3.44772 3 4V16C3 16.5523 3.44772 17 4 17H7", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("path", { d: "M13 7L17 10L13 13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("path", { d: "M17 10H9", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] }), _jsx("span", { children: "Disconnect" })] })] })] }) }), showChainSelector && (_jsx("div", { "data-abstraxn-sdk-root": true, className: `wallet-modal-overlay wallet-modal-theme-${theme}`, style: { zIndex: 10006, pointerEvents: "auto" }, onClick: (e) => {
|
|
304
316
|
e.stopPropagation();
|
|
305
317
|
setShowChainSelector(false);
|
|
306
318
|
}, children: _jsx("div", { className: `wallet-modal-content wallet-modal-theme-${theme}`, onClick: (e) => e.stopPropagation(), children: _jsx(ChainSelector, { isOpen: showChainSelector, onClose: () => setShowChainSelector(false), currentChain: currentChain || null, availableChains: availableChains || [], onChainSelect: async (chainId) => {
|
|
@@ -317,7 +329,11 @@ export function WalletModal({ isOpen, onClose, onRampUrl }) {
|
|
|
317
329
|
// Switch to a different chain
|
|
318
330
|
if (switchChainEnhanced) {
|
|
319
331
|
try {
|
|
332
|
+
setIsBalanceRefreshing(true);
|
|
320
333
|
await switchChainEnhanced(chainId);
|
|
334
|
+
if (refetchBalance) {
|
|
335
|
+
await refetchBalance();
|
|
336
|
+
}
|
|
321
337
|
// Only close if switch was successful
|
|
322
338
|
setShowChainSelector(false);
|
|
323
339
|
}
|
|
@@ -325,6 +341,9 @@ export function WalletModal({ isOpen, onClose, onRampUrl }) {
|
|
|
325
341
|
console.error("Failed to switch chain:", error);
|
|
326
342
|
// Don't close the selector on error so user can try again
|
|
327
343
|
}
|
|
344
|
+
finally {
|
|
345
|
+
setIsBalanceRefreshing(false);
|
|
346
|
+
}
|
|
328
347
|
}
|
|
329
348
|
else {
|
|
330
349
|
console.warn("switchChainEnhanced not available");
|
|
@@ -14,6 +14,9 @@ import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react
|
|
|
14
14
|
import { PhantomWalletAdapter, SolflareWalletAdapter } from "@solana/wallet-adapter-wallets";
|
|
15
15
|
export function AbstraxnProvider({ config, children }) {
|
|
16
16
|
const externalWalletsEnabled = config.externalWallets?.enabled ?? false;
|
|
17
|
+
const solanaEndpoint = config.chains?.solanaEndpoint ??
|
|
18
|
+
config.solanaEndpoint ??
|
|
19
|
+
"https://api.mainnet-beta.solana.com";
|
|
17
20
|
const externalSsr = config.externalWallets?.ssr ?? false;
|
|
18
21
|
// Get or create QueryClient - tries to use existing one from context first
|
|
19
22
|
// This ensures we use the app's QueryClient instance if it exists, preventing multiple instances
|
|
@@ -197,7 +200,7 @@ export function AbstraxnProvider({ config, children }) {
|
|
|
197
200
|
}
|
|
198
201
|
// Do NOT pass key to WagmiProvider. Remounting loses connection state and causes
|
|
199
202
|
// "Connector not connected" in Next.js (e.g. when config loads after hydration).
|
|
200
|
-
return (_jsx(QueryClientWrapper, { queryClient: queryClient, children: _jsx(ConnectionProvider, { endpoint:
|
|
203
|
+
return (_jsx(QueryClientWrapper, { queryClient: queryClient, children: _jsx(ConnectionProvider, { endpoint: solanaEndpoint, children: _jsx(WalletProvider, { wallets: solanaWallets, autoConnect: true, children: _jsx(WagmiProvider, { config: wagmiConfig, initialState: config.initialState, children: _jsx(AbstraxnProviderWithWagmi, { config: config, children: children }) }) }) }) }));
|
|
201
204
|
}
|
|
202
205
|
// If external wallets are disabled, use the provider without wagmi
|
|
203
206
|
return (_jsx(AbstraxnProviderWithoutWagmi, { config: config, children: children }));
|
|
@@ -3225,6 +3225,60 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3225
3225
|
const currentChainId = isExternalWalletConnected && externalWalletChainId
|
|
3226
3226
|
? externalWalletChainId
|
|
3227
3227
|
: chainId;
|
|
3228
|
+
// Custom chains passed via `config.chains` (array form) are not part of the static `EVM_CHAINS` map.
|
|
3229
|
+
// Normalize them into `ChainData` so the wallet UI can resolve chain + balance correctly.
|
|
3230
|
+
const customChainsById = useMemo(() => {
|
|
3231
|
+
const chainConfig = config.chains;
|
|
3232
|
+
const map = new Map();
|
|
3233
|
+
if (!Array.isArray(chainConfig) || chainConfig.length === 0)
|
|
3234
|
+
return map;
|
|
3235
|
+
for (const raw of chainConfig) {
|
|
3236
|
+
if (!raw || typeof raw.id !== "number" || !raw.nativeCurrency)
|
|
3237
|
+
continue;
|
|
3238
|
+
// Extract RPC URL (support viem shape + sdk-integrate custom shape)
|
|
3239
|
+
let rpcUrl;
|
|
3240
|
+
if (raw.rpcUrls?.default?.http) {
|
|
3241
|
+
rpcUrl = Array.isArray(raw.rpcUrls.default.http)
|
|
3242
|
+
? raw.rpcUrls.default.http[0]
|
|
3243
|
+
: raw.rpcUrls.default.http;
|
|
3244
|
+
}
|
|
3245
|
+
else if (raw.rpcUrls?.public?.http) {
|
|
3246
|
+
rpcUrl = Array.isArray(raw.rpcUrls.public.http)
|
|
3247
|
+
? raw.rpcUrls.public.http[0]
|
|
3248
|
+
: raw.rpcUrls.public.http;
|
|
3249
|
+
}
|
|
3250
|
+
else if (typeof raw.rpcUrl === "string") {
|
|
3251
|
+
rpcUrl = raw.rpcUrl;
|
|
3252
|
+
}
|
|
3253
|
+
if (!rpcUrl || rpcUrl.trim() === "")
|
|
3254
|
+
continue;
|
|
3255
|
+
const explorerUrl = raw.blockExplorers?.default?.url ||
|
|
3256
|
+
raw.explorerUrl || '';
|
|
3257
|
+
const displayName = raw.displayName || raw.name || `Chain ${raw.id}`;
|
|
3258
|
+
const name = (raw.name ? String(raw.name) : `chain-${raw.id}`)
|
|
3259
|
+
.toLowerCase()
|
|
3260
|
+
.replace(/\s+/g, "-");
|
|
3261
|
+
const isTestnet = raw.testnet === true ||
|
|
3262
|
+
raw.isTestnet === true ||
|
|
3263
|
+
// Heuristic fallback for unknown custom chains
|
|
3264
|
+
(raw.id !== 1 && raw.id !== 10 && raw.id !== 137 && raw.id !== 8453 && raw.id !== 42161 && raw.id !== 2741);
|
|
3265
|
+
const chainData = {
|
|
3266
|
+
id: raw.id,
|
|
3267
|
+
name,
|
|
3268
|
+
displayName,
|
|
3269
|
+
rpcUrl,
|
|
3270
|
+
explorerUrl,
|
|
3271
|
+
iconUrl: raw.iconUrl,
|
|
3272
|
+
icon: raw.icon,
|
|
3273
|
+
nativeCurrency: raw.nativeCurrency,
|
|
3274
|
+
type: "evm",
|
|
3275
|
+
isTestnet,
|
|
3276
|
+
blockExplorer: raw.blockExplorer,
|
|
3277
|
+
};
|
|
3278
|
+
map.set(chainData.id, chainData);
|
|
3279
|
+
}
|
|
3280
|
+
return map;
|
|
3281
|
+
}, [config.chains]);
|
|
3228
3282
|
// Fetch balance for Abstraxn wallet (not external wallet) - reusable for on-demand refetch
|
|
3229
3283
|
const refetchWalletBalance = useCallback(async () => {
|
|
3230
3284
|
if (isExternalWalletConnected || !address || !currentChainId) {
|
|
@@ -3232,7 +3286,7 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3232
3286
|
return;
|
|
3233
3287
|
}
|
|
3234
3288
|
try {
|
|
3235
|
-
const currentChain = getChainById(currentChainId);
|
|
3289
|
+
const currentChain = getChainById(currentChainId) || customChainsById.get(currentChainId);
|
|
3236
3290
|
if (!currentChain || currentChain.type !== "evm") {
|
|
3237
3291
|
setWalletBalance(null);
|
|
3238
3292
|
return;
|
|
@@ -3259,24 +3313,35 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3259
3313
|
console.error("Failed to fetch balance:", error);
|
|
3260
3314
|
setWalletBalance(null);
|
|
3261
3315
|
}
|
|
3262
|
-
}, [address, currentChainId, isExternalWalletConnected]);
|
|
3316
|
+
}, [address, currentChainId, isExternalWalletConnected, customChainsById]);
|
|
3263
3317
|
useEffect(() => {
|
|
3264
3318
|
refetchWalletBalance();
|
|
3265
3319
|
}, [refetchWalletBalance]);
|
|
3266
3320
|
// Single refetch for UI: Abstraxn wallet = RPC call, external wallet = wagmi refetch
|
|
3267
|
-
const refetchBalance = useCallback(() => {
|
|
3321
|
+
const refetchBalance = useCallback(async () => {
|
|
3268
3322
|
if (isExternalWalletConnected && wagmiBalance?.refetch) {
|
|
3269
|
-
|
|
3323
|
+
await wagmiBalance.refetch();
|
|
3270
3324
|
return;
|
|
3271
3325
|
}
|
|
3272
|
-
|
|
3326
|
+
await refetchWalletBalance();
|
|
3273
3327
|
}, [isExternalWalletConnected, wagmiBalance, refetchWalletBalance]);
|
|
3274
3328
|
// Compute available chains from config - supports both legacy and new format
|
|
3275
3329
|
const availableChains = useMemo(() => {
|
|
3276
3330
|
const chains = [];
|
|
3277
3331
|
const chainConfig = config.chains;
|
|
3278
|
-
// Priority 1: Check
|
|
3279
|
-
if (chainConfig
|
|
3332
|
+
// Priority 1: Check if chains is an array of viem/custom chain objects
|
|
3333
|
+
if (Array.isArray(chainConfig) && chainConfig.length > 0) {
|
|
3334
|
+
for (const raw of chainConfig) {
|
|
3335
|
+
const normalized = raw && typeof raw.id === "number"
|
|
3336
|
+
? customChainsById.get(raw.id)
|
|
3337
|
+
: undefined;
|
|
3338
|
+
if (normalized)
|
|
3339
|
+
chains.push(normalized);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
// Priority 2: Check new chains config format
|
|
3343
|
+
if (!Array.isArray(chainConfig) &&
|
|
3344
|
+
chainConfig?.supportedEvmChains &&
|
|
3280
3345
|
chainConfig.supportedEvmChains.length > 0) {
|
|
3281
3346
|
// Use configured chains from new format
|
|
3282
3347
|
chainConfig.supportedEvmChains.forEach((chainName) => {
|
|
@@ -3286,8 +3351,10 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3286
3351
|
}
|
|
3287
3352
|
});
|
|
3288
3353
|
}
|
|
3289
|
-
// Priority
|
|
3290
|
-
else if (
|
|
3354
|
+
// Priority 3: Check legacy supportedChains format
|
|
3355
|
+
else if (!Array.isArray(chainConfig) &&
|
|
3356
|
+
config.supportedChains &&
|
|
3357
|
+
config.supportedChains.length > 0) {
|
|
3291
3358
|
// Convert legacy Chain format to ChainData
|
|
3292
3359
|
config.supportedChains.forEach((legacyChain) => {
|
|
3293
3360
|
// Try to find matching chain by ID
|
|
@@ -3313,7 +3380,7 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3313
3380
|
}
|
|
3314
3381
|
});
|
|
3315
3382
|
}
|
|
3316
|
-
// Priority
|
|
3383
|
+
// Priority 4: Default chains only if nothing configured at all
|
|
3317
3384
|
// Only show defaults if both config.chains and config.supportedChains are missing
|
|
3318
3385
|
else if (!chainConfig && !config.supportedChains) {
|
|
3319
3386
|
chains.push(EVM_CHAINS.ethereum, EVM_CHAINS.polygon, EVM_CHAINS.base);
|
|
@@ -3330,13 +3397,15 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3330
3397
|
: !chainConfig && !config.supportedChains
|
|
3331
3398
|
? [EVM_CHAINS.ethereum, EVM_CHAINS.polygon, EVM_CHAINS.base]
|
|
3332
3399
|
: [];
|
|
3333
|
-
}, [config.chains, config.supportedChains]);
|
|
3400
|
+
}, [config.chains, config.supportedChains, customChainsById]);
|
|
3334
3401
|
// Get current chain data
|
|
3335
3402
|
const currentChain = useMemo(() => {
|
|
3336
3403
|
if (!currentChainId)
|
|
3337
3404
|
return null;
|
|
3338
|
-
return getChainById(currentChainId) ||
|
|
3339
|
-
|
|
3405
|
+
return (getChainById(currentChainId) ||
|
|
3406
|
+
customChainsById.get(currentChainId) ||
|
|
3407
|
+
null);
|
|
3408
|
+
}, [currentChainId, customChainsById]);
|
|
3340
3409
|
// Enhanced switchChain that works for both EVM and Solana
|
|
3341
3410
|
const switchChainEnhanced = useCallback(async (targetChainId) => {
|
|
3342
3411
|
if (!walletRef.current) {
|
|
@@ -3347,7 +3416,7 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3347
3416
|
if (!targetChainInAvailable) {
|
|
3348
3417
|
throw new Error(`Chain with ID ${targetChainId} is not available. Please configure it in your SDK setup.`);
|
|
3349
3418
|
}
|
|
3350
|
-
const targetChain = getChainById(targetChainId);
|
|
3419
|
+
const targetChain = getChainById(targetChainId) || customChainsById.get(targetChainId);
|
|
3351
3420
|
if (!targetChain) {
|
|
3352
3421
|
throw new Error(`Chain with ID ${targetChainId} is not supported`);
|
|
3353
3422
|
}
|
|
@@ -3407,7 +3476,13 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3407
3476
|
finally {
|
|
3408
3477
|
setLoading(false);
|
|
3409
3478
|
}
|
|
3410
|
-
}, [
|
|
3479
|
+
}, [
|
|
3480
|
+
isExternalWalletConnected,
|
|
3481
|
+
wagmiSwitchChain,
|
|
3482
|
+
switchChain,
|
|
3483
|
+
availableChains,
|
|
3484
|
+
customChainsById,
|
|
3485
|
+
]);
|
|
3411
3486
|
const setEmailForOtp = useCallback((email) => {
|
|
3412
3487
|
setEmailForOtpState(email);
|
|
3413
3488
|
}, []);
|
|
@@ -3611,6 +3686,7 @@ export function AbstraxnProviderInner({ config, children, base, wagmi, solana, }
|
|
|
3611
3686
|
setEmailForOtp: setEmailForOtp,
|
|
3612
3687
|
// Global config flags
|
|
3613
3688
|
enableSolana: config.chains?.enableSolana,
|
|
3689
|
+
solanaGaslessConfig: config.solanaGasless,
|
|
3614
3690
|
};
|
|
3615
3691
|
// Ref to store latest value to avoid dependency issues (defined after value)
|
|
3616
3692
|
const valueRef = useRef(value);
|
|
@@ -95,7 +95,7 @@ export function useSendTransaction({ sendTransaction, currentChain, fromAddress,
|
|
|
95
95
|
data: '0x',
|
|
96
96
|
chainId: currentChain.id,
|
|
97
97
|
gas: {
|
|
98
|
-
gasLimit,
|
|
98
|
+
gasLimit: gasLimit,
|
|
99
99
|
maxFeePerGas: gasFees.maxFeePerGas,
|
|
100
100
|
maxPriorityFeePerGas: gasFees.maxPriorityFeePerGas,
|
|
101
101
|
gasPrice: gasFees.gasPrice,
|
|
@@ -9,7 +9,7 @@ export declare function formatAddress(addr: string | null | undefined): string;
|
|
|
9
9
|
* Formats a balance with proper decimals and symbol
|
|
10
10
|
* Removes trailing zeros and unnecessary decimals
|
|
11
11
|
*/
|
|
12
|
-
export declare function formatBalance(balance: bigint | null | undefined, decimals: number, symbol: string): string;
|
|
12
|
+
export declare function formatBalance(balance: bigint | null | undefined, decimals: number, symbol: string, maxFractionDigits?: number): string;
|
|
13
13
|
/**
|
|
14
14
|
* Formats a balance value (number) with proper decimals
|
|
15
15
|
*/
|
|
@@ -15,13 +15,12 @@ export function formatAddress(addr) {
|
|
|
15
15
|
* Formats a balance with proper decimals and symbol
|
|
16
16
|
* Removes trailing zeros and unnecessary decimals
|
|
17
17
|
*/
|
|
18
|
-
export function formatBalance(balance, decimals, symbol) {
|
|
18
|
+
export function formatBalance(balance, decimals, symbol, maxFractionDigits = decimals) {
|
|
19
19
|
if (!balance || balance === 0n)
|
|
20
20
|
return `0 ${symbol}`;
|
|
21
21
|
const balanceValue = Number(balance) / Math.pow(10, decimals);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const formatted = balanceValue.toFixed(decimals);
|
|
22
|
+
const safeFractionDigits = Math.max(0, Math.min(maxFractionDigits, decimals));
|
|
23
|
+
const formatted = balanceValue.toFixed(safeFractionDigits);
|
|
25
24
|
// Remove trailing zeros and decimal point if not needed
|
|
26
25
|
const trimmed = formatted.replace(/\.?0+$/, '');
|
|
27
26
|
return `${trimmed} ${symbol}`;
|