@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
|
@@ -59,20 +59,27 @@ const BetterAuthProvider = ({ partnerId }) => {
|
|
|
59
59
|
hasAttemptedRestore.current = true;
|
|
60
60
|
const restoreSession = async () => {
|
|
61
61
|
debug("Attempting session restore");
|
|
62
|
+
// A _ba_token in the URL OUTRANKS a cached Feathers JWT: the user just
|
|
63
|
+
// arrived through a live sign-in link (magic link / OAuth callback), and
|
|
64
|
+
// restoring a previous identity first would strand them as the wrong
|
|
65
|
+
// user with the token silently dropped.
|
|
66
|
+
const incomingBaToken = new URLSearchParams(window.location.search).get("_ba_token");
|
|
62
67
|
// 1. Try existing Feathers JWT first (fastest — no network call to Better Auth)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
if (!incomingBaToken) {
|
|
69
|
+
try {
|
|
70
|
+
const response = await app.reAuthenticate();
|
|
71
|
+
if (response?.user) {
|
|
72
|
+
debug("Feathers JWT restored", { userId: response.user.userId });
|
|
73
|
+
setUser(response.user);
|
|
74
|
+
setIsAuthenticated(true);
|
|
75
|
+
setIsConnected(true);
|
|
76
|
+
setIsAuthenticating(false);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
debug("No existing Feathers JWT");
|
|
72
82
|
}
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
debug("No existing Feathers JWT");
|
|
76
83
|
}
|
|
77
84
|
// 2. Check for _ba_token in URL (OAuth callback with third-party cookie bypass).
|
|
78
85
|
// When the API and frontend are on different domains, browsers with third-party
|
|
@@ -112,6 +119,23 @@ const BetterAuthProvider = ({ partnerId }) => {
|
|
|
112
119
|
}
|
|
113
120
|
catch (err) {
|
|
114
121
|
debug("_ba_token exchange failed", err);
|
|
122
|
+
// A dead injected token (expired link param, revoked session) must
|
|
123
|
+
// not strand a user who HAS a valid cached session — fall back to
|
|
124
|
+
// the reAuthenticate lane the token's presence skipped.
|
|
125
|
+
try {
|
|
126
|
+
const cached = await app.reAuthenticate();
|
|
127
|
+
if (cached?.user) {
|
|
128
|
+
debug("Fell back to cached Feathers JWT after _ba_token failure", { userId: cached.user.userId });
|
|
129
|
+
setUser(cached.user);
|
|
130
|
+
setIsAuthenticated(true);
|
|
131
|
+
setIsConnected(true);
|
|
132
|
+
setIsAuthenticating(false);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
debug("No cached session to fall back to");
|
|
138
|
+
}
|
|
115
139
|
}
|
|
116
140
|
}
|
|
117
141
|
// 3. Check for a Better Auth session via cookie (works when API and frontend
|
|
@@ -64,6 +64,13 @@ export declare function AnySpend(props: {
|
|
|
64
64
|
allowDirectTransfer?: boolean;
|
|
65
65
|
/** Fixed destination token amount (in wei/smallest unit). When provided, user cannot change the amount. */
|
|
66
66
|
destinationTokenAmount?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Editable default destination token amount (in wei/smallest unit). Seeds the same
|
|
69
|
+
* destination-amount input as `destinationTokenAmount`, but leaves it user-editable. Ignored
|
|
70
|
+
* when `destinationTokenAmount` is also set — that prop locks the field and wins.
|
|
71
|
+
* Seeds the field once on first apply; a later change to this value does not re-fill it, so it never overwrites what the user typed.
|
|
72
|
+
*/
|
|
73
|
+
defaultDestinationTokenAmount?: string;
|
|
67
74
|
/** Opaque metadata passed to the order for callbacks (e.g., workflow form data) */
|
|
68
75
|
callbackMetadata?: Record<string, unknown>;
|
|
69
76
|
/** Optional sender (payer) address — pre-fills token balances when the user address is known ahead of time */
|
|
@@ -78,4 +85,16 @@ export declare function AnySpend(props: {
|
|
|
78
85
|
kycEnabled?: boolean;
|
|
79
86
|
/** Whether to show the "Pay with Fiat" tab. Defaults to true. When false, the fiat tab is hidden and activeTab is forced to "crypto". */
|
|
80
87
|
showFiatOption?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Internal: true when `AnySpendDeposit` has an amount to prefill (`defaultDestinationTokenAmount`
|
|
90
|
+
* set), as opposed to any other `AnySpend`/`AnySpendDeposit` usage. Only changes
|
|
91
|
+
* `CryptoReceiveSection`'s fixed-destination branch: it lets the receive amount become an
|
|
92
|
+
* editable input (still gated by `disableAmountInput`) instead of a read-only display. Scoped
|
|
93
|
+
* this narrowly on purpose — `AnySpendDeposit` backs 15+ funding modals that are source-exact-in
|
|
94
|
+
* (the user sets the "pay with" amount; the receive side is an intended computed display), so
|
|
95
|
+
* this must stay off for all of them and only turn on for the one page that actually prefills a
|
|
96
|
+
* receive amount. Not intended for external callers — `AnySpendDeposit` derives this itself.
|
|
97
|
+
* @internal
|
|
98
|
+
*/
|
|
99
|
+
isDepositMode?: boolean;
|
|
81
100
|
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -116,6 +116,14 @@ export interface AnySpendDepositProps {
|
|
|
116
116
|
pureTransferOnly?: boolean;
|
|
117
117
|
/** Fixed destination token amount (in wei/smallest unit). When provided, user cannot change the amount. */
|
|
118
118
|
destinationTokenAmount?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Editable default destination token amount (in wei/smallest unit). Seeds the same
|
|
121
|
+
* destination-amount input as `destinationTokenAmount`, but leaves it user-editable. Ignored
|
|
122
|
+
* when `destinationTokenAmount` is also set — that prop locks the field and wins. Applies to
|
|
123
|
+
* the standard deposit flow only; custom-function deposits ignore it.
|
|
124
|
+
* Seeds the field once on first apply; a later change to this value does not re-fill it, so it never overwrites what the user typed.
|
|
125
|
+
*/
|
|
126
|
+
defaultDestinationTokenAmount?: string;
|
|
119
127
|
/** Opaque metadata passed to the order for callbacks (e.g., workflow form data) */
|
|
120
128
|
callbackMetadata?: Record<string, unknown>;
|
|
121
129
|
/** Optional sender (payer) address — pre-fills token balances when the user address is known ahead of time */
|
|
@@ -162,4 +170,4 @@ export interface AnySpendDepositProps {
|
|
|
162
170
|
* onSuccess={(amount) => console.log(`Deposited ${amount}`)}
|
|
163
171
|
* />
|
|
164
172
|
*/
|
|
165
|
-
export declare function AnySpendDeposit({ loadOrder, mode, recipientAddress, paymentType: initialPaymentType, sourceTokenAddress, sourceTokenChainId: initialSourceChainId, destinationTokenAddress, destinationTokenChainId, lockDestinationToken, onSuccess, onOpenCustomModal, mainFooter, onTokenSelect, customUsdInputValues, preferEoa, minDestinationAmount, header, orderType, depositContractConfig, showChainSelection, showFiatOption, supportedChains, topChainsCount, onClose, returnToHomeUrl, customRecipientLabel, returnHomeLabel, isCustomDeposit, classes, allowDirectTransfer, pureTransferOnly, destinationTokenAmount, callbackMetadata, senderAddress, slots, content, theme, }: AnySpendDepositProps): import("react/jsx-runtime").JSX.Element | null;
|
|
173
|
+
export declare function AnySpendDeposit({ loadOrder, mode, recipientAddress, paymentType: initialPaymentType, sourceTokenAddress, sourceTokenChainId: initialSourceChainId, destinationTokenAddress, destinationTokenChainId, lockDestinationToken, onSuccess, onOpenCustomModal, mainFooter, onTokenSelect, customUsdInputValues, preferEoa, minDestinationAmount, header, orderType, depositContractConfig, showChainSelection, showFiatOption, supportedChains, topChainsCount, onClose, returnToHomeUrl, customRecipientLabel, returnHomeLabel, isCustomDeposit, classes, allowDirectTransfer, pureTransferOnly, destinationTokenAmount, defaultDestinationTokenAmount, callbackMetadata, senderAddress, slots, content, theme, }: AnySpendDepositProps): import("react/jsx-runtime").JSX.Element | null;
|
package/package.json
CHANGED
|
@@ -146,6 +146,13 @@ export function AnySpend(props: {
|
|
|
146
146
|
allowDirectTransfer?: boolean;
|
|
147
147
|
/** Fixed destination token amount (in wei/smallest unit). When provided, user cannot change the amount. */
|
|
148
148
|
destinationTokenAmount?: string;
|
|
149
|
+
/**
|
|
150
|
+
* Editable default destination token amount (in wei/smallest unit). Seeds the same
|
|
151
|
+
* destination-amount input as `destinationTokenAmount`, but leaves it user-editable. Ignored
|
|
152
|
+
* when `destinationTokenAmount` is also set — that prop locks the field and wins.
|
|
153
|
+
* Seeds the field once on first apply; a later change to this value does not re-fill it, so it never overwrites what the user typed.
|
|
154
|
+
*/
|
|
155
|
+
defaultDestinationTokenAmount?: string;
|
|
149
156
|
/** Opaque metadata passed to the order for callbacks (e.g., workflow form data) */
|
|
150
157
|
callbackMetadata?: Record<string, unknown>;
|
|
151
158
|
/** Optional sender (payer) address — pre-fills token balances when the user address is known ahead of time */
|
|
@@ -160,6 +167,18 @@ export function AnySpend(props: {
|
|
|
160
167
|
kycEnabled?: boolean;
|
|
161
168
|
/** Whether to show the "Pay with Fiat" tab. Defaults to true. When false, the fiat tab is hidden and activeTab is forced to "crypto". */
|
|
162
169
|
showFiatOption?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* Internal: true when `AnySpendDeposit` has an amount to prefill (`defaultDestinationTokenAmount`
|
|
172
|
+
* set), as opposed to any other `AnySpend`/`AnySpendDeposit` usage. Only changes
|
|
173
|
+
* `CryptoReceiveSection`'s fixed-destination branch: it lets the receive amount become an
|
|
174
|
+
* editable input (still gated by `disableAmountInput`) instead of a read-only display. Scoped
|
|
175
|
+
* this narrowly on purpose — `AnySpendDeposit` backs 15+ funding modals that are source-exact-in
|
|
176
|
+
* (the user sets the "pay with" amount; the receive side is an intended computed display), so
|
|
177
|
+
* this must stay off for all of them and only turn on for the one page that actually prefills a
|
|
178
|
+
* receive amount. Not intended for external callers — `AnySpendDeposit` derives this itself.
|
|
179
|
+
* @internal
|
|
180
|
+
*/
|
|
181
|
+
isDepositMode?: boolean;
|
|
163
182
|
}) {
|
|
164
183
|
const fingerprintConfig = getFingerprintConfig();
|
|
165
184
|
|
|
@@ -194,10 +213,12 @@ function AnySpendInner({
|
|
|
194
213
|
classes,
|
|
195
214
|
allowDirectTransfer = false,
|
|
196
215
|
destinationTokenAmount,
|
|
216
|
+
defaultDestinationTokenAmount,
|
|
197
217
|
callbackMetadata,
|
|
198
218
|
senderAddress,
|
|
199
219
|
kycEnabled = false,
|
|
200
220
|
showFiatOption = true,
|
|
221
|
+
isDepositMode = false,
|
|
201
222
|
}: {
|
|
202
223
|
sourceChainId?: number;
|
|
203
224
|
destinationTokenAddress?: string;
|
|
@@ -220,6 +241,7 @@ function AnySpendInner({
|
|
|
220
241
|
classes?: AnySpendClasses;
|
|
221
242
|
allowDirectTransfer?: boolean;
|
|
222
243
|
destinationTokenAmount?: string;
|
|
244
|
+
defaultDestinationTokenAmount?: string;
|
|
223
245
|
callbackMetadata?: Record<string, unknown>;
|
|
224
246
|
senderAddress?: string;
|
|
225
247
|
slots?: AnySpendSlots;
|
|
@@ -227,6 +249,7 @@ function AnySpendInner({
|
|
|
227
249
|
theme?: AnySpendTheme;
|
|
228
250
|
kycEnabled?: boolean;
|
|
229
251
|
showFiatOption?: boolean;
|
|
252
|
+
isDepositMode?: boolean;
|
|
230
253
|
}) {
|
|
231
254
|
const { slots, content } = useAnySpendCustomization();
|
|
232
255
|
const searchParams = useSearchParamsSSR();
|
|
@@ -438,6 +461,21 @@ function AnySpendInner({
|
|
|
438
461
|
}, [selectedDstToken, isBuyMode]);
|
|
439
462
|
|
|
440
463
|
const [isSrcInputDirty, setIsSrcInputDirty] = useState(true);
|
|
464
|
+
// Synchronous mirror of `isSrcInputDirty`. `setIsSrcInputDirty` is async relative to OTHER
|
|
465
|
+
// effects in the same commit — the quote effect (declared later, same commit) would otherwise
|
|
466
|
+
// read a pre-flip closure value and clear a just-seeded receive amount before it ever paints.
|
|
467
|
+
// Every flip goes through `markSrcDirty`, which writes this ref in the same statement, so
|
|
468
|
+
// same-commit siblings always see the latest intent. Deliberately NOT kept in sync by its own
|
|
469
|
+
// effect: an effect reading `isSrcInputDirty` from its closure would itself go stale across
|
|
470
|
+
// React 18 StrictMode's dev-mode double-invoke of mount effects (same closure re-run twice)
|
|
471
|
+
// and re-stomp this ref back to the pre-flip value between the two passes.
|
|
472
|
+
const isSrcInputDirtyRef = useRef(isSrcInputDirty);
|
|
473
|
+
// Single writer for the direction flag: keeps state and its synchronous ref mirror in lockstep
|
|
474
|
+
// so no call site — parent or child (CryptoPaySection) — can flip one without the other.
|
|
475
|
+
const markSrcDirty = useCallback((next: boolean) => {
|
|
476
|
+
setIsSrcInputDirty(next);
|
|
477
|
+
isSrcInputDirtyRef.current = next;
|
|
478
|
+
}, []);
|
|
441
479
|
// Add refs to track if we've applied metadata
|
|
442
480
|
const appliedSrcMetadataRef = useRef(false);
|
|
443
481
|
const appliedDstMetadataRef = useRef(false);
|
|
@@ -500,17 +538,23 @@ function AnySpendInner({
|
|
|
500
538
|
appliedDstMetadataRef.current = false;
|
|
501
539
|
}, [selectedDstToken.address, selectedDstToken.chainId]);
|
|
502
540
|
|
|
503
|
-
// Prefill destination amount if provided
|
|
541
|
+
// Prefill destination amount if provided — fixed (locking) mode wins over the editable default,
|
|
542
|
+
// since `destinationTokenAmount` also disables the input (see `disableAmountInput` below).
|
|
504
543
|
const appliedDestinationAmount = useRef(false);
|
|
544
|
+
const seedDestinationAmount = destinationTokenAmount ?? defaultDestinationTokenAmount;
|
|
505
545
|
useEffect(() => {
|
|
506
546
|
// Only apply when we have real metadata (not default decimals)
|
|
507
|
-
if (
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
547
|
+
if (seedDestinationAmount && dstTokenMetadata?.decimals && !appliedDestinationAmount.current) {
|
|
548
|
+
try {
|
|
549
|
+
const formattedAmount = formatUnits(BigInt(seedDestinationAmount), dstTokenMetadata.decimals);
|
|
550
|
+
appliedDestinationAmount.current = true;
|
|
551
|
+
setDstAmount(formattedAmount);
|
|
552
|
+
markSrcDirty(false); // Switch to EXACT_OUTPUT mode
|
|
553
|
+
} catch {
|
|
554
|
+
// invalid seed value — no-op rather than throw into the effect (fail-closed)
|
|
555
|
+
}
|
|
512
556
|
}
|
|
513
|
-
}, [
|
|
557
|
+
}, [seedDestinationAmount, dstTokenMetadata, markSrcDirty]);
|
|
514
558
|
|
|
515
559
|
// Load swap configuration from URL on initial render
|
|
516
560
|
useEffect(() => {
|
|
@@ -535,7 +579,7 @@ function AnySpendInner({
|
|
|
535
579
|
setSrcAmountOnRamp(fromAmountParam);
|
|
536
580
|
} else {
|
|
537
581
|
setSrcAmount(fromAmountParam);
|
|
538
|
-
|
|
582
|
+
markSrcDirty(true);
|
|
539
583
|
}
|
|
540
584
|
}
|
|
541
585
|
} catch (error) {
|
|
@@ -548,7 +592,7 @@ function AnySpendInner({
|
|
|
548
592
|
|
|
549
593
|
// Mark that we've processed the initial URL
|
|
550
594
|
initialUrlProcessed.current = true;
|
|
551
|
-
}, [searchParams, loadOrder, disableUrlParamManagement]);
|
|
595
|
+
}, [searchParams, loadOrder, disableUrlParamManagement, markSrcDirty]);
|
|
552
596
|
|
|
553
597
|
// Update URL when swap configuration changes - but not on initial load
|
|
554
598
|
const updateSwapParamsInURL = useCallback(() => {
|
|
@@ -805,6 +849,14 @@ function AnySpendInner({
|
|
|
805
849
|
|
|
806
850
|
// Update dependent amount when relay price changes
|
|
807
851
|
useEffect(() => {
|
|
852
|
+
// Read the ref (not the closed-over `isSrcInputDirty` param) for the branch decision: the
|
|
853
|
+
// SEED effect (declared above, same commit when it fires) writes to the ref synchronously
|
|
854
|
+
// when it flips this to exact-out mode, so this effect sees that flip immediately even
|
|
855
|
+
// though `setIsSrcInputDirty`'s state update won't be visible in THIS render's closure until
|
|
856
|
+
// the next commit. Using the stale closure value here was the actual bug — it caused this
|
|
857
|
+
// effect to still treat a freshly-seeded exact-out amount as exact-in-with-no-data and clear
|
|
858
|
+
// it before it ever painted. The dependency array below stays on the real state, so this
|
|
859
|
+
// effect still re-runs correctly on every genuine transition.
|
|
808
860
|
if (
|
|
809
861
|
anyspendQuote?.data &&
|
|
810
862
|
anyspendQuote.data.currencyIn?.amount &&
|
|
@@ -812,7 +864,7 @@ function AnySpendInner({
|
|
|
812
864
|
anyspendQuote.data.currencyOut?.amount &&
|
|
813
865
|
anyspendQuote.data.currencyOut?.currency?.decimals
|
|
814
866
|
) {
|
|
815
|
-
if (
|
|
867
|
+
if (isSrcInputDirtyRef.current) {
|
|
816
868
|
// The user is driving the SOURCE (exact-in): reflect the quote's OUTPUT even
|
|
817
869
|
// when a fixed destinationTokenAmount pre-filled the receive. That lock only
|
|
818
870
|
// governs the INITIAL exact-out state (untouched source → compute the pay for
|
|
@@ -828,7 +880,7 @@ function AnySpendInner({
|
|
|
828
880
|
setSrcAmount(formatTokenAmount(BigInt(amount), decimals, 6, false));
|
|
829
881
|
}
|
|
830
882
|
} else {
|
|
831
|
-
if (
|
|
883
|
+
if (isSrcInputDirtyRef.current) {
|
|
832
884
|
setDstAmount("");
|
|
833
885
|
} else {
|
|
834
886
|
setSrcAmount("");
|
|
@@ -1446,7 +1498,7 @@ function AnySpendInner({
|
|
|
1446
1498
|
srcAmount={srcAmount}
|
|
1447
1499
|
setSrcAmount={setSrcAmount}
|
|
1448
1500
|
isSrcInputDirty={isSrcInputDirty}
|
|
1449
|
-
setIsSrcInputDirty={
|
|
1501
|
+
setIsSrcInputDirty={markSrcDirty}
|
|
1450
1502
|
selectedCryptoPaymentMethod={effectiveCryptoPaymentMethod}
|
|
1451
1503
|
onSelectCryptoPaymentMethod={() => navigateToPanel(PanelView.CRYPTO_PAYMENT_METHOD, "forward")}
|
|
1452
1504
|
anyspendQuote={anyspendQuote}
|
|
@@ -1537,7 +1589,7 @@ function AnySpendInner({
|
|
|
1537
1589
|
{/* Receive section - Hidden when fiat tab is active */}
|
|
1538
1590
|
{activeTab === "crypto" && (
|
|
1539
1591
|
<CryptoReceiveSection
|
|
1540
|
-
isDepositMode={
|
|
1592
|
+
isDepositMode={isDepositMode}
|
|
1541
1593
|
isBuyMode={isBuyMode}
|
|
1542
1594
|
effectiveRecipientAddress={effectiveRecipientAddress}
|
|
1543
1595
|
recipientName={recipientName || undefined}
|
|
@@ -1550,7 +1602,7 @@ function AnySpendInner({
|
|
|
1550
1602
|
setSelectedDstToken={setSelectedDstToken}
|
|
1551
1603
|
isSrcInputDirty={isSrcInputDirty}
|
|
1552
1604
|
onChangeDstAmount={value => {
|
|
1553
|
-
|
|
1605
|
+
markSrcDirty(false);
|
|
1554
1606
|
setDstAmount(value);
|
|
1555
1607
|
}}
|
|
1556
1608
|
disableAmountInput={!!destinationTokenAmount}
|
|
@@ -140,6 +140,14 @@ export interface AnySpendDepositProps {
|
|
|
140
140
|
pureTransferOnly?: boolean;
|
|
141
141
|
/** Fixed destination token amount (in wei/smallest unit). When provided, user cannot change the amount. */
|
|
142
142
|
destinationTokenAmount?: string;
|
|
143
|
+
/**
|
|
144
|
+
* Editable default destination token amount (in wei/smallest unit). Seeds the same
|
|
145
|
+
* destination-amount input as `destinationTokenAmount`, but leaves it user-editable. Ignored
|
|
146
|
+
* when `destinationTokenAmount` is also set — that prop locks the field and wins. Applies to
|
|
147
|
+
* the standard deposit flow only; custom-function deposits ignore it.
|
|
148
|
+
* Seeds the field once on first apply; a later change to this value does not re-fill it, so it never overwrites what the user typed.
|
|
149
|
+
*/
|
|
150
|
+
defaultDestinationTokenAmount?: string;
|
|
143
151
|
/** Opaque metadata passed to the order for callbacks (e.g., workflow form data) */
|
|
144
152
|
callbackMetadata?: Record<string, unknown>;
|
|
145
153
|
/** Optional sender (payer) address — pre-fills token balances when the user address is known ahead of time */
|
|
@@ -289,6 +297,7 @@ export function AnySpendDeposit({
|
|
|
289
297
|
allowDirectTransfer = false,
|
|
290
298
|
pureTransferOnly = false,
|
|
291
299
|
destinationTokenAmount,
|
|
300
|
+
defaultDestinationTokenAmount,
|
|
292
301
|
callbackMetadata,
|
|
293
302
|
senderAddress,
|
|
294
303
|
slots,
|
|
@@ -845,6 +854,10 @@ export function AnySpendDeposit({
|
|
|
845
854
|
destinationTokenAddress={destinationTokenAddress}
|
|
846
855
|
destinationTokenChainId={destinationTokenChainId}
|
|
847
856
|
lockDestinationToken={lockDestinationToken}
|
|
857
|
+
// Scoped to ONLY the prefill case (not every AnySpendDeposit caller — 15+ other
|
|
858
|
+
// funding modals rely on the source-exact-in "computed display" behavior this would
|
|
859
|
+
// otherwise change): editable destination amount only when there's a value to seed.
|
|
860
|
+
isDepositMode={!!defaultDestinationTokenAmount}
|
|
848
861
|
onSuccess={txHash => onSuccess?.(txHash ?? "")}
|
|
849
862
|
onTokenSelect={onTokenSelect}
|
|
850
863
|
customUsdInputValues={customUsdInputValues}
|
|
@@ -857,6 +870,7 @@ export function AnySpendDeposit({
|
|
|
857
870
|
classes={classes?.anySpend}
|
|
858
871
|
allowDirectTransfer={allowDirectTransfer}
|
|
859
872
|
destinationTokenAmount={destinationTokenAmount}
|
|
873
|
+
defaultDestinationTokenAmount={defaultDestinationTokenAmount}
|
|
860
874
|
callbackMetadata={callbackMetadata}
|
|
861
875
|
senderAddress={senderAddress}
|
|
862
876
|
showFiatOption={showFiatOption}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { formatUnits } from "viem";
|
|
4
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
5
|
+
import { StrictMode, useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression test for fix round 3: the destination-amount prefill (`defaultDestinationTokenAmount`)
|
|
10
|
+
* never reached the visible input on the live `/b3os-deposit?...&amount=...` page, even though the
|
|
11
|
+
* SEED effect in `AnySpend.tsx` fired and called `setDstAmount(formattedAmount)`.
|
|
12
|
+
*
|
|
13
|
+
* Root cause (confirmed via live console instrumentation, not guessed): `AnySpend.tsx` declares two
|
|
14
|
+
* effects that both touch `dstAmount` — SEED (prefills from `defaultDestinationTokenAmount` once
|
|
15
|
+
* `dstTokenMetadata` resolves) and QUOTE (mirrors the relay quote into `dstAmount`/`srcAmount`,
|
|
16
|
+
* branching on `isSrcInputDirty`). On the `/b3os-deposit` page, `dstTokenMetadata` for the
|
|
17
|
+
* destination token is already resolved by the time `<AnySpend>` mounts, so SEED and QUOTE both run
|
|
18
|
+
* within the SAME initial-mount effects flush. SEED flips `isSrcInputDirty` to false, but that state
|
|
19
|
+
* update isn't visible in QUOTE's closure until the NEXT render — so within that same flush, QUOTE
|
|
20
|
+
* still reads the stale `isSrcInputDirty === true` and, seeing no quote data yet (the quote query is
|
|
21
|
+
* disabled until a nonzero destination amount seeds the swap), clears `dstAmount` back to ""
|
|
22
|
+
* immediately after SEED set it. The fix mirrors `isSrcInputDirty` into a ref written in the same
|
|
23
|
+
* statement as the state flip, and has QUOTE read `isSrcInputDirtyRef.current`.
|
|
24
|
+
*
|
|
25
|
+
* Fix round 4 tightens that ref-sync into a SINGLE writer, `markSrcDirty`, that flips state and ref
|
|
26
|
+
* together. The earlier round wrote the ref inline at three of four call sites, but the fourth — the
|
|
27
|
+
* `setIsSrcInputDirty` prop handed to `CryptoPaySection` (the pay-amount input) — passed the RAW
|
|
28
|
+
* state setter, so typing a source amount after an exact-out prefill flipped state to true while the
|
|
29
|
+
* ref stayed false. The next quote then took the EXACT_OUTPUT branch and froze the receive amount
|
|
30
|
+
* instead of mirroring a fresh quote. Routing every write (SEED, URL-loader, dst-amount handler, AND
|
|
31
|
+
* the CryptoPaySection prop) through `markSrcDirty` closes that gap for present and future callers.
|
|
32
|
+
*
|
|
33
|
+
* This harness copies the SEED and QUOTE effects out of `AnySpend.tsx` verbatim (same state names,
|
|
34
|
+
* same `markSrcDirty` single-writer shape, same effect bodies and dependency arrays — see
|
|
35
|
+
* `AnySpend.tsx`'s ref declaration, SEED effect, and QUOTE effect), rather than mounting the real
|
|
36
|
+
* `<AnySpend>` component. A full mount needs ~20 hooks (auth, wallet, router, modal store,
|
|
37
|
+
* geo-onramp, gas price, quote, create-order, payment-method state, etc.) mocked with controllable
|
|
38
|
+
* return values just to reach this effect pair — the same tradeoff already made twice in this suite
|
|
39
|
+
* for much smaller subtrees (see the doc comments in `CryptoReceiveSection.test.tsx` and
|
|
40
|
+
* `AnySpendDeposit.isDepositMode.test.tsx`). What this test verifies instead is the exact mechanism
|
|
41
|
+
* that broke: real React 18 timing (uncontrolled, unmocked), under `StrictMode` so the double-invoke
|
|
42
|
+
* this bug depended on is actually exercised, plus a source-scan lock so no future call site can
|
|
43
|
+
* reintroduce a bare setter that bypasses the ref.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const DST_DECIMALS = 18;
|
|
47
|
+
const SEED_AMOUNT_WEI = "100000000000000000"; // 0.1 ETH, matching the live repro's &amount= param
|
|
48
|
+
const TYPED_PAY_AMOUNT = "0.05"; // what the user types into the PAY field after the prefill lands
|
|
49
|
+
const FRESH_QUOTE_DST = "0.099"; // stand-in for a fresh quote's currencyOut (receive) amount
|
|
50
|
+
const FRESH_QUOTE_SRC = "0.101"; // stand-in for a fresh quote's currencyIn (pay) amount
|
|
51
|
+
|
|
52
|
+
function SeedEffectHarness({ hasQuoteData }: { hasQuoteData: boolean }) {
|
|
53
|
+
const [dstAmount, setDstAmount] = useState("");
|
|
54
|
+
const [srcAmount, setSrcAmount] = useState("");
|
|
55
|
+
const [isSrcInputDirty, setIsSrcInputDirty] = useState(true);
|
|
56
|
+
const isSrcInputDirtyRef = useRef(isSrcInputDirty);
|
|
57
|
+
|
|
58
|
+
// Single writer for the direction flag (verbatim shape from AnySpend.tsx): flips state and its
|
|
59
|
+
// synchronous ref mirror together, so no call site — including the pay-input path below — can
|
|
60
|
+
// flip one without the other.
|
|
61
|
+
const markSrcDirty = useCallback((next: boolean) => {
|
|
62
|
+
setIsSrcInputDirty(next);
|
|
63
|
+
isSrcInputDirtyRef.current = next;
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const appliedDestinationAmount = useRef(false);
|
|
67
|
+
// dstTokenMetadata is available synchronously from the first render — this is the precondition
|
|
68
|
+
// that put SEED and QUOTE in the SAME initial-mount effects flush on the live page.
|
|
69
|
+
const dstTokenMetadata = { decimals: DST_DECIMALS };
|
|
70
|
+
|
|
71
|
+
// --- SEED effect (verbatim from AnySpend.tsx) ---
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (SEED_AMOUNT_WEI && dstTokenMetadata?.decimals && !appliedDestinationAmount.current) {
|
|
74
|
+
appliedDestinationAmount.current = true;
|
|
75
|
+
const formattedAmount = formatUnits(BigInt(SEED_AMOUNT_WEI), dstTokenMetadata.decimals);
|
|
76
|
+
setDstAmount(formattedAmount);
|
|
77
|
+
markSrcDirty(false); // Switch to EXACT_OUTPUT mode
|
|
78
|
+
}
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [dstTokenMetadata]);
|
|
81
|
+
|
|
82
|
+
// --- QUOTE effect (verbatim from AnySpend.tsx) ---
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (hasQuoteData) {
|
|
85
|
+
if (isSrcInputDirtyRef.current) {
|
|
86
|
+
setDstAmount(FRESH_QUOTE_DST); // stand-in for a real quote's currencyOut amount
|
|
87
|
+
} else {
|
|
88
|
+
setSrcAmount(FRESH_QUOTE_SRC); // stand-in for a real quote's currencyIn amount
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
if (isSrcInputDirtyRef.current) {
|
|
92
|
+
setDstAmount("");
|
|
93
|
+
} else {
|
|
94
|
+
setSrcAmount("");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [hasQuoteData, isSrcInputDirty]);
|
|
99
|
+
|
|
100
|
+
// Simulates CryptoPaySection's pay-amount onChangeInput, which now receives `markSrcDirty` as its
|
|
101
|
+
// `setIsSrcInputDirty` prop in AnySpend.tsx. Fires AFTER the exact-out prefill has landed.
|
|
102
|
+
const onPayInput = () => {
|
|
103
|
+
markSrcDirty(true);
|
|
104
|
+
setSrcAmount(TYPED_PAY_AMOUNT);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div>
|
|
109
|
+
<div data-testid="dst-amount">{dstAmount}</div>
|
|
110
|
+
<div data-testid="src-amount">{srcAmount}</div>
|
|
111
|
+
<button data-testid="pay-input" onClick={onPayInput}>
|
|
112
|
+
edit pay
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("AnySpend seed/quote effect race (fix round 3)", () => {
|
|
119
|
+
it("prefill survives the same-commit SEED+QUOTE race with no quote data yet (matches the live /b3os-deposit repro)", () => {
|
|
120
|
+
render(
|
|
121
|
+
<StrictMode>
|
|
122
|
+
<SeedEffectHarness hasQuoteData={false} />
|
|
123
|
+
</StrictMode>,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Bug: this was "" — QUOTE's stale isSrcInputDirty read clobbered SEED's setDstAmount("0.1").
|
|
127
|
+
expect(screen.getByTestId("dst-amount").textContent).toBe("0.1");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("prefill survives when a quote resolves in the same flush (isSrcInputDirtyRef routes the quote to srcAmount, not dstAmount)", () => {
|
|
131
|
+
render(
|
|
132
|
+
<StrictMode>
|
|
133
|
+
<SeedEffectHarness hasQuoteData={true} />
|
|
134
|
+
</StrictMode>,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Once seeded, the swap is exact-out: a resolving quote should update the PAY side,
|
|
138
|
+
// leaving the seeded receive amount untouched.
|
|
139
|
+
expect(screen.getByTestId("dst-amount").textContent).toBe("0.1");
|
|
140
|
+
expect(screen.getByTestId("src-amount").textContent).toBe(FRESH_QUOTE_SRC);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("AnySpend pay-side edit after exact-out prefill (fix round 4)", () => {
|
|
145
|
+
it("editing the PAY amount re-quotes the receive side because markSrcDirty flips the ref, not just state", () => {
|
|
146
|
+
render(
|
|
147
|
+
<StrictMode>
|
|
148
|
+
<SeedEffectHarness hasQuoteData={true} />
|
|
149
|
+
</StrictMode>,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Precondition: the exact-out prefill landed and the quote routed to the PAY side.
|
|
153
|
+
expect(screen.getByTestId("dst-amount").textContent).toBe("0.1");
|
|
154
|
+
expect(screen.getByTestId("src-amount").textContent).toBe(FRESH_QUOTE_SRC);
|
|
155
|
+
|
|
156
|
+
// User edits the PAY amount. Through the RAW setter this flipped state true but left the ref
|
|
157
|
+
// false, so the next quote took the EXACT_OUTPUT branch — freezing the receive amount at the
|
|
158
|
+
// seeded 0.1 and overwriting the typed pay value. Through markSrcDirty the ref flips too.
|
|
159
|
+
fireEvent.click(screen.getByTestId("pay-input"));
|
|
160
|
+
|
|
161
|
+
// Receive mirrors a FRESH quote (not frozen at the seeded 0.1)...
|
|
162
|
+
expect(screen.getByTestId("dst-amount").textContent).toBe(FRESH_QUOTE_DST);
|
|
163
|
+
// ...and the typed pay value sticks (not overwritten by the exact-out quote).
|
|
164
|
+
expect(screen.getByTestId("src-amount").textContent).toBe(TYPED_PAY_AMOUNT);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("AnySpend.tsx routes every isSrcInputDirty write through markSrcDirty (no bare setter can bypass the ref)", () => {
|
|
168
|
+
let source: string;
|
|
169
|
+
try {
|
|
170
|
+
source = readFileSync(path.resolve(__dirname, "../AnySpend.tsx"), "utf8");
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error("Could not read AnySpend.tsx — if the file was moved, update this path");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The state tuple `[isSrcInputDirty, setIsSrcInputDirty]` never matches `setIsSrcInputDirty(`.
|
|
176
|
+
// Exactly one call may exist: the write inside `markSrcDirty`. Any other is a ref-sync gap.
|
|
177
|
+
const bareCalls = source.match(/setIsSrcInputDirty\(/g) ?? [];
|
|
178
|
+
expect(bareCalls.length).toBe(1);
|
|
179
|
+
|
|
180
|
+
// And that single writer must flip the ref in the same body, or the "one writer" is not
|
|
181
|
+
// actually ref-syncing.
|
|
182
|
+
expect(source).toMatch(
|
|
183
|
+
/const markSrcDirty = useCallback\(\(next: boolean\) => \{\s*setIsSrcInputDirty\(next\);\s*isSrcInputDirtyRef\.current = next;/,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// The original round-4 bug wasn't a bare call at all — it was the raw setter handed to a
|
|
187
|
+
// child as a PROP VALUE (`setIsSrcInputDirty={setIsSrcInputDirty}`), which the bare-call
|
|
188
|
+
// regex above doesn't catch and TypeScript doesn't flag. Guard against that shape too.
|
|
189
|
+
expect(source).not.toMatch(/=\s*\{\s*setIsSrcInputDirty\s*\}/);
|
|
190
|
+
});
|
|
191
|
+
});
|