@zerodev/smart-routing-address-react-ui 0.0.1

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.
Files changed (100) hide show
  1. package/README.md +117 -0
  2. package/dist/_types/components/AddressDisplay/index.d.ts +43 -0
  3. package/dist/_types/components/AddressDisplay/index.d.ts.map +1 -0
  4. package/dist/_types/components/ErrorRetryCard/index.d.ts +21 -0
  5. package/dist/_types/components/ErrorRetryCard/index.d.ts.map +1 -0
  6. package/dist/_types/components/FeeBreakdown/feeInfo.d.ts +24 -0
  7. package/dist/_types/components/FeeBreakdown/feeInfo.d.ts.map +1 -0
  8. package/dist/_types/components/FeeBreakdown/index.d.ts +28 -0
  9. package/dist/_types/components/FeeBreakdown/index.d.ts.map +1 -0
  10. package/dist/_types/components/LoadingCard/index.d.ts +6 -0
  11. package/dist/_types/components/LoadingCard/index.d.ts.map +1 -0
  12. package/dist/_types/components/PendingDeposits/index.d.ts +14 -0
  13. package/dist/_types/components/PendingDeposits/index.d.ts.map +1 -0
  14. package/dist/_types/components/QrSheet/QrCode.d.ts +28 -0
  15. package/dist/_types/components/QrSheet/QrCode.d.ts.map +1 -0
  16. package/dist/_types/components/QrSheet/index.d.ts +16 -0
  17. package/dist/_types/components/QrSheet/index.d.ts.map +1 -0
  18. package/dist/_types/components/TxnItem/ExplorerLink.d.ts +16 -0
  19. package/dist/_types/components/TxnItem/ExplorerLink.d.ts.map +1 -0
  20. package/dist/_types/components/TxnItem/index.d.ts +19 -0
  21. package/dist/_types/components/TxnItem/index.d.ts.map +1 -0
  22. package/dist/_types/constants.d.ts +20 -0
  23. package/dist/_types/constants.d.ts.map +1 -0
  24. package/dist/_types/context/SmartRoutingAddressContext.d.ts +32 -0
  25. package/dist/_types/context/SmartRoutingAddressContext.d.ts.map +1 -0
  26. package/dist/_types/context/SmartRoutingAddressProvider.d.ts +13 -0
  27. package/dist/_types/context/SmartRoutingAddressProvider.d.ts.map +1 -0
  28. package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts +23 -0
  29. package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts.map +1 -0
  30. package/dist/_types/hooks/useDepositStatus.d.ts +25 -0
  31. package/dist/_types/hooks/useDepositStatus.d.ts.map +1 -0
  32. package/dist/_types/hooks/useNewDeposits.d.ts +8 -0
  33. package/dist/_types/hooks/useNewDeposits.d.ts.map +1 -0
  34. package/dist/_types/hooks/useProviderFees.d.ts +22 -0
  35. package/dist/_types/hooks/useProviderFees.d.ts.map +1 -0
  36. package/dist/_types/hooks/useSmartRoutingAddress.d.ts +20 -0
  37. package/dist/_types/hooks/useSmartRoutingAddress.d.ts.map +1 -0
  38. package/dist/_types/iconAssets.d.ts +7 -0
  39. package/dist/_types/iconAssets.d.ts.map +1 -0
  40. package/dist/_types/index.d.ts +14 -0
  41. package/dist/_types/index.d.ts.map +1 -0
  42. package/dist/_types/pages/Deposit.d.ts +12 -0
  43. package/dist/_types/pages/Deposit.d.ts.map +1 -0
  44. package/dist/_types/pages/PastDeposits.d.ts +14 -0
  45. package/dist/_types/pages/PastDeposits.d.ts.map +1 -0
  46. package/dist/_types/pages/TransactionDetails.d.ts +11 -0
  47. package/dist/_types/pages/TransactionDetails.d.ts.map +1 -0
  48. package/dist/_types/pages/index.d.ts +25 -0
  49. package/dist/_types/pages/index.d.ts.map +1 -0
  50. package/dist/_types/types.d.ts +75 -0
  51. package/dist/_types/types.d.ts.map +1 -0
  52. package/dist/_types/utils/chains.d.ts +8 -0
  53. package/dist/_types/utils/chains.d.ts.map +1 -0
  54. package/dist/_types/utils/config.d.ts +35 -0
  55. package/dist/_types/utils/config.d.ts.map +1 -0
  56. package/dist/_types/utils/deposits.d.ts +29 -0
  57. package/dist/_types/utils/deposits.d.ts.map +1 -0
  58. package/dist/_types/utils/fees.d.ts +14 -0
  59. package/dist/_types/utils/fees.d.ts.map +1 -0
  60. package/dist/_types/utils/format.d.ts +27 -0
  61. package/dist/_types/utils/format.d.ts.map +1 -0
  62. package/dist/_types/utils/providerFees.d.ts +103 -0
  63. package/dist/_types/utils/providerFees.d.ts.map +1 -0
  64. package/dist/index.cjs +2 -0
  65. package/dist/index.cjs.map +1 -0
  66. package/dist/index.mjs +2180 -0
  67. package/dist/index.mjs.map +1 -0
  68. package/dist/styles.css +2 -0
  69. package/package.json +91 -0
  70. package/src/components/AddressDisplay/index.tsx +108 -0
  71. package/src/components/ErrorRetryCard/index.tsx +68 -0
  72. package/src/components/FeeBreakdown/feeInfo.ts +25 -0
  73. package/src/components/FeeBreakdown/index.tsx +218 -0
  74. package/src/components/LoadingCard/index.tsx +27 -0
  75. package/src/components/PendingDeposits/index.tsx +152 -0
  76. package/src/components/QrSheet/QrCode.tsx +163 -0
  77. package/src/components/QrSheet/index.tsx +80 -0
  78. package/src/components/TxnItem/ExplorerLink.tsx +37 -0
  79. package/src/components/TxnItem/index.tsx +178 -0
  80. package/src/constants.ts +102 -0
  81. package/src/context/SmartRoutingAddressContext.ts +48 -0
  82. package/src/context/SmartRoutingAddressProvider.tsx +145 -0
  83. package/src/hooks/useCreateSmartRoutingAddress.ts +28 -0
  84. package/src/hooks/useDepositStatus.ts +110 -0
  85. package/src/hooks/useNewDeposits.ts +25 -0
  86. package/src/hooks/useProviderFees.ts +235 -0
  87. package/src/hooks/useSmartRoutingAddress.ts +25 -0
  88. package/src/iconAssets.ts +130 -0
  89. package/src/index.ts +27 -0
  90. package/src/pages/Deposit.tsx +605 -0
  91. package/src/pages/PastDeposits.tsx +232 -0
  92. package/src/pages/TransactionDetails.tsx +466 -0
  93. package/src/pages/index.tsx +165 -0
  94. package/src/types.ts +84 -0
  95. package/src/utils/chains.ts +26 -0
  96. package/src/utils/config.ts +142 -0
  97. package/src/utils/deposits.ts +106 -0
  98. package/src/utils/fees.ts +51 -0
  99. package/src/utils/format.ts +108 -0
  100. package/src/utils/providerFees.ts +301 -0
