@eth-optimism/actions-sdk 0.4.0 → 0.5.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.
Files changed (98) hide show
  1. package/dist/actions.d.ts +9 -0
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +12 -1
  4. package/dist/actions.js.map +1 -1
  5. package/dist/ens/EnsNamespace.d.ts +57 -0
  6. package/dist/ens/EnsNamespace.d.ts.map +1 -0
  7. package/dist/ens/EnsNamespace.js +158 -0
  8. package/dist/ens/EnsNamespace.js.map +1 -0
  9. package/dist/ens/EnsNamespace.spec.d.ts +2 -0
  10. package/dist/ens/EnsNamespace.spec.d.ts.map +1 -0
  11. package/dist/ens/EnsNamespace.spec.js +144 -0
  12. package/dist/ens/EnsNamespace.spec.js.map +1 -0
  13. package/dist/ens/errors.d.ts +24 -0
  14. package/dist/ens/errors.d.ts.map +1 -0
  15. package/dist/ens/errors.js +35 -0
  16. package/dist/ens/errors.js.map +1 -0
  17. package/dist/ens/index.d.ts +4 -0
  18. package/dist/ens/index.d.ts.map +1 -0
  19. package/dist/ens/index.js +4 -0
  20. package/dist/ens/index.js.map +1 -0
  21. package/dist/ens/types.d.ts +63 -0
  22. package/dist/ens/types.d.ts.map +1 -0
  23. package/dist/ens/types.js +14 -0
  24. package/dist/ens/types.js.map +1 -0
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/services/ChainManager.d.ts +7 -0
  30. package/dist/services/ChainManager.d.ts.map +1 -1
  31. package/dist/services/ChainManager.js +9 -0
  32. package/dist/services/ChainManager.js.map +1 -1
  33. package/dist/services/__mocks__/MockChainManager.d.ts +1 -0
  34. package/dist/services/__mocks__/MockChainManager.d.ts.map +1 -1
  35. package/dist/services/__mocks__/MockChainManager.js +5 -0
  36. package/dist/services/__mocks__/MockChainManager.js.map +1 -1
  37. package/dist/swap/core/SwapProvider.d.ts +6 -6
  38. package/dist/swap/core/SwapProvider.d.ts.map +1 -1
  39. package/dist/swap/core/SwapProvider.js +4 -8
  40. package/dist/swap/core/SwapProvider.js.map +1 -1
  41. package/dist/swap/namespaces/BaseSwapNamespace.d.ts +3 -1
  42. package/dist/swap/namespaces/BaseSwapNamespace.d.ts.map +1 -1
  43. package/dist/swap/namespaces/BaseSwapNamespace.js +16 -11
  44. package/dist/swap/namespaces/BaseSwapNamespace.js.map +1 -1
  45. package/dist/swap/namespaces/WalletSwapNamespace.d.ts +2 -1
  46. package/dist/swap/namespaces/WalletSwapNamespace.d.ts.map +1 -1
  47. package/dist/swap/namespaces/WalletSwapNamespace.js +9 -4
  48. package/dist/swap/namespaces/WalletSwapNamespace.js.map +1 -1
  49. package/dist/swap/namespaces/__tests__/BaseSwapNamespace.spec.js +4 -4
  50. package/dist/swap/namespaces/__tests__/BaseSwapNamespace.spec.js.map +1 -1
  51. package/dist/swap/providers/uniswap/UniswapSwapProvider.d.ts +3 -2
  52. package/dist/swap/providers/uniswap/UniswapSwapProvider.d.ts.map +1 -1
  53. package/dist/swap/providers/uniswap/UniswapSwapProvider.js.map +1 -1
  54. package/dist/swap/providers/velodrome/VelodromeSwapProvider.d.ts +3 -2
  55. package/dist/swap/providers/velodrome/VelodromeSwapProvider.d.ts.map +1 -1
  56. package/dist/swap/providers/velodrome/VelodromeSwapProvider.js.map +1 -1
  57. package/dist/swap/providers/velodrome/__tests__/VelodromeSwapProvider.test.js +1 -0
  58. package/dist/swap/providers/velodrome/__tests__/VelodromeSwapProvider.test.js.map +1 -1
  59. package/dist/types/swap/base.d.ts +5 -4
  60. package/dist/types/swap/base.d.ts.map +1 -1
  61. package/dist/types/swap/base.js.map +1 -1
  62. package/dist/utils/ens.d.ts +25 -0
  63. package/dist/utils/ens.d.ts.map +1 -0
  64. package/dist/utils/ens.js +53 -0
  65. package/dist/utils/ens.js.map +1 -0
  66. package/dist/utils/ens.test.d.ts +2 -0
  67. package/dist/utils/ens.test.d.ts.map +1 -0
  68. package/dist/utils/ens.test.js +75 -0
  69. package/dist/utils/ens.test.js.map +1 -0
  70. package/dist/utils/validation.d.ts +5 -0
  71. package/dist/utils/validation.d.ts.map +1 -1
  72. package/dist/utils/validation.js +10 -0
  73. package/dist/utils/validation.js.map +1 -1
  74. package/dist/wallet/core/wallets/abstract/Wallet.d.ts.map +1 -1
  75. package/dist/wallet/core/wallets/abstract/Wallet.js +3 -1
  76. package/dist/wallet/core/wallets/abstract/Wallet.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/actions.ts +15 -0
  79. package/src/ens/EnsNamespace.spec.ts +171 -0
  80. package/src/ens/EnsNamespace.ts +210 -0
  81. package/src/ens/errors.ts +45 -0
  82. package/src/ens/index.ts +12 -0
  83. package/src/ens/types.ts +76 -0
  84. package/src/index.ts +9 -0
  85. package/src/services/ChainManager.ts +10 -0
  86. package/src/services/__mocks__/MockChainManager.ts +8 -0
  87. package/src/swap/core/SwapProvider.ts +16 -15
  88. package/src/swap/namespaces/BaseSwapNamespace.ts +43 -27
  89. package/src/swap/namespaces/WalletSwapNamespace.ts +12 -4
  90. package/src/swap/namespaces/__tests__/BaseSwapNamespace.spec.ts +4 -0
  91. package/src/swap/providers/uniswap/UniswapSwapProvider.ts +4 -2
  92. package/src/swap/providers/velodrome/VelodromeSwapProvider.ts +4 -2
  93. package/src/swap/providers/velodrome/__tests__/VelodromeSwapProvider.test.ts +1 -0
  94. package/src/types/swap/base.ts +5 -4
  95. package/src/utils/ens.test.ts +104 -0
  96. package/src/utils/ens.ts +85 -0
  97. package/src/utils/validation.ts +11 -0
  98. package/src/wallet/core/wallets/abstract/Wallet.ts +3 -0
