The Wallet — Fiat, EVM, and a Separate Auth Flow

The wallet page shows one balance. Behind it are two independent data sources: a fiat wallet tracked by the backend, and an EVM wallet on-chain. They load separately, fail separately, and get merged into a single number only at the ViewModel layer.
Below the balance are withdraw and exchange buttons. Clicking them doesn't just call an API. It starts a second authentication flow — OTP plus optional MFA — completely separate from the session the user already has.
Two repositories, one composable
useRepositoryWallet holds fiat state — the USD balance, pending deposits, pending withdrawals. useRepositoryEvm holds EVM state — crypto balance, RWA token value, 24h changes per chain.
useWallet() opens both and computes everything the UI needs:
const fiatBalance = computed(() => getWalletState.value.data?.currentBalance ?? 0);
const cryptoBalance = computed(() => getEvmWalletState.value.data?.fundingBalance ?? 0);
const rwaValue = computed(() => getEvmWalletState.value.data?.rwaValue ?? 0);
const totalBalance = computed(
() => fiatBalance.value + cryptoBalance.value + rwaValue.value,
);totalBalance is the number shown at the top of the page. Each component of it can arrive at a different time, fail independently, and be individually undefined. The ?? 0 at each leaf handles the missing-data cases — the total is always a number, never NaN.
The View calls useWallet() once. It doesn't know which repositories exist or that there are two of them.
The effectScope problem
A watcher inside a composable is normally tied to the component that called it. When that component unmounts, the watcher stops.
That's wrong for the wallet. The wallet data should update whenever the selected profile changes — and the selected profile can change from a different screen. A watcher that dies when the wallet page unmounts stops updating state that other features depend on.
if (!walletEffectsScope) {
walletEffectsScope = effectScope(true);
walletEffectsScope.run(() => {
watch(
() => [
selectedUserProfileData.value?.id,
selectedUserProfileData.value?.kyc_status,
selectedEvmNetwork.value,
],
() => {
void nextTick(() => updateData());
},
{ immediate: true },
);
});
}effectScope(true) creates a detached scope — it lives until explicitly stopped, not until a component unmounts. The if (!walletEffectsScope) guard means the scope is created once, the first time any component calls useWallet(). Subsequent calls reuse the running scope.
If the user switches profiles from the settings page, the wallet data reloads. The wallet component doesn't need to be mounted for this to work.
Loading rules
Both repositories gate their loads on the same conditions: a valid session, a selected profile with an ID, and the right KYC status.
const canLoadWalletData = computed(() => canLoadWalletDataRule(
selectedUserProfileData.value,
selectedUserProfileId.value,
userLoggedIn.value,
getWalletState.value.loading,
));The rules are extracted into walletLoadRules.ts as pure functions so they can be tested without mounting a component or setting up a Pinia store.
When canLoadWalletData is false and there's already data loaded, the repository resets — the data belonged to a profile that's no longer selected:
const updateWalletData = async () => {
if (canLoadWalletData.value && !getWalletState.value.loading && !getWalletState.value.error) {
await walletRepository.getWalletByProfile(selectedUserProfileId.value);
} else if (!canLoadWalletData.value && getWalletState.value.data) {
walletRepository.resetAll();
}
};isWalletDataLoading shows a skeleton only while both fiat and EVM are in their initial unloaded state. As soon as either one resolves, the skeleton goes away. The other section gets its own loading state inline.
Wallet auth — a second authentication layer
The user is already logged in. That session covers reading data: balances, transaction history, portfolio. It does not cover executing transactions.
Withdraw and exchange require a separate proof-of-identity step. The user submits an email OTP. If MFA is registered, they submit a TOTP code on top. Only after both does the transaction go through.
This is not the user's login session. It's a signer session managed by a third-party wallet provider. The two auth systems run independently.
useWalletAuth manages this as an explicit state machine. Every step is a named string:
type WalletAuthStep =
| 'intro'
| 'sending_otp'
| 'awaiting_otp'
| 'awaiting_mfa'
| 'binding'
| 'success'
| 'error';State is stored per profile — a user might have an LLC profile and a personal account, each with its own wallet binding state. profileStates is a record keyed by profile ID:
const profileStates = ref<Record<string, WalletAuthProfileState>>({});currentProfileState derives the active profile's state. The dialog always renders the state for currentProfileId.
Tracing a withdrawal through the flow
The user taps Withdraw. Before any API call, the component checks whether the signer session is active:
const triggerZeroTransactionWarmup = async (payload) => {
const hasActiveSession = await walletAuthAdapter.hasActiveSession();
if (hasActiveSession) {
await walletAuthAdapter.sendZeroTransaction();
return 'completed';
}
// No active signer session — defer until auth completes
setPendingPostAuthAction({
profileId: payload.profileId,
successMarker: 'zero_transaction_warmup',
run: async () => {
await walletAuthAdapter.sendZeroTransaction();
},
});
await startFlowForProfile(payload);
return 'deferred_to_wallet_auth';
};If the signer session is active, the warmup transaction runs immediately and returns 'completed'. The withdrawal proceeds.
If not, the intended operation is stored as a pendingPostAuthAction — a closure that knows what to run after auth succeeds. Then startFlowForProfile opens the dialog and starts the OTP flow.
Walking through OTP
startFlowForProfile resets the profile state, opens the dialog, then calls startEmailOtpFlow:
const startEmailOtpFlow = async (profileId, email, profileType) => {
patchStep(profileId, 'sending_otp', { email, errorMessage: '', profileType });
// Disconnect any stale signer session from a previous user after logout
await walletAuthAdapter.resetSession();
const nextStep = await walletAuthAdapter.startEmailOtp(email);
if (nextStep === 'connected') {
// Email OTP was enough — signer was already warm
await completeWalletBind(profileId);
return;
}
patchStep(profileId, nextStep, { errorMessage: '' });
};walletAuthAdapter.startEmailOtp talks to the signer provider and returns the next step the provider requires. If MFA isn't set up, nextStep is 'awaiting_otp'. If the signer session was already valid — the provider remembered this browser — it returns 'connected' immediately.
submitOtp handles OTP code entry:
const submitOtp = async (otpCode) => {
const result = await walletAuthAdapter.submitOtp(otpCode);
if (result === 'awaiting_mfa') {
patchStep(profileId, 'awaiting_mfa', { errorMessage: '' });
return;
}
// OTP verified, MFA not required — proceed to binding
await completeWalletBind(profileId);
};If MFA is required, step changes to 'awaiting_mfa'. The user enters their TOTP code. submitMfa calls finalizeAfterMfa, which either runs the pending post-auth action or completes the wallet bind.
The pendingPostAuthAction
The deferred-action pattern solves a clean problem: the user triggered a transaction, auth was required, and now auth is complete. The component that triggered the original action doesn't need to know auth happened. It set a pending action before opening the dialog; runPendingPostAuthAction runs it after success.
const runPendingPostAuthAction = async (profileId) => {
const action = pendingPostAuthAction.value;
if (!action || action.profileId !== profileId) return;
pendingPostAuthAction.value = null;
try {
await action.run();
completedPostAuthAction.value = action.successMarker ?? null;
} catch {
// The caller handles its own errors
}
};The action clears itself before running — so if run() throws, there's no stale pending action left.
completedPostAuthAction is the signal back to the rest of the app: this specific operation finished. Watchers elsewhere check it to update balances or show a success state.


