Skip to content

Commit cc1c2de

Browse files
authored
fix: stabilize tron send flow and miniapp transfer sheets (#469)
1 parent dc32753 commit cc1c2de

18 files changed

Lines changed: 1005 additions & 116 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { Effect } from 'effect'
3+
import { httpFetch } from './http'
4+
5+
describe('httpFetch', () => {
6+
afterEach(() => {
7+
vi.restoreAllMocks()
8+
})
9+
10+
it('serializes bigint body values before POST', async () => {
11+
const fetchSpy = vi
12+
.spyOn(globalThis, 'fetch')
13+
.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
14+
15+
await Effect.runPromise(
16+
httpFetch({
17+
url: 'https://example.test/api',
18+
method: 'POST',
19+
body: { amount: 1000000000n },
20+
}),
21+
)
22+
23+
expect(fetchSpy).toHaveBeenCalledTimes(1)
24+
const requestInit = fetchSpy.mock.calls[0]?.[1]
25+
expect(requestInit?.body).toBe('{"amount":"1000000000"}')
26+
})
27+
})

packages/chain-effect/src/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export function httpFetch<T>(options: FetchOptions<T>): Effect.Effect<T, FetchEr
147147
};
148148

149149
if (method === 'POST' && body !== undefined) {
150-
requestInit.body = JSON.stringify(body);
150+
requestInit.body = stableStringify(body);
151151
}
152152

153153
const response = await fetch(finalUrl, requestInit);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { renderHook, act, waitFor } from '@testing-library/react';
3+
import { Amount } from '@/types/amount';
4+
import type { AssetInfo } from '@/types/asset';
5+
import type { ChainConfig } from '@/services/chain-config';
6+
7+
const { mockSubmitWeb3Transfer, mockFetchWeb3Fee } = vi.hoisted(() => ({
8+
mockSubmitWeb3Transfer: vi.fn(),
9+
mockFetchWeb3Fee: vi.fn(),
10+
}));
11+
12+
vi.mock('./use-send.web3', async () => {
13+
const actual = await vi.importActual<typeof import('./use-send.web3')>('./use-send.web3');
14+
return {
15+
...actual,
16+
fetchWeb3Fee: mockFetchWeb3Fee,
17+
submitWeb3Transfer: mockSubmitWeb3Transfer,
18+
validateWeb3Address: vi.fn(() => null),
19+
};
20+
});
21+
22+
import { useSend } from './use-send';
23+
24+
const mockAsset: AssetInfo = {
25+
assetType: 'TRX',
26+
name: 'Tron',
27+
amount: Amount.fromRaw('100000000', 6, 'TRX'),
28+
decimals: 6,
29+
};
30+
31+
const mockChainConfig = {
32+
id: 'tron',
33+
name: 'Tron',
34+
symbol: 'TRX',
35+
decimals: 6,
36+
chainKind: 'tron',
37+
} as ChainConfig;
38+
39+
describe('useSend submit message propagation', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
mockFetchWeb3Fee.mockResolvedValue({
43+
amount: Amount.fromRaw('1000', 6, 'TRX'),
44+
symbol: 'TRX',
45+
});
46+
});
47+
48+
it('returns and stores detailed submit error message for web3 transfer', async () => {
49+
const detailedMessage = 'Broadcast failed: SIGERROR';
50+
mockSubmitWeb3Transfer.mockResolvedValue({
51+
status: 'error',
52+
message: detailedMessage,
53+
});
54+
55+
const { result } = renderHook(() =>
56+
useSend({
57+
initialAsset: mockAsset,
58+
useMock: false,
59+
walletId: 'wallet-1',
60+
fromAddress: 'TFromAddress',
61+
chainConfig: mockChainConfig,
62+
}),
63+
);
64+
65+
act(() => {
66+
result.current.setToAddress('TToAddress');
67+
result.current.setAmount(Amount.fromRaw('100000', 6, 'TRX'));
68+
});
69+
70+
let submitResult: Awaited<ReturnType<typeof result.current.submit>>;
71+
await act(async () => {
72+
submitResult = await result.current.submit('wallet-lock');
73+
});
74+
75+
expect(submitResult).toEqual({
76+
status: 'error',
77+
message: detailedMessage,
78+
});
79+
await waitFor(() => {
80+
expect(result.current.state.errorMessage).toBe(detailedMessage);
81+
});
82+
});
83+
});

src/hooks/use-send.test.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,25 +93,69 @@ describe('useSend', () => {
9393
})
9494