@@ -3,6 +3,10 @@ import { formatUnits } from 'viem'
3
3
 
4
4
  import type { SupportedChainId } from '@/constants/supportedChains.js'
5
5
  import { ACTIONS_SUPPORTED_CHAIN_IDS } from '@/constants/supportedChains.js'
6
+ import type {
7
+ SwapExecuteParamsResolved,
8
+ SwapQuoteParamsResolved,
9
+ } from '@/ens/types.js'
6
10
  import type { ChainManager } from '@/services/ChainManager.js'
7
11
  import { UNIVERSAL_ROUTER_MSG_SENDER } from '@/swap/core/markets.js'
8
12
  import type { SwapSettings } from '@/types/actions.js'
@@ -16,7 +20,6 @@ import type {
16
20
  SwapMarketConfig,
17
21
  SwapProviderConfig,
18
22
  SwapQuote,
19
- SwapQuoteParams,
20
23
  SwapTransaction,
21
24
  SwapTransactionData,
22
25
  } from '@/types/swap/index.js'
@@ -40,6 +43,7 @@ import {
40
43
  validateNotBothAmounts,
41
44
  validateNotSameAsset,
42
45
  validateNotZeroAddress,
46
+ validateRecipient,
43
47
  validateSlippage,
44
48
  } from '@/utils/validation.js'
45
49
 
@@ -129,14 +133,15 @@ export abstract class SwapProvider<
129
133
  * @returns Transaction data ready for wallet execution
130
134
  */
