Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/chain-effect/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Effect } from 'effect'
import { httpFetch } from './http'

describe('httpFetch', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('serializes bigint body values before POST', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))

await Effect.runPromise(
httpFetch({
url: 'https://example.test/api',
method: 'POST',
body: { amount: 1000000000n },
}),
)

expect(fetchSpy).toHaveBeenCalledTimes(1)
const requestInit = fetchSpy.mock.calls[0]?.[1]
expect(requestInit?.body).toBe('{"amount":"1000000000"}')
})
})
2 changes: 1 addition & 1 deletion packages/chain-effect/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export function httpFetch<T>(options: FetchOptions<T>): Effect.Effect<T, FetchEr
};

if (method === 'POST' && body !== undefined) {
requestInit.body = JSON.stringify(body);
requestInit.body = stableStringify(body);
}

const response = await fetch(finalUrl, requestInit);
Expand Down
83 changes: 83 additions & 0 deletions src/hooks/use-send.submit-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { Amount } from '@/types/amount';
import type { AssetInfo } from '@/types/asset';
import type { ChainConfig } from '@/services/chain-config';

const { mockSubmitWeb3Transfer, mockFetchWeb3Fee } = vi.hoisted(() => ({
mockSubmitWeb3Transfer: vi.fn(),
mockFetchWeb3Fee: vi.fn(),
}));

vi.mock('./use-send.web3', async () => {
const actual = await vi.importActual<typeof import('./use-send.web3')>('./use-send.web3');
return {
...actual,
fetchWeb3Fee: mockFetchWeb3Fee,
submitWeb3Transfer: mockSubmitWeb3Transfer,
validateWeb3Address: vi.fn(() => null),
};
});

import { useSend } from './use-send';

const mockAsset: AssetInfo = {
assetType: 'TRX',
name: 'Tron',
amount: Amount.fromRaw('100000000', 6, 'TRX'),
decimals: 6,
};

const mockChainConfig = {
id: 'tron',
name: 'Tron',
symbol: 'TRX',
decimals: 6,
chainKind: 'tron',
} as ChainConfig;

describe('useSend submit message propagation', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchWeb3Fee.mockResolvedValue({
amount: Amount.fromRaw('1000', 6, 'TRX'),
symbol: 'TRX',
});
});

it('returns and stores detailed submit error message for web3 transfer', async () => {
const detailedMessage = 'Broadcast failed: SIGERROR';
mockSubmitWeb3Transfer.mockResolvedValue({
status: 'error',
message: detailedMessage,
});

const { result } = renderHook(() =>
useSend({
initialAsset: mockAsset,
useMock: false,
walletId: 'wallet-1',
fromAddress: 'TFromAddress',
chainConfig: mockChainConfig,
}),
);

act(() => {
result.current.setToAddress('TToAddress');
result.current.setAmount(Amount.fromRaw('100000', 6, 'TRX'));
});

let submitResult: Awaited<ReturnType<typeof result.current.submit>>;
await act(async () => {
submitResult = await result.current.submit('wallet-lock');
});

expect(submitResult).toEqual({
status: 'error',
message: detailedMessage,
});
await waitFor(() => {
expect(result.current.state.errorMessage).toBe(detailedMessage);
});
});
});
52 changes: 48 additions & 4 deletions src/hooks/use-send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,25 +93,69 @@ describe('useSend', () => {
})

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

act(() => {
result.current.setAsset(mockAsset)
})
expect(result.current.state.asset).toEqual(mockAsset)
expect(result.current.state.feeLoading).toBe(false)
expect(result.current.state.feeAmount).toBeNull()
expect(result.current.state.feeSymbol).toBe('')
})

it('estimates fee after form becomes complete with debounce', async () => {
const { result } = renderHook(() => useSend({ initialAsset: mockAsset }))

act(() => {
result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678')
result.current.setAmount(Amount.fromFormatted('0.5', 18, 'ETH'))
})

act(() => {
vi.advanceTimersByTime(299)
})
expect(result.current.state.feeAmount).toBeNull()
expect(result.current.state.feeLoading).toBe(true)

// Wait for fee estimation
act(() => {
vi.advanceTimersByTime(300)
vi.advanceTimersByTime(1)
})

expect(result.current.state.feeLoading).toBe(false)
expect(result.current.state.feeAmount).not.toBeNull()
expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002')
expect(result.current.state.feeSymbol).toBe('ETH')
})

it('re-estimates fee after amount change with debounce', async () => {
const { result } = renderHook(() => useSend({ initialAsset: mockAsset }))

act(() => {
result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678')
result.current.setAmount(Amount.fromFormatted('0.5', 18, 'ETH'))
})
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002')

act(() => {
result.current.setAmount(Amount.fromFormatted('0.6', 18, 'ETH'))
})
expect(result.current.state.feeLoading).toBe(true)

act(() => {
vi.advanceTimersByTime(299)
})
expect(result.current.state.feeAmount).toBeNull()

act(() => {
vi.advanceTimersByTime(1)
})
expect(result.current.state.feeLoading).toBe(false)
expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002')
})
})