@@ -0,0 +1,102 @@
1
+ import type { TOKEN_TYPE } from '@zerodev/smart-routing-address'
2
+ import { SUPPORTED_TOKENS } from '@zerodev/smart-routing-address'
3
+ import type { Chain } from 'viem'
4
+ import {
5
+ arbitrum,
6
+ base,
7
+ blast,
8
+ bsc,
9
+ celo,
10
+ flowMainnet,
11
+ hyperEvm,
12
+ ink,
13
+ linea,
14
+ mainnet,
15
+ megaeth,
16
+ mode,
17
+ monad,
18
+ optimism,
19
+ plasma,
20
+ polygon,
21
+ scroll,
22
+ soneium,
23
+ tempo,
24
+ unichain,
25
+ worldchain,
26
+ zora,
27
+ } from 'viem/chains'
28
+ import type { SourceToken } from './types'
29
+
30
+ export const DEFAULT_POLLING_INTERVAL_MS = 5_000
31
+
32
+ export const DEFAULT_FILL_TIME_SECONDS = 30
33
+
34
+ export const DEFAULT_DASHBOARD_URL = 'https://smart-routing-address.zerodev.app'
35
+
36
+ /**
37
+ * Chains supported by the smart routing address SDK; mirrors the
38
+ * smart-account-plus core `supportedChainIds` list.
39
+ */
40
+ export const SUPPORTED_CHAINS: readonly Chain[] = [
41
+ mainnet,
42
+ optimism,
43
+ arbitrum,
44
+ polygon,
45
+ plasma,
46
+ base,
47
+ celo,
48
+ mode,
49
+ monad,
50
+ blast,
51
+ bsc,
52
+ flowMainnet,
53
+ hyperEvm,
54
+ linea,
55
+ ink,
56
+ megaeth,
57
+ scroll,
58
+ soneium,
59
+ tempo,
60
+ unichain,
61
+ worldchain,
62
+ zora,
63
+ ]
64
+
65
+ export const CHAINS_BY_ID: ReadonlyMap<number, Chain> = new Map(
66
+ SUPPORTED_CHAINS.map((chain) => [chain.id, chain]),
67
+ )
68
+
69
+ /**
70
+ * Token types the SDK ships in `SUPPORTED_TOKENS` but the SRA server does not
71
+ * yet recognize. Sending one of these as a source token makes the server
72
+ * reject the whole `createSmartRoutingAddress` request with
73
+ * `Invalid params: Token address … is not supported on chain N`, which breaks
74
+ * the entire destination (e.g. Base). Remove entries here as the server
75
+ * registry catches up.
76
+ *
77
+ * TODO: file/track ZeroDev SRA server bug for EURC on Base
78
+ * (chain 8453, address 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42) — once the
79
+ * server accepts it, drop this filter.
80
+ */
81
+ const UNSUPPORTED_TOKEN_TYPES: ReadonlySet<TOKEN_TYPE> = new Set(['EURC'])
82
+
83
+ /**
84
+ * Source tokens offered for deposits: every supported mainnet token the SDK
85
+ * exposes (NATIVE plus each ERC-20, with wrapped native surfaced as WETH),
86
+ * mapped to its viem chain object. Testnets are excluded from the default;
87
+ * chains this package cannot resolve to a viem chain are skipped, and token
88
+ * types the server can't route yet are filtered via `UNSUPPORTED_TOKEN_TYPES`.
89
+ */
90
+ export const DEFAULT_SOURCE_TOKENS: SourceToken[] = Object.entries(
91
+ SUPPORTED_TOKENS,
92
+ ).flatMap(([chainId, tokens]) => {
93
+ // Skip chains this package cannot resolve to a viem chain object
94
+ const chain = CHAINS_BY_ID.get(Number(chainId))
95
+ if (!chain) return []
96
+ return Object.keys(tokens)
97
+ .filter((t) => !UNSUPPORTED_TOKEN_TYPES.has(t as TOKEN_TYPE))
98
+ .map((tokenType) => ({
99
+ chain,
100
+ tokenType: tokenType as TOKEN_TYPE,
101
+ }))
102
+ })
@@ -0,0 +1,48 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { Address } from 'viem'
3
+ import type {
4
+ ActiveRoute,
5
+ AddressState,
6
+ SmartRoutingAddressConfig,
7
+ } from '../types'
8
+
9
+ export type SmartRoutingAddressContextValue = {
10
+ config: SmartRoutingAddressConfig
11
+ addressState: AddressState
12
+ /** Recipient of the active (or last requested) smart routing address */
13
+ recipient?: Address | undefined
14
+ /**
15
+ * Return the smart routing address for the recipient, creating it if it
16
+ * has not been created yet. Safe to call multiple times; concurrent calls
17
+ * for the same recipient share one request, while a new recipient
18
+ * triggers a fresh creation. Rejects when creation fails (the failure
19
+ * also lands in `addressState`).
20
+ */
21
+ getOrCreateAddress: (recipient: Address) => Promise<Address>
22
+ /**
23
+ * Re-run address creation for the current recipient. No-op if no recipient
24
+ * has been set yet. Wired to the "Failed to create deposit address" retry
25
+ * button in the deposit UI.
26
+ */
27
+ retry: () => Promise<void>
28
+ /**
29
+ * The route currently shown in the deposit UI. `null` until the picker
30
+ * seeds a selection. Hosts can read this to mirror the widget's state.
31
+ */
32
+ activeRoute: ActiveRoute | null
33
+ /** Deposit UI internal — writes the current picker selection to context. */
34
+ setActiveRoute: (route: ActiveRoute | null) => void
35
+ }
36
+
37
+ export const SmartRoutingAddressContext =
38
+ createContext<SmartRoutingAddressContextValue | null>(null)
39
+
40
+ export function useSmartRoutingAddressContext(): SmartRoutingAddressContextValue {
41
+ const context = useContext(SmartRoutingAddressContext)
42
+ if (!context) {
43
+ throw new Error(
44
+ 'Smart routing address components must be rendered inside <SmartRoutingAddressProvider>',
45
+ )
46
+ }
47
+ return context
48
+ }
@@ -0,0 +1,145 @@
1
+ import { createSmartRoutingAddress } from '@zerodev/smart-routing-address'
2
+ import { type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
3
+ import type { Address } from 'viem'
4
+ import type {
5
+ ActiveRoute,
6
+ AddressState,
7
+ SmartRoutingAddressConfig,
8
+ } from '../types'
9
+ import {
10
+ resolveActions,
11
+ resolveBaseUrl,
12
+ resolveDestChain,
13
+ resolveSourceTokens,
14
+ resolveVersion,
15
+ } from '../utils/config'
16
+ import { SmartRoutingAddressContext } from './SmartRoutingAddressContext'
17
+
18
+ export type SmartRoutingAddressProviderProps = {
19
+ config: SmartRoutingAddressConfig
20
+ children: ReactNode
21
+ }
22
+
23
+ /**
24
+ * Holds the config and the lazily created routing address. Wrap the subtree
25
+ * that renders <SmartRoutingAddress /> with this provider — the deposit UI
26
+ * itself is rendered inline by the consumer, not by this provider.
27
+ */
28
+ export function SmartRoutingAddressProvider({
29
+ config,
30
+ children,
31
+ }: SmartRoutingAddressProviderProps) {
32
+ const [addressState, setAddressState] = useState<AddressState>({
33
+ status: 'idle',
34
+ })
35
+ const [recipient, setRecipient] = useState<Address | undefined>(undefined)
36
+ const [activeRoute, setActiveRoute] = useState<ActiveRoute | null>(null)
37
+ const pendingRef = useRef<Promise<Address> | null>(null)
38
+ const recipientRef = useRef<Address | null>(null)
39
+ // Each creation request gets a generation; only the latest generation
40
+ // may write state, so superseded requests are ignored entirely
41
+ const genRef = useRef(0)
42
+ const configRef = useRef(config)
43
+
44
+ const getOrCreateAddress = useCallback(
45
+ async (nextRecipient: Address): Promise<Address> => {
46
+ // A new config invalidates any cached request
47
+ if (configRef.current !== config) {
48
+ configRef.current = config
49
+ genRef.current += 1
50
+ pendingRef.current = null
51
+ recipientRef.current = null
52
+ }
53
+
54
+ // Reuse the pending/settled request for the same recipient
55
+ if (pendingRef.current && recipientRef.current === nextRecipient) {
56
+ return pendingRef.current
57
+ }
58
+
59
+ genRef.current += 1
60
+ const gen = genRef.current
61
+ recipientRef.current = nextRecipient
62
+ setRecipient(nextRecipient)
63
+
64
+ const request = (async () => {
65
+ setAddressState({ status: 'loading' })
66
+ try {
67
+ const baseUrl = resolveBaseUrl(config)
68
+ const result = await createSmartRoutingAddress({
69
+ owner: nextRecipient,
70
+ destChain: resolveDestChain(config),
71
+ ...(config.slippage !== undefined && {
72
+ slippage: config.slippage,
73
+ }),
74
+ srcTokens: resolveSourceTokens(config),
75
+ ...(resolveActions(config, nextRecipient) && {
76
+ actions: resolveActions(config, nextRecipient),
77
+ }),
78
+ // Drop source tokens without an available route instead
79
+ // of failing the whole address creation
80
+ allowPartialRoutes: true,
81
+ config: {
82
+ ...(baseUrl && { baseUrl }),
83
+ version: resolveVersion(config),
84
+ },
85
+ })
86
+ // State writes are generation-gated (results superseded by a newer
87
+ // request must not clobber the live one), but the promise still
88
+ // resolves with the address this call created — per-call semantics
89
+ // stay honest even after the provider has moved on.
90
+ if (genRef.current === gen) {
91
+ setAddressState({
92
+ status: 'success',
93
+ address: result.smartRoutingAddress,
94
+ estimatedFees: result.estimatedFees,
95
+ })
96
+ }
97
+ return result.smartRoutingAddress
98
+ } catch (error) {
99
+ const err = error instanceof Error ? error : new Error(String(error))
100
+ // Superseded failures must not clear the live request
101
+ if (genRef.current === gen) {
102
+ // Allow a retry after failures
103
+ pendingRef.current = null
104
+ setAddressState({ status: 'error', error: err })
105
+ }
106
+ throw err
107
+ }
108
+ })()
109
+
110
+ pendingRef.current = request
111
+ return request
112
+ },
113
+ [config],
114
+ )
115
+
116
+ const retry = useCallback(async () => {
117
+ const current = recipientRef.current
118
+ if (!current) return
119
+ // Force a fresh request even if we still have a settled one cached
120
+ pendingRef.current = null
121
+ recipientRef.current = null
122
+ // Failures surface via `addressState`; swallow the rejection so retry
123
+ // callers (e.g. the ErrorRetryCard click handler) don't need a catch.
124
+ await getOrCreateAddress(current).catch(() => {})
125
+ }, [getOrCreateAddress])
126
+
127
+ const value = useMemo(
128
+ () => ({
129
+ config,
130
+ addressState,
131
+ recipient,
132
+ getOrCreateAddress,
133
+ retry,
134
+ activeRoute,
135
+ setActiveRoute,
136
+ }),
137
+ [config, addressState, recipient, getOrCreateAddress, retry, activeRoute],
138
+ )
139
+
140
+ return (
141
+ <SmartRoutingAddressContext.Provider value={value}>
142
+ {children}
143
+ </SmartRoutingAddressContext.Provider>
144
+ )
145
+ }
@@ -0,0 +1,28 @@
1
+ import type { Address } from 'viem'
2
+ import { useSmartRoutingAddressContext } from '../context/SmartRoutingAddressContext'
3
+
4
+ export type UseCreateSmartRoutingAddressResult = {
5
+ /**
6
+ * Return the deposit address for the recipient, creating it if it doesn't
7
+ * exist yet. Idempotent per recipient — concurrent and repeated calls
8
+ * share one request, so it's safe to call on every hover/focus/mount.
9
+ * Rejects when creation fails (the failure also lands in the read hook's
10
+ * `addressState`). Creation params (`actions`, `slippage`, …) come from
11
+ * the provider config so the address always matches what the widget
12
+ * displays — there are no per-call overrides. To create addresses with
13
+ * bespoke params, use `createSmartRoutingAddress` from
14
+ * `@zerodev/smart-routing-address`.
15
+ */
16
+ getOrCreateAddress: (recipient: Address) => Promise<Address>
17
+ }
18
+
19
+ /**
20
+ * Action counterpart to `useSmartRoutingAddress` (which is read-only): lets
21
+ * hosts embedding the `<SmartRoutingAddress />` widget pre-create the
22
+ * deposit address — e.g. on hover or route entry, before the modal opens —
23
+ * so the widget renders instantly.
24
+ */
25
+ export function useCreateSmartRoutingAddress(): UseCreateSmartRoutingAddressResult {
26
+ const { getOrCreateAddress } = useSmartRoutingAddressContext()
27
+ return { getOrCreateAddress }
28
+ }
@@ -0,0 +1,110 @@
1
+ import type { DepositedToken } from '@zerodev/smart-routing-address'
2
+ import { getSmartRoutingAddressStatus } from '@zerodev/smart-routing-address'
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import type { Address } from 'viem'
5
+ import { DEFAULT_POLLING_INTERVAL_MS } from '../constants'
6
+
7
+ export type UseDepositStatusParams = {
8
+ address: Address | undefined
9
+ enabled?: boolean | undefined
10
+ pollingInterval?: number | undefined
11
+ baseUrl?: string | undefined
12
+ }
13
+
14
+ export type UseDepositStatusResult = {
15
+ deposits: DepositedToken[]
16
+ totalCount: number
17
+ /** True once at least one status response has been received */
18
+ hasLoaded: boolean
19
+ isLoading: boolean
20
+ error: Error | null
21
+ /** Trigger an immediate poll — used by the "Retry" button after a fetch
22
+ * error. No-op while no address is set. */
23
+ refetch: () => void
24
+ }
25
+
26
+ const INITIAL_STATE = {
27
+ deposits: [] as DepositedToken[],
28
+ totalCount: 0,
29
+ hasLoaded: false,
30
+ isLoading: false,
31
+ error: null as Error | null,
32
+ }
33
+
34
+ /**
35
+ * Polls the smart routing address status endpoint while enabled, returning
36
+ * the current list of deposits.
37
+ */
38
+ export function useDepositStatus({
39
+ address,
40
+ enabled = true,
41
+ pollingInterval = DEFAULT_POLLING_INTERVAL_MS,
42
+ baseUrl,
43
+ }: UseDepositStatusParams): UseDepositStatusResult {
44
+ const [state, setState] = useState(INITIAL_STATE)
45
+ // Exposed via `refetch`; reassigned every time the effect runs so we always
46
+ // point at the poller bound to the current (address, baseUrl) pair.
47
+ const pollRef = useRef<(() => void) | null>(null)
48
+
49
+ useEffect(() => {
50
+ if (!address || !enabled) {
51
+ pollRef.current = null
52
+ return
53
+ }
54
+
55
+ let cancelled = false
56
+ // Each `poll()` call bumps `gen`; only the latest generation may write
57
+ // to state. Prevents a stale interval-driven response from clobbering a
58
+ // fresh `refetch()` (or vice-versa) when the two overlap in flight.
59
+ let gen = 0
60
+
61
+ const poll = async () => {
62
+ gen += 1
63
+ const requestGen = gen
64
+ try {
65
+ const result = await getSmartRoutingAddressStatus({
66
+ smartRoutingAddress: address,
67
+ ...(baseUrl && { config: { baseUrl } }),
68
+ })
69
+ if (cancelled || requestGen !== gen) return
70
+ setState({
71
+ deposits: result.deposits,
72
+ totalCount: result.totalCount,
73
+ hasLoaded: true,
74
+ isLoading: false,
75
+ error: null,
76
+ })
77
+ } catch (error) {
78
+ if (cancelled || requestGen !== gen) return
79
+ setState((previous) => ({
80
+ ...previous,
81
+ isLoading: false,
82
+ error: error instanceof Error ? error : new Error(String(error)),
83
+ }))
84
+ }
85
+ }
86
+
87
+ pollRef.current = () => {
88
+ setState((previous) => ({ ...previous, isLoading: true, error: null }))
89
+ poll().catch(() => {})
90
+ }
91
+
92
+ setState((previous) => ({ ...previous, isLoading: true }))
93
+ poll().catch(() => {})
94
+ const intervalId = setInterval(() => {
95
+ poll().catch(() => {})
96
+ }, pollingInterval)
97
+
98
+ return () => {
99
+ cancelled = true
100
+ clearInterval(intervalId)
101
+ pollRef.current = null
102
+ }
103
+ }, [address, enabled, pollingInterval, baseUrl])
104
+
105
+ const refetch = useCallback(() => {
106
+ pollRef.current?.()
107
+ }, [])
108
+
109
+ return { ...state, refetch }
110
+ }
@@ -0,0 +1,25 @@
1
+ import type { DepositedToken } from '@zerodev/smart-routing-address'
2
+ import { useMemo, useRef } from 'react'
3
+ import { getDepositKey, getNewDeposits } from '../utils/deposits'
4
+
5
+ /**
6
+ * Tracks deposits that arrived after the first loaded status snapshot.
7
+ * The snapshot taken when `ready` first becomes true acts as the baseline,
8
+ * so pre-existing deposits are not reported as new.
9
+ */
10
+ export function useNewDeposits(
11
+ deposits: DepositedToken[],
12
+ ready: boolean,
13
+ ): DepositedToken[] {
14
+ const baselineRef = useRef<ReadonlySet<string> | null>(null)
15
+
16
+ if (ready && baselineRef.current === null) {
17
+ baselineRef.current = new Set(deposits.map(getDepositKey))
18
+ }
19
+
20
+ const baseline = baselineRef.current
21
+ return useMemo(() => {
22
+ if (!baseline) return []
23
+ return getNewDeposits(deposits, baseline)
24
+ }, [deposits, baseline])
25
+ }
@@ -0,0 +1,235 @@
1
+ import type { TOKEN_TYPE } from '@zerodev/smart-routing-address'
2
+ import { useEffect, useState } from 'react'
3
+ import type { Address, Chain } from 'viem'
4
+ import { zeroAddress } from 'viem'
5
+ import type { EstimatedFeeData, SourceToken } from '../types'
6
+ import { resolveTokenAddress } from '../utils/fees'
7
+ import {
8
+ AcrossSuggestedFeesSchema,
9
+ type FeeBreakdown,
10
+ parseAcrossFees,
11
+ parseRelayFees,
12
+ RelayQuoteSchema,
13
+ } from '../utils/providerFees'
14
+
15
+ const ACROSS_API = 'https://app.across.to/api/suggested-fees'
16
+ const RELAY_API = 'https://api.relay.link/quote'
17
+ // Quotes barely depend on the caller; used only when no recipient is known
18
+ const QUOTE_USER: Address = '0x1111111111111111111111111111111111111111'
19
+
20
+ type Route = {
21
+ tokenType: TOKEN_TYPE
22
+ originChainId: number
23
+ destinationChainId: number
24
+ amount: string
25
+ user: Address
26
+ symbol: string
27
+ decimals: number
28
+ }
29
+
30
+ /** Across is keyed by ERC-20 addresses; native is quoted via its wrapped form */
31
+ function acrossToken(tokenType: TOKEN_TYPE, chainId: number): Address | null {
32
+ if (tokenType === 'NATIVE') return resolveTokenAddress('WETH', chainId)
33
+ return resolveTokenAddress(tokenType, chainId)
34
+ }
35
+
36
+ /** Relay represents native as the zero address; ERC-20s by their address */
37
+ function relayToken(tokenType: TOKEN_TYPE, chainId: number): Address | null {
38
+ if (tokenType === 'NATIVE') return zeroAddress
39
+ return resolveTokenAddress(tokenType, chainId)
40
+ }
41
+
42
+ async function fetchAcross(
43
+ route: Route,
44
+ signal: AbortSignal,
45
+ ): Promise<FeeBreakdown | null> {
46
+ const inputToken = acrossToken(route.tokenType, route.originChainId)
47
+ const outputToken = acrossToken(route.tokenType, route.destinationChainId)
48
+ if (!inputToken || !outputToken) return null
49
+ const params = new URLSearchParams({
50
+ inputToken,
51
+ outputToken,
52
+ originChainId: String(route.originChainId),
53
+ destinationChainId: String(route.destinationChainId),
54
+ amount: route.amount,
55
+ })
56
+ const res = await fetch(`${ACROSS_API}?${params}`, { signal })
57
+ if (!res.ok) return null
58
+ const json = await res.json()
59
+ if (!json || json.error) return null
60
+ // Validate the response shape before handing it to the parser — if Across
61
+ // changes their API in a way we don't recognise, we degrade gracefully to
62
+ // the collapsed fee view instead of computing garbage from missing fields.
63
+ const parsed = AcrossSuggestedFeesSchema.safeParse(json)
64
+ if (!parsed.success) {
65
+ // biome-ignore lint/suspicious/noConsole: schema-drift signal for developers; silent failure is what we're trying to avoid.
66
+ console.warn(
67
+ '[useProviderFees] Across response failed schema validation',
68
+ parsed.error.issues,
69
+ )
70
+ return null
71
+ }
72
+ return parseAcrossFees(parsed.data, route.symbol, route.decimals)
73
+ }
74
+
75
+ async function fetchRelay(
76
+ route: Route,
77
+ signal: AbortSignal,
78
+ ): Promise<FeeBreakdown | null> {
79
+ const originCurrency = relayToken(route.tokenType, route.originChainId)
80
+ const destinationCurrency = relayToken(
81
+ route.tokenType,
82
+ route.destinationChainId,
83
+ )
84
+ if (!originCurrency || !destinationCurrency) return null
85
+ const res = await fetch(RELAY_API, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ signal,
89
+ body: JSON.stringify({
90
+ user: route.user,
91
+ recipient: route.user,
92
+ originChainId: route.originChainId,
93
+ destinationChainId: route.destinationChainId,
94
+ originCurrency,
95
+ destinationCurrency,
96
+ amount: route.amount,
97
+ tradeType: 'EXACT_INPUT',
98
+ }),
99
+ })
100
+ if (!res.ok) return null
101
+ const json = await res.json()
102
+ if (!json || json.error) return null
103
+ // See fetchAcross above — schema validation is the last line of defence
104
+ // between a silently changed API and wrong fees rendered to the user.
105
+ const parsed = RelayQuoteSchema.safeParse(json)
106
+ if (!parsed.success) {
107
+ // biome-ignore lint/suspicious/noConsole: schema-drift signal for developers; silent failure is what we're trying to avoid.
108
+ console.warn(
109
+ '[useProviderFees] Relay response failed schema validation',
110
+ parsed.error.issues,
111
+ )
112
+ return null
113
+ }
114
+ return parseRelayFees(parsed.data)
115
+ }
116
+
117
+ /** Pick the cheaper live route by full quoted cost; else the first available */
118
+ function bestRoute(routes: (FeeBreakdown | null)[]): FeeBreakdown | null {
119
+ const available = routes.filter((r): r is FeeBreakdown => r !== null)
120
+ if (available.length === 0) return null
121
+ const withUsd = available.filter((r) => r.quotedTotalUsd !== null)
122
+ if (withUsd.length === 0) return available[0] ?? null
123
+ return withUsd.reduce((best, r) =>
124
+ (r.quotedTotalUsd as number) < (best.quotedTotalUsd as number) ? r : best,
125
+ )
126
+ }
127
+
128
+ export type ProviderFeesState = {
129
+ /** Cheapest normalised quote, or null while loading / unquotable */
130
+ fees: FeeBreakdown | null
131
+ /** True from the moment the route changes until the quotes resolve */
132
+ loading: boolean
133
+ }
134
+
135
+ /**
136
+ * Fetch live fee quotes for the selected route from every supported provider
137
+ * (Across + Relay), normalise them, and return the cheapest. Across and Relay
138
+ * expose the itemised bridge legs the SRA fee estimate alone does not, so this
139
+ * enriches the breakdown with how the user is actually charged and which route
140
+ * is currently best.
141
+ *
142
+ * Returns null while loading, for routes that can't be quoted (native /
143
+ * unresolvable tokens), or when every request fails — callers fall back to
144
+ * the SRA execution fee on their own.
145
+ */
146
+ export function useProviderFees(
147
+ source: SourceToken | null,
148
+ destChain: Chain,
149
+ feeData: EstimatedFeeData | null,
150
+ recipient?: string,
151
+ ): ProviderFeesState {
152
+ const [breakdown, setBreakdown] = useState<FeeBreakdown | null>(null)
153
+ const [loading, setLoading] = useState(false)
154
+
155
+ const sourceChainId = source?.chain.id
156
+ const tokenType = source?.tokenType
157
+ const destChainId = destChain.id
158
+ const minDeposit = feeData?.minDeposit
159
+ const symbol = feeData?.name
160
+ const decimals = feeData?.decimal
161
+
162
+ useEffect(() => {
163
+ setBreakdown(null)
164
+ if (
165
+ tokenType === undefined ||
166
+ sourceChainId === undefined ||
167
+ !minDeposit ||
168
+ symbol === undefined ||
169
+ decimals === undefined
170
+ ) {
171
+ setLoading(false)
172
+ return
173
+ }
174
+ setLoading(true)
175
+
176
+ let amount: string
177
+ try {
178
+ amount = BigInt(minDeposit).toString()
179
+ } catch {
180
+ // Malformed `minDeposit` — nothing to quote. Clear loading so the
181
+ // caller doesn't sit in a spinner forever until the route changes.
182
+ setLoading(false)
183
+ return
184
+ }
185
+
186
+ const route: Route = {
187
+ tokenType,
188
+ originChainId: sourceChainId,
189
+ destinationChainId: destChainId,
190
+ amount,
191
+ user: (recipient as Address) || QUOTE_USER,
192
+ symbol,
193
+ decimals,
194
+ }
195
+
196
+ const controller = new AbortController()
197
+ // Swallow provider failures so one broken provider doesn't take out the
198
+ // other — but log everything except the expected abort so a real bug
199
+ // (network, parser regression) doesn't disappear silently.
200
+ const safe = (label: string, p: Promise<FeeBreakdown | null>) =>
201
+ p.catch((err) => {
202
+ if (err?.name !== 'AbortError') {
203
+ // biome-ignore lint/suspicious/noConsole: surfaces network / parser regressions that would otherwise be invisible.
204
+ console.warn(`[useProviderFees] ${label} failed`, err)
205
+ }
206
+ return null
207
+ })
208
+ Promise.all([
209
+ safe('Across', fetchAcross(route, controller.signal)),
210
+ safe('Relay', fetchRelay(route, controller.signal)),
211
+ ])
212
+ .then(([across, relay]) => {
213
+ const chosen = bestRoute([across, relay])
214
+ if (!controller.signal.aborted) {
215
+ setBreakdown(chosen)
216
+ setLoading(false)
217
+ }
218
+ })
219
+ .catch(() => {
220
+ // All providers failed → caller shows the SRA fee
221
+ if (!controller.signal.aborted) setLoading(false)
222
+ })
223
+ return () => controller.abort()
224
+ }, [
225
+ sourceChainId,
226
+ tokenType,
227
+ destChainId,
228
+ minDeposit,
229
+ symbol,
230
+ decimals,
231
+ recipient,
232
+ ])
233
+
234
+ return { fees: breakdown, loading }
235
+ }