9595
describe('setAsset', () => {
96-
it('updates asset and estimates fee', async () => {
96+
it('updates asset without estimating fee before form completion', async () => {
9797
const { result } = renderHook(() => useSend())
9898

9999
act(() => {
100100
result.current.setAsset(mockAsset)
101101
})
102102
expect(result.current.state.asset).toEqual(mockAsset)
103+
expect(result.current.state.feeLoading).toBe(false)
104+
expect(result.current.state.feeAmount).toBeNull()
105+
expect(result.current.state.feeSymbol).toBe('')
106+
})
107+
108+
it('estimates fee after form becomes complete with debounce', async () => {
109+
const { result } = renderHook(() => useSend({ initialAsset: mockAsset }))
110+
111+
act(() => {
112+
result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678')
113+
result.current.setAmount(Amount.fromFormatted('0.5', 18, 'ETH'))
114+
})
115+
116+
act(() => {
117+
vi.advanceTimersByTime(299)
118+
})
119+
expect(result.current.state.feeAmount).toBeNull()
103120
expect(result.current.state.feeLoading).toBe(true)
104121

105-
// Wait for fee estimation
106122
act(() => {
107-
vi.advanceTimersByTime(300)
123+
vi.advanceTimersByTime(1)
108124
})
109125

110126
expect(result.current.state.feeLoading).toBe(false)
111-
expect(result.current.state.feeAmount).not.toBeNull()
112127
expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002')
113128
expect(result.current.state.feeSymbol).toBe('ETH')
114129
})
130+
131+
it('re-estimates fee after amount change with debounce', async () => {
132+
const { result } = renderHook(() => useSend({ initialAsset: mockAsset }))
133+
134+
act(() => {
135+
result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678')
136+
result.current.setAmount(Amount.fromFormatted('0.5', 18, 'ETH'))
137+
})
138+
act(() => {
139+
vi.advanceTimersByTime(300)
140+
})
141+
expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002')
142+
143+
act(() => {
144+
result.current.setAmount(Amount.fromFormatted('0.6', 18, 'ETH'))
145+
})
146+
expect(result.current.state.feeLoading).toBe(true)
147+
148+
act(() => {
149+
vi.advanceTimersByTime(299)
150+
})
151+
expect(result.current.state.feeAmount).toBeNull()
152+
153+
act(() => {
154+
vi.advanceTimersByTime(1)
155+
})
156+
expect(result.current.state.feeLoading).toBe(false)
157+
expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002')
158+
})
115159
})
116160

117161
describe('canProceed', () => {

src/hooks/use-send.ts

Lines changed: 97 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
2222
asset: initialAsset ?? null,
2323
});
2424

25-
const feeInitKeyRef = useRef<string | null>(null);
25+
const feeEstimateDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
26+
const feeEstimateSeqRef = useRef(0);
2627

2728
const isBioforestChain = chainConfig?.chainKind === 'bioforest';
2829
const isWeb3Chain =
@@ -81,33 +82,83 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
8182
setState((prev) => ({
8283
...prev,
8384
asset,
84-
feeLoading: true,
85+
feeAmount: null,
86+
feeMinAmount: null,
87+
feeSymbol: '',
88+
feeLoading: false,
8589
}));
90+
},
91+
[],
92+
);
8693

