@b3dotfun/sdk 0.1.70-alpha.21 → 0.1.70-alpha.22

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 (22) hide show
  1. package/dist/cjs/anyspend/react/components/AnySpend.d.ts +19 -0
  2. package/dist/cjs/anyspend/react/components/AnySpend.js +45 -15
  3. package/dist/cjs/anyspend/react/components/AnySpendDeposit.d.ts +9 -1
  4. package/dist/cjs/anyspend/react/components/AnySpendDeposit.js +6 -2
  5. package/dist/cjs/anyspend/react/components/common/CryptoReceiveSection.js +6 -2
  6. package/dist/cjs/global-account/react/components/B3Provider/BetterAuthProvider.js +36 -12
  7. package/dist/esm/anyspend/react/components/AnySpend.d.ts +19 -0
  8. package/dist/esm/anyspend/react/components/AnySpend.js +45 -15
  9. package/dist/esm/anyspend/react/components/AnySpendDeposit.d.ts +9 -1
  10. package/dist/esm/anyspend/react/components/AnySpendDeposit.js +6 -2
  11. package/dist/esm/anyspend/react/components/common/CryptoReceiveSection.js +6 -2
  12. package/dist/esm/global-account/react/components/B3Provider/BetterAuthProvider.js +36 -12
  13. package/dist/types/anyspend/react/components/AnySpend.d.ts +19 -0
  14. package/dist/types/anyspend/react/components/AnySpendDeposit.d.ts +9 -1
  15. package/package.json +1 -1
  16. package/src/anyspend/react/components/AnySpend.tsx +66 -14
  17. package/src/anyspend/react/components/AnySpendDeposit.tsx +14 -0
  18. package/src/anyspend/react/components/__tests__/AnySpend.seedEffect.test.tsx +191 -0
  19. package/src/anyspend/react/components/__tests__/AnySpendDeposit.isDepositMode.test.tsx +117 -0
  20. package/src/anyspend/react/components/common/CryptoReceiveSection.tsx +23 -2
  21. package/src/anyspend/react/components/common/__tests__/CryptoReceiveSection.test.tsx +110 -0
  22. package/src/global-account/react/components/B3Provider/BetterAuthProvider.tsx +35 -11