describe('canProceed', () => {
Expand Down
138 changes: 97 additions & 41 deletions src/hooks/use-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
asset: initialAsset ?? null,
});

const feeInitKeyRef = useRef<string | null>(null);
const feeEstimateDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const feeEstimateSeqRef = useRef(0);

const isBioforestChain = chainConfig?.chainKind === 'bioforest';
const isWeb3Chain =
Expand Down Expand Up @@ -81,33 +82,83 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
setState((prev) => ({
...prev,
asset,
feeLoading: true,
feeAmount: null,
feeMinAmount: null,
feeSymbol: '',
feeLoading: false,
}));
},
[],
);

const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress;
useEffect(() => {
if (feeEstimateDebounceRef.current) {
clearTimeout(feeEstimateDebounceRef.current);
feeEstimateDebounceRef.current = null;
}

if (shouldUseMock) {
// Mock fee estimation delay
setTimeout(() => {
const fee = MOCK_FEES[asset.assetType] ?? { amount: '0.001', symbol: asset.assetType };
const feeAmount = Amount.fromFormatted(fee.amount, asset.decimals, fee.symbol);
setState((prev) => ({
...prev,
feeAmount: feeAmount,
feeMinAmount: feeAmount,
feeSymbol: fee.symbol,
feeLoading: false,
}));
}, 300);
return;
}
if (!state.asset) {
return;
}

const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress;
const toAddress = state.toAddress.trim();
const hasValidAmount = state.amount?.isPositive() === true;
const isTronSelfTransfer =
chainConfig?.chainKind === 'tron' &&
fromAddress !== undefined &&
fromAddress.trim().length > 0 &&
fromAddress.trim() === toAddress;
const canEstimateFee = toAddress.length > 0 && hasValidAmount && !isTronSelfTransfer;

if (!canEstimateFee) {
setState((prev) => {
if (!prev.feeLoading && prev.feeAmount === null && prev.feeMinAmount === null && prev.feeSymbol === '') {
return prev;
}
return {
...prev,
feeLoading: false,
feeAmount: null,
feeMinAmount: null,
feeSymbol: '',
};
});
return;
}

setState((prev) => ({
...prev,
feeLoading: true,
feeAmount: null,
feeMinAmount: null,
feeSymbol: '',
}));

const requestSeq = ++feeEstimateSeqRef.current;
feeEstimateDebounceRef.current = setTimeout(() => {
void (async () => {
try {
// Use appropriate fee fetcher based on chain type
const feeEstimate = isWeb3Chain
? await fetchWeb3Fee(chainConfig, fromAddress)
: await fetchBioforestFee(chainConfig, fromAddress);
const feeEstimate = shouldUseMock
? (() => {
const fee = MOCK_FEES[state.asset!.assetType] ?? { amount: '0.001', symbol: state.asset!.assetType };
return {
amount: Amount.fromFormatted(fee.amount, state.asset!.decimals, fee.symbol),
symbol: fee.symbol,
};
})()
: isWeb3Chain && chainConfig && fromAddress
? await fetchWeb3Fee({
chainConfig,
fromAddress,
toAddress,
amount: state.amount ?? undefined,
})
: await fetchBioforestFee(chainConfig!, fromAddress!);

if (requestSeq !== feeEstimateSeqRef.current) {
return;
}

setState((prev) => ({
...prev,
Expand All @@ -117,32 +168,37 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
feeLoading: false,
}));
} catch (error) {
if (requestSeq !== feeEstimateSeqRef.current) {
return;
}
setState((prev) => ({
...prev,
feeAmount: null,
feeMinAmount: null,
feeSymbol: '',
feeLoading: false,
errorMessage: error instanceof Error ? error.message : t('error:transaction.feeEstimateFailed'),
}));
}
})();
},
[chainConfig, fromAddress, isBioforestChain, isWeb3Chain, useMock],
);
}, 300);

useEffect(() => {
if (!state.asset) return;
if (state.feeLoading) return;

const feeKey = `${chainConfig?.id ?? 'unknown'}:${fromAddress ?? ''}:${state.asset.assetType}`;
const feeKeyChanged = feeInitKeyRef.current !== feeKey;
if (feeKeyChanged) {
feeInitKeyRef.current = feeKey;
}

if (!feeKeyChanged && state.feeAmount) return;
if (!feeKeyChanged && !state.feeAmount) return;

setAsset(state.asset);
}, [chainConfig?.id, fromAddress, setAsset, state.asset, state.feeAmount, state.feeLoading]);
return () => {
if (feeEstimateDebounceRef.current) {
clearTimeout(feeEstimateDebounceRef.current);
feeEstimateDebounceRef.current = null;
}
};
}, [
chainConfig,
fromAddress,
isBioforestChain,
isWeb3Chain,
state.amount,
state.asset,
state.toAddress,
useMock,
]);

// Get current balance from external source (single source of truth)
const currentBalance = useMemo(() => {
Expand Down Expand Up @@ -291,7 +347,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn {
txHash: null,
errorMessage: result.message,
}));
return { status: 'error' as const };
return { status: 'error' as const, message: result.message };
}

setState((prev) => ({
Expand Down
Loading