diff --git a/src/components/crosschain/WithdrawForm.tsx b/src/components/crosschain/WithdrawForm.tsx index 640ebb2..51bde04 100644 --- a/src/components/crosschain/WithdrawForm.tsx +++ b/src/components/crosschain/WithdrawForm.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useAccount, useChainId } from "wagmi"; import { toast } from "sonner"; import { MIDEN_VIRTUAL_CHAIN_ID } from "@epoch-protocol/epoch-intents-sdk"; @@ -20,6 +20,7 @@ import { import { WithdrawTokenFields } from "./withdraw/WithdrawTokenFields"; import { WithdrawAccountFields } from "./withdraw/WithdrawAccountFields"; import { WithdrawQuoteSummary } from "./withdraw/WithdrawQuoteSummary"; +import type { ResourceLockStatus } from "@epoch-protocol/epoch-intents-sdk"; interface Props { accounts: MidenAccount[]; @@ -30,6 +31,7 @@ interface Props { isFetchingQuote: boolean; isLoading: boolean; isSDKReady: boolean; + resourceLockStatus?: ResourceLockStatus | null; } export function WithdrawForm({ @@ -41,6 +43,7 @@ export function WithdrawForm({ isFetchingQuote, isLoading, isSDKReady, + resourceLockStatus, }: Props) { const [evmToken, setEvmToken] = useState(WITHDRAW_TOKENS[0].address); const [customToken, setCustomToken] = useState(""); @@ -51,6 +54,22 @@ export function WithdrawForm({ ); const [status, setStatus] = useState(""); + useEffect(() => { + if (!resourceLockStatus) return; + const labels: Record = { + "compact-prepared": "Preparing Compact deposit…", + "awaiting-approval": "Approve token spending in your wallet…", + "approval-confirmed": "Approval confirmed — preparing Compact deposit…", + "awaiting-deposit": "Confirm Compact deposit in your wallet…", + "deposit-confirmed": + "Deposit confirmed — submitting the intent to Epoch…", + "submitting-allocation": + "Deposit confirmed — submitting the intent to Epoch…", + "allocation-accepted": "Intent accepted — polling for Miden settlement…", + }; + setStatus(labels[resourceLockStatus.phase]); + }, [resourceLockStatus]); + const { address: connectedAddress } = useAccount(); const walletChainId = useChainId(); @@ -124,8 +143,8 @@ export function WithdrawForm({ // Two-stage toast: (1) Compact deposit signature/confirmation, // (2) SIO Miden settlement — dismissed by parent WithdrawTab once the // synthetic Miden row appears in the status poll. - setStatus("Awaiting Compact deposit signature in wallet…"); - toast.loading("Sign Compact deposit in wallet…", { + setStatus("Preparing Compact deposit…"); + toast.loading("Preparing Compact deposit…", { id: WITHDRAW_DEPOSIT_TOAST_ID, }); try { diff --git a/src/components/tabs/WithdrawTab.tsx b/src/components/tabs/WithdrawTab.tsx index cecf10f..3580468 100644 --- a/src/components/tabs/WithdrawTab.tsx +++ b/src/components/tabs/WithdrawTab.tsx @@ -85,6 +85,7 @@ export function WithdrawTab() { isFetchingQuote={withdraw.isFetchingQuote} isLoading={withdraw.isLoading} isSDKReady={withdraw.isSDKReady} + resourceLockStatus={withdraw.resourceLockStatus} /> (null); + const [resourceLockStatus, setResourceLockStatus] = + useState(null); + const pendingRequestRef = useRef(null); const sdk = useEpochSdk(); const { data: walletClient } = useWalletClient(); @@ -29,6 +43,39 @@ export function useWithdrawIntent() { // Read at render — inside the callback a stale client stamped the old chain. const depositChainId = walletClient?.chain?.id; + // A reload after the wallet confirms must only retry the idempotent allocator + // submission. It must never reconstruct or re-send the on-chain deposit. + useEffect(() => { + if (!sdk || typeof window === "undefined") return; + const raw = window.localStorage.getItem(PENDING_ALLOCATION_KEY); + if (!raw) return; + let pending: PendingAllocation; + try { + pending = JSON.parse(raw) as PendingAllocation; + } catch { + window.localStorage.removeItem(PENDING_ALLOCATION_KEY); + return; + } + setResourceLockStatus({ + phase: "deposit-confirmed", + chainId: Number(pending.request.chainId), + claimHash: "0x" as `0x${string}`, + transactionHash: pending.depositHash as `0x${string}`, + }); + void sdk + .submitAllocation(pending.request) + .then(() => { + setResourceLockStatus((current) => + current ? { ...current, phase: "allocation-accepted" } : current, + ); + window.localStorage.removeItem(PENDING_ALLOCATION_KEY); + }) + .catch(() => { + // Keep the record: a later reload or explicit retry can safely resend + // this exact request without asking the wallet to sign again. + }); + }, [sdk]); + const fetchQuote = useCallback( async (params: EVMToMidenIntentParams) => { if (!sdk) @@ -67,10 +114,36 @@ export function useWithdrawIntent() { if (!pendingQuote) throw new Error("Fetch a quote first"); setWithdrawResult(null); + setResourceLockStatus(null); const result = await buildEVMToMidenIntent(sdk, { ...pendingQuote.params, evmSourceAddress: address, preFetchedQuote: pendingQuote, + onResourceLockStatus: (next) => { + if (next.compactRequest) + pendingRequestRef.current = next.compactRequest; + setResourceLockStatus(next); + if ( + next.phase === "deposit-confirmed" && + next.transactionHash && + pendingRequestRef.current && + typeof window !== "undefined" + ) { + window.localStorage.setItem( + PENDING_ALLOCATION_KEY, + JSON.stringify({ + request: pendingRequestRef.current, + depositHash: next.transactionHash, + } satisfies PendingAllocation), + ); + } + if ( + next.phase === "allocation-accepted" && + typeof window !== "undefined" + ) { + window.localStorage.removeItem(PENDING_ALLOCATION_KEY); + } + }, }); const { nonce } = extractIntentIdentity(result); @@ -104,6 +177,7 @@ export function useWithdrawIntent() { isLoading, isFetchingQuote, error: quoteError ?? confirmError?.message ?? null, + resourceLockStatus, address, isSDKReady: !!sdk, }; diff --git a/src/services/epoch-bridge.ts b/src/services/epoch-bridge.ts index 11a623d..2d2a7d7 100644 --- a/src/services/epoch-bridge.ts +++ b/src/services/epoch-bridge.ts @@ -231,7 +231,10 @@ export async function getEVMToMidenQuote( export async function buildEVMToMidenIntent( sdk: EpochIntentSDK, - params: EVMToMidenIntentParams & { preFetchedQuote?: EVMToMidenQuote }, + params: EVMToMidenIntentParams & { + preFetchedQuote?: EVMToMidenQuote; + onResourceLockStatus?: SolveIntentParams["onResourceLockStatus"]; + }, ): Promise { let taskTypeString: string; let intentData: unknown; @@ -252,6 +255,7 @@ export async function buildEVMToMidenIntent( intentData, quoteResult, collateralType: "evm" as CollateralType, + onResourceLockStatus: params.onResourceLockStatus, }); return {