131
135
  async execute(
132
- params: SwapExecuteParams | SwapQuote,
136
+ params: SwapExecuteParamsResolved | SwapQuote,
133
137
  ): Promise<SwapTransaction> {
134
- this.validateSwapExecute(params)
135
-
136
138
  if (QUOTE_DISCRIMINATOR in params) {
139
+ this.validateSwapExecute(params)
137
140
  return this.executeFromQuote(params)
138
141
  }
139
142
 
143
+ this.validateSwapExecute(params)
144
+
140
145
  // Raw params only
141
146
  validateNotBothAmounts(params.amountIn, params.amountOut)
142
147
  validateNotZeroAddress(params.walletAddress, 'walletAddress')
@@ -149,7 +154,7 @@ export abstract class SwapProvider<
149
154
  * @param params - Quote parameters (assets, amounts, chain, slippage)
150
155
  * @returns SwapQuote with pricing, amounts, and pre-encoded calldata
151
156
  */
152
- async getQuote(params: SwapQuoteParams): Promise<SwapQuote> {
157
+ async getQuote(params: SwapQuoteParamsResolved): Promise<SwapQuote> {
153
158
  validateChainSupported(params.chainId, this.supportedChainIds())
154
159
  return this._getQuote(params)
155
160
  }
@@ -267,7 +272,7 @@ export abstract class SwapProvider<
267
272
  * @param params - Raw quote params from the user
268
273
  * @returns Resolved slippage, deadline, recipient, amountInRaw, and current timestamp
269
274
  */
270
- protected resolveQuoteDefaults(params: SwapQuoteParams) {
275
+ protected resolveQuoteDefaults(params: SwapQuoteParamsResolved) {
271
276
  const slippage = params.slippage ?? this.defaultSlippage
272
277
  const now = Math.floor(Date.now() / 1000)
273
278
  const deadline = params.deadline ?? now + this.quoteExpirationSeconds
@@ -449,13 +454,7 @@ export abstract class SwapProvider<
449
454
  validateAmountPositiveIfExists(params.amountIn)
450
455
  validateAmountPositiveIfExists(params.amountOut)
451
456
  validateSlippage(params.slippage ?? this.defaultSlippage, this.maxSlippage)
452
- this.validateRecipient(params)
453
- }
454
-
455
- private validateRecipient(params: SwapExecuteParams | SwapQuote): void {
456
- if ('recipient' in params && params.recipient) {
457
- validateNotZeroAddress(params.recipient, 'recipient')
458
- }
457
+ validateRecipient('recipient' in params ? params.recipient : undefined)
459
458
  }
460
459
 
461
460
  private validateQuoteExpiration(quote: SwapQuote): void {
@@ -467,7 +466,7 @@ export abstract class SwapProvider<
467
466
  }
468
467
  }
469
468
 
470
- private resolveParams(params: SwapExecuteParams): ResolvedSwapParams {
469
+ private resolveParams(params: SwapExecuteParamsResolved): ResolvedSwapParams {
471
470
  return {
472
471
  amountInRaw: parseAssetAmount(params.assetIn, params.amountIn),
473
472
  amountOutRaw: parseAssetAmount(params.assetOut, params.amountOut),
@@ -555,7 +554,9 @@ export abstract class SwapProvider<
555
554
  params: ResolvedSwapParams,
556
555
  ): Promise<SwapTransaction>
557
556
 
558
- protected abstract _getQuote(params: SwapQuoteParams): Promise<SwapQuote>
557
+ protected abstract _getQuote(
558
+ params: SwapQuoteParamsResolved,
559
+ ): Promise<SwapQuote>
559
560
 
560
561
  /**
561
562
  * Build provider-specific approval transactions for a swap.
@@ -1,4 +1,5 @@
1
1
  import type { SupportedChainId } from '@/constants/supportedChains.js'
2
+ import type { SwapQuoteParamsResolved } from '@/ens/types.js'
2
3
  import type { SwapProvider } from '@/swap/core/SwapProvider.js'
3
4
  import type { SwapProviderName, SwapSettings } from '@/types/actions.js'
4
5
  import type { Asset } from '@/types/asset.js'
@@ -11,15 +12,21 @@ import type {
11
12
  SwapQuote,
12
13
  SwapQuoteParams,
13
14
  } from '@/types/swap/index.js'
15
+ import { passthroughResolver, type RecipientResolver } from '@/utils/ens.js'
14
16
 
15
17
  /**
16
18
  * Base swap namespace with shared read-only operations
17
19
  */
18
20
  export abstract class BaseSwapNamespace {
21
+ protected readonly resolveRecipient: RecipientResolver
22
+
19
23
  constructor(
20
24
  protected readonly providers: SwapProviders,
25
+ resolveRecipient?: RecipientResolver,
21
26
  protected readonly settings?: SwapSettings,
22
- ) {}
27
+ ) {
28
+ this.resolveRecipient = resolveRecipient ?? passthroughResolver
29
+ }
23
30
 
24
31
  /**
25
32
  * Get a swap quote with pre-built execution data.
@@ -29,30 +36,31 @@ export abstract class BaseSwapNamespace {
29
36
  * @returns The best available SwapQuote
30
37
  */
31
38
  async getQuote(params: SwapQuoteParams): Promise<SwapQuote> {
39
+ const recipient = await this.resolveRecipient(params.recipient)
40
+ const resolved: SwapQuoteParamsResolved = { ...params, recipient }
41
+
32
42
  // Explicit provider — skip routing
33
- if (params.provider) {
34
- const provider = this.resolveProvider(
35
- params.provider,
36
- params.assetIn,
37
- params.assetOut,
38
- params.chainId,
39
- )
40
- return provider.getQuote(params)
43
+ if (resolved.provider) {
44
+ return this.resolveProvider(
45
+ resolved.provider,
46
+ resolved.assetIn,
47
+ resolved.assetOut,
48
+ resolved.chainId,
49
+ ).getQuote(resolved)
41
50
  }
42
51
 
43
52
  // Price routing — quote all eligible providers, return best
44
53
  if (this.settings?.routing === 'price') {
45
- return this.getBestQuote(params)
54
+ return this.getBestQuote(resolved)
46
55
  }
47
56
 
48
57
  // No routing — resolve single provider via fallback logic
49
- const provider = this.resolveProvider(
58
+ return this.resolveProvider(
50
59
  undefined,
51
- params.assetIn,
52
- params.assetOut,
53
- params.chainId,
54
- )
55
- return provider.getQuote(params)
60
+ resolved.assetIn,
61
+ resolved.assetOut,
62
+ resolved.chainId,
63
+ ).getQuote(resolved)
56
64
  }
57
65
 
58
66
  /**
@@ -61,7 +69,9 @@ export abstract class BaseSwapNamespace {
61
69
  * @returns The quote with the highest amountOut
62
70
  * @throws If no provider returns a valid quote
63
71
  */
64
- private async getBestQuote(params: SwapQuoteParams): Promise<SwapQuote> {
72
+ private async getBestQuote(
73
+ params: SwapQuoteParamsResolved,
74
+ ): Promise<SwapQuote> {
65
75
  const quotes = await this.fetchAllQuotes(params)
66
76
 
67
77
  let best: SwapQuote | null = null
@@ -86,7 +96,9 @@ export abstract class BaseSwapNamespace {
86
96
  * @param params - Quote parameters
87
97
  * @returns Array of successful quotes (may be empty if all providers fail)
88
98
  */
89
- private async fetchAllQuotes(params: SwapQuoteParams): Promise<SwapQuote[]> {
99
+ private async fetchAllQuotes(
100
+ params: SwapQuoteParamsResolved,
101
+ ): Promise<SwapQuote[]> {
90
102
  const eligible = this.getAllProviders().filter((p) =>
91
103
  p.isMarketSupported(params.assetIn, params.assetOut, params.chainId),
92
104
  )
@@ -116,17 +128,21 @@ export abstract class BaseSwapNamespace {
116
128
  * @returns Array of SwapQuotes sorted by amountOut descending (best first)
117
129
  */
118
130
  async getQuotes(params: SwapQuoteParams): Promise<SwapQuote[]> {
119
- if (params.provider) {
120
- const provider = this.resolveProvider(
121
- params.provider,
122
- params.assetIn,
123
- params.assetOut,
124
- params.chainId,
125
- )
126
- return [await provider.getQuote(params)]
131
+ const recipient = await this.resolveRecipient(params.recipient)
132
+ const resolved: SwapQuoteParamsResolved = { ...params, recipient }
133
+
134
+ if (resolved.provider) {
135
+ return [
136
+ await this.resolveProvider(
137
+ resolved.provider,
138
+ resolved.assetIn,
139
+ resolved.assetOut,
140
+ resolved.chainId,
141
+ ).getQuote(resolved),
142
+ ]
127
143
  }
128
144
 
129
- const quotes = await this.fetchAllQuotes(params)
145
+ const quotes = await this.fetchAllQuotes(resolved)
130
146
  return quotes.sort((a, b) =>
131
147
  a.amountOutRaw > b.amountOutRaw
132
148
  ? -1
@@ -1,4 +1,5 @@
1
1
  import type { SupportedChainId } from '@/constants/supportedChains.js'
2
+ import type { SwapExecuteParamsResolved } from '@/ens/types.js'
2
3
  import { QUOTE_DISCRIMINATOR } from '@/swap/core/SwapProvider.js'
3
4
  import { BaseSwapNamespace } from '@/swap/namespaces/BaseSwapNamespace.js'
4
5
  import type { SwapSettings } from '@/types/actions.js'
@@ -10,6 +11,7 @@ import type {
10
11
  SwapTransaction,
11
12
  WalletSwapParams,
12
13
  } from '@/types/swap/index.js'
14
+ import type { RecipientResolver } from '@/utils/ens.js'
13
15
  import type { Wallet } from '@/wallet/core/wallets/abstract/Wallet.js'
14
16
 
15
17
  /**
@@ -20,9 +22,10 @@ export class WalletSwapNamespace extends BaseSwapNamespace {
20
22
  constructor(
21
23
  providers: SwapProviders,
22
24
  private readonly wallet: Wallet,
25
+ resolveRecipient?: RecipientResolver,
23
26
  settings?: SwapSettings,
24
27
  ) {
25
- super(providers, settings)
28
+ super(providers, resolveRecipient, settings)
26
29
  }
27
30
 
28
31
  /**
@@ -54,11 +57,16 @@ export class WalletSwapNamespace extends BaseSwapNamespace {
54
57
  */
55
58
  async execute(params: WalletSwapParams | SwapQuote): Promise<SwapReceipt> {
56
59
  // Inject walletAddress — raw params need it for validation,
57
- // quotes need it for on-chain allowance checks during approval building
58
- const executeParams =
60
+ // quotes need it for on-chain allowance checks during approval building.
61
+ // Resolve ENS recipient here so providers only ever receive an Address.
62
+ const executeParams: SwapExecuteParamsResolved | SwapQuote =
59
63
  QUOTE_DISCRIMINATOR in params
60
64
  ? { ...params, recipient: this.wallet.address }
61
- : { ...params, walletAddress: this.wallet.address }
65
+ : {
66
+ ...params,
67
+ walletAddress: this.wallet.address,
68
+ recipient: await this.resolveRecipient(params.recipient),
69
+ }
62
70
 
63
71
  const provider = this.resolveProvider(
64
72
  params.provider,
@@ -67,6 +67,7 @@ describe('BaseSwapNamespace', () => {
67
67
 
68
68
  const namespace = new ActionsSwapNamespace(
69
69
  { uniswap: cheapProvider, velodrome: expensiveProvider },
70
+ undefined,
70
71
  { routing: 'price' },
71
72
  )
72
73
 
@@ -94,6 +95,7 @@ describe('BaseSwapNamespace', () => {
94
95
 
95
96
  const namespace = new ActionsSwapNamespace(
96
97
  { uniswap: failingProvider, velodrome: workingProvider },
98
+ undefined,
97
99
  { routing: 'price' },
98
100
  )
99
101
 
@@ -115,6 +117,7 @@ describe('BaseSwapNamespace', () => {
115
117
 
116
118
  const namespace = new ActionsSwapNamespace(
117
119
  { uniswap: failingProvider },
120
+ undefined,
118
121
  { routing: 'price' },
119
122
  )
120
123
 
@@ -140,6 +143,7 @@ describe('BaseSwapNamespace', () => {
140
143
 
141
144
  const namespace = new ActionsSwapNamespace(
142
145
  { uniswap: cheapProvider, velodrome: expensiveProvider },
146
+ undefined,
143
147
  { routing: 'price' },
144
148
  )
145
149
 
@@ -2,6 +2,7 @@ import { formatUnits } from 'viem'
2
2
 
3
3
  import { UNISWAP } from '@/constants/providers.js'
4
4
  import type { SupportedChainId } from '@/constants/supportedChains.js'
5
+ import type { SwapQuoteParamsResolved } from '@/ens/types.js'
5
6
  import { expandMarkets, findMarket } from '@/swap/core/markets.js'
6
7
  import { SwapProvider } from '@/swap/core/SwapProvider.js'
7
8
  import {
@@ -27,7 +28,6 @@ import type {
27
28
  ResolvedSwapParams,
28
29
  SwapMarket,
29
30
  SwapQuote,
30
- SwapQuoteParams,
31
31
  SwapTransaction,
32
32
  } from '@/types/swap/index.js'
33
33
  import { isNativeAsset, parseAssetAmount } from '@/utils/assets.js'
@@ -84,7 +84,9 @@ export class UniswapSwapProvider extends SwapProvider<UniswapSwapProviderConfig>
84
84
  )
85
85
  }
86
86
 
87
- protected async _getQuote(params: SwapQuoteParams): Promise<SwapQuote> {
87
+ protected async _getQuote(
88
+ params: SwapQuoteParamsResolved,
89
+ ): Promise<SwapQuote> {
88
90
  const { chainId, assetIn, assetOut } = params
89
91
  const addresses = getUniswapAddresses(chainId)
90
92
  const publicClient = this.chainManager.getPublicClient(chainId)
@@ -2,6 +2,7 @@ import { formatUnits } from 'viem'
2
2
 
3
3
  import { VELODROME } from '@/constants/providers.js'
4
4
  import type { SupportedChainId } from '@/constants/supportedChains.js'
5
+ import type { SwapQuoteParamsResolved } from '@/ens/types.js'
5
6
  import { expandMarkets, findMarket } from '@/swap/core/markets.js'
6
7
  import { SwapProvider } from '@/swap/core/SwapProvider.js'
7
8
  import {
@@ -29,7 +30,6 @@ import type {
29
30
  ResolvedSwapParams,
30
31
  SwapMarket,
31
32
  SwapQuote,
32
- SwapQuoteParams,
33
33
  SwapTransaction,
34
34
  } from '@/types/swap/index.js'
35
35
  import { getAssetAddress, isNativeAsset } from '@/utils/assets.js'
@@ -113,7 +113,9 @@ export class VelodromeSwapProvider extends SwapProvider<VelodromeSwapProviderCon
113
113
  * @returns SwapQuote with amounts, price, route, and encoded calldata
114
114
  * @throws If amountOut is provided (Velodrome only supports exact-input)
115
115
  */
116
- protected async _getQuote(params: SwapQuoteParams): Promise<SwapQuote> {
116
+ protected async _getQuote(
117
+ params: SwapQuoteParamsResolved,
118
+ ): Promise<SwapQuote> {
117
119
  const { chainId, assetIn, assetOut } = params
118
120
 
119
121
  if (params.amountOut !== undefined) {
@@ -34,6 +34,7 @@ function createMockChainManager(): ChainManager {
34
34
 
35
35
  return {
36
36
  getPublicClient: vi.fn().mockReturnValue(mockPublicClient),
37
+ tryGetPublicClient: vi.fn().mockReturnValue(undefined),
37
38
  getSupportedChains: vi.fn().mockReturnValue([CHAIN_ID]),
38
39
  } as unknown as ChainManager
39
40
  }
@@ -1,6 +1,7 @@
1
1
  import type { Address, Hex } from 'viem'
2
2
 
3
3
  import type { SupportedChainId } from '@/constants/supportedChains.js'
4
+ import type { EnsName } from '@/ens/types.js'
4
5
  import type { SwapProviderName } from '@/types/actions.js'
5
6
  import type { Asset } from '@/types/asset.js'
6
7
  import type { TransactionData } from '@/types/transaction.js'
@@ -88,8 +89,8 @@ export interface WalletSwapParams {
88
89
  slippage?: number
89
90
  /** Transaction deadline as Unix timestamp. Defaults to now + 1 minute. */
90
91
  deadline?: number
91
- /** Recipient address. Defaults to wallet address. */
92
- recipient?: Address
92
+ /** Recipient address or ENS name (e.g. "vitalik.eth"). Defaults to wallet address. */
93
+ recipient?: Address | EnsName
93
94
  /** Explicitly select a swap provider. Overrides routing config. */
94
95
  provider?: SwapProviderName
95
96
  }
@@ -156,8 +157,8 @@ export interface SwapQuoteParams {
156
157
  slippage?: number
157
158
  /** Transaction deadline as Unix timestamp */
158
159
  deadline?: number
159
- /** Recipient address */
160
- recipient?: Address
160
+ /** Recipient address or ENS name (e.g. "vitalik.eth"). Defaults to wallet address. */
161
+ recipient?: Address | EnsName
161
162
  /** Explicitly select a swap provider */
162
163
  provider?: SwapProviderName
163
164
  }
@@ -0,0 +1,104 @@
1
+ import type { Address, PublicClient } from 'viem'
2
+ import { zeroAddress } from 'viem'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { resolveAddress } from '@/utils/ens.js'
6
+
7
+ const REAL_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Address
8
+ const ZERO_ADDRESS = zeroAddress
9
+
10
+ function mockClient(
11
+ returnValue: Address | null,
12
+ rejects = false,
13
+ ): PublicClient {
14
+ const getEnsAddress = rejects
15
+ ? vi.fn().mockRejectedValue(new Error('network error'))
16
+ : vi.fn().mockResolvedValue(returnValue)
17
+ return { getEnsAddress } as unknown as PublicClient
18
+ }
19
+
20
+ describe('resolveAddress', () => {
21
+ describe('hex address input', () => {
22
+ it('returns a valid hex address as-is', async () => {
23
+ expect(await resolveAddress(REAL_ADDRESS)).toBe(REAL_ADDRESS)
24
+ })
25
+
26
+ it('does not require a mainnet client for hex addresses', async () => {
27
+ await expect(resolveAddress(REAL_ADDRESS)).resolves.toBe(REAL_ADDRESS)
28
+ })
29
+ })
30
+
31
+ describe('ENS name input', () => {
32
+ it('resolves a valid ENS name', async () => {
33
+ const client = mockClient(REAL_ADDRESS)
34
+ const result = await resolveAddress('vitalik.eth', client)
35
+ expect(result).toBe(REAL_ADDRESS)
36
+ expect(client.getEnsAddress).toHaveBeenCalledWith({ name: 'vitalik.eth' })
37
+ })
38
+
39
+ it('normalises the ENS name before resolving', async () => {
40
+ const client = mockClient(REAL_ADDRESS)
41
+ await resolveAddress('Vitalik.ETH', client)
42
+ expect(client.getEnsAddress).toHaveBeenCalledWith({ name: 'vitalik.eth' })
43
+ })
44
+
45
+ it('throws EnsNotConfiguredError when no mainnet client is provided', async () => {
46
+ const { EnsNotConfiguredError } = await import('@/ens/errors.js')
47
+ await expect(resolveAddress('vitalik.eth')).rejects.toThrow(
48
+ EnsNotConfiguredError,
49
+ )
50
+ })
51
+
52
+ it('includes chain ID 1 in the error when no mainnet client provided', async () => {
53
+ await expect(resolveAddress('vitalik.eth')).rejects.toThrow('1')
54
+ })
55
+
56
+ it('throws when ENS name cannot be resolved (returns null)', async () => {
57
+ const client = mockClient(null)
58
+ await expect(resolveAddress('unresolvable.eth', client)).rejects.toThrow(
59
+ '"unresolvable.eth" could not be resolved',
60
+ )
61
+ })
62
+
63
+ it('throws when ENS name resolves to zero address', async () => {
64
+ const client = mockClient(ZERO_ADDRESS)
65
+ await expect(resolveAddress('zero.eth', client)).rejects.toThrow(
66
+ 'zero address',
67
+ )
68
+ })
69
+
70
+ it('throws with RPC error label on network failure', async () => {
71
+ const client = mockClient(null, true)
72
+ await expect(resolveAddress('vitalik.eth', client)).rejects.toThrow(
73
+ 'RPC error',
74
+ )
75
+ })
76
+
77
+ it('preserves the original cause on RPC failure', async () => {
78
+ const client = mockClient(null, true)
79
+ let caught: Error | undefined
80
+ try {
81
+ await resolveAddress('vitalik.eth', client)
82
+ } catch (e) {
83
+ caught = e as Error
84
+ }
85
+ expect(caught?.cause).toBeInstanceOf(Error)
86
+ expect((caught?.cause as Error).message).toBe('network error')
87
+ })
88
+ })
89
+
90
+ describe('invalid input', () => {
91
+ it('throws for ENS-shaped strings that fail normalisation', async () => {
92
+ // Has a dot so satisfies EnsName, but contains invalid ENS characters
93
+ await expect(
94
+ resolveAddress('not!valid.eth', {} as PublicClient),
95
+ ).rejects.toThrow('Invalid address or ENS name')
96
+ })
97
+
98
+ it('includes the invalid input in the error message', async () => {
99
+ await expect(
100
+ resolveAddress('!!bad!!.eth', {} as PublicClient),
101
+ ).rejects.toThrow('"!!bad!!.eth"')
102
+ })
103
+ })
104
+ })
@@ -0,0 +1,85 @@
1
+ import type { Address, PublicClient } from 'viem'
2
+ import { isAddress, isAddressEqual, zeroAddress } from 'viem'
3
+ import { normalize } from 'viem/ens'
4
+
5
+ import {
6
+ EnsNotConfiguredError,
7
+ EnsResolutionError,
8
+ EnsRpcError,
9
+ } from '@/ens/errors.js'
10
+ import type { EnsName } from '@/ens/types.js'
11
+
12
+ export { type EnsName, isEnsName } from '@/ens/types.js'
13
+
14
+ /** Resolves an ENS name or address to a checksummed hex Address, or returns undefined. */
15
+ export type RecipientResolver = (
16
+ recipient: Address | EnsName | undefined,
17
+ ) => Promise<Address | undefined>
18
+
19
+ /** Pass-through resolver used when no ENS resolution is configured. Throws on ENS names. */
20
+ export const passthroughResolver: RecipientResolver = (r) => {
21
+ if (r !== undefined && !isAddress(r, { strict: false })) {
22
+ throw new Error(`ENS resolution is not configured; received "${r}"`)
23
+ }
24
+ return Promise.resolve(r as Address | undefined)
25
+ }
26
+
27
+ /**
28
+ * Low-level utility to resolve an ENS name or hex address to a checksummed hex address.
29
+ * Use this when you manage your own viem PublicClient (e.g. inside providers or scripts).
30
+ *
31
+ * If you have an {@link Actions} instance, prefer `actions.ens.getAddress()` instead —
32
+ * it handles client lookup and fallback automatically from your chain configuration.
33
+ *
34
+ * Hex addresses (0x...) are returned as-is after format validation.
35
+ * ENS names require a mainnet public client for on-chain resolution.
36
+ * @param input - Hex address (0x...) or ENS name (e.g. "vitalik.eth")
37
+ * @param mainnetClient - Public client connected to Ethereum mainnet (required for ENS names)
38
+ * @returns Resolved hex address
39
+ * @throws {EnsNotConfiguredError} If mainnet client is not provided
40
+ * @throws {EnsResolutionError} If the name is invalid or cannot be resolved
41
+ * @throws {EnsRpcError} If the RPC call fails
42
+ */
43
+ export async function resolveAddress(
44
+ input: Address | EnsName,
45
+ mainnetClient?: PublicClient,
46
+ ): Promise<Address> {
47
+ if (isAddress(input, { strict: false })) return input
48
+
49
+ if (!mainnetClient) throw new EnsNotConfiguredError()
50
+
51
+ const normalized = (() => {
52
+ try {
53
+ return normalize(input)
54
+ } catch (cause) {
55
+ throw new EnsResolutionError(
56
+ `Invalid address or ENS name: "${input}"`,
57
+ input,
58
+ { cause },
59
+ )
60
+ }
61
+ })()
62
+
63
+ const resolved = await mainnetClient
64
+ .getEnsAddress({ name: normalized })
65
+ .catch((cause: unknown) => {
66
+ throw new EnsRpcError(
67
+ `ENS resolution failed for "${input}": RPC error`,
68
+ input,
69
+ { cause },
70
+ )
71
+ })
72
+
73
+ if (!resolved)
74
+ throw new EnsResolutionError(
75
+ `ENS name "${input}" could not be resolved`,
76
+ input,
77
+ )
78
+ if (isAddressEqual(resolved, zeroAddress)) {
79
+ throw new EnsResolutionError(
80
+ `ENS name "${input}" resolved to the zero address`,
81
+ input,
82
+ )
83
+ }
84
+ return resolved
85
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Address } from 'viem'
2
+ import { isAddress } from 'viem'
2
3
 
3
4
  import type { SupportedChainId } from '@/constants/supportedChains.js'
4
5
  import type { Asset } from '@/types/asset.js'
@@ -74,3 +75,13 @@ export function validateAssetOnChain(
74
75
  )
75
76
  }
76
77
  }
78
+
79
+ /**
80
+ * Validate that a resolved recipient address is not the zero address.
81
+ * ENS names are skipped — only resolved `Address` values are checked.
82
+ */
83
+ export function validateRecipient(recipient: string | undefined): void {
84
+ if (recipient && isAddress(recipient)) {
85
+ validateNotZeroAddress(recipient, 'recipient')
86
+ }
87
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Address, LocalAccount } from 'viem'
2
2
 
3
3
  import type { SupportedChainId } from '@/constants/supportedChains.js'
4
+ import { EnsNamespace } from '@/ens/index.js'
4
5
  import { WalletLendNamespace } from '@/lend/namespaces/WalletLendNamespace.js'
5
6
  import type { ChainManager } from '@/services/ChainManager.js'
6
7
  import { fetchERC20Balance, fetchETHBalance } from '@/services/tokenBalance.js'
@@ -72,9 +73,11 @@ export abstract class Wallet {
72
73
  this.lend = new WalletLendNamespace(this.lendProviders, this)
73
74
  }
74
75
  if (Object.values(this.swapProviders).some(Boolean)) {
76
+ const ens = new EnsNamespace(this.chainManager)
75
77
  this.swap = new WalletSwapNamespace(
76
78
  this.swapProviders,
77
79
  this,
80
+ (r) => (r ? ens.getAddress(r) : Promise.resolve(undefined)),
78
81
  swapSettings,
79
82
  )
80
83
  }