@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.
- package/dist/cjs/anyspend/react/components/AnySpend.d.ts +19 -0
- package/dist/cjs/anyspend/react/components/AnySpend.js +45 -15
- package/dist/cjs/anyspend/react/components/AnySpendDeposit.d.ts +9 -1
- package/dist/cjs/anyspend/react/components/AnySpendDeposit.js +6 -2
- package/dist/cjs/anyspend/react/components/common/CryptoReceiveSection.js +6 -2
- package/dist/cjs/global-account/react/components/B3Provider/BetterAuthProvider.js +36 -12
- package/dist/esm/anyspend/react/components/AnySpend.d.ts +19 -0
- package/dist/esm/anyspend/react/components/AnySpend.js +45 -15
- package/dist/esm/anyspend/react/components/AnySpendDeposit.d.ts +9 -1
- package/dist/esm/anyspend/react/components/AnySpendDeposit.js +6 -2
- package/dist/esm/anyspend/react/components/common/CryptoReceiveSection.js +6 -2
- package/dist/esm/global-account/react/components/B3Provider/BetterAuthProvider.js +36 -12
- package/dist/types/anyspend/react/components/AnySpend.d.ts +19 -0
- package/dist/types/anyspend/react/components/AnySpendDeposit.d.ts +9 -1
- package/package.json +1 -1
- package/src/anyspend/react/components/AnySpend.tsx +66 -14
- package/src/anyspend/react/components/AnySpendDeposit.tsx +14 -0
- package/src/anyspend/react/components/__tests__/AnySpend.seedEffect.test.tsx +191 -0
- package/src/anyspend/react/components/__tests__/AnySpendDeposit.isDepositMode.test.tsx +117 -0
- package/src/anyspend/react/components/common/CryptoReceiveSection.tsx +23 -2
- package/src/anyspend/react/components/common/__tests__/CryptoReceiveSection.test.tsx +110 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|