@b3dotfun/sdk 0.1.70-alpha.15 → 0.1.70-alpha.17

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 (29) hide show
  1. package/dist/cjs/anyspend/react/components/AnySpend.d.ts +2 -1
  2. package/dist/cjs/anyspend/react/components/AnySpend.js +81 -16
  3. package/dist/cjs/anyspend/react/components/AnySpendCustomExactIn.js +4 -1
  4. package/dist/cjs/anyspend/react/components/AnySpendDeposit.js +16 -2
  5. package/dist/cjs/anyspend/react/components/QRDeposit.d.ts +9 -1
  6. package/dist/cjs/anyspend/react/components/QRDeposit.js +3 -3
  7. package/dist/cjs/anyspend/react/components/common/PureTransferView.d.ts +30 -0
  8. package/dist/cjs/anyspend/react/components/common/PureTransferView.js +64 -0
  9. package/dist/cjs/global-account/client-manager.js +32 -0
  10. package/dist/esm/anyspend/react/components/AnySpend.d.ts +2 -1
  11. package/dist/esm/anyspend/react/components/AnySpend.js +81 -16
  12. package/dist/esm/anyspend/react/components/AnySpendCustomExactIn.js +4 -1
  13. package/dist/esm/anyspend/react/components/AnySpendDeposit.js +16 -2
  14. package/dist/esm/anyspend/react/components/QRDeposit.d.ts +9 -1
  15. package/dist/esm/anyspend/react/components/QRDeposit.js +4 -4
  16. package/dist/esm/anyspend/react/components/common/PureTransferView.d.ts +30 -0
  17. package/dist/esm/anyspend/react/components/common/PureTransferView.js +61 -0
  18. package/dist/esm/global-account/client-manager.js +32 -0
  19. package/dist/types/anyspend/react/components/AnySpend.d.ts +2 -1
  20. package/dist/types/anyspend/react/components/QRDeposit.d.ts +9 -1
  21. package/dist/types/anyspend/react/components/common/PureTransferView.d.ts +30 -0
  22. package/package.json +1 -1
  23. package/src/anyspend/react/components/AnySpend.tsx +108 -18
  24. package/src/anyspend/react/components/AnySpendCustomExactIn.tsx +5 -1
  25. package/src/anyspend/react/components/AnySpendDeposit.tsx +18 -1
  26. package/src/anyspend/react/components/QRDeposit.tsx +55 -4
  27. package/src/anyspend/react/components/__tests__/QRDeposit.test.tsx +39 -1
  28. package/src/anyspend/react/components/common/PureTransferView.tsx +212 -0
  29. package/src/global-account/client-manager.ts +33 -0
@@ -71,6 +71,7 @@ import { LoginStep } from "@b3dotfun/sdk/global-account/react/components/SignInW
71
71
  import { PanelOnramp } from "./common/PanelOnramp";
72
72
  import { PanelOnrampPayment } from "./common/PanelOnrampPayment";
73
73
  import { PointsDetailPanel } from "./common/PointsDetailPanel";
74
+ import { PureTransferView } from "./common/PureTransferView";
74
75
  import { RecipientSelection } from "./common/RecipientSelection";
75
76
  import { TabSection } from "./common/TabSection";
76
77
  import { AnySpendCustomizationProvider, useAnySpendCustomization } from "./context/AnySpendCustomizationContext";
@@ -100,6 +101,7 @@ export enum PanelView {
100
101
  DIRECT_TRANSFER_SUCCESS,
101
102
  FIAT_KYC,
102
103
  FIAT_AUTH,
104
+ PURE_TRANSFER_ADDRESS,
103
105
  }
104
106
 
105
107
  const ANYSPEND_RECIPIENTS_KEY = "anyspend_recipients";
@@ -687,7 +689,10 @@ function AnySpendInner({
687
689
  globalAddress,
688
690
  });
689
691
 
690
- const recipientProfile = useProfile({ address: effectiveRecipientAddress, fresh: true });
692
+ const recipientProfile = useProfile({
693
+ address: effectiveRecipientAddress,
694
+ fresh: true,
695
+ });
691
696
  const recipientName = recipientProfile.data?.name;
692
697
 
693
698
  // Check token balance for crypto payments
