@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.
- package/README.md +117 -0
- package/dist/_types/components/AddressDisplay/index.d.ts +43 -0
- package/dist/_types/components/AddressDisplay/index.d.ts.map +1 -0
- package/dist/_types/components/ErrorRetryCard/index.d.ts +21 -0
- package/dist/_types/components/ErrorRetryCard/index.d.ts.map +1 -0
- package/dist/_types/components/FeeBreakdown/feeInfo.d.ts +24 -0
- package/dist/_types/components/FeeBreakdown/feeInfo.d.ts.map +1 -0
- package/dist/_types/components/FeeBreakdown/index.d.ts +28 -0
- package/dist/_types/components/FeeBreakdown/index.d.ts.map +1 -0
- package/dist/_types/components/LoadingCard/index.d.ts +6 -0
- package/dist/_types/components/LoadingCard/index.d.ts.map +1 -0
- package/dist/_types/components/PendingDeposits/index.d.ts +14 -0
- package/dist/_types/components/PendingDeposits/index.d.ts.map +1 -0
- package/dist/_types/components/QrSheet/QrCode.d.ts +28 -0
- package/dist/_types/components/QrSheet/QrCode.d.ts.map +1 -0
- package/dist/_types/components/QrSheet/index.d.ts +16 -0
- package/dist/_types/components/QrSheet/index.d.ts.map +1 -0
- package/dist/_types/components/TxnItem/ExplorerLink.d.ts +16 -0
- package/dist/_types/components/TxnItem/ExplorerLink.d.ts.map +1 -0
- package/dist/_types/components/TxnItem/index.d.ts +19 -0
- package/dist/_types/components/TxnItem/index.d.ts.map +1 -0
- package/dist/_types/constants.d.ts +20 -0
- package/dist/_types/constants.d.ts.map +1 -0
- package/dist/_types/context/SmartRoutingAddressContext.d.ts +32 -0
- package/dist/_types/context/SmartRoutingAddressContext.d.ts.map +1 -0
- package/dist/_types/context/SmartRoutingAddressProvider.d.ts +13 -0
- package/dist/_types/context/SmartRoutingAddressProvider.d.ts.map +1 -0
- package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts +23 -0
- package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts.map +1 -0
- package/dist/_types/hooks/useDepositStatus.d.ts +25 -0
- package/dist/_types/hooks/useDepositStatus.d.ts.map +1 -0
- package/dist/_types/hooks/useNewDeposits.d.ts +8 -0
- package/dist/_types/hooks/useNewDeposits.d.ts.map +1 -0
- package/dist/_types/hooks/useProviderFees.d.ts +22 -0
- package/dist/_types/hooks/useProviderFees.d.ts.map +1 -0
- package/dist/_types/hooks/useSmartRoutingAddress.d.ts +20 -0
- package/dist/_types/hooks/useSmartRoutingAddress.d.ts.map +1 -0
- package/dist/_types/iconAssets.d.ts +7 -0
- package/dist/_types/iconAssets.d.ts.map +1 -0
- package/dist/_types/index.d.ts +14 -0
- package/dist/_types/index.d.ts.map +1 -0
- package/dist/_types/pages/Deposit.d.ts +12 -0
- package/dist/_types/pages/Deposit.d.ts.map +1 -0
- package/dist/_types/pages/PastDeposits.d.ts +14 -0
- package/dist/_types/pages/PastDeposits.d.ts.map +1 -0
- package/dist/_types/pages/TransactionDetails.d.ts +11 -0
- package/dist/_types/pages/TransactionDetails.d.ts.map +1 -0
- package/dist/_types/pages/index.d.ts +25 -0
- package/dist/_types/pages/index.d.ts.map +1 -0
- package/dist/_types/types.d.ts +75 -0
- package/dist/_types/types.d.ts.map +1 -0
- package/dist/_types/utils/chains.d.ts +8 -0
- package/dist/_types/utils/chains.d.ts.map +1 -0
- package/dist/_types/utils/config.d.ts +35 -0
- package/dist/_types/utils/config.d.ts.map +1 -0
- package/dist/_types/utils/deposits.d.ts +29 -0
- package/dist/_types/utils/deposits.d.ts.map +1 -0
- package/dist/_types/utils/fees.d.ts +14 -0
- package/dist/_types/utils/fees.d.ts.map +1 -0
- package/dist/_types/utils/format.d.ts +27 -0
- package/dist/_types/utils/format.d.ts.map +1 -0
- package/dist/_types/utils/providerFees.d.ts +103 -0
- package/dist/_types/utils/providerFees.d.ts.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +2180 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles.css +2 -0
- package/package.json +91 -0
- package/src/components/AddressDisplay/index.tsx +108 -0
- package/src/components/ErrorRetryCard/index.tsx +68 -0
- package/src/components/FeeBreakdown/feeInfo.ts +25 -0
- package/src/components/FeeBreakdown/index.tsx +218 -0
- package/src/components/LoadingCard/index.tsx +27 -0
- package/src/components/PendingDeposits/index.tsx +152 -0
- package/src/components/QrSheet/QrCode.tsx +163 -0
- package/src/components/QrSheet/index.tsx +80 -0
- package/src/components/TxnItem/ExplorerLink.tsx +37 -0
- package/src/components/TxnItem/index.tsx +178 -0
- package/src/constants.ts +102 -0
- package/src/context/SmartRoutingAddressContext.ts +48 -0
- package/src/context/SmartRoutingAddressProvider.tsx +145 -0
- package/src/hooks/useCreateSmartRoutingAddress.ts +28 -0
- package/src/hooks/useDepositStatus.ts +110 -0
- package/src/hooks/useNewDeposits.ts +25 -0
- package/src/hooks/useProviderFees.ts +235 -0
- package/src/hooks/useSmartRoutingAddress.ts +25 -0
- package/src/iconAssets.ts +130 -0
- package/src/index.ts +27 -0
- package/src/pages/Deposit.tsx +605 -0
- package/src/pages/PastDeposits.tsx +232 -0
- package/src/pages/TransactionDetails.tsx +466 -0
- package/src/pages/index.tsx +165 -0
- package/src/types.ts +84 -0
- package/src/utils/chains.ts +26 -0
- package/src/utils/config.ts +142 -0
- package/src/utils/deposits.ts +106 -0
- package/src/utils/fees.ts +51 -0
- package/src/utils/format.ts +108 -0
- package/src/utils/providerFees.ts +301 -0
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|