@shapeshiftoss/swap-widget 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ An embeddable React widget that enables multi-chain token swaps using ShapeShift
11
11
  - [Wallet Connection](#wallet-connection)
12
12
  - [Props Reference](#props-reference)
13
13
  - [Filtering Chains and Assets](#filtering-chains-and-assets)
14
+ - [Exact Output and Locked Destinations](#exact-output-and-locked-destinations)
14
15
  - [Theming](#theming)
15
16
  - [Examples](#examples)
16
17
  - [Exported Types](#exported-types)
@@ -139,6 +140,10 @@ when `allowShapeshiftRedirect` is enabled.
139
140
  | `allowedSwapperNames` | `SwapperName[]` | all enabled | Limit quotes to specific swappers. See [Supported Swappers](#supported-swappers). |
140
141
  | `allowShapeshiftRedirect`| `boolean` | `true` | When a swap isn't executable in-widget, redirect to app.shapeshift.com instead of hiding it. |
141
142
  | `isBuyAssetLocked` | `boolean` | `false` | Prevent the user from changing the buy asset. |
143
+ | `defaultBuyAmountCryptoBaseUnit` | `string` | – | Drive the trade from the buy side: the user receives exactly this amount and the sell amount is derived. Restricts routing to exact-output swappers. See [Exact Output and Locked Destinations](#exact-output-and-locked-destinations). |
144
+ | `isBuyAmountLocked` | `boolean` | `false` | Prevent the user from changing the buy amount. When an amount is supplied it also locks the buy asset, since a base-unit amount is meaningless without the asset it counts. |
145
+ | `defaultReceiveAddress` | `string` | – | Prefill the destination address. Falls back to the connected wallet's address for the buy chain when unset. |
146
+ | `isReceiveAddressLocked` | `boolean` | `false` | Prevent the user from changing the destination address. |
142
147
  | `theme` | `ThemeMode \| ThemeConfig` | `"dark"` | Theme mode (`"light"` or `"dark"`) or a full theme configuration object. See [Theming](#theming). |
143
148
  | `defaultSlippage` | `string` | `"0.5"` | Default slippage tolerance, as a percentage string. |
144
149
  | `showPoweredBy` | `boolean` | `true` | Show the "Powered by ShapeShift" footer. |
@@ -183,6 +188,162 @@ function App() {
183
188
  }
184
189
  ```
185
190
 
191
+ ## Exact Output and Locked Destinations
192
+
193
+ By default the user enters what they want to spend. These props invert that, and fix where the funds
194
+ land — letting you configure the widget for one specific swap.
195
+
196
+ ### Fixed receive amount
197
+
198
+ `defaultBuyAmountCryptoBaseUnit` drives the trade from the buy side. The user still chooses what to
199
+ sell, but the sell amount comes back derived from whichever route they pick.
200
+
201
+ ```tsx
202
+ <SwapWidget
203
+ walletConnectProjectId="..."
204
+ defaultBuyAsset={usdcOnBase}
205
+ defaultBuyAmountCryptoBaseUnit="500000" // 0.5 USDC, in base units
206
+ isBuyAmountLocked
207
+ />
208
+ ```
209
+
210
+ Without `isBuyAmountLocked` the amount is only a **prefill**: both fields stay editable, and
211
+ whichever one the user types into becomes the side that drives the trade — the other is then derived
212
+ from the selected route. Adding the lock fixes the buy amount, and makes the sell field read-only,
213
+ since typing there would clear the amount you locked.
214
+
215
+ Because a base-unit amount only means something alongside the asset it counts, `isBuyAmountLocked`
216
+ locks the buy asset too. Changing an unlocked buy asset keeps the entered amount and recalculates its
217
+ base units at the new precision, matching how the sell side already behaves.
218
+
219
+ Only swappers that can honour an exact output are routed to — currently **NEAR Intents** and
220
+ **Relay**. The rest report `ExactOutputNotSupported` and are left out of the rate list, so expect
221
+ fewer routes than a normal swap, and none at all for pairs those two don't cover.
222
+
223
+ Slippage applies to the **sell** side. The amount received is fixed; what varies is what it costs.
224
+
225
+ ### Fixed destination
226
+
227
+ `defaultReceiveAddress` prefills the destination. Add `isReceiveAddressLocked` to stop the user
228
+ changing it — the locked address then outranks both a user entry and the connected wallet's own
229
+ address.
230
+
231
+ | Props | Behaviour |
232
+ | ---------------------------- | ---------------------------------- |
233
+ | `defaultReceiveAddress` only | Prefilled, user can still edit it |
234
+ | Both | Locked to the address you supplied |
235
+
236
+ A locked address is checked against the buy asset's chain, and if it doesn't validate there the
237
+ widget says so and blocks the swap rather than falling back to the connected wallet — paying the
238
+ user's own address is never what a locked destination meant. This is reachable whenever the buy
239
+ asset is left unlocked, since the user can switch to a chain the address doesn't belong to.
240
+
241
+ An **unlocked** prefill is checked the same way but fails quietly: one that doesn't match the buy
242
+ chain is dropped, and the connected wallet's address takes over as if you had passed nothing. Verify
243
+ a prefill against the chain of the buy asset you pair it with.
244
+
245
+ A lock is only accepted alongside the value it locks — `isReceiveAddressLocked` on its own is a type
246
+ error, as is `isBuyAmountLocked` without `defaultBuyAmountCryptoBaseUnit`. Locking with nothing to
247
+ lock would fall back to the connected wallet's address, which is undefined whenever the wallet
248
+ doesn't cover the buy asset's chain, leaving no address and no way to enter one.
249
+
250
+ If you build props dynamically, import `ReceiveAddressProps` and `BuyAmountProps` and construct each
251
+ pair together:
252
+
253
+ ```tsx
254
+ const receiveAddressProps: ReceiveAddressProps = address
255
+ ? { defaultReceiveAddress: address, isReceiveAddressLocked: true }
256
+ : {}
257
+
258
+ <SwapWidget {...receiveAddressProps} />
259
+ ```
260
+
261
+ ### Payment mode
262
+
263
+ Locking **both** the buy amount and the address — `defaultBuyAmountCryptoBaseUnit` with
264
+ `isBuyAmountLocked`, plus `defaultReceiveAddress` with `isReceiveAddressLocked` — puts the widget in
265
+ **payment mode**: a set amount, sent to an address you supplied.
266
+
267
+ ```tsx
268
+ <SwapWidget
269
+ walletConnectProjectId="..."
270
+ defaultBuyAsset={usdcOnBase}
271
+ defaultBuyAmountCryptoBaseUnit="500000"
272
+ isBuyAmountLocked
273
+ defaultReceiveAddress="0x…"
274
+ isReceiveAddressLocked
275
+ onSwapSuccess={txHash => recordPayment(txHash)}
276
+ />
277
+ ```
278
+
279
+ The success screen is then terminal — no "New Swap" button. Repeating a payment means paying twice,
280
+ so whether there's another swap is your call rather than the widget's; use `onSwapSuccess` to decide
281
+ what happens next.
282
+
283
+ Locking only **one** of the two stays repeatable, and keeps the button: a set amount sent to the
284
+ user's own wallet, or topping up a locked address again, are both things a user may reasonably do
285
+ twice.
286
+ `isBuyAssetLocked` never affects this — restricting swaps to a given token is just a configuration.
287
+
288
+ ### Redirects are disabled by either lock
289
+
290
+ The app.shapeshift.com redirect carries neither the destination nor the buy amount, so following it
291
+ would drop whichever constraint you set. Locking the buy amount **or** the receive address
292
+ therefore disables it outright: `allowShapeshiftRedirect` has no effect, and assets on
293
+ non-executable chains drop out of the asset pickers rather than dead-ending. The pickers share that
294
+ filter, so Cosmos-SDK assets go from the **buy** side too, even though a swap into them works — only
295
+ the sell side needs a signature. The remaining redirect-only chains lose nothing: the widget has no
296
+ address validator for them, so they were never usable as a destination.
297
+
298
+ Note this is a wider condition than payment mode — locking either one is enough, because a single
299
+ dropped constraint can send funds somewhere you didn't intend.
300
+
301
+ ### Configuration is applied at mount
302
+
303
+ Every `default*` prop is applied a single time, when the widget mounts — the same semantics as
304
+ `defaultValue` on an `<input>`. After that the value belongs to the user, so changing the prop on an
305
+ already mounted widget has no effect, and neither does resolving it asynchronously: if you fetch
306
+ `Asset` objects, hold off rendering until you have them.
307
+
308
+ **Locked values are the exception.** A locked value is yours rather than the user's, so it tracks its
309
+ prop rather than seeding once — change `defaultBuyAmountCryptoBaseUnit` alongside `isBuyAmountLocked`,
310
+ or `defaultReceiveAddress` alongside `isReceiveAddressLocked`, and the widget follows without a
311
+ remount. Both apply while the user is on the input step. Once they've asked for a quote a change may
312
+ not land at all — that quote carries the amount and address it was built with — so change them
313
+ before the user starts, or remount. `defaultBuyAsset` seeds once even when `isBuyAssetLocked`.
314
+
315
+ To change anything else, or to start a fresh swap after a payment completes, **remount**.
316
+ If the widget lives in a modal that unmounts its children while closed, that happens for free:
317
+
318
+ ```tsx
319
+ {isOpen && <SwapWidget onSwapSuccess={() => setIsOpen(false)} {...config} />}
320
+ ```
321
+
322
+ Inline — or in a modal that keeps its children mounted — bump a `key` instead:
323
+
324
+ ```tsx
325
+ const [swapSession, setSwapSession] = useState(0)
326
+
327
+ <SwapWidget
328
+ key={swapSession}
329
+ onSwapSuccess={txHash => {
330
+ recordSwap(txHash)
331
+ setSwapSession(n => n + 1)
332
+ }}
333
+ {...config}
334
+ />
335
+ ```
336
+
337
+ Remounting is cheap. The AppKit instance and the React Query cache are module-level singletons
338
+ rather than widget state, so the user stays connected and asset and balance data isn't refetched —
339
+ only the swap itself resets.
340
+
341
+ ### Refunds
342
+
343
+ If a swap can't be completed, the provider returns the funds to the **sending** address — the wallet
344
+ the user swapped from — not to the receive address. A locked destination does not affect where a
345
+ refund goes.
346
+
186
347
  ## Theming
187
348
 
188
349
  The widget supports a simple light/dark mode or a full theme configuration object.
@@ -303,8 +464,10 @@ end in a specific token.
303
464
  import type {
304
465
  Asset,
305
466
  AssetId,
467
+ BuyAmountProps,
306
468
  Chain,
307
469
  ChainId,
470
+ ReceiveAddressProps,
308
471
  SwapWidgetFilters,
309
472
  SwapWidgetProps,
310
473
  ThemeConfig,
@@ -522,5 +685,12 @@ revenue attribution works.
522
685
  - **Balances and USD prices.** When a wallet is connected, the widget shows balances and USD prices
523
686
  for the selected assets.
524
687
  - **Redirects.** Assets on non-executable chains (Cosmos, Zcash, Tron, Sui, TON, NEAR, Starknet)
525
- send the user to app.shapeshift.com to finish the swap, unless `allowShapeshiftRedirect={false}`.
688
+ send the user to app.shapeshift.com to finish the swap, unless `allowShapeshiftRedirect={false}`
689
+ or the buy amount or receive address is locked (see
690
+ [Redirects are disabled by either lock](#redirects-are-disabled-by-either-lock)).
691
+ - **Configuration is applied at mount.** `default*` props are read once; locked values keep
692
+ tracking their prop. Remount to change anything else, or to start a fresh swap. See
693
+ [Configuration is applied at mount](#configuration-is-applied-at-mount).
694
+ - **`onSwapSuccess` reports the sell transaction.** The hash it receives is the transaction the user
695
+ signed on the sell chain. On cross-chain routes the destination transfer may still be in flight.
526
696
  - **Mobile responsive.** The widget is designed to work on mobile as well as desktop.
package/dist/index.css CHANGED
@@ -155,6 +155,22 @@
155
155
  .ssw-amount-input::placeholder {
156
156
  color: var(--ssw-text-muted);
157
157
  }
158
+ .ssw-amount-input-pending::placeholder {
159
+ animation: ssw-placeholder-pulse 1.4s ease-in-out infinite;
160
+ }
161
+ @keyframes ssw-placeholder-pulse {
162
+ 0%, 100% {
163
+ opacity: 1;
164
+ }
165
+ 50% {
166
+ opacity: 0.3;
167
+ }
168
+ }
169
+ @media (prefers-reduced-motion: reduce) {
170
+ .ssw-amount-input-pending::placeholder {
171
+ animation: none;
172
+ }
173
+ }
158
174
  .ssw-token-btn {
159
175
  display: flex;
160
176
  align-items: center;
@@ -446,6 +462,9 @@
446
462
  .ssw-receive-row.ssw-attention {
447
463
  box-shadow: 0 0 16px -2px var(--ssw-accent);
448
464
  }
465
+ .ssw-receive-row.ssw-receive-row-invalid {
466
+ border-color: var(--ssw-error);
467
+ }
449
468
  .ssw-quotes {
450
469
  padding: 0 16px 16px;
451
470
  }
package/dist/index.d.ts CHANGED
@@ -99,7 +99,21 @@ type SwapWidgetFilters = {
99
99
  allowedAssetIds?: AssetId[];
100
100
  disabledAssetIds?: AssetId[];
101
101
  };
102
- type SwapWidgetProps = {
102
+ type ReceiveAddressProps = {
103
+ defaultReceiveAddress: string;
104
+ isReceiveAddressLocked?: boolean;
105
+ } | {
106
+ defaultReceiveAddress?: never;
107
+ isReceiveAddressLocked?: false;
108
+ };
109
+ type BuyAmountProps = {
110
+ defaultBuyAmountCryptoBaseUnit: string;
111
+ isBuyAmountLocked?: boolean;
112
+ } | {
113
+ defaultBuyAmountCryptoBaseUnit?: never;
114
+ isBuyAmountLocked?: false;
115
+ };
116
+ type SwapWidgetProps = ReceiveAddressProps & BuyAmountProps & {
103
117
  partnerCode?: string;
104
118
  apiBaseUrl?: string;
105
119
  allowShapeshiftRedirect?: boolean;
@@ -192,4 +206,4 @@ declare const useChains: () => AssetQueryResult<ChainInfo[]>;
192
206
  declare const useAssetsByChainId: (chainId: ChainId | undefined) => AssetQueryResult<Asset[]>;
193
207
  declare const useAssetSearch: (query: string, chainId?: ChainId) => AssetQueryResult<Asset[]>;
194
208
 
195
- export { type Asset, COSMOS_CHAIN_IDS, type Chain, EVM_CHAIN_IDS, OTHER_CHAIN_IDS, REDIRECT_ONLY_CHAIN_IDS, SwapWidget, type SwapWidgetFilters, type SwapWidgetProps, SwapperName, type ThemeConfig, type ThemeMode, type TradeQuote, type TradeRate, UTXO_CHAIN_IDS, formatAmount, getBaseAsset, getChainColor, getChainIcon, getChainName, getChainType, getEvmNetworkId, getExplorerTxLink, isEvmChainId, isWidgetExecutableChainId, isWidgetSupportedChainId, parseAmount, truncateAddress, useAssetById, useAssetSearch, useAssets, useAssetsByChainId, useChains };
209
+ export { type Asset, type BuyAmountProps, COSMOS_CHAIN_IDS, type Chain, EVM_CHAIN_IDS, OTHER_CHAIN_IDS, REDIRECT_ONLY_CHAIN_IDS, type ReceiveAddressProps, SwapWidget, type SwapWidgetFilters, type SwapWidgetProps, SwapperName, type ThemeConfig, type ThemeMode, type TradeQuote, type TradeRate, UTXO_CHAIN_IDS, formatAmount, getBaseAsset, getChainColor, getChainIcon, getChainName, getChainType, getEvmNetworkId, getExplorerTxLink, isEvmChainId, isWidgetExecutableChainId, isWidgetSupportedChainId, parseAmount, truncateAddress, useAssetById, useAssetSearch, useAssets, useAssetsByChainId, useChains };