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

blog image

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:

typescript
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.

typescript
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.

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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.

typescript
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.

May Development Update

article May Development Update image

Why Flutter's mobile app is a good idea for business in 2020

article Why Flutter's mobile app is a good idea for business in 2020 image

Long Term Relationships With Global Torque

article Long Term Relationships With Global Torque image