@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
@@ -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
- try {
64
- const response = await app.reAuthenticate();
65
- if (response?.user) {
66
- debug("Feathers JWT restored", { userId: response.user.userId });
67
- setUser(response.user);
68
- setIsAuthenticated(true);
69
- setIsConnected(true);
70
- setIsAuthenticating(false);
71
- return;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b3dotfun/sdk",
3
- "version": "0.1.70-alpha.21",
3
+ "version": "0.1.70-alpha.22",
4
4
  "source": "src/index.ts",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "react-native": "./dist/cjs/index.native.js",
@@ -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 (for fixed amount mode)
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 (destinationTokenAmount && dstTokenMetadata?.decimals && !appliedDestinationAmount.current) {
508
- appliedDestinationAmount.current = true;
509
- const formattedAmount = formatUnits(BigInt(destinationTokenAmount), dstTokenMetadata.decimals);
510
- setDstAmount(formattedAmount);
511
- setIsSrcInputDirty(false); // Switch to EXACT_OUTPUT mode
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
- }, [destinationTokenAmount, dstTokenMetadata]);
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
- setIsSrcInputDirty(true);
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 (isSrcInputDirty) {
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 (isSrcInputDirty) {
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={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={false}
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
- setIsSrcInputDirty(false);
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
+ });