Skip to content
Open
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
6 changes: 6 additions & 0 deletions pages/sgho.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ const SGhoVaultWithdrawModal = dynamic(() =>
(module) => module.SGhoVaultWithdrawModal
)
);
const StkGhoMigrateModal = dynamic(() =>
import('../src/components/transactions/StkGhoMigrate/StkGhoMigrateModal').then(
(module) => module.StkGhoMigrateModal
)
);

export default function SavingsGho() {
const [trackEvent, currentMarket, setCurrentMarket] = useRootStore(
Expand Down Expand Up @@ -91,6 +96,7 @@ SavingsGho.getLayout = function getLayout(page: React.ReactElement) {
<StakeRewardClaimModal />
<SGhoVaultDepositModal />
<SGhoVaultWithdrawModal />
<StkGhoMigrateModal />
{/** End of modals */}
</MainLayout>
);
Expand Down
128 changes: 128 additions & 0 deletions src/components/transactions/StkGhoMigrate/StkGhoMigrateActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { evmAddress, useStkGhoMigrate } from '@aave/react';
import { useSendTransaction } from '@aave/react/viem';
import { Trans } from '@lingui/macro';
import { BoxProps } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import { errAsync } from 'neverthrow';
import React, { useEffect } from 'react';
import { oracles, stakedTokens } from 'src/hooks/stake/common';
import { useModalContext } from 'src/hooks/useModal';
import { useSavingsMarketData } from 'src/hooks/useSavingsMarketData';
import { useWeb3Context } from 'src/libs/hooks/useWeb3Context';
import { useSGhoVaultContext } from 'src/modules/sGho/SGhoVaultContext';
import { useRootStore } from 'src/store/root';
import { queryKeysFactory } from 'src/ui-config/queries';
import { wagmiConfig } from 'src/ui-config/wagmiConfig';
import { useWalletClient } from 'wagmi';
import { waitForTransactionReceipt } from 'wagmi/actions';
import { useShallow } from 'zustand/shallow';

import { TxActionsWrapper } from '../TxActionsWrapper';

// Static recommendation: migrate redeems the full stkGHO position and deposits
// it into the sGHO vault. No approval step exists (the migrator holds the
// stkGHO claim-helper role and redeems on the user's behalf), so this is a safe
// upper bound for the single migrate() call.
const STK_GHO_MIGRATE_GAS_LIMIT = 250_000;

export interface StkGhoMigrateActionsProps extends BoxProps {
isWrongNetwork: boolean;
blocked: boolean;
}

export const StkGhoMigrateActions = React.memo(
({ isWrongNetwork, blocked, sx, ...props }: StkGhoMigrateActionsProps) => {
const { currentAccount } = useWeb3Context();
const { chainId: targetChainId, sdkChainId } = useSavingsMarketData();
const { mainTxState, setMainTxState, setTxError, setGasLimit } = useModalContext();
const { refresh } = useSGhoVaultContext();
const queryClient = useQueryClient();
const [user, marketData] = useRootStore(
useShallow((state) => [state.account, state.currentMarketData])
);

const { data: walletClient } = useWalletClient();
const [migrate] = useStkGhoMigrate();
const [sendTransaction] = useSendTransaction(walletClient);

useEffect(() => {
setGasLimit(STK_GHO_MIGRATE_GAS_LIMIT.toString());
}, [setGasLimit]);

const action = async () => {
if (!currentAccount || !walletClient) return;
setMainTxState({ loading: true });
setTxError(undefined);

const result = await migrate({
user: evmAddress(currentAccount),
chainId: sdkChainId,
}).andThen((plan) => {
// The migrator holds the stkGHO claim-helper role and redeems on the
// user's behalf, so migration never requires an approval — the backend
// always returns a plain TransactionRequest. Guard the other
// ExecutionPlan union members defensively.
if (plan.__typename !== 'TransactionRequest') {
return errAsync(new Error('Unexpected migration plan; expected a transaction request.'));
}
return sendTransaction(plan);
});

if (result.isErr()) {
setMainTxState({ loading: false });
setTxError({
blocking: true,
actionBlocked: true,
rawError: result.error as Error,
error: <span>{(result.error as Error).message}</span>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
txAction: 0 as any,
});
return;
}

const submittedTxHash = result.value;
setMainTxState({ loading: true, txHash: submittedTxHash });

// Wait for the receipt on the connected chain (works on forks too).
try {
await waitForTransactionReceipt(wagmiConfig, {
hash: submittedTxHash as `0x${string}`,
chainId: targetChainId,
});
} catch (e) {
console.warn('waitForTransactionReceipt failed', e);
}

// Refresh the sGHO vault cache (new shares) and invalidate the stkGHO
// position + pool balances so both panels reflect the migration.
refresh();
await new Promise((resolve) => setTimeout(resolve, 1000));

queryClient.invalidateQueries({ queryKey: queryKeysFactory.pool });
queryClient.invalidateQueries({
queryKey: queryKeysFactory.userStakeUiData(user, marketData, stakedTokens, oracles),
});

setMainTxState({ loading: false, success: true, txHash: submittedTxHash });
};

return (
<TxActionsWrapper
requiresApproval={false}
preparingTransactions={false}
mainTxState={mainTxState}
isWrongNetwork={isWrongNetwork}
handleAction={action}
symbol="stkGHO"
actionText={<Trans>Proceed with migration</Trans>}
actionInProgressText={<Trans>Migrating</Trans>}
sx={sx}
blocked={blocked}
{...props}
/>
);
}
);

StkGhoMigrateActions.displayName = 'StkGhoMigrateActions';
24 changes: 24 additions & 0 deletions src/components/transactions/StkGhoMigrate/StkGhoMigrateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Trans } from '@lingui/macro';
import { BasicModal } from 'src/components/primitives/BasicModal';
import { ModalContextType, ModalType, useModalContext } from 'src/hooks/useModal';

import { ModalWrapper } from '../FlowCommons/ModalWrapper';
import { StkGhoMigrateModalContent } from './StkGhoMigrateModalContent';

export const StkGhoMigrateModal = () => {
const { type, close, args } = useModalContext() as ModalContextType<{
underlyingAsset: string;
}>;

return (
<BasicModal open={type === ModalType.StkGhoMigrate} setOpen={close}>
<ModalWrapper
title={<Trans>Migrate stkGHO to sGHO</Trans>}
underlyingAsset={args.underlyingAsset}
hideTitleSymbol
>
{() => <StkGhoMigrateModalContent />}
</ModalWrapper>
</BasicModal>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Stake } from '@aave/contract-helpers';
import { bigDecimal, useSghoVaultPreviewDeposit } from '@aave/react';
import { Trans } from '@lingui/macro';
import { formatEther } from 'ethers/lib/utils';
import { useRef } from 'react';
import { useUserStakeUiData } from 'src/hooks/stake/useUserStakeUiData';
import { useModalContext } from 'src/hooks/useModal';
import { useSavingsMarketData } from 'src/hooks/useSavingsMarketData';
import { useSGhoVaultContext } from 'src/modules/sGho/SGhoVaultContext';
import { useRootStore } from 'src/store/root';

import { useWeb3Context } from '../../../libs/hooks/useWeb3Context';
import { TxErrorView } from '../FlowCommons/Error';
import { TxSuccessView } from '../FlowCommons/Success';
import { DetailsNumberLineWithSub, TxModalDetails } from '../FlowCommons/TxModalDetails';
import { StkGhoMigrateActions } from './StkGhoMigrateActions';

export const StkGhoMigrateModalContent = () => {
const { chainId: connectedChainId } = useWeb3Context();
const { chainId: targetChainId, sdkChainId } = useSavingsMarketData();
const { mainTxState, txError, gasLimit } = useModalContext();

const currentMarketData = useRootStore((store) => store.currentMarketData);
const { data: stakeUserResult } = useUserStakeUiData(currentMarketData, Stake.gho);

// stkGHO is redeemable 1:1 to GHO, so the migrated GHO amount equals the
// user's full stkGHO position. We use it to preview the sGHO shares minted.
const stkGhoBalance = formatEther(stakeUserResult?.[0]?.stakeTokenRedeemableAmount || '0');

const previewAmount = +stkGhoBalance > 0 ? stkGhoBalance : '0';
const { data: previewShares, loading: previewFetching } = useSghoVaultPreviewDeposit({
amount: bigDecimal(previewAmount),
chainId: sdkChainId,
});

// USD pricing from the sGHO vault. stkGHO is 1:1 to GHO, so it's priced at the
// GHO rate (`usdPerToken`). sGHO shares appreciate (not 1:1 to GHO), so they're
// priced at the vault share price = totalAssetsUSD / totalSupply.
const { vault } = useSGhoVaultContext();
const ghoUsdPerToken = +(vault?.totalAssets?.usdPerToken ?? '1');
const totalAssetsUsd = +(vault?.totalAssets?.usd ?? '0');
const totalSupply = +(vault?.totalSupply?.value ?? '0');
const sghoUsdPerShare =
totalSupply > 0 && totalAssetsUsd > 0 ? totalAssetsUsd / totalSupply : ghoUsdPerToken;

const stkGhoUSD = (+stkGhoBalance * ghoUsdPerToken).toString();
const sghoUSD = (+(previewShares?.value ?? '0') * sghoUsdPerShare).toString();

const isWrongNetwork = connectedChainId !== targetChainId;

// Snapshot the received shares at submit time — once the tx mines the Actions
// invalidate the stake data, so `stkGhoBalance` (and thus `previewShares`)
// refetches to 0 and would otherwise blank the success view.
const receivedSharesRef = useRef<string | null>(null);
if (mainTxState.txHash && receivedSharesRef.current === null) {
receivedSharesRef.current = previewShares?.value ?? '0';
}
Comment on lines +55 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Defer the received-shares snapshot until preview data exists

If the user submits the migration while useSghoVaultPreviewDeposit is still loading, this branch runs as soon as txHash is set and permanently stores '0' because previewShares is still undefined. When the preview response arrives, the ref no longer updates, so the success view can report that the user received 0 sGHO even though the migration succeeded. Consider disabling submission until the preview is ready or only snapshotting once previewShares?.value is available.

Useful? React with 👍 / 👎.

if (!mainTxState.txHash && !mainTxState.success && receivedSharesRef.current !== null) {
receivedSharesRef.current = null;
}

if (txError && txError.blocking) return <TxErrorView txError={txError} />;
if (mainTxState.success) {
return (
<TxSuccessView
action={<Trans>received</Trans>}
amount={receivedSharesRef.current ?? previewShares?.value ?? '0'}
symbol="sGHO"
/>
);
}

return (
<>
<TxModalDetails gasLimit={gasLimit} chainId={targetChainId}>
<DetailsNumberLineWithSub
description={<Trans>Migrating</Trans>}
futureValue={stkGhoBalance}
futureValueUSD={stkGhoUSD}
symbol="stkGHO"
/>
<DetailsNumberLineWithSub
description={<Trans>You&apos;ll receive</Trans>}
futureValue={previewShares?.value ?? '0'}
futureValueUSD={sghoUSD}
symbol="sGHO"
loading={previewFetching}
/>
</TxModalDetails>

<StkGhoMigrateActions
isWrongNetwork={isWrongNetwork}
blocked={+stkGhoBalance <= 0}
sx={{ mt: '48px' }}
/>
</>
);
};
4 changes: 3 additions & 1 deletion src/hooks/compliance/service-compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export type ComplianceCheckResponse = {

export const checkCompliance = async (address: string): Promise<ComplianceCheckResponse> => {
try {
const res = await fetch(`/api/preflight-compliance?address=${encodeURIComponent(address)}`);
// NOTE: trailing slash is required because next.config.js sets `trailingSlash: true`.
// Without it Next issues a 308 redirect that loops -> ERR_TOO_MANY_REDIRECTS.
const res = await fetch(`/api/preflight-compliance/?address=${encodeURIComponent(address)}`);
const data = await res.json();

if (res.ok) {
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/useModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum ModalType {
SavingsGhoWithdraw,
SGhoVaultDeposit,
SGhoVaultWithdraw,
StkGhoMigrate,
CancelCowOrder,

// Swaps
Expand Down Expand Up @@ -151,6 +152,7 @@ export interface ModalContextType<T extends ModalArgsType> {
openSavingsGhoWithdraw: () => void;
openSGhoVaultDeposit: () => void;
openSGhoVaultWithdraw: () => void;
openStkGhoMigrate: () => void;
openCancelCowOrder: (
transaction: TransactionHistoryItem<SwapActionFields[ActionName.Swap]>
) => void;
Expand Down Expand Up @@ -439,6 +441,11 @@ export const ModalContextProvider: React.FC<PropsWithChildren> = ({ children })
setType(ModalType.SGhoVaultWithdraw);
setArgs({ underlyingAsset: AaveV3Ethereum.ASSETS.GHO.UNDERLYING.toLowerCase() });
},
openStkGhoMigrate: () => {
trackEvent(GENERAL.OPEN_MODAL, { modal: 'stkGHO to sGHO Migration' });
setType(ModalType.StkGhoMigrate);
setArgs({ underlyingAsset: AaveV3Ethereum.ASSETS.GHO.UNDERLYING.toLowerCase() });
},
openCancelCowOrder: (transaction) => {
trackEvent(GENERAL.OPEN_MODAL, {
modal: 'Cancel CoW Order',
Expand Down
2 changes: 1 addition & 1 deletion src/locales/el/messages.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/locales/en/messages.js

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions src/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -2020,6 +2020,10 @@ msgstr "Can't validate the wallet address. Try again."
msgid "GHO balance"
msgstr "GHO balance"

#: src/components/transactions/StkGhoMigrate/StkGhoMigrateModalContent.tsx
msgid "received"
msgstr "received"

#: pages/dashboard.page.tsx
#: src/components/transactions/Borrow/BorrowModal.tsx
#: src/modules/dashboard/lists/BorrowAssetsList/BorrowAssetsListItem.tsx
Expand Down Expand Up @@ -2363,6 +2367,7 @@ msgstr "VIEW"

#: src/components/transactions/SGhoVault/SGhoVaultDepositModalContent.tsx
#: src/components/transactions/SGhoVault/SGhoVaultWithdrawModalContent.tsx
#: src/components/transactions/StkGhoMigrate/StkGhoMigrateModalContent.tsx
msgid "You'll receive"
msgstr "You'll receive"

Expand Down Expand Up @@ -3220,6 +3225,10 @@ msgstr "To repay on behalf of a user an explicit amount to repay is needed"
msgid "Repayment amount to reach {0}% utilization"
msgstr "Repayment amount to reach {0}% utilization"

#: src/components/transactions/StkGhoMigrate/StkGhoMigrateActions.tsx
msgid "Proceed with migration"
msgstr "Proceed with migration"

#: src/modules/umbrella/UmbrellaModalContent.tsx
msgid "You can not stake this amount because it will cause collateral call"
msgstr "You can not stake this amount because it will cause collateral call"
Expand Down Expand Up @@ -3427,6 +3436,10 @@ msgstr "GHO yield with instant withdraws."
msgid "Both"
msgstr "Both"

#: src/components/transactions/StkGhoMigrate/StkGhoMigrateModal.tsx
msgid "Migrate stkGHO to sGHO"
msgstr "Migrate stkGHO to sGHO"

#: src/components/SecondsToString.tsx
msgid "{h}h"
msgstr "{h}h"
Expand Down Expand Up @@ -3938,6 +3951,8 @@ msgstr "Tip: Try improving your order parameters"

#: src/components/transactions/MigrateV3/MigrateV3Actions.tsx
#: src/components/transactions/StakingMigrate/StakingMigrateActions.tsx
#: src/components/transactions/StkGhoMigrate/StkGhoMigrateActions.tsx
#: src/components/transactions/StkGhoMigrate/StkGhoMigrateModalContent.tsx
msgid "Migrating"
msgstr "Migrating"

Expand Down Expand Up @@ -4185,6 +4200,7 @@ msgstr "Flashloan is disabled for this asset, hence this position cannot be migr
#: src/components/transactions/StakingMigrate/StakingMigrateActions.tsx
#: src/modules/markets/Gho/GhoBanner.tsx
#: src/modules/markets/Gho/GhoBanner.tsx
#: src/modules/stkGho/StkGhoDepositRow.tsx
msgid "Migrate"
msgstr "Migrate"

Expand Down
2 changes: 1 addition & 1 deletion src/locales/es/messages.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/locales/fr/messages.js

Large diffs are not rendered by default.

Loading
Loading