@@ -0,0 +1,117 @@
1
+ import { USDC_BASE } from "@b3dotfun/sdk/anyspend";
2
+ import { render } from "@testing-library/react";
3
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4
+
5
+ /**
6
+ * Regression test for fix round 2: `AnySpendDeposit` backs 15+ funding modals that are all
7
+ * source-exact-in (b3os-web's InsufficientBalanceModal, GasBalanceWarningModal,
8
+ * HyperliquidFundingModal, TreasuryModal, WalletsTab, FundTokenModal, anyspend-web's
9
+ * obsdn-deposit/deposit pages, etc.) — the user sets the "pay with" amount, and the destination
10
+ * side is an INTENDED read-only computed display. Round 1 turned `isDepositMode` on
11
+ * unconditionally for every one of those callers, which would have added a second, competing
12
+ * amount input on all of them. The fix scopes it to ONLY the one case that actually has an
13
+ * amount to prefill: `isDepositMode={!!defaultDestinationTokenAmount}`. Only
14
+ * `b3os-deposit-client.tsx` ever passes `defaultDestinationTokenAmount` (and only when the
15
+ * page's `?amount` query param is present) — every other caller passes neither prop, so
16
+ * `isDepositMode` stays `false` and their behavior is unchanged.
17
+ *
18
+ * This asserts the derivation at its actual seam: the `isDepositMode` prop `AnySpendDeposit`
19
+ * hands to `<AnySpend>`. `AnySpend` itself is mocked out (a prop-capturing spy) rather than
20
+ * rendered for real — its own render tree pulls in quote-fetching, gas-price, and
21
+ * payment-method hooks that aren't mocked anywhere in the SDK test suite (see the sibling
22
+ * `CryptoReceiveSection.test.tsx` for why that full mount was skipped there too). Mocking it out
23
+ * here keeps this test to exactly the seam under test — the value of one prop — without
24
+ * building that mock surface just to prove a boolean derivation.
25
+ */
26
+
27
+ const RECIPIENT = "0x1111111111111111111111111111111111111111";
28
+
29
+ // Captures the props the real AnySpendDeposit passes to <AnySpend>.
30
+ let capturedAnySpendProps: any;
31
+
32
+ vi.mock("../AnySpend", () => ({
33
+ AnySpend: (props: any) => {
34
+ capturedAnySpendProps = props;
35
+ return <div data-testid="anyspend-mock" />;
36
+ },
37
+ }));
38
+
39
+ // Same mock recipe QRDeposit.test.tsx already proved renders the REAL AnySpendDeposit —
40
+ // AnySpendDeposit itself (not just QRDeposit) directly uses these three hooks.
41
+ vi.mock("@b3dotfun/sdk/global-account/react/hooks/useAccountWallet", () => ({
42
+ useAccountWallet: () => ({ connectedEOAWallet: undefined }),
43
+ }));
44
+
45
+ vi.mock("@b3dotfun/sdk/global-account/react/hooks/usePortfolioBalance", () => ({
46
+ usePortfolioBalance: () => ({ data: undefined, isLoading: false, isError: false }),
47
+ usePortfolioTokenBalance: () => ({ data: undefined, isLoading: false, isError: false }),
48
+ }));
49
+
50
+ vi.mock("@b3dotfun/sdk/global-account/react/hooks/useTokenData", () => ({
51
+ useTokenData: () => ({ data: null, isLoading: false }),
52
+ }));
53
+
54
+ // AnySpendDeposit statically imports AnySpendCustomExactIn too (even though this test's
55
+ // `isCustomDeposit=false` default never renders it), and that module transitively imports the
56
+ // Relay TokenSelector — mock it so the import chain resolves under happy-dom.
57
+ vi.mock("@relayprotocol/relay-kit-ui", () => ({
58
+ TokenSelector: () => <div data-testid="token-selector" />,
59
+ }));
60
+
61
+ vi.mock("@stripe/stripe-js", () => ({
62
+ loadStripe: vi.fn(() => Promise.resolve(null)),
63
+ }));
64
+
65
+ // Imported lazily so the module-level mocks above are registered before AnySpendDeposit (and
66
+ // its AnySpend/AnySpendCustomExactIn import chain) loads.
67
+ let AnySpendDeposit: typeof import("../AnySpendDeposit").AnySpendDeposit;
68
+
69
+ beforeAll(async () => {
70
+ ({ AnySpendDeposit } = await import("../AnySpendDeposit"));
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.clearAllMocks();
75
+ capturedAnySpendProps = undefined;
76
+ });
77
+
78
+ function renderDeposit(overrides: Record<string, unknown> = {}) {
79
+ return render(
80
+ <AnySpendDeposit
81
+ recipientAddress={RECIPIENT}
82
+ destinationTokenAddress={USDC_BASE.address}
83
+ destinationTokenChainId={USDC_BASE.chainId}
84
+ // Provided so the component lands directly on the "deposit" step (which renders
85
+ // <AnySpend>) instead of the chain-selection chooser screen.
86
+ sourceTokenChainId={USDC_BASE.chainId}
87
+ {...overrides}
88
+ />,
89
+ );
90
+ }
91
+
92
+ describe("AnySpendDeposit — isDepositMode is scoped to the prefill case only (fix round 2)", () => {
93
+ it("defaultDestinationTokenAmount set -> isDepositMode is true", () => {
94
+ renderDeposit({ defaultDestinationTokenAmount: "2000000" });
95
+
96
+ expect(capturedAnySpendProps).toBeDefined();
97
+ expect(capturedAnySpendProps.isDepositMode).toBe(true);
98
+ });
99
+
100
+ it("no prefill at all (every other AnySpendDeposit caller: 15+ funding modals) -> isDepositMode is false", () => {
101
+ renderDeposit();
102
+
103
+ expect(capturedAnySpendProps).toBeDefined();
104
+ expect(capturedAnySpendProps.isDepositMode).toBe(false);
105
+ });
106
+
107
+ it("both destinationTokenAmount (lock) and defaultDestinationTokenAmount (prefill) set -> isDepositMode is still true, but disableAmountInput (derived from destinationTokenAmount) wins downstream in CryptoReceiveSection", () => {
108
+ renderDeposit({ destinationTokenAmount: "5000000", defaultDestinationTokenAmount: "2000000" });
109
+
110
+ expect(capturedAnySpendProps).toBeDefined();
111
+ expect(capturedAnySpendProps.isDepositMode).toBe(true);
112
+ // AnySpend derives its own disableAmountInput from destinationTokenAmount (unchanged by this
113
+ // fix, not part of what AnySpendDeposit passes) — asserting the raw input here so the
114
+ // precedence claim is checked against what actually reaches <AnySpend>, not assumed.
115
+ expect(capturedAnySpendProps.destinationTokenAmount).toBe("5000000");
116
+ });
117
+ });
@@ -106,9 +106,30 @@ export function CryptoReceiveSection({
106
106
  )}
