We replaced 6 legacy per-action hooks with a single consolidated useFTUSDActions hook. This mirrors the pattern used in lending (useLendingActions).
useMintWithPermitAndFee— buy (mint) ftUSDuseRedeemWithPermit— sell (redeem) ftUSDuseStakeWithPermit— stake ftUSD to vaultuseUnstake— unstake from vaultuseClaim— claim rewardsuseMintAndStake— mint + stake combo
Net result: -1,071 lines (deleted 2,161 lines of legacy code, added 1,090 lines of consolidated code).
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 })
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.
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,
});directActions.mint // UseContractWriteReturn
directActions.redeem // UseContractWriteReturn
directActions.stake // UseContractWriteReturn
directActions.unstake // UseContractWriteReturn
directActions.claim // UseContractWriteReturn
directActions.buyAndStake // BuyAndStakeState (custom)
directActions.resetAll() // resets everythingEach 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 },
}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.
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.
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();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 modeinterface 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;
}| 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 |
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
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.
callbacks: {
onTransactionHash: (hash) => { setTransactionStep(2); setTransactionHash(hash); },
onSuccess: () => { setTransactionStatus('success'); setTransactionStep(0); },
onError: () => { setTransactionStatus('error'); },
onCancelled: () => { setTransactionStatus('cancelled'); },
}resetMintWithPermitAndFee: directActions.mint.reset,
resetStakeWithPermit: directActions.stake.reset,
resetMintAndStake: directActions.buyAndStake.reset,4 calls: approve collateral → mint → approve ftUSD → deposit
- EIP-5792 wallet: single
wallet_sendCallsbatch → one popup, atomic - Fallback: sequential with direct RPC allowance checks
| 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 #547: useTransaction routing + useFTUSDActions consolidation ← CURRENT
├── PR 2: ftUSD migration (useContractWrite → useTransaction, add session intents)
└── PR 3: Lending migration (useWriteContract → useTransaction)
| 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 |
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