@shapeshiftoss/swap-widget 0.9.0 → 0.10.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
@@ -12,6 +12,7 @@ An embeddable React widget that enables multi-chain token swaps using ShapeShift
12
12
  - [Props Reference](#props-reference)
13
13
  - [Filtering Chains and Assets](#filtering-chains-and-assets)
14
14
  - [Exact Output and Locked Destinations](#exact-output-and-locked-destinations)
15
+ - [External Wallets and Deposit Addresses](#external-wallets-and-deposit-addresses)
15
16
  - [Theming](#theming)
16
17
  - [Examples](#examples)
17
18
  - [Exported Types](#exported-types)
@@ -122,7 +123,8 @@ Once connected, the widget can sign and broadcast transactions for three wallet
122
123
  The header shows a **Connect** button by default (toggle with `showConnectButton`) that opens the
123
124
  AppKit modal. Swaps whose sell asset is not in an executable namespace (see
124
125
  [Supported Chains](#supported-chains)) redirect to [app.shapeshift.com](https://app.shapeshift.com)
125
- when `allowShapeshiftRedirect` is enabled.
126
+ when `allowShapeshiftRedirect` is enabled. When such a swap is quoted by an externally paid route,
127
+ both are offered: continue without a wallet, or proceed on ShapeShift.
126
128
 
127
129
  ## Props Reference
128
130
 
@@ -150,7 +152,7 @@ when `allowShapeshiftRedirect` is enabled.
150
152
  | `showConnectButton` | `boolean` | `true` | Show the built-in Connect button in the widget header. |
151
153
  | `ratesRefetchInterval` | `number` | `15000` | How often (ms) to refetch swap rates. |
152
154
  | `onSwapSuccess` | `(txHash: string) => void` | – | Called when a swap transaction succeeds. |
153
- | `onSwapError` | `(error: Error) => void` | – | Called when a swap transaction fails. |
155
+ | `onSwapError` | `(error: Error) => void` | – | Called when a swap fails, or when its outcome can no longer be tracked. |
154
156
 
155
157
  ## Filtering Chains and Assets
156
158
 
@@ -233,14 +235,16 @@ address.
233
235
  | `defaultReceiveAddress` only | Prefilled, user can still edit it |
234
236
  | Both | Locked to the address you supplied |
235
237
 
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.
238
+ The address belongs to the chain of the `defaultBuyAsset` you pair it with. It is usable there, and
239
+ on any other EVM chain if it is an EVM address, since those share an address space; nowhere else,
240
+ even where the format would pass a Bitcoin address is not a Bitcoin Cash address. A locked address
241
+ the buy chain can't use blocks the swap and says so, rather than falling back to the connected
242
+ wallet — paying the user's own address is never what a locked destination meant. This is reachable
243
+ whenever the buy asset is left unlocked, since the user can switch chains.
240
244
 
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.
245
+ An **unlocked** prefill follows the same rule but fails quietly: switching to a chain that can't use
246
+ it drops it, and the connected wallet's address takes over as if you had passed nothing. Verify a
247
+ prefill against the chain of the buy asset you pair it with.
244
248
 
245
249
  A lock is only accepted alongside the value it locks — `isReceiveAddressLocked` on its own is a type
246
250
  error, as is `isBuyAmountLocked` without `defaultBuyAmountCryptoBaseUnit`. Locking with nothing to
@@ -285,15 +289,14 @@ user's own wallet, or topping up a locked address again, are both things a user
285
289
  twice.
286
290
  `isBuyAssetLocked` never affects this — restricting swaps to a given token is just a configuration.
287
291
 
288
- ### Redirects are disabled by either lock
292
+ ### Locking the buy amount or receive address disables redirects
289
293
 
290
294
  The app.shapeshift.com redirect carries neither the destination nor the buy amount, so following it
291
295
  would drop whichever constraint you set. Locking the buy amount **or** the receive address
292
296
  therefore disables it outright: `allowShapeshiftRedirect` has no effect, and assets on
293
297
  non-executable chains drop out of the asset pickers rather than dead-ending. The pickers share that
294
298
  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.
299
+ the sell side needs a signature. The remaining redirect-only chains go with them.
297
300
 
298
301
  Note this is a wider condition than payment mode — locking either one is enough, because a single
299
302
  dropped constraint can send funds somewhere you didn't intend.
@@ -341,8 +344,70 @@ only the swap itself resets.
341
344
  ### Refunds
342
345
 
343
346
  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.
347
+ the user swapped from, or the address they entered in the [deposit
348
+ flow](#external-wallets-and-deposit-addresses) — not to the receive address. A locked destination
349
+ does not affect where a refund goes.
350
+
351
+ ## External Wallets and Deposit Addresses
352
+
353
+ Some protocols execute a swap by issuing a **deposit address**: the user sends the sell asset to it
354
+ from any wallet, and the protocol handles the rest. The widget uses this to let people swap with no
355
+ wallet connected at all, and to serve chains it can't sign for.
356
+
357
+ There is nothing to configure. When the selected route comes from a deposit-address protocol and no
358
+ wallet is connected for the sell chain, the primary button becomes **Continue without a wallet**
359
+ instead of Connect Wallet.
360
+
361
+ ### What the user provides
362
+
363
+ Two addresses, both entered in the widget and validated against their chains:
364
+
365
+ - **Receive address** — where the bought asset is sent.
366
+ - **Refund address** — their own address on the sell chain, returned to if the swap can't complete.
367
+ This is also recorded as the swap's send address; the two are always the same value. There is no
368
+ prop for it, because it must belong to the user.
369
+
370
+ ### The deposit screen
371
+
372
+ After quoting, the widget shows the exact amount to send, the deposit address with a QR code, a
373
+ countdown to the quote's expiry, and a summary of the receive and refund addresses. The user pays
374
+ from any external wallet.
375
+
376
+ The QR carries the bare deposit address by default, which any wallet that scans addresses can read.
377
+ A **With amount** option switches it to a payment URI (BIP-21, EIP-681, Solana Pay, TON) that also
378
+ prefills the amount, with a note that not every wallet reads it. Chains with no adopted URI scheme
379
+ (Tron, Sui, NEAR, Starknet) only ever show the address.
380
+
381
+ Tracking then proceeds on its own — the widget polls the ShapeShift API, which learns of the deposit
382
+ from the protocol and reports the sell transaction once it lands. From that point the flow is
383
+ identical to a wallet swap. Tracking is bounded: the API abandons a swap still unsettled a day after
384
+ it was registered, and the widget stops with it and says the swap may still be settling, rather than
385
+ spinning indefinitely.
386
+
387
+ ### Expiry and recovery
388
+
389
+ A deposit window is finite (NEAR Intents quotes run one hour, or four when a UTXO chain is
390
+ involved; Chainflip channels six hours).
391
+ When the countdown reaches zero the screen warns not to send and offers a fresh quote. The same
392
+ happens if the API stops recognising the quote before any deposit was seen. Treat expiry as a hard
393
+ cutoff: what happens to a late deposit is protocol-specific — NEAR Intents refunds it to the refund
394
+ address, while an expired Chainflip channel stops being watched altogether and recovering funds sent
395
+ to it is not guaranteed. If a late deposit is credited anyway, the expired screen still resolves to
396
+ the final result rather than stranding there.
397
+
398
+ The deposit is persisted to `localStorage` for as long as it is tracked — while funds are owed and
399
+ after they land — and restored if the page reloads, so a reload mid-settlement doesn't lose the
400
+ swap. A reload after the deposit was seen rejoins tracking rather than asking for it again. It is
401
+ dropped when the swap finishes, when tracking gives up, or when the user starts a new swap.
402
+
403
+ ### Limitations
404
+
405
+ - **Not every route can be paid this way.** Swappers that sign transactions, and a few routes that
406
+ cannot take a plain transfer (currently TON via NEAR Intents), keep the Connect Wallet or
407
+ redirect path instead.
408
+ - **Wallet QR scanners vary.** Support for payment URIs is uneven: some wallets read only the
409
+ address from one, and some fail to parse or misinterpret it. That is why the QR defaults to the
410
+ bare address. The amount and address are always shown as copyable text alongside it.
346
411
 
347
412
  ## Theming
348
413
 
@@ -624,7 +689,8 @@ React Query client).
624
689
  Assets on the following chains appear in the selector. Swaps are **executed in-widget** only for EVM,
625
690
  UTXO, and Solana assets (`isWidgetExecutableChainId` returns `true`). Cosmos-SDK and redirect-only
626
691
  chains are selectable but route the user to [app.shapeshift.com](https://app.shapeshift.com) to
627
- complete the swap (when `allowShapeshiftRedirect` is enabled).
692
+ complete the swap (when `allowShapeshiftRedirect` is enabled), unless an
693
+ [externally paid route](#external-wallets-and-deposit-addresses) quotes them, which needs no signer.
628
694
 
629
695
  | Chain | Chain ID | Type | Executable in-widget |
630
696
  | ----------------- | ----------------------------------------- | ------ | -------------------- |
@@ -656,11 +722,15 @@ complete the swap (when `allowShapeshiftRedirect` is enabled).
656
722
  The widget aggregates quotes across the protocols below and surfaces the best rate. Use
657
723
  `allowedSwapperNames` to restrict which are used.
658
724
 
659
- - **NEAR Intents** (`SwapperName.NearIntents`)
725
+ - **NEAR Intents** (`SwapperName.NearIntents`) — deposit address
726
+ - **Chainflip** (`SwapperName.Chainflip`) — deposit address
660
727
  - **Relay** (`SwapperName.Relay`)
661
728
  - **THORChain** (`SwapperName.Thorchain`)
662
729
  - **MAYAChain** (`SwapperName.Mayachain`)
663
730
 
731
+ Protocols marked *deposit address* can be paid from any wallet — see [External Wallets and Deposit
732
+ Addresses](#external-wallets-and-deposit-addresses).
733
+
664
734
  > The set of enabled swappers changes over time. Treat this list as current-at-publish; the
665
735
  > authoritative source is the `SwapperName` enum exported by this package.
666
736
 
@@ -687,10 +757,16 @@ revenue attribution works.
687
757
  - **Redirects.** Assets on non-executable chains (Cosmos, Zcash, Tron, Sui, TON, NEAR, Starknet)
688
758
  send the user to app.shapeshift.com to finish the swap, unless `allowShapeshiftRedirect={false}`
689
759
  or the buy amount or receive address is locked (see
690
- [Redirects are disabled by either lock](#redirects-are-disabled-by-either-lock)).
760
+ [Locking the buy amount or receive address disables redirects](#locking-the-buy-amount-or-receive-address-disables-redirects)).
761
+ An [externally paid route](#external-wallets-and-deposit-addresses) takes precedence where one is
762
+ available, since it can be paid without any wallet.
691
763
  - **Configuration is applied at mount.** `default*` props are read once; locked values keep
692
764
  tracking their prop. Remount to change anything else, or to start a fresh swap. See
693
765
  [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.
766
+ - **`onSwapSuccess` reports the sell transaction.** The hash it receives is the transaction that
767
+ paid the swap on the sell chain signed by the user, or their deposit as the protocol reported
768
+ it. On cross-chain routes the destination transfer may still be in flight.
769
+ - **`onSwapError` does not always mean the swap failed.** It also fires when the widget stops
770
+ tracking a swap whose outcome it never learned, which can still settle afterwards. Treat it as
771
+ "not confirmed" rather than "failed" if you act on it.
696
772
  - **Mobile responsive.** The widget is designed to work on mobile as well as desktop.
package/dist/index.css CHANGED
@@ -325,7 +325,10 @@
325
325
  .ssw-swap-btn:disabled:active {
326
326
  transform: none;
327
327
  }
328
- .ssw-receive-row {
328
+ .ssw-address-group {
329
+ display: flex;
330
+ flex-direction: column;
331
+ gap: 12px;
329
332
  margin-top: 12px;
330
333
  background: var(--ssw-bg-tertiary);
331
334
  border: 1px solid var(--ssw-border);
@@ -333,10 +336,14 @@
333
336
  padding: 12px 16px;
334
337
  transition: box-shadow 0.15s ease;
335
338
  }
336
- .ssw-receive-row:focus-within {
339
+ .ssw-address-group:focus-within {
337
340
  border-color: var(--ssw-accent);
338
341
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
339
342
  }
343
+ .ssw-address-group > * + * {
344
+ border-top: 1px solid var(--ssw-border);
345
+ padding-top: 12px;
346
+ }
340
347
  .ssw-receive-row-resolved {
341
348
  display: flex;
342
349
  align-items: center;
@@ -459,10 +466,14 @@
459
466
  font-size: 12px;
460
467
  color: var(--ssw-error);
461
468
  }
462
- .ssw-receive-row.ssw-attention {
469
+ .ssw-receive-hint {
470
+ font-size: 12px;
471
+ color: var(--ssw-text-muted);
472
+ }
473
+ .ssw-address-group.ssw-attention {
463
474
  box-shadow: 0 0 16px -2px var(--ssw-accent);
464
475
  }
465
- .ssw-receive-row.ssw-receive-row-invalid {
476
+ .ssw-address-group.ssw-address-group-invalid {
466
477
  border-color: var(--ssw-error);
467
478
  }
468
479
  .ssw-quotes {
@@ -709,19 +720,43 @@
709
720
  width: 100%;
710
721
  max-width: 320px;
711
722
  }
723
+ .ssw-or-divider {
724
+ display: flex;
725
+ align-items: center;
726
+ gap: 12px;
727
+ margin: -4px 16px 12px;
728
+ color: var(--ssw-text-muted);
729
+ font-size: 12px;
730
+ text-transform: uppercase;
731
+ letter-spacing: 0.08em;
732
+ }
733
+ .ssw-or-divider::before,
734
+ .ssw-or-divider::after {
735
+ content: "";
736
+ flex: 1;
737
+ height: 1px;
738
+ background: var(--ssw-border);
739
+ }
712
740
  .ssw-step-actions .ssw-action-btn {
713
741
  flex: 1;
714
742
  width: auto;
715
743
  margin: 0;
716
744
  white-space: nowrap;
717
745
  }
718
- .ssw-step-actions .ssw-secondary {
746
+ .ssw-step-actions .ssw-secondary,
747
+ .ssw-deposit > .ssw-secondary,
748
+ .ssw-btn-outline .ssw-step-actions .ssw-action-btn.ssw-secondary,
749
+ .ssw-btn-outline .ssw-deposit > .ssw-action-btn.ssw-secondary {
719
750
  background: var(--ssw-bg-tertiary);
720
- border: 1px solid var(--ssw-border);
721
- color: var(--ssw-text-secondary);
751
+ border: 1px solid var(--ssw-border-hover);
752
+ color: var(--ssw-text-primary);
722
753
  }
723
- .ssw-step-actions .ssw-secondary:hover {
724
- border-color: var(--ssw-border-hover);
754
+ .ssw-step-actions .ssw-action-btn.ssw-secondary:hover:not(:disabled),
755
+ .ssw-deposit > .ssw-action-btn.ssw-secondary:hover:not(:disabled),
756
+ .ssw-btn-outline .ssw-step-actions .ssw-action-btn.ssw-secondary:hover:not(:disabled),
757
+ .ssw-btn-outline .ssw-deposit > .ssw-action-btn.ssw-secondary:hover:not(:disabled) {
758
+ background: var(--ssw-bg-tertiary);
759
+ border-color: color-mix(in srgb, var(--ssw-accent) 55%, transparent);
725
760
  color: var(--ssw-text-primary);
726
761
  }
727
762
  .ssw-btn-outline .ssw-action-btn {
@@ -749,6 +784,191 @@
749
784
  color: var(--ssw-text-primary);
750
785
  background: var(--ssw-bg-hover);
751
786
  }
787
+ .ssw-deposit {
788
+ display: flex;
789
+ flex-direction: column;
790
+ align-items: center;
791
+ gap: 14px;
792
+ padding: 12px 0 4px;
793
+ }
794
+ .ssw-deposit-title {
795
+ font-size: 16px;
796
+ font-weight: 600;
797
+ color: var(--ssw-text-primary);
798
+ text-align: center;
799
+ }
800
+ .ssw-deposit-field {
801
+ display: flex;
802
+ flex-direction: column;
803
+ gap: 6px;
804
+ width: 100%;
805
+ }
806
+ .ssw-deposit-field-label {
807
+ font-size: 13px;
808
+ font-weight: 500;
809
+ color: var(--ssw-text-secondary);
810
+ }
811
+ .ssw-qr {
812
+ border-radius: var(--ssw-radius-md);
813
+ padding: 8px;
814
+ background: #ffffff;
815
+ }
816
+ .ssw-deposit-qr-mode {
817
+ display: inline-flex;
818
+ border: 1px solid var(--ssw-border);
819
+ border-radius: var(--ssw-radius-md);
820
+ overflow: hidden;
821
+ }
822
+ .ssw-deposit-qr-mode button {
823
+ padding: 6px 12px;
824
+ border: none;
825
+ background: transparent;
826
+ color: var(--ssw-text-secondary);
827
+ font-family: var(--ssw-font-family);
828
+ font-size: 12px;
829
+ font-weight: 600;
830
+ cursor: pointer;
831
+ }
832
+ .ssw-deposit-qr-mode button + button {
833
+ border-left: 1px solid var(--ssw-border);
834
+ }
835
+ .ssw-deposit-qr-mode button[aria-pressed=true] {
836
+ background: var(--ssw-accent-light);
837
+ color: var(--ssw-accent);
838
+ }
839
+ .ssw-deposit-qr-mode button:focus-visible {
840
+ outline: 2px solid var(--ssw-accent);
841
+ outline-offset: -2px;
842
+ }
843
+ .ssw-deposit-qr-controls {
844
+ position: relative;
845
+ display: flex;
846
+ }
847
+ .ssw-deposit-qr-info {
848
+ position: absolute;
849
+ top: 50%;
850
+ left: calc(100% + 6px);
851
+ transform: translateY(-50%);
852
+ display: grid;
853
+ place-items: center;
854
+ width: 24px;
855
+ height: 24px;
856
+ padding: 0;
857
+ border: none;
858
+ border-radius: 50%;
859
+ background: transparent;
860
+ color: var(--ssw-text-muted);
861
+ cursor: help;
862
+ }
863
+ .ssw-deposit-qr-info:hover,
864
+ .ssw-deposit-qr-info:focus-visible {
865
+ color: var(--ssw-text-primary);
866
+ }
867
+ .ssw-deposit-qr-info:focus-visible {
868
+ outline: 2px solid var(--ssw-accent);
869
+ outline-offset: 1px;
870
+ }
871
+ .ssw-deposit-qr-tooltip {
872
+ position: absolute;
873
+ top: calc(100% + 8px);
874
+ left: 50%;
875
+ z-index: 10;
876
+ display: grid;
877
+ gap: 6px;
878
+ width: max-content;
879
+ max-width: 240px;
880
+ padding: 8px 10px;
881
+ border: 1px solid var(--ssw-border);
882
+ border-radius: var(--ssw-radius-sm);
883
+ background: var(--ssw-bg-secondary);
884
+ color: var(--ssw-text-secondary);
885
+ font-size: 12px;
886
+ line-height: 1.4;
887
+ text-align: left;
888
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
889
+ opacity: 0;
890
+ visibility: hidden;
891
+ pointer-events: none;
892
+ transform: translateX(-50%);
893
+ }
894
+ .ssw-deposit-qr-tooltip strong {
895
+ color: var(--ssw-text-primary);
896
+ font-weight: 600;
897
+ }
898
+ .ssw-deposit-qr-info:hover + .ssw-deposit-qr-tooltip,
899
+ .ssw-deposit-qr-info:focus-visible + .ssw-deposit-qr-tooltip,
900
+ .ssw-deposit-qr-tooltip.ssw-open {
901
+ opacity: 1;
902
+ visibility: visible;
903
+ }
904
+ .ssw-deposit-qr-controls .ssw-deposit-qr-info + .ssw-deposit-qr-tooltip.ssw-dismissed {
905
+ opacity: 0;
906
+ visibility: hidden;
907
+ }
908
+ @media (prefers-reduced-motion: no-preference) {
909
+ .ssw-deposit-qr-tooltip {
910
+ transition: opacity 0.12s ease, visibility 0.12s ease;
911
+ }
912
+ }
913
+ .ssw-deposit-value {
914
+ display: flex;
915
+ align-items: center;
916
+ justify-content: space-between;
917
+ gap: 8px;
918
+ width: 100%;
919
+ padding: 12px;
920
+ border: 1px solid var(--ssw-border);
921
+ border-radius: var(--ssw-radius-md);
922
+ background: var(--ssw-bg-input);
923
+ color: var(--ssw-text-primary);
924
+ font-family: monospace;
925
+ font-size: 13px;
926
+ font-variant-numeric: tabular-nums;
927
+ cursor: pointer;
928
+ }
929
+ .ssw-deposit-value:hover {
930
+ border-color: var(--ssw-border-hover);
931
+ }
932
+ .ssw-deposit-copy {
933
+ color: var(--ssw-accent);
934
+ font-family: var(--ssw-font-family);
935
+ font-size: 12px;
936
+ font-weight: 600;
937
+ }
938
+ .ssw-deposit-countdown {
939
+ font-size: 13px;
940
+ color: var(--ssw-text-muted);
941
+ }
942
+ .ssw-deposit-watching {
943
+ display: flex;
944
+ align-items: center;
945
+ gap: 8px;
946
+ margin-top: 12px;
947
+ max-width: 280px;
948
+ font-size: 13px;
949
+ color: var(--ssw-text-muted);
950
+ line-height: 1.5;
951
+ text-align: left;
952
+ }
953
+ .ssw-deposit-summary {
954
+ display: flex;
955
+ flex-direction: column;
956
+ gap: 8px;
957
+ width: 100%;
958
+ margin-top: 2px;
959
+ padding-top: 14px;
960
+ border-top: 1px solid var(--ssw-border);
961
+ }
962
+ .ssw-deposit .ssw-action-btn {
963
+ margin-top: 6px;
964
+ }
965
+ .ssw-deposit-row {
966
+ display: flex;
967
+ align-items: center;
968
+ justify-content: space-between;
969
+ font-size: 13px;
970
+ color: var(--ssw-text-secondary);
971
+ }
752
972
 
753
973
  /* src/components/QuoteSelector.css */
754
974
  .ssw-quote-selector {
package/dist/index.d.ts CHANGED
@@ -8,7 +8,8 @@ declare enum SwapperName {
8
8
  NearIntents = "NEAR Intents",
9
9
  Relay = "Relay",
10
10
  Thorchain = "THORChain",
11
- Mayachain = "MAYAChain"
11
+ Mayachain = "MAYAChain",
12
+ Chainflip = "Chainflip"
12
13
  }
13
14
  type Chain = {
14
15
  chainId: ChainId;
@@ -71,6 +72,7 @@ type TradeRate = {
71
72
  shapeshiftBps: string;
72
73
  affiliateBps: string;
73
74
  networkFeeCryptoBaseUnit?: string;
75
+ supportsExternalPayment?: boolean;
74
76
  error?: {
75
77
  code: string;
76
78
  message: string;