107
107
  </div>
108
108
  {isBuyMode || isDepositMode ? (
109
- // Fixed destination token display for buy mode and deposit mode
109
+ // Fixed destination TOKEN for buy mode and deposit mode. The AMOUNT differs: buy mode
110
+ // always derives the receive amount from the source pay-in (read-only here), but deposit
111
+ // mode is the flow where the user states how much to receive directly — so when it's not
112
+ // locked (no destinationTokenAmount from the caller), render an editable amount input
113
+ // while keeping the token side fixed/non-interactive (hideTokenSelect).
110
114
  <div className={classes?.inputContainer || "flex items-center justify-between"}>
111
- <div className={classes?.input || "text-as-primary text-2xl font-bold"}>{dstAmount || "0"}</div>
115
+ {isDepositMode && !disableAmountInput ? (
116
+ <OrderTokenAmount
117
+ address={effectiveRecipientAddress}
118
+ context="to"
119
+ inputValue={dstAmount}
120
+ onChangeInput={onChangeDstAmount || (() => {})}
121
+ chainId={selectedDstChainId || dstToken.chainId}
122
+ setChainId={() => {}}
123
+ token={dstToken}
124
+ setToken={() => {}}
125
+ hideTokenSelect
126
+ className="w-auto flex-1 gap-0 rounded-none border-0 p-0"
127
+ innerClassName="gap-0"
128
+ amountClassName={classes?.input || "text-as-primary text-2xl font-bold"}
129
+ />
130
+ ) : (
131
+ <div className={classes?.input || "text-as-primary text-2xl font-bold"}>{dstAmount || "0"}</div>
132
+ )}
112
133
  <div
113
134
  className={
114
135
  classes?.tokenSelector ||
@@ -0,0 +1,110 @@
1
+ import { USDC_BASE } from "@b3dotfun/sdk/anyspend";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4
+
5
+ /**
6
+ * Regression test for the "b3os-deposit prefill is inert" bug: `CryptoReceiveSection`'s
7
+ * `isBuyMode || isDepositMode` branch rendered the destination amount as a plain, static
8
+ * `<div>` — never an `<input>` — regardless of `disableAmountInput`. That branch is exactly
9
+ * what the b3os-deposit page hits (`AnySpend.tsx`'s `isBuyMode` is unconditionally true there,
10
+ * since `lockDestinationToken` defaults `true`), so `defaultDestinationTokenAmount`'s seeded
11
+ * value could never actually be edited, no matter how correctly it was threaded/seeded upstream.
12
+ *
13
+ * The fix: within that branch, when `isDepositMode && !disableAmountInput`, render the amount
14
+ * through `OrderTokenAmount` (the same editable-input component the non-buy/non-deposit branch
15
+ * already uses) with `hideTokenSelect` — token stays fixed, only the amount becomes editable.
16
+ * `isBuyMode`-only callers (every existing `AnySpend`/`AnySpendCustomExactIn` consumer other
17
+ * than `AnySpendDeposit`, which never set `isDepositMode`) are unaffected: they keep the
18
+ * original static `<div>` display verbatim.
19
+ *
20
+ * Tested directly against `CryptoReceiveSection` (not the full `AnySpend` render tree): `AnySpend`
21
+ * pulls in quote-fetching, gas-price, recipient-address-state, and payment-method hooks that
22
+ * aren't mocked anywhere in the existing SDK test suite, and building that mock surface from
23
+ * scratch would test a lot of unrelated machinery for no added signal on THIS bug, which lives
24
+ * entirely inside `CryptoReceiveSection`'s render branch. The `defaultDestinationTokenAmount` ->
25
+ * `formatUnits` seeding math (`AnySpend.tsx`/`AnySpendCustomExactIn.tsx`) is untouched by this
26
+ * fix and was verified separately (by reading + typecheck) in the prior round; `dstAmount` below
27
+ * stands in for whatever already-formatted display string that seeding effect hands to this
28
+ * component (e.g. `formatUnits(2_000_000n, 6) === "2"`).
29
+ */
30
+
31
+ // OrderTokenAmount (used by the editable branch and the pre-existing swap-mode branch)
32
+ // transitively imports the Relay TokenSelector — mock it so the module resolves under
33
+ // happy-dom without a real Relay provider. Mirrors the same mock in QRDeposit.test.tsx.
34
+ vi.mock("@relayprotocol/relay-kit-ui", () => ({
35
+ TokenSelector: () => <div data-testid="token-selector" />,
36
+ }));
37
+
38
+ // Imported lazily so the module-level mock above is registered before CryptoReceiveSection
39
+ // (and its OrderTokenAmount -> relay-kit-ui import chain) loads.
40
+ let CryptoReceiveSection: typeof import("../CryptoReceiveSection").CryptoReceiveSection;
41
+
42
+ beforeAll(async () => {
43
+ ({ CryptoReceiveSection } = await import("../CryptoReceiveSection"));
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.clearAllMocks();
48
+ });
49
+
50
+ const noop = () => {};
51
+
52
+ function renderReceiveSection(overrides: Record<string, unknown> = {}) {
53
+ const onChangeDstAmount = vi.fn();
54
+ const utils = render(
55
+ <CryptoReceiveSection
56
+ isBuyMode
57
+ isDepositMode
58
+ onSelectRecipient={noop}
59
+ dstAmount="2"
60
+ dstToken={USDC_BASE}
61
+ isSrcInputDirty={false}
62
+ onChangeDstAmount={onChangeDstAmount}
63
+ {...overrides}
64
+ />,
65
+ );
66
+ return { ...utils, onChangeDstAmount };
67
+ }
68
+
69
+ describe("CryptoReceiveSection — deposit-mode editable amount (b3os-deposit prefill fix)", () => {
70
+ it("deposit mode, unlocked: the amount renders as an editable input, seeded with the prefill value, and typing updates it", () => {
71
+ const { onChangeDstAmount } = renderReceiveSection({ disableAmountInput: false });
72
+
73
+ // (a) an editable <input> exists for the amount — the OLD code rendered a plain <div>, no
74
+ // input at all, in this branch. There is exactly one text input on screen because the token
75
+ // side is `hideTokenSelect` (no TokenSelector rendered) and recipient selection is a button.
76
+ const input = screen.getByRole("textbox");
77
+ expect(input).toBeTruthy();
78
+
79
+ // ...and it is NOT disabled/readOnly — the whole point of "editable". (No jest-dom matchers
80
+ // are configured in this package's vitest setup, so assert the raw DOM properties directly.)
81
+ expect((input as HTMLInputElement).disabled).toBe(false);
82
+ expect((input as HTMLInputElement).readOnly).toBe(false);
83
+
84
+ // (b) seeded with the (already-formatted) prefill value handed in via `dstAmount` — stands
85
+ // in for `formatUnits(<raw defaultDestinationTokenAmount>, decimals)` from the seeding effect.
86
+ expect((input as HTMLInputElement).value).toBe("2");
87
+
88
+ // (c) firing a change lets the user actually type a new amount.
89
+ fireEvent.change(input, { target: { value: "5" } });
90
+ expect(onChangeDstAmount).toHaveBeenCalledWith("5");
91
+ });
92
+
93
+ it("deposit mode, locked (destinationTokenAmount set -> disableAmountInput=true): the amount stays the static, non-editable display", () => {
94
+ renderReceiveSection({ disableAmountInput: true });
95
+
96
+ // Lock path preserved byte-for-byte: NO <input> is rendered for the amount at all — same as
97
+ // before this fix, and same as every other `disableAmountInput` caller.
98
+ expect(screen.queryByRole("textbox")).toBeNull();
99
+ expect(screen.getByText("2")).toBeTruthy();
100
+ });
101
+
102
+ it("buy mode without deposit mode (every existing AnySpend/AnySpendCustomExactIn caller other than AnySpendDeposit): unaffected — still the static display", () => {
103
+ // isDepositMode defaults false everywhere except AnySpendDeposit's <AnySpend> render, so this
104
+ // is the exact prop shape every pre-existing "Buy Mode" consumer passes today.
105
+ renderReceiveSection({ isDepositMode: false, disableAmountInput: false });
106
+
107
+ expect(screen.queryByRole("textbox")).toBeNull();
108
+ expect(screen.getByText("2")).toBeTruthy();
109
+ });
110
+ });
@@ -64,19 +64,27 @@ const BetterAuthProvider = ({ partnerId }: { partnerId: string }) => {
64
64
  const restoreSession = async () => {
65
65
  debug("Attempting session restore");
66
66
 
67
+ // A _ba_token in the URL OUTRANKS a cached Feathers JWT: the user just
68
+ // arrived through a live sign-in link (magic link / OAuth callback), and
69
+ // restoring a previous identity first would strand them as the wrong
70
+ // user with the token silently dropped.
71
+ const incomingBaToken = new URLSearchParams(window.location.search).get("_ba_token");
72
+
67
73
  // 1. Try existing Feathers JWT first (fastest — no network call to Better Auth)
68
- try {
69
- const response = await app.reAuthenticate();
70
- if (response?.user) {
71
- debug("Feathers JWT restored", { userId: response.user.userId });
72
- setUser(response.user);
73
- setIsAuthenticated(true);
74
- setIsConnected(true);
75
- setIsAuthenticating(false);
76
- return;
74
+ if (!incomingBaToken) {
75
+ try {
76
+ const response = await app.reAuthenticate();
77
+ if (response?.user) {
78
+ debug("Feathers JWT restored", { userId: response.user.userId });
79
+ setUser(response.user);
80
+ setIsAuthenticated(true);
81
+ setIsConnected(true);
82
+ setIsAuthenticating(false);
83
+ return;
84
+ }
85
+ } catch {
86
+ debug("No existing Feathers JWT");
77
87
  }
78
- } catch {
79
- debug("No existing Feathers JWT");
80
88
  }
81
89
 
82
90
  // 2. Check for _ba_token in URL (OAuth callback with third-party cookie bypass).
@@ -121,6 +129,22 @@ const BetterAuthProvider = ({ partnerId }: { partnerId: string }) => {
121
129
  return;
122
130
  } catch (err) {
123
131
  debug("_ba_token exchange failed", err);
132
+ // A dead injected token (expired link param, revoked session) must
133
+ // not strand a user who HAS a valid cached session — fall back to
134
+ // the reAuthenticate lane the token's presence skipped.
135
+ try {
136
+ const cached = await app.reAuthenticate();
137
+ if (cached?.user) {
138
+ debug("Fell back to cached Feathers JWT after _ba_token failure", { userId: cached.user.userId });
139
+ setUser(cached.user);
140
+ setIsAuthenticated(true);
141
+ setIsConnected(true);
142
+ setIsAuthenticating(false);
143
+ return;
144
+ }
145
+ } catch {
146
+ debug("No cached session to fall back to");
147
+ }
124
148
  }
125
149
  }
126
150