@@ -906,15 +911,36 @@ function AnySpendInner({
906
911
  const isDirectTransfer = isSameChainSameToken && allowDirectTransfer;
907
912
 
908
913
  // Determine button state and text
909
- const btnInfo: { text: string; disable: boolean; error: boolean; loading: boolean } = useMemo(() => {
914
+ const btnInfo: {
915
+ text: string;
916
+ disable: boolean;
917
+ error: boolean;
918
+ loading: boolean;
919
+ } = useMemo(() => {
910
920
  // For fiat tab, check srcAmountOnRamp; for crypto tab, check activeInputAmountInWei
911
921
  const hasAmount =
912
922
  activeTab === "fiat" ? srcAmountOnRamp && parseFloat(srcAmountOnRamp) > 0 : activeInputAmountInWei !== "0";
913
- if (!hasAmount) return { text: "Enter an amount", disable: true, error: false, loading: false };
923
+ if (!hasAmount)
924
+ return {
925
+ text: "Enter an amount",
926
+ disable: true,
927
+ error: false,
928
+ loading: false,
929
+ };
914
930
  if (isSameChainSameToken && !allowDirectTransfer)
915
- return { text: "Select a different token or chain", disable: true, error: false, loading: false };
931
+ return {
932
+ text: "Select a different token or chain",
933
+ disable: true,
934
+ error: false,
935
+ loading: false,
936
+ };
916
937
  if (isLoadingAnyspendQuote && !isSameChainSameToken)
917
- return { text: "Loading quote...", disable: true, error: false, loading: true };
938
+ return {
939
+ text: "Loading quote...",
940
+ disable: true,
941
+ error: false,
942
+ loading: true,
943
+ };
918
944
  if (isCreatingOrder || isCreatingOnrampOrder || isSwitchingOrExecuting)
919
945
  return {
920
946
  text: isSwitchingOrExecuting ? "Transferring..." : "Creating order...",
@@ -923,15 +949,31 @@ function AnySpendInner({
923
949
  loading: true,
924
950
  };
925
951
  if ((!anyspendQuote || !anyspendQuote.success) && !(isSameChainSameToken && allowDirectTransfer))
926
- return { text: "No quote found", disable: true, error: false, loading: false };
952
+ return {
953
+ text: "No quote found",
954
+ disable: true,
955
+ error: false,
956
+ loading: false,
957
+ };
927
958
 
928
959
  if (activeTab === "fiat") {
929
960
  // For fiat: check recipient first, then payment method
930
- if (!effectiveRecipientAddress) return { text: "Select recipient", disable: false, error: false, loading: false };
961
+ if (!effectiveRecipientAddress)
962
+ return {
963
+ text: "Select recipient",
964
+ disable: false,
965
+ error: false,
966
+ loading: false,
967
+ };
931
968
 
932
969
  // If no fiat payment method selected, show "Select payment method"
933
970
  if (selectedFiatPaymentMethod === FiatPaymentMethod.NONE) {
934
- return { text: "Select payment method", disable: false, error: false, loading: false };
971
+ return {
972
+ text: "Select payment method",
973
+ disable: false,
974
+ error: false,
975
+ loading: false,
976
+ };
935
977
  }
936
978
  // If payment method is selected, show "Continue"
937
979
  return { text: "Continue", disable: false, error: false, loading: false };
@@ -942,11 +984,22 @@ function AnySpendInner({
942
984
 
943
985
  // If no payment method selected, show "Choose payment method"
944
986
  if (effectiveCryptoPaymentMethod === CryptoPaymentMethodType.NONE) {
945
- return { text: "Choose payment method", disable: false, error: false, loading: false };
987
+ return {
988
+ text: "Choose payment method",
989
+ disable: false,
990
+ error: false,
991
+ loading: false,
992
+ };
946
993
  }
947
994
 
948
995
  // Check recipient after payment method
949
- if (!effectiveRecipientAddress) return { text: "Select recipient", disable: false, error: false, loading: false };
996
+ if (!effectiveRecipientAddress)
997
+ return {
998
+ text: "Select recipient",
999
+ disable: false,
1000
+ error: false,
1001
+ loading: false,
1002
+ };
950
1003
 
951
1004
  // If payment method selected, show appropriate action
952
1005
  if (
@@ -954,10 +1007,22 @@ function AnySpendInner({
954
1007
  effectiveCryptoPaymentMethod === CryptoPaymentMethodType.GLOBAL_WALLET
955
1008
  ) {
956
1009
  const buttonText = isSameChainSameToken && allowDirectTransfer ? "Transfer" : "Swap";
957
- return { text: buttonText, disable: false, error: false, loading: false };
1010
+ return {
1011
+ text: buttonText,
1012
+ disable: false,
1013
+ error: false,
1014
+ loading: false,
1015
+ };
958
1016
  }
959
1017
  if (effectiveCryptoPaymentMethod === CryptoPaymentMethodType.TRANSFER_CRYPTO) {
960
- return { text: "Continue to payment", disable: false, error: false, loading: false };
1018
+ const transferText =
1019
+ isSameChainSameToken && allowDirectTransfer ? "Show deposit address" : "Continue to payment";
1020
+ return {
1021
+ text: transferText,
1022
+ disable: false,
1023
+ error: false,
1024
+ loading: false,
1025
+ };
961
1026
  }
962
1027
  }
963
1028
 
@@ -1063,10 +1128,18 @@ function AnySpendInner({
1063
1128
  // Handle crypto swap creation
1064
1129
  const handleCryptoSwap = async (method: CryptoPaymentMethodType) => {
1065
1130
  try {
1066
- const isDirectTransfer = isSameChainSameToken && allowDirectTransfer;
1131
+ // TRANSFER_CRYPTO is the manual deposit-address flow — no connected wallet to send from.
1132
+ const isDirectTransfer =
1133
+ isSameChainSameToken && allowDirectTransfer && method !== CryptoPaymentMethodType.TRANSFER_CRYPTO;
1067
1134
 
1068
1135
  invariant(effectiveRecipientAddress, "Recipient address is not found");
1069
1136
 
1137
+ // Same-token manual deposit → show recipient address + watch, no relayer order, no fee
1138
+ if (isSameChainSameToken && allowDirectTransfer && method === CryptoPaymentMethodType.TRANSFER_CRYPTO) {
1139
+ navigateToPanel(PanelView.PURE_TRANSFER_ADDRESS, "forward");
1140
+ return;
1141
+ }
1142
+
1070
1143
  const srcAmountBigInt = parseUnits(srcAmount.replace(/,/g, ""), selectedSrcToken.decimals);
1071
1144
 
1072
1145
  // Handle direct transfer (same chain/token) - bypass backend, transfer directly
@@ -1089,10 +1162,6 @@ function AnySpendInner({
1089
1162
  // Regular swap flow - use backend
1090
1163
  invariant(anyspendQuote, "Relay price is not found");
1091
1164
 
1092
- // Debug: Check payment method values
1093
- console.log("handleCryptoSwap - method parameter:", method);
1094
- console.log("handleCryptoSwap - selectedCryptoPaymentMethod state:", selectedCryptoPaymentMethod);
1095
-
1096
1165
  createOrder({
1097
1166
  recipientAddress: effectiveRecipientAddress,
1098
1167
  orderType: "swap",
@@ -1100,7 +1169,11 @@ function AnySpendInner({
1100
1169
  dstChain: isBuyMode ? destinationTokenChainId : selectedDstChainId,
1101
1170
  srcToken: selectedSrcToken,
1102
1171
  dstToken: isBuyMode
1103
- ? { ...selectedDstToken, chainId: destinationTokenChainId, address: destinationTokenAddress }
1172
+ ? {
1173
+ ...selectedDstToken,
1174
+ chainId: destinationTokenChainId,
1175
+ address: destinationTokenAddress,
1176
+ }
1104
1177
  : selectedDstToken,
1105
1178
  srcAmount: srcAmountBigInt.toString(),
1106
1179
  expectedDstAmount: anyspendQuote?.data?.currencyOut?.amount || "0",
@@ -1759,6 +1832,20 @@ function AnySpendInner({
1759
1832
  </div>
1760
1833
  );
1761
1834
 
1835
+ const pureTransferAddressView = (
1836
+ <PureTransferView
1837
+ mode={mode}
1838
+ recipientAddress={effectiveRecipientAddress ?? ""}
1839
+ chainId={selectedSrcChainId}
1840
+ token={selectedSrcToken}
1841
+ displayAmount={srcAmount && srcAmount !== "0" ? `${srcAmount} ${selectedSrcToken.symbol}` : undefined}
1842
+ isActive={activePanel === PanelView.PURE_TRANSFER_ADDRESS}
1843
+ onBack={navigateBack}
1844
+ onClose={() => setB3ModalOpen(false)}
1845
+ onTransferDetected={() => onSuccess?.()}
1846
+ />
1847
+ );
1848
+
1762
1849
  // Add tabs to the main component when no order is loaded
1763
1850
  return (
1764
1851
  <StyleRoot>
@@ -1839,6 +1926,9 @@ function AnySpendInner({
1839
1926
  <div key="fiat-auth-view" className={cn(mode === "page" && "p-6")}>
1840
1927
  {authView}
1841
1928
  </div>,
1929
+ <div key="pure-transfer-address-view" className={cn(mode === "page" && "p-6")}>
1930
+ {pureTransferAddressView}
1931
+ </div>,
1842
1932
  ]}
1843
1933
  </TransitionPanel>
1844
1934
  </div>
@@ -574,7 +574,11 @@ function AnySpendCustomExactInInner({
574
574
 
575
575
  const handleCryptoOrder = async () => {
576
576
  try {
577
- const isDirectTransfer = isSameChainSameToken && allowDirectTransfer;
577
+ // TRANSFER_CRYPTO is the manual deposit-address flow — no connected wallet to send from.
578
+ const isDirectTransfer =
579
+ isSameChainSameToken &&
580
+ allowDirectTransfer &&
581
+ effectiveCryptoPaymentMethod !== CryptoPaymentMethodType.TRANSFER_CRYPTO;
578
582
 
579
583
  if (!isDirectTransfer) {
580
584
  invariant(anyspendQuote, "Relay price is not found");
@@ -3,6 +3,7 @@ import { components } from "@b3dotfun/sdk/anyspend/types/api";
3
3
  import { GetQuoteResponse } from "@b3dotfun/sdk/anyspend/types/api_req_res";
4
4
  import { Skeleton, useAccountWallet, useSimBalance, useTokenData } from "@b3dotfun/sdk/global-account/react";
5
5
  import { cn } from "@b3dotfun/sdk/shared/utils/cn";
6
+ import { formatTokenAmount } from "@b3dotfun/sdk/shared/utils/number";
6
7
  import {
7
8
  NetworkArbitrumOne,
8
9
  NetworkBase,
@@ -631,7 +632,9 @@ export function AnySpendDeposit({
631
632
  "anyspend-deposit-option-description text-as-secondary text-xs"
632
633
  }
633
634
  >
634
- Send tokens directly to deposit address
635
+ {pureTransferOnly
636
+ ? "Send tokens directly to deposit address"
637
+ : "Send any token — we'll convert it for you"}
635
638
  </p>
636
639
  </div>
637
640
  </div>
@@ -700,6 +703,19 @@ export function AnySpendDeposit({
700
703
  // `KNOWN_FUNDING_TOKENS` key — so `hasKnownDestination` is always true and this
701
704
  // branch never fires for them. The guard is defensive for FUTURE callers that pass
702
705
  // an unknown token together with `pureTransferOnly=true`.
706
+ // Only surface the fixed destination amount on the QR path when its decimals are trustworthy
707
+ // (a known funding token or a resolved API lookup) — otherwise `destinationToken.decimals`
708
+ // is the generic 18 fallback and formatting a 6-decimal token's wei against it would be off by
709
+ // 10^12. When unresolved we still show the destination token/chain, just without an amount.
710
+ const destinationDecimalsResolved = hasKnownDestination || !!destinationTokenData;
711
+ const destinationAmountDisplay =
712
+ destinationTokenAmount &&
713
+ /^\d+$/.test(destinationTokenAmount) &&
714
+ BigInt(destinationTokenAmount) > 0n &&
715
+ destinationDecimalsResolved
716
+ ? formatTokenAmount(BigInt(destinationTokenAmount), destinationToken.decimals, 6, false)
717
+ : undefined;
718
+
703
719
  if (pureTransferOnly && !hasKnownDestination && !destinationTokenData && isDestinationTokenLoading) {
704
720
  return (
705
721
  <div
@@ -722,6 +738,7 @@ export function AnySpendDeposit({
722
738
  recipientAddress={recipientAddress}
723
739
  destinationToken={destinationToken}
724
740
  destinationChainId={destinationTokenChainId}
741
+ destinationAmountDisplay={destinationAmountDisplay}
725
742
  pureTransferOnly={pureTransferOnly}
726
743
  depositContractConfig={depositContractConfig}
727
744
  onBack={handleBack}
@@ -36,6 +36,14 @@ export interface QRDepositProps {
36
36
  destinationToken: components["schemas"]["Token"];
37
37
  /** The destination chain ID */
38
38
  destinationChainId: number;
39
+ /**
40
+ * Human-formatted destination amount (e.g. "9.365547"), shown as an APPROXIMATE
41
+ * target in swap mode only. The QR deposit path accepts an arbitrary sent amount,
42
+ * so this is never a guaranteed receive figure — the UI frames it as "any amount
43
+ * works". Omitted when no fixed amount is set or the destination decimals are
44
+ * unresolved (to avoid a mis-scaled number). Ignored in pure-transfer mode.
45
+ */
46
+ destinationAmountDisplay?: string;
39
47
  /**
40
48
  * When true, the deposit is a PURE TRANSFER: the destination token/chain mirror
41
49
  * the user's currently selected source token/chain, so the exact token the user
@@ -96,6 +104,7 @@ export function QRDeposit({
96
104
  sourceChainId: sourceChainIdProp,
97
105
  destinationToken,
98
106
  destinationChainId,
107
+ destinationAmountDisplay,
99
108
  pureTransferOnly = false,
100
109
  creatorAddress,
101
110
  depositContractConfig,
@@ -410,6 +419,32 @@ export function QRDeposit({
410
419
  />
411
420
  </div>
412
421
 
422
+ {/* Receive (swap mode only) — makes the conversion explicit: what the sent token
423
+ becomes and on which chain. Gated on the computed isPureTransfer (not the static
424
+ prop) so a runtime source change back to the same token correctly hides it. */}
425
+ {!isPureTransfer && (
426
+ <div className="anyspend-qr-receive flex flex-col gap-1.5">
427
+ <label className="anyspend-qr-receive-label text-as-secondary text-sm">Receive</label>
428
+ <div className="anyspend-qr-receive-row border-as-stroke bg-as-surface-secondary flex w-full items-center gap-2 rounded-xl border px-3 py-2.5">
429
+ {effectiveDestinationToken.metadata?.logoURI ? (
430
+ <ChainTokenIcon
431
+ chainUrl={ALL_CHAINS[effectiveDestinationChainId]?.logoUrl}
432
+ tokenUrl={effectiveDestinationToken.metadata.logoURI}
433
+ className="h-8 min-h-8 w-8 min-w-8"
434
+ />
435
+ ) : (
436
+ <div className="h-8 w-8 rounded-full bg-gray-700" />
437
+ )}
438
+ <div className="flex flex-col items-start gap-0">
439
+ <div className="text-as-primary font-semibold">{effectiveDestinationToken.symbol || "Token"}</div>
440
+ <div className="text-as-primary/70 text-xs">
441
+ {ALL_CHAINS[effectiveDestinationChainId]?.name ?? "Unknown"}
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </div>
446
+ )}
447
+
413
448
  {/* QR Code and Address - horizontal layout */}
414
449
  <div
415
450
  className={
@@ -454,10 +489,26 @@ export function QRDeposit({
454
489
 
455
490
  {/* Warnings */}
456
491
  <ChainWarningText chainId={effectiveDestinationChainId} />
457
- <WarningText>
458
- Only send {sourceToken.symbol} on {ALL_CHAINS[sourceChainId]?.name ?? "the specified chain"}. Other tokens
459
- will not be converted.
460
- </WarningText>
492
+ {isPureTransfer ? (
493
+ <WarningText>
494
+ Only send {sourceToken.symbol} on {ALL_CHAINS[sourceChainId]?.name ?? "the specified chain"}. Other tokens
495
+ will not be converted.
496
+ </WarningText>
497
+ ) : (
498
+ <>
499
+ <WarningText>
500
+ Only send {sourceToken.symbol} on {ALL_CHAINS[sourceChainId]?.name ?? "the specified chain"} to this
501
+ address — it will be converted to {effectiveDestinationToken.symbol || "the destination token"} on{" "}
502
+ {ALL_CHAINS[effectiveDestinationChainId]?.name ?? "the destination chain"}.
503
+ </WarningText>
504
+ {destinationAmountDisplay && (
505
+ <p className="anyspend-qr-amount-hint text-as-secondary text-xs">
506
+ Target ≈ {destinationAmountDisplay} {effectiveDestinationToken.symbol || "tokens"}. Any amount of{" "}
507
+ {sourceToken.symbol || "tokens"} works — you'll receive whatever your deposit converts to.
508
+ </p>
509
+ )}
510
+ </>
511
+ )}
461
512
 
462
513
  {/* Watching indicator for pure transfers */}
463
514
  {isPureTransfer && isWatchingTransfer && (
@@ -164,6 +164,11 @@ describe("QRDeposit pureTransferOnly (Fund Wallet)", () => {
164
164
 
165
165
  // The displayed deposit address is the recipient's own address, NOT a global/order address.
166
166
  expect(screen.getByText(RECIPIENT)).toBeTruthy();
167
+
168
+ // Pure-transfer UI is unchanged: NO "Receive" row (there is no distinct destination),
169
+ // and the accurate "will not be converted" warning is kept verbatim.
170
+ expect(screen.queryByText("Receive")).toBeNull();
171
+ expect(screen.getByText(/Other tokens will not be converted/)).toBeTruthy();
167
172
  });
168
173
 
169
174
  it("Test B — switching the source token stays a pure transfer (no order; mirror follows selection)", async () => {
@@ -206,7 +211,14 @@ describe("QRDeposit pureTransferOnly (Fund Wallet)", () => {
206
211
 
207
212
  // No pureTransferOnly: source defaults to ETH-on-Base, which differs from the USDC dest,
208
213
  // so this is a real swap and the normal deposit_first order path must run.
209
- render(<QRDeposit recipientAddress={RECIPIENT} destinationToken={USDC_BASE} destinationChainId={8453} />);
214
+ const { container } = render(
215
+ <QRDeposit
216
+ recipientAddress={RECIPIENT}
217
+ destinationToken={USDC_BASE}
218
+ destinationChainId={8453}
219
+ destinationAmountDisplay="9.365547"
220
+ />,
221
+ );
210
222
 
211
223
  // The flag is what gates the pure-transfer behavior: without it, an order IS created once.
212
224
  expect(createOrderSpy).toHaveBeenCalledTimes(1);
@@ -218,6 +230,32 @@ describe("QRDeposit pureTransferOnly (Fund Wallet)", () => {
218
230
  expect(orderArgs.dstToken.decimals).toBe(6);
219
231
  // Source defaulted to native ETH on Base (the differing token that forces a swap).
220
232
  expect(orderArgs.srcToken.address).toBe("0x0000000000000000000000000000000000000000");
233
+
234
+ // Swap-mode UX clarity: a read-only "Receive" row now discloses the destination token…
235
+ expect(screen.getByText("Receive")).toBeTruthy();
236
+ // …the misleading "will not be converted" copy is gone…
237
+ expect(screen.queryByText(/will not be converted/)).toBeNull();
238
+ // …the warning states the conversion explicitly, and the fixed amount shows as an
239
+ // approximate target (never a guaranteed receive figure).
240
+ expect(container.textContent).toContain("it will be converted to USDC on Base");
241
+ expect(container.textContent).toContain("Target ≈ 9.365547 USDC");
242
+ });
243
+
244
+ it("Test C2 — swap mode without a fixed amount shows the Receive row but no target hint", async () => {
245
+ const QRDeposit = await loadQRDeposit();
246
+
247
+ // Swap mode (source ETH-on-Base != USDC dest) but NO destinationAmountDisplay — the common
248
+ // open-ended funding case. The destination must still be disclosed, but there must be no
249
+ // dangling "Target ≈" line (and no blank amount).
250
+ const { container } = render(
251
+ <QRDeposit recipientAddress={RECIPIENT} destinationToken={USDC_BASE} destinationChainId={8453} />,
252
+ );
253
+
254
+ // Destination is still disclosed…
255
+ expect(screen.getByText("Receive")).toBeTruthy();
256
+ expect(container.textContent).toContain("it will be converted to USDC on Base");
257
+ // …but no amount hint is rendered when no amount is provided.
258
+ expect(container.textContent).not.toContain("Target ≈");
221
259
  });
222
260
  });
223
261
 
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ import { getChainName, getPaymentUrl, ZERO_ADDRESS } from "@b3dotfun/sdk/anyspend";
4
+ import type { components } from "@b3dotfun/sdk/anyspend/types/api";
5
+ import { CopyToClipboard } from "@b3dotfun/sdk/global-account/react";
6
+ import { cn } from "@b3dotfun/sdk/shared/utils/cn";
7
+ import { ChevronLeft, Loader2, X } from "lucide-react";
8
+ import { QRCodeSVG } from "qrcode.react";
9
+ import { useCallback, useEffect, useMemo, useState } from "react";
10
+ import type { TransferResult } from "../../hooks/useWatchTransfer";
11
+ import { useWatchTransfer } from "../../hooks/useWatchTransfer";
12
+ import { ChainWarningText, WarningText } from "./WarningText";
13
+ import { TransferResultScreen } from "./TransferResultScreen";
14
+
15
+ export interface PureTransferViewProps {
16
+ mode?: "modal" | "page";
17
+ /** The recipient address where funds will land (the user's own wallet) */
18
+ recipientAddress: string;
19
+ /** Chain ID to watch on */
20
+ chainId: number;
21
+ /** Token the user sends — drives the QR code and balance watcher */
22
+ token: components["schemas"]["Token"];
23
+ /** Human-readable amount to display (display-only; skip row if empty or "0") */
24
+ displayAmount?: string;
25
+ /** Whether this panel is the currently active panel; gates the balance watcher */
26
+ isActive?: boolean;
27
+ /** Callback when back chevron is clicked */
28
+ onBack?: () => void;
29
+ /** Callback when X close button is clicked */
30
+ onClose?: () => void;
31
+ /** Callback when an incoming transfer is detected */
32
+ onTransferDetected?: (result: TransferResult) => void;
33
+ }
34
+
35
+ /**
36
+ * A self-contained view that shows the recipient address as a QR code + copy button and
37
+ * watches for an incoming same-chain/same-token transfer via `useWatchTransfer`. When a
38
+ * transfer is detected it renders `TransferResultScreen`.
39
+ *
40
+ * This is the "TRANSFER_CRYPTO" path for same-token AnySpend deposits — no relayer order,
41
+ * no fee; the user simply sends the token directly to their own address.
42
+ */
43
+ export function PureTransferView({
44
+ mode = "modal",
45
+ recipientAddress,
46
+ chainId,
47
+ token,
48
+ displayAmount,
49
+ isActive,
50
+ onBack,
51
+ onClose,
52
+ onTransferDetected,
53
+ }: PureTransferViewProps) {
54
+ const [transferResult, setTransferResult] = useState<TransferResult | null>(null);
55
+
56
+ // Fix 4: reset transferResult when the panel becomes active again
57
+ useEffect(() => {
58
+ if (isActive) setTransferResult(null);
59
+ }, [isActive]);
60
+
61
+ // Fix 3: stabilize the callback so it doesn't re-run the watcher effect every render
62
+ const handleTransferDetected = useCallback(
63
+ (result: TransferResult) => {
64
+ setTransferResult(result);
65
+ onTransferDetected?.(result);
66
+ },
67
+ [onTransferDetected],
68
+ );
69
+
70
+ const { isWatching } = useWatchTransfer({
71
+ address: recipientAddress,
72
+ chainId,
73
+ tokenAddress: token.address,
74
+ tokenDecimals: token.decimals,
75
+ // Fix 1: also require a non-empty recipient before enabling the watcher
76
+ enabled: !!recipientAddress && !transferResult && isActive,
77
+ onTransferDetected: handleTransferDetected,
78
+ });
79
+
80
+ // Fix 2: guard qrValue when recipientAddress is empty
81
+ const qrValue = useMemo(
82
+ () =>
83
+ recipientAddress
84
+ ? getPaymentUrl(
85
+ recipientAddress,
86
+ undefined,
87
+ token.address === ZERO_ADDRESS ? "ETH" : token.address,
88
+ chainId,
89
+ token.decimals,
90
+ )
91
+ : "",
92
+ [recipientAddress, token.address, token.decimals, chainId],
93
+ );
94
+
95
+ const chainName = useMemo(() => {
96
+ try {
97
+ return getChainName(chainId);
98
+ } catch {
99
+ return "the specified chain";
100
+ }
101
+ }, [chainId]);
102
+
103
+ // Guard: if no address yet, render nothing
104
+ if (!recipientAddress) return null;
105
+
106
+ // Once a transfer is detected, hand off to the shared success screen
107
+ if (transferResult) {
108
+ return (
109
+ <TransferResultScreen
110
+ mode={mode}
111
+ transferResult={transferResult}
112
+ token={token}
113
+ chainId={chainId}
114
+ recipientAddress={recipientAddress}
115
+ onBack={onBack}
116
+ onClose={onClose}
117
+ />
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div
123
+ className={cn(
124
+ "anyspend-container anyspend-pure-transfer font-inter bg-as-surface-primary mx-auto w-full max-w-[460px] p-6",
125
+ )}
126
+ >
127
+ <div className="anyspend-pure-transfer-content flex flex-col gap-4">
128
+ {/* Header */}
129
+ <div className="anyspend-pure-transfer-header flex items-center justify-between">
130
+ {onBack ? (
131
+ <button
132
+ onClick={onBack}
133
+ className="anyspend-pure-transfer-back text-as-secondary hover:text-as-primary"
134
+ aria-label="Go back"
135
+ >
136
+ <ChevronLeft className="h-5 w-5" />
137
+ </button>
138
+ ) : (
139
+ <div className="w-5" />
140
+ )}
141
+ <h2 className="anyspend-pure-transfer-title text-as-primary text-base font-semibold">Send {token.symbol}</h2>
142
+ {onClose ? (
143
+ <button
144
+ onClick={onClose}
145
+ className="anyspend-pure-transfer-close text-as-secondary hover:text-as-primary"
146
+ aria-label="Close"
147
+ >
148
+ <X className="h-5 w-5" />
149
+ </button>
150
+ ) : (
151
+ <div className="w-5" />
152
+ )}
153
+ </div>
154
+
155
+ {/* Optional amount badge */}
156
+ {displayAmount && (
157
+ <div className="anyspend-pure-transfer-amount-badge border-as-border-secondary bg-as-surface-secondary flex items-center justify-center rounded-xl border px-4 py-2">
158
+ <span className="text-as-primary text-sm font-medium">{displayAmount}</span>
159
+ </div>
160
+ )}
161
+
162
+ {/* QR Code and address block — mirrors QRDeposit ~lines 413-453 */}
163
+ <div className="anyspend-pure-transfer-qr-content border-as-stroke flex items-start gap-4 rounded-xl border p-4">
164
+ {/* QR Code */}
165
+ <div className="anyspend-pure-transfer-qr-code-container flex flex-col items-center gap-2">
166
+ <div className="anyspend-pure-transfer-qr-code rounded-lg bg-white p-2">
167
+ <QRCodeSVG value={qrValue} size={120} level="M" marginSize={0} />
168
+ </div>
169
+ <span className="anyspend-pure-transfer-qr-scan-hint text-as-secondary text-xs">
170
+ SCAN WITH <span className="inline-block">🦊</span>
171
+ </span>
172
+ </div>
173
+
174
+ {/* Address info */}
175
+ <div className="anyspend-pure-transfer-address-container flex flex-1 flex-col gap-1">
176
+ <span className="anyspend-pure-transfer-address-label text-as-secondary text-sm">Deposit address:</span>
177
+ <div className="anyspend-pure-transfer-address-row flex items-start gap-1">
178
+ <span className="anyspend-pure-transfer-address text-as-primary break-all font-mono text-sm leading-relaxed">
179
+ {recipientAddress}
180
+ </span>
181
+ <CopyToClipboard
182
+ text={recipientAddress}
183
+ className="anyspend-pure-transfer-copy-icon text-as-secondary hover:text-as-primary mt-0.5 shrink-0"
184
+ />
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ {/* Warnings — mirrors QRDeposit ~lines 456-460 */}
190
+ <ChainWarningText chainId={chainId} />
191
+ <WarningText>
192
+ Only send {token.symbol} on {chainName}. Other tokens will not be converted.
193
+ </WarningText>
194
+
195
+ {/* Watching indicator — mirrors QRDeposit ~lines 462-473 */}
196
+ {isWatching && (
197
+ <div className="anyspend-pure-transfer-watching bg-as-brand/10 flex items-center justify-center gap-2 rounded-lg p-3">
198
+ <Loader2 className="text-as-brand h-4 w-4 animate-spin" />
199
+ <span className="text-as-brand text-sm">Watching for incoming transfer...</span>
200
+ </div>
201
+ )}
202
+
203
+ {/* Full-width copy button — mirrors QRDeposit ~lines 476-484 */}
204
+ <CopyToClipboard text={recipientAddress}>
205
+ <button className="anyspend-pure-transfer-copy-button bg-as-brand hover:bg-as-brand/90 flex w-full items-center justify-center gap-2 rounded-xl py-3.5 font-medium text-white transition-all">
206
+ Copy deposit address
207
+ </button>
208
+ </CopyToClipboard>
209
+ </div>
210
+ </div>
211
+ );
212
+ }