87-
const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress;
94+
useEffect(() => {
95+
if (feeEstimateDebounceRef.current) {
96+
clearTimeout(feeEstimateDebounceRef.current);
97+
feeEstimateDebounceRef.current = null;
98+
}
8899

89-
if (shouldUseMock) {
90-
// Mock fee estimation delay
91-
setTimeout(() => {
92-
const fee = MOCK_FEES[asset.assetType] ?? { amount: '0.001', symbol: asset.assetType };
93-
const feeAmount = Amount.fromFormatted(fee.amount, asset.decimals, fee.symbol);
94-
setState((prev) => ({
95-
...prev,
96-
feeAmount: feeAmount,
97-
feeMinAmount: feeAmount,
98-
feeSymbol: fee.symbol,
99-
feeLoading: false,
100-
}));
101-
}, 300);
102-
return;
103-
}
100+
if (!state.asset) {
101+
return;
102+
}
103+
104+
const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress;
105+
const toAddress = state.toAddress.trim();
106+
const hasValidAmount = state.amount?.isPositive() === true;
107+
const isTronSelfTransfer =
108+
chainConfig?.chainKind === 'tron' &&
109+
fromAddress !== undefined &&
110+
fromAddress.trim().length > 0 &&
111+
fromAddress.trim() === toAddress;
112+
const canEstimateFee = toAddress.length > 0 && hasValidAmount && !isTronSelfTransfer;
113+
114+
if (!canEstimateFee) {
115+
setState((prev) => {
116+
if (!prev.feeLoading && prev.feeAmount === null && prev.feeMinAmount === null && prev.feeSymbol === '') {
117+
return prev;
118+
}
119+
return {
120+
...prev,
121+
feeLoading: false,
122+
feeAmount: null,
123+
feeMinAmount: null,
124+
feeSymbol: '',
125+
};
126+
});
127+
return;
128+
}
104129

130+
setState((prev) => ({
131+
...prev,
132+
feeLoading: true,
133+
feeAmount: null,
134+
feeMinAmount: null,
135+
feeSymbol: '',
136+
}));
137+
138+
const requestSeq = ++feeEstimateSeqRef.current;
139+
feeEstimateDebounceRef.current = setTimeout(() => {
105140
void (async () => {
106141
try {
107-
// Use appropriate fee fetcher based on chain type
108-
const feeEstimate = isWeb3Chain
109-
? await fetchWeb3Fee(chainConfig, fromAddress)
110-
: await fetchBioforestFee(chainConfig, fromAddress);
142+
const feeEstimate = shouldUseMock
143+
? (() => {
144+
const fee = MOCK_FEES[state.asset!.assetType] ?? { amount: '0.001', symbol: state.asset!.assetType };
145+
return {
146+
amount: Amount.fromFormatted(fee.amount, state.asset!.decimals, fee.symbol),
147+
symbol: fee.symbol,
148+
};
149+
})()
150+
: isWeb3Chain && chainConfig && fromAddress
151+
? await fetchWeb3Fee({
152+
chainConfig,
153+
fromAddress,
154+
toAddress,
155+
amount: state.amount ?? undefined,
156+
})
157+
: await fetchBioforestFee(chainConfig!, fromAddress!);
158+
159+
if (requestSeq !== feeEstimateSeqRef.current) {
160+
return;
161+
}
111162

112163
setState((prev) => ({
113164
...prev,
@@ -117,32 +168,37 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
117168
feeLoading: false,
118169
}));
119170
} catch (error) {
171+
if (requestSeq !== feeEstimateSeqRef.current) {
172+
return;
173+
}
120174
setState((prev) => ({
121175
...prev,
176+
feeAmount: null,
177+
feeMinAmount: null,
178+
feeSymbol: '',
122179
feeLoading: false,
123180
errorMessage: error instanceof Error ? error.message : t('error:transaction.feeEstimateFailed'),
124181
}));
125182
}
126183
})();
127-
},
128-
[chainConfig, fromAddress, isBioforestChain, isWeb3Chain, useMock],
129-
);
184+
}, 300);
130185

131-
useEffect(() => {
132-
if (!state.asset) return;
133-
if (state.feeLoading) return;
134-
135-
const feeKey = `${chainConfig?.id ?? 'unknown'}:${fromAddress ?? ''}:${state.asset.assetType}`;
136-
const feeKeyChanged = feeInitKeyRef.current !== feeKey;
137-
if (feeKeyChanged) {
138-
feeInitKeyRef.current = feeKey;
139-
}
140-
141-
if (!feeKeyChanged && state.feeAmount) return;
142-
if (!feeKeyChanged && !state.feeAmount) return;
143-
144-
setAsset(state.asset);
145-
}, [chainConfig?.id, fromAddress, setAsset, state.asset, state.feeAmount, state.feeLoading]);
186+
return () => {
187+
if (feeEstimateDebounceRef.current) {
188+
clearTimeout(feeEstimateDebounceRef.current);
189+
feeEstimateDebounceRef.current = null;
190+
}
191+
};
192+
}, [
193+
chainConfig,
194+
fromAddress,
195+
isBioforestChain,
196+
isWeb3Chain,
197+
state.amount,
198+
state.asset,
199+
state.toAddress,
200+
useMock,
201+
]);
146202

147203
// Get current balance from external source (single source of truth)
148204
const currentBalance = useMemo(() => {
@@ -291,7 +347,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
291347
txHash: null,
292348
errorMessage: result.message,
293349
}));
294-
return { status: 'error' as const };
350+
return { status: 'error' as const, message: result.message };
295351
}
296352

297353
setState((prev) => ({

0 commit comments

Comments
 (0)