Skip to content

Instantly share code, notes, and snippets.

@patcito
Last active February 26, 2026 19:48
Show Gist options
  • Select an option

  • Save patcito/5742145e41ef267fe55537c9bfa7ba62 to your computer and use it in GitHub Desktop.

Select an option

Save patcito/5742145e41ef267fe55537c9bfa7ba62 to your computer and use it in GitHub Desktop.
ftUSD Hook Architecture — useFTUSDActions consolidation guide for frontend devs

ftUSD Hook Architecture — useFTUSDActions

What changed

We replaced 6 legacy per-action hooks with a single consolidated useFTUSDActions hook. This mirrors the pattern used in lending (useLendingActions).

Deleted hooks

  • useMintWithPermitAndFee — buy (mint) ftUSD
  • useRedeemWithPermit — sell (redeem) ftUSD
  • useStakeWithPermit — stake ftUSD to vault
  • useUnstake — unstake from vault
  • useClaim — claim rewards
  • useMintAndStake — mint + stake combo

Net result: -1,071 lines (deleted 2,161 lines of legacy code, added 1,090 lines of consolidated code).


Architecture overview

useTransaction(options)                    ← routing layer (PR #547)
  ├── mode === 'direct':  useContractWrite  (approval + write + EIP-5792 + gas)
  └── mode === 'session': useSessionContractAction (fee est → activation → intents)

useFTUSDActions(props)                     ← product hook (PR #547, soon PR 2)
  ├── mint:        useContractWrite → future: useTransaction({ direct, session })
  ├── redeem:      useContractWrite → future: useTransaction({ direct, session })
  ├── stake:       useContractWrite → future: useTransaction({ direct, session })
  ├── unstake:     useContractWrite → future: useTransaction({ direct, session })
  ├── claim:       useContractWrite → future: useTransaction({ direct, session })
  └── buyAndStake: custom (EIP-5792 4-call batch direct + 2-intent session)

useLendingActions(props)                   ← product hook (future PR 3)
  ├── deposit:     useTransaction({ direct, session })
  ├── withdraw:    useTransaction({ direct, session })
  ├── borrow:      useTransaction({ direct, session })
  └── repay:       useTransaction({ direct, session })

New hook: useFTUSDActions

File: packages/ft-sdk/src/hooks/ftusd/useFTUSDActions.ts

Single hook that returns all 6 ftUSD actions. Each single action uses useContractWrite under the hood, which gives you EIP-5792 atomic batching, gas estimation, and allowance management for free. buyAndStake has custom multi-call handling.

Interface

import { useFTUSDActions, type UseFTUSDActionsProps } from '@ft-sdk/hooks';

const directActions = useFTUSDActions({
  chainId: 1,
  callbacks: {
    onTransactionHash: (hash) => { /* tx submitted */ },
    onSuccess:         ()     => { /* tx confirmed */ },
    onError:           (err)  => { /* tx failed */ },
    onCancelled:       ()     => { /* user rejected in wallet */ },
    onStepChange:      (step) => { /* 0=approval, 1=write */ },
    refetchBalances:   ()     => { /* refresh balances */ },
  },

  // Mint params (only needed when user is on Buy tab)
  collateralToken:  '0x...',
  collateralAmount: 1000000n,
  minFtUSDOut:      990000...n,

  // Redeem params (only needed when user is on Sell tab)
  redeemCollateralToken: '0x...',
  ftUsdAmount:           1000...n,
  minCollateralOut:      990000n,

  // Stake / Unstake
  stakeAmount:   1000...n,
  unstakeAmount: 1000...n,

  // BuyAndStake
  buyAndStakeCollateralToken:    '0x...',
  buyAndStakeCollateralAmount:   1000000n,
  buyAndStakeMinFtUSDOut:        990000...n,
  buyAndStakeExpectedMintOutput: 1000000...n,
});

What you get back

directActions.mint       // UseContractWriteReturn
directActions.redeem     // UseContractWriteReturn
directActions.stake      // UseContractWriteReturn
directActions.unstake    // UseContractWriteReturn
directActions.claim      // UseContractWriteReturn
directActions.buyAndStake // BuyAndStakeState (custom)
directActions.resetAll() // resets everything

Each UseContractWriteReturn gives you:

{
  execute:           () => Promise<void>,
  reset:             () => void,
  isLoading:         boolean,
  isSuccess:         boolean,
  isError:           boolean,
  currentStep:       'idle' | 'approval' | 'write' | 'success' | 'error',
  needsApproval:     boolean | undefined,
  supportsAtomicBatch: boolean,
  gas: {
    currentStepGasUsd: number | null,
    isEstimating:      boolean,
  },
  approval: { hash },
  write:    { hash },
}

Executing an action

await directActions.mint.execute();

If the wallet supports EIP-5792, the approve + write calls are batched into a single wallet popup. Otherwise they run sequentially.


useTransaction — dual-mode routing layer

File: packages/ft-sdk/src/hooks/useTransaction.ts

Routes between direct (useContractWrite) and session (useSessionContractAction) execution modes. Both hooks are always instantiated (React hooks rules), but only the active mode's execute is wired into the returned function.

Direct-only usage

const tx = useTransaction({
  direct: {
    contractAddress: VAULT,
    abi: VaultABI,
    functionName: 'deposit',
    args: [amount, receiver],
    approval: { type: 'erc20', token: TOKEN, spender: VAULT, amount },
    gasEstimation: { autoEstimate: true },
    writeCallbacks: { onSuccess: () => refetch() },
  },
});

await tx.execute();

Dual-mode usage (product hook picks mode from settings)

const tx = useTransaction<MintParams>({
  mode: sessionEnabled ? 'session' : 'direct',
  direct: {
    contractAddress: VAULT,
    abi: VaultABI,
    functionName: 'deposit',
    args: [amount, receiver],
    approval: { type: 'erc20', token: TOKEN, spender: VAULT, amount },
  },
  session: {
    sessionManager,
    sessionActivation,
    intents: [mintIntent],
    setStep: (step) => setCurrentStep(step),
  },
});

await tx.execute(params); // params used only in session mode

Unified return type

interface UseTransactionReturn<TParams = void> {
  execute: (...) => Promise<void>;
  reset: () => void;

  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
  isDeclined: boolean;

  hash: Address | undefined;
  hashes: `0x${string}`[];
  currentStep: string;
  needsApproval: boolean | undefined;
  supportsAtomicBatch: boolean;
  supportsPermit: boolean;

  gas: GasEstimationState;
  relayerFeeUsd: number;
  mode: 'direct' | 'session';

  _direct: UseContractWriteReturn;
  _session: UseSessionContractActionReturn;
}

Field mapping

Unified field Direct source Session source
isLoading directResult.isLoading isPending || isConfirming
isError directResult.isError !!error || isReceiptError
isDeclined pattern match on write error sessionResult.isDeclined
hash write.hash sessionResult.hash
hashes [write.hash].filter(Boolean) sessionResult.hashes
gas directResult.gas { currentStepGasUsd: gasFeeEstimationUsd }
relayerFeeUsd 0 relayerFeeEstimationUsd
supportsPermit false sessionResult.supportsPermit

Noop configs

When mode is 'direct', the session hook receives NOOP_SESSION_MANAGER and NOOP_SESSION_ACTIVATION. When mode is 'session', the direct hook gets NOOP_DIRECT_CONFIG with enabled: false.

File: packages/ft-sdk/src/hooks/sessions/noopSessionManager.ts


How FTUSDProvider wires it

1. Reactive params via useMemo

const directMintCollateralAmount = useMemo(() => {
  if (activeTab !== FTUSD_TAB_MODE.BUY || tokenAmount <= 0) return undefined;
  return parseUnits(toDecimalString(tokenAmount, selectedToken.decimals), selectedToken.decimals);
}, [activeTab, tokenAmount, selectedToken.decimals]);

Passing undefined disables the action's gas estimation and allowance checks.

2. Callbacks drive state

callbacks: {
  onTransactionHash: (hash) => { setTransactionStep(2); setTransactionHash(hash); },
  onSuccess:         ()     => { setTransactionStatus('success'); setTransactionStep(0); },
  onError:           ()     => { setTransactionStatus('error'); },
  onCancelled:       ()     => { setTransactionStatus('cancelled'); },
}

3. Context API is unchanged

resetMintWithPermitAndFee: directActions.mint.reset,
resetStakeWithPermit:      directActions.stake.reset,
resetMintAndStake:         directActions.buyAndStake.reset,

BuyAndStake: EIP-5792 atomic batching

4 calls: approve collateral → mint → approve ftUSD → deposit

  • EIP-5792 wallet: single wallet_sendCalls batch → one popup, atomic
  • Fallback: sequential with direct RPC allowance checks

Key design decisions

Decision Why
Params as reactive props useContractWrite auto-estimates gas before execute
Shared callbacks via ref Latest callbacks without re-instantiating hooks
Disambiguated vault ABIs Overloaded deposit — minimal ABIs avoid ambiguity
Direct RPC for allowance checks Bypass cached FT API proxy in sequential buyAndStake
Step mapping guard Prevent step-mapping effect from overriding step 2
Noop configs for inactive mode Both hooks instantiated per React rules; noops keep inactive one inert

PR dependency chain

PR #547: useTransaction routing + useFTUSDActions consolidation  ← CURRENT
  ├── PR 2: ftUSD migration (useContractWrite → useTransaction, add session intents)
  └── PR 3: Lending migration (useWriteContract → useTransaction)

Environment variables

Variable Default Description
NEXT_PUBLIC_NO_SESSION_FTUSD=true off Bypass session relayer, use direct approve+tx
NEXT_PUBLIC_SHOW_LOG_PROD=true off Keep console.logs in production builds

File map

packages/ft-sdk/src/hooks/
├── ftusd/
│   └── useFTUSDActions.ts              ← consolidated hook
├── sessions/
│   ├── useSessionContractAction.ts     ← session execution
│   ├── useSessionManager.ts            ← EIP-712 session management
│   └── noopSessionManager.ts           ← noop values for inactive mode
├── contract/
│   └── useContractWrite.ts             ← generic: approval + write + EIP-5792 + gas
├── useTransaction.ts                   ← dual-mode routing (direct ↔ session)
└── index.ts                            ← SDK exports

hooks/providers/
└── FTUSDProvider.tsx                    ← wiring: reactive params → callbacks → context
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment