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
25 changes: 22 additions & 3 deletions src/components/crosschain/WithdrawForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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[];
Expand All @@ -30,6 +31,7 @@ interface Props {
isFetchingQuote: boolean;
isLoading: boolean;
isSDKReady: boolean;
resourceLockStatus?: ResourceLockStatus | null;
}

export function WithdrawForm({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-high-complexity-react-function (warning)

WithdrawForm has cyclomatic complexity 20, cognitive complexity 15, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.

Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.

Docs

Expand All @@ -41,6 +43,7 @@ export function WithdrawForm({
isFetchingQuote,
isLoading,
isSDKReady,
resourceLockStatus,
}: Props) {
const [evmToken, setEvmToken] = useState(WITHDRAW_TOKENS[0].address);
const [customToken, setCustomToken] = useState("");
Expand All @@ -51,6 +54,22 @@ export function WithdrawForm({
);
const [status, setStatus] = useState("");

useEffect(() => {
if (!resourceLockStatus) return;
const labels: Record<ResourceLockStatus["phase"], string> = {
"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();

Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/components/tabs/WithdrawTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function WithdrawTab() {
isFetchingQuote={withdraw.isFetchingQuote}
isLoading={withdraw.isLoading}
isSDKReady={withdraw.isSDKReady}
resourceLockStatus={withdraw.resourceLockStatus}
/>
<IntentStatus
result={withdraw.withdrawResult}
Expand Down
76 changes: 75 additions & 1 deletion src/hooks/useWithdrawIntent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAccount, useWalletClient } from "wagmi";
import {
Expand All @@ -9,6 +9,17 @@ import {
import type { EVMToMidenIntentParams, IntentResult } from "../types/miden";
import { useEpochSdk } from "../lib/epoch-sdk";
import { extractIntentIdentity } from "../lib/intent-result";
import type {
CompactRequest,
ResourceLockStatus,
} from "@epoch-protocol/epoch-intents-sdk";

const PENDING_ALLOCATION_KEY = "epoch:miden:pending-allocation:v1";

type PendingAllocation = {
request: CompactRequest;
depositHash: string;
};

/** Mirrors useEpochIntent: quoting is a read (useState), confirming is a mutation. */
export function useWithdrawIntent() {
Expand All @@ -20,6 +31,9 @@ export function useWithdrawIntent() {
);
const [isFetchingQuote, setIsFetchingQuote] = useState(false);
const [quoteError, setQuoteError] = useState<string | null>(null);
const [resourceLockStatus, setResourceLockStatus] =
useState<ResourceLockStatus | null>(null);
const pendingRequestRef = useRef<CompactRequest | null>(null);

const sdk = useEpochSdk();
const { data: walletClient } = useWalletClient();
Expand All @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -104,6 +177,7 @@ export function useWithdrawIntent() {
isLoading,
isFetchingQuote,
error: quoteError ?? confirmError?.message ?? null,
resourceLockStatus,
address,
isSDKReady: !!sdk,
};
Expand Down
6 changes: 5 additions & 1 deletion src/services/epoch-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IntentResult> {
let taskTypeString: string;
let intentData: unknown;
Expand All @@ -252,6 +255,7 @@ export async function buildEVMToMidenIntent(
intentData,
quoteResult,
collateralType: "evm" as CollateralType,
onResourceLockStatus: params.onResourceLockStatus,
});

return {
Expand Down
Loading