From 64808b89d6a29ff728c773a2d6c77ae6532e325f Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Mon, 3 Aug 2026 11:31:01 +0530 Subject: [PATCH 1/3] feat(miden): private/public collateral note selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the sponsor choose whether the P2IDE note backing a Miden→EVM intent is private or public, and threads the choice through the note factory to the SDK. useMidenPrivateNotesSupport reads midenPrivateNotesSupported from GET /miden-recipient and disables the option when the allocator does not advertise it. It blocks rather than silently downgrading to public: a private note whose body the allocator cannot accept can be neither validated nor consumed, and a private P2IDE whose body is lost can never be reclaimed either — the serial number is random and reclaiming means consuming. An allocator that omits the flag reads as unsupported. --- src/components/crosschain/IntentForm.tsx | 43 +++++++++++-- .../intent/IntentNoteVisibilityField.tsx | 64 +++++++++++++++++++ src/hooks/useEpochIntent.ts | 14 +++- src/hooks/useMidenP2IDNoteFactory.ts | 27 +++++++- src/hooks/useMidenPrivateNotesSupport.ts | 35 ++++++++++ src/services/epoch-bridge.ts | 3 + 6 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 src/components/crosschain/intent/IntentNoteVisibilityField.tsx create mode 100644 src/hooks/useMidenPrivateNotesSupport.ts diff --git a/src/components/crosschain/IntentForm.tsx b/src/components/crosschain/IntentForm.tsx index 42a7b7a..fbd3e8d 100644 --- a/src/components/crosschain/IntentForm.tsx +++ b/src/components/crosschain/IntentForm.tsx @@ -1,7 +1,10 @@ import { useState } from "react"; import { useAccount, useChainId } from "wagmi"; import { toast } from "sonner"; -import type { SolveIntentParams } from "@epoch-protocol/epoch-intents-sdk"; +import type { + MidenNoteVisibility, + SolveIntentParams, +} from "@epoch-protocol/epoch-intents-sdk"; import type { CrossChainIntentParams, MidenAssetOption, @@ -20,12 +23,14 @@ import { } from "../../lib/intent-result"; import { useIntentSettlementView } from "../../hooks/useIntentSettlementView"; import { useMidenP2IDNoteFactory } from "../../hooks/useMidenP2IDNoteFactory"; +import { useMidenPrivateNotesSupport } from "../../hooks/useMidenPrivateNotesSupport"; import { Button } from "@/components/ui/button"; import { IntentSourceAssetField } from "./intent/IntentSourceAssetField"; import { IntentDestinationFields, type IntentDestination, } from "./intent/IntentDestinationFields"; +import { IntentNoteVisibilityField } from "./intent/IntentNoteVisibilityField"; import { IntentQuoteSummary } from "./intent/IntentQuoteSummary"; import { SettlementPendingCard } from "./intent/SettlementPendingCard"; import { ExplorerHashCard } from "./intent/ExplorerHashCard"; @@ -35,9 +40,10 @@ interface Props { midenAssets: MidenAssetOption[]; isLoadingMidenAssets: boolean; onFetchQuote: (params: CrossChainIntentParams) => Promise; - onConfirmIntent: ( - createMidenP2IDNote: SolveIntentParams["createMidenP2IDNote"], - ) => Promise; + onConfirmIntent: (args: { + createMidenP2IDNote: SolveIntentParams["createMidenP2IDNote"]; + midenNoteVisibility: SolveIntentParams["midenNoteVisibility"]; + }) => Promise; onClearQuote: () => void; quotePhase: IntentQuotePhase; isSDKReady: boolean; @@ -120,6 +126,17 @@ export function IntentForm({ onNoteCreated: setLocalMidenNoteId, }); + const { isSupported: isPrivateSupported, isLoading: isLoadingSupport } = + useMidenPrivateNotesSupport(); + const [noteVisibility, setNoteVisibility] = + useState("public"); + // The select disables "private" when unsupported, but that state can go stale + // (allocator config change, a different backend). Block the submit rather than + // quietly downgrading: silently publishing the account and amount someone + // asked to keep off-chain is worse than refusing. + const privateUnavailable = + noteVisibility === "private" && !isPrivateSupported; + const buildParams = (): CrossChainIntentParams => { if (!destination.evmAddress) { throw new Error("Connect EVM wallet first"); @@ -176,11 +193,20 @@ export function IntentForm({ const handleConfirm = () => { if (quotePhase.status !== "ready") return; + if (privateUnavailable) { + toast.error( + "This allocator cannot accept private notes yet. Switch to public, or point at an allocator that advertises midenPrivateNotesSupported.", + ); + return; + } void toast.promise( (async () => { setConfirmStatus("Submitting intent…"); - const result = await onConfirmIntent(createMidenP2IDNote); + const result = await onConfirmIntent({ + createMidenP2IDNote, + midenNoteVisibility: noteVisibility, + }); const solverError = readIntentError(result); if (solverError) throw new Error(solverError); @@ -243,6 +269,13 @@ export function IntentForm({ onChange={editDestination} /> + + {activeQuote && ( void; + /** Allocator advertises `midenPrivateNotesSupported` — see useMidenPrivateNotesSupport. */ + isPrivateSupported: boolean; + isLoadingSupport: boolean; +} + +export function IntentNoteVisibilityField({ + value, + onSelect, + isPrivateSupported, + isLoadingSupport, +}: Props) { + return ( +
+ + onSelect(v as MidenNoteVisibility)} + > + + + + + Public — full note on Miden + + Private — only the commitment on Miden + {isPrivateSupported ? "" : " (allocator unsupported)"} + + + +

+ {value === "private" ? ( + <> + Your Miden account, the faucet and the amount stay off-chain. The + note body is sent to the allocator with the intent — it is the only + copy, so a failed submission strands the collateral. + + ) : ( + <> + The note's target account, faucet and amount are published to + Miden's note database and readable by anyone. + {isLoadingSupport + ? " Checking whether the allocator supports private notes…" + : isPrivateSupported + ? "" + : " This allocator cannot accept private notes yet."} + + )} +

+
+ ); +} diff --git a/src/hooks/useEpochIntent.ts b/src/hooks/useEpochIntent.ts index 1794e17..05220dc 100644 --- a/src/hooks/useEpochIntent.ts +++ b/src/hooks/useEpochIntent.ts @@ -15,6 +15,12 @@ import { import { useEpochSdk } from "../lib/epoch-sdk"; import { readIntentError } from "../lib/intent-result"; +/** What the form hands over at confirm time — the minter plus how to mint. */ +export interface ConfirmIntentArgs { + createMidenP2IDNote: SolveIntentParams["createMidenP2IDNote"]; + midenNoteVisibility: SolveIntentParams["midenNoteVisibility"]; +} + export type IntentQuotePhase = | { status: "idle" } | { status: "fetching" } @@ -60,9 +66,10 @@ export function useEpochIntent() { isPending: isConfirming, error: confirmError, } = useMutation({ - mutationFn: async ( - createMidenP2IDNote: SolveIntentParams["createMidenP2IDNote"], - ) => { + mutationFn: async ({ + createMidenP2IDNote, + midenNoteVisibility, + }: ConfirmIntentArgs) => { if (!sdk) throw new Error("Epoch SDK not ready"); if (!pendingQuote) throw new Error("Fetch a quote first"); @@ -72,6 +79,7 @@ export function useEpochIntent() { collateralType: CollateralType.Miden, midenSourceAccount: pendingQuote.params.midenAccountId, createMidenP2IDNote, + midenNoteVisibility, preFetchedQuote: pendingQuote, }); // Set before throwing: an in-band failure still has a result worth showing. diff --git a/src/hooks/useMidenP2IDNoteFactory.ts b/src/hooks/useMidenP2IDNoteFactory.ts index 535b155..00beaab 100644 --- a/src/hooks/useMidenP2IDNoteFactory.ts +++ b/src/hooks/useMidenP2IDNoteFactory.ts @@ -39,6 +39,11 @@ function toAccountId(id: string): AccountId { * attachment, so we build a custom `TransactionRequest` whose output note is a * P2IDE note created with `Note.createP2IDENote(..., reclaim, type, attachment)` * — the one API that supports reclaim + attachment together. + * + * Visibility is chosen by the SDK, not here, so it always matches what the + * allocator was told to expect. For a private mint the serialized note is + * returned alongside the id: the chain stores only the commitment, so the + * allocator has no other way to validate or later consume it. */ export function useMidenP2IDNoteFactory({ midenAccountId, @@ -57,8 +62,12 @@ export function useMidenP2IDNoteFactory({ allocatorId, recallBlocks, bindingAttachmentFelts, + noteVisibility, ) => { - onStatus("Resource lock required — creating P2IDE note on Miden…"); + const isPrivate = noteVisibility === "private"; + onStatus( + `Resource lock required — creating ${isPrivate ? "private" : "public"} P2IDE note on Miden…`, + ); try { if (!midenAccountId) { throw new Error("Missing Miden account id"); @@ -100,11 +109,23 @@ export function useMidenP2IDNoteFactory({ assets, reclaimHeight, undefined, // no time-lock - NoteType.Public, + isPrivate ? NoteType.Private : NoteType.Public, attachment, ); const noteId = note.id().toString(); + // A private note publishes only its commitment, so these bytes are the + // only readable copy of the body. Serialize BEFORE submitting: if the + // note lands on-chain and the body is lost, the collateral is gone for + // good — reclaiming a P2IDE means consuming it, which needs this data, + // and the serial number is random. + const noteBytes = isPrivate ? note.serialize() : undefined; + if (isPrivate && !noteBytes?.length) { + throw new Error( + "Could not serialize the private note — refusing to mint a note whose body cannot be recovered", + ); + } + const txRequest = new TransactionRequestBuilder() .withOwnOutputNotes(new NoteArray([note])) .build(); @@ -131,7 +152,7 @@ export function useMidenP2IDNoteFactory({ } onNoteCreated(noteId); - return { success: true, noteId }; + return { success: true, noteId, noteBytes }; } catch (err) { return { success: false, diff --git a/src/hooks/useMidenPrivateNotesSupport.ts b/src/hooks/useMidenPrivateNotesSupport.ts new file mode 100644 index 0000000..19b68d2 --- /dev/null +++ b/src/hooks/useMidenPrivateNotesSupport.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { EPOCH_API_BASE_URL } from "../lib/epoch-sdk"; + +/** + * Whether the allocator can accept a PRIVATE collateral note. + * + * A private note publishes only its commitment, so the allocator needs the note + * body sent alongside the intent to validate it and later consume it. An + * allocator that does not accept `midenNoteBytes` cannot do either, and the + * minted note would be stranded — the SDK refuses such a mint, and this hook + * lets the UI disable the option instead of failing at confirm time. + * + * Older allocators omit the flag entirely, which reads as unsupported. + */ +export function useMidenPrivateNotesSupport(): { + isSupported: boolean; + isLoading: boolean; +} { + const { data, isLoading } = useQuery({ + queryKey: ["midenPrivateNotesSupport", EPOCH_API_BASE_URL], + queryFn: async (): Promise => { + const res = await fetch(`${EPOCH_API_BASE_URL}/miden-recipient`); + if (!res.ok) return false; + const json = (await res.json()) as { + midenPrivateNotesSupported?: boolean; + }; + return json?.midenPrivateNotesSupported === true; + }, + // Allocator capability is static for a deployment; don't refetch on focus. + staleTime: 5 * 60_000, + retry: 1, + }); + + return { isSupported: data === true, isLoading }; +} diff --git a/src/services/epoch-bridge.ts b/src/services/epoch-bridge.ts index 193c4b1..f440f3d 100644 --- a/src/services/epoch-bridge.ts +++ b/src/services/epoch-bridge.ts @@ -303,6 +303,8 @@ export async function buildCrossChainIntent( collateralType?: CollateralType; midenSourceAccount?: string; createMidenP2IDNote?: SolveIntentParams["createMidenP2IDNote"]; + /** Defaults to "public" in the SDK when omitted. */ + midenNoteVisibility?: SolveIntentParams["midenNoteVisibility"]; /** Pre-fetched quote from getCrossChainQuote — skips getTaskData step. */ preFetchedQuote?: CrossChainQuote; }, @@ -333,6 +335,7 @@ export async function buildCrossChainIntent( midenFaucetId: midenFaucetIdHex, midenSourceAccount: midenSourceHex, createMidenP2IDNote: params.createMidenP2IDNote, + midenNoteVisibility: params.midenNoteVisibility, }); return { From b48d2196f9c7d53f965c3d833c5334aa284c227b Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Tue, 4 Aug 2026 02:25:31 +0530 Subject: [PATCH 2/3] feat(miden): private payout selector and note recovery on the withdraw tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Payout note visibility" selector to the EVM→Miden withdraw flow and the two ways a user gets the note body back. Private is opt-in, never inherited: the note publishes only a commitment, so its body is the only thing that can consume it. Changing the selection clears any pending quote, since the choice is part of the signed mandate and a stale quote would carry the claim hash for the other visibility. WithdrawNoteFileCard hands the body over at settlement — download as the exact binary a wallet imports, or copy the base64. RecoverNotesCard covers the case that cannot be solved on the device: the intent nonce lives only in the browser and is not on-chain, so a user on a new machine cannot ask for their own note. One signature lists their past intents and re-downloads any private body. Sign-out revokes the session server-side — clearing local state alone would leave a valid bearer session for its full life. All of it goes through the SDK rather than raw fetch. The EIP-4361 message has to byte-match the allocator, and an app-local copy is a landmine for anyone integrating from this codebase. Copy states plainly that this hides the payout from other chain observers, not from Epoch, which mints it. --- src/components/crosschain/IntentStatus.tsx | 2 + .../crosschain/QuoteSummaryCard.tsx | 6 + src/components/crosschain/WithdrawForm.tsx | 15 ++ .../crosschain/withdraw/RecoverNotesCard.tsx | 180 ++++++++++++++++++ .../withdraw/WithdrawNoteFileCard.tsx | 80 ++++++++ .../withdraw/WithdrawNoteVisibilityField.tsx | 65 +++++++ .../withdraw/WithdrawQuoteSummary.tsx | 29 +++ src/components/tabs/WithdrawTab.tsx | 12 ++ src/hooks/useEpochSession.ts | 54 ++++++ src/hooks/useIntentFlowStatus.ts | 2 + src/lib/note-file.ts | 15 ++ src/services/epoch-bridge.ts | 22 +++ src/types/miden.ts | 8 + 13 files changed, 490 insertions(+) create mode 100644 src/components/crosschain/withdraw/RecoverNotesCard.tsx create mode 100644 src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx create mode 100644 src/components/crosschain/withdraw/WithdrawNoteVisibilityField.tsx create mode 100644 src/hooks/useEpochSession.ts create mode 100644 src/lib/note-file.ts diff --git a/src/components/crosschain/IntentStatus.tsx b/src/components/crosschain/IntentStatus.tsx index 9970627..b7eda9d 100644 --- a/src/components/crosschain/IntentStatus.tsx +++ b/src/components/crosschain/IntentStatus.tsx @@ -14,6 +14,8 @@ export interface IntentFlowStatus { midenTxId?: string; midenStatus?: string; midenNoteId?: string; + /** Base64 NoteFile — private EVM→Miden payouts only. */ + midenNoteBytes?: string; latestStatusLabel?: string; latestChainId?: string; statusCount?: number; diff --git a/src/components/crosschain/QuoteSummaryCard.tsx b/src/components/crosschain/QuoteSummaryCard.tsx index 6e5d534..6d958ea 100644 --- a/src/components/crosschain/QuoteSummaryCard.tsx +++ b/src/components/crosschain/QuoteSummaryCard.tsx @@ -1,9 +1,13 @@ +import type { ReactNode } from "react"; + interface Props { amountText: string; /** "Miden" or "EVM" — which wallet must hold the funds. */ walletNoun: string; clearLabel: string; onClear: () => void; + /** Rendered under the amount, above the reminder. */ + detail?: ReactNode; } export function QuoteSummaryCard({ @@ -11,6 +15,7 @@ export function QuoteSummaryCard({ walletNoun, clearLabel, onClear, + detail, }: Props) { return (
@@ -32,6 +37,7 @@ export function QuoteSummaryCard({ {amountText}

+ {detail}

Keep at least this amount in your {walletNoun} wallet before confirming.

diff --git a/src/components/crosschain/WithdrawForm.tsx b/src/components/crosschain/WithdrawForm.tsx index 47bfbe0..aae326d 100644 --- a/src/components/crosschain/WithdrawForm.tsx +++ b/src/components/crosschain/WithdrawForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { MidenNoteVisibility } from "@epoch-protocol/epoch-intents-sdk"; import { useAccount, useChainId } from "wagmi"; import { toast } from "sonner"; import { MIDEN_VIRTUAL_CHAIN_ID } from "@epoch-protocol/epoch-intents-sdk"; @@ -17,6 +18,7 @@ import { WITHDRAW_SETTLE_TOAST_ID, } from "./withdraw/withdraw-toasts"; import { WithdrawTokenFields } from "./withdraw/WithdrawTokenFields"; +import { WithdrawNoteVisibilityField } from "./withdraw/WithdrawNoteVisibilityField"; import { WithdrawAccountFields } from "./withdraw/WithdrawAccountFields"; import { WithdrawQuoteSummary } from "./withdraw/WithdrawQuoteSummary"; @@ -49,6 +51,8 @@ export function WithdrawForm({ "0xfc90f0f4da30e51168453b60eafed7", ); const [status, setStatus] = useState(""); + const [noteVisibility, setNoteVisibility] = + useState("public"); const { address: connectedAddress } = useAccount(); const walletChainId = useChainId(); @@ -79,6 +83,7 @@ export function WithdrawForm({ midenRecipientId, midenFaucetId: resolvedFaucetId, minTokenOut: minTokenOut.trim(), + midenNoteVisibility: noteVisibility, }; }; @@ -194,6 +199,16 @@ export function WithdrawForm({ }} /> + { + setNoteVisibility(v); + // The choice is part of the signed mandate, so a stale quote would + // register a claim hash for the OTHER visibility. + onClearQuote(); + }} + /> + (null); + const [busy, setBusy] = useState(null); + + const load = useCallback( + async (id: string) => { + try { + if (!sdk) return; + setIntents(await sdk.listMyIntents(id)); + } catch (err) { + const msg = err instanceof Error ? err.message : "Lookup failed"; + toast.error(msg); + // A rejected session is usually an expired one. + if (/session/i.test(msg)) void signOut(); + } + }, + [sdk, signOut], + ); + + const handleSignIn = async () => { + try { + await load(await signIn()); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Sign-in failed"); + } + }; + + const handleFetch = async (nonce: string) => { + if (!sessionId || !sdk) return; + setBusy(nonce); + try { + const note = await sdk.getIntentNote(sessionId, nonce); + if (!note) { + toast.info("That payout was public — nothing to download"); + return; + } + downloadNoteFile(note.midenNoteBytes, note.midenNoteId); + toast.success("Note file saved"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Could not fetch note"); + } finally { + setBusy(null); + } + }; + + if (!connectedAddress) return null; + + const recoverable = intents?.filter(mayHaveRecoverableNote) ?? []; + + return ( +
+
+

+ Lost a private note? +

+

+ Sign a message to prove this wallet is yours, and we will list your + past intents so you can re-download any private note file. +

+
+ + {!sessionId ? ( + + ) : ( +
+
+ + +
+ + {intents?.length === 0 && ( +

No past intents found.

+ )} + + {intents && intents.length > 0 && recoverable.length === 0 && ( +

+ No recoverable payouts — only an EVM→Miden intent mints a private + note file. +

+ )} + + {recoverable.length > 0 && ( +
    + {recoverable.map((it) => ( +
  • + + {/* Defensive String(): a bad field must degrade one row, + not blank the whole recovery card. */} + {it.midenNoteId + ? `${String(it.midenNoteId).slice(0, 18)}…` + : `intent ${String(it.nonce).slice(0, 12)}…`} + + + {new Date(it.createdAt).toLocaleDateString()} + + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx b/src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx new file mode 100644 index 0000000..61c3bc2 --- /dev/null +++ b/src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { downloadNoteFile } from "../../../lib/note-file"; + +interface Props { + /** Base64 `NoteFile` from the Miden settlement row. Absent for public payouts. */ + noteBytes?: string; + noteId?: string; +} + +/** The body of a PRIVATE payout note — the only thing that can claim it. */ +export function WithdrawNoteFileCard({ noteBytes, noteId }: Props) { + const [saved, setSaved] = useState(false); + if (!noteBytes) return null; + + const fileName = `miden-note-${(noteId ?? "payout").replace(/^0x/, "").slice(0, 16)}.mno`; + + const handleDownload = () => { + try { + downloadNoteFile(noteBytes, noteId); + setSaved(true); + toast.success(`Note file saved · ${fileName}`); + } catch (err) { + toast.error( + `Could not save the note file: ${err instanceof Error ? err.message : "unknown error"}`, + ); + } + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(noteBytes); + setSaved(true); + toast.success("Note file copied as base64"); + } catch { + toast.error("Clipboard blocked — use Download instead"); + } + }; + + return ( +
+
+

+ {saved ? "Note file saved" : "Save your note file"} +

+

+ This is a private note, so the chain publishes only a + commitment — this file is the only way to claim it. Import it into the + Miden wallet that owns the recipient account. The note stays yours + indefinitely; if you lose the file, use + Recover my notes below to fetch it again. +

+
+ +
+ + +
+ +
+ + Show raw base64 ({noteBytes.length} chars) + +

+ {noteBytes} +

+
+
+ ); +} diff --git a/src/components/crosschain/withdraw/WithdrawNoteVisibilityField.tsx b/src/components/crosschain/withdraw/WithdrawNoteVisibilityField.tsx new file mode 100644 index 0000000..4743a72 --- /dev/null +++ b/src/components/crosschain/withdraw/WithdrawNoteVisibilityField.tsx @@ -0,0 +1,65 @@ +import { Label } from "@/components/ui/label"; +import { + SelectContent, + SelectItem, + SelectRoot, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { MidenNoteVisibility } from "@epoch-protocol/epoch-intents-sdk"; + +interface Props { + value: MidenNoteVisibility; + onSelect: (value: MidenNoteVisibility) => void; +} + +/** + * Visibility of the Miden note the user RECEIVES. + * + * Distinct from the deposit-side selector: there the user mints their own + * collateral note, here the allocator mints the payout. The choice is signed + * into the mandate, so no service in the path can quietly downgrade it. + * + * No allocator capability check — unlike the collateral direction, the + * allocator is the one minting, and it always supports both. + */ +export function WithdrawNoteVisibilityField({ value, onSelect }: Props) { + const isPrivate = value === "private"; + return ( +
+ + onSelect(v as MidenNoteVisibility)} + > + + + + + + Public — visible on the Miden chain + + + Private — only a commitment is published + + + + + {isPrivate ? ( + // Deliberately blunt. The note body is the only way to claim a private + // payout: until it is imported, the funds exist only in our database. +

+ Save the note file after settlement. A private note + publishes only a commitment, so the file is the only way to claim it. + The note stays yours indefinitely, and you can fetch the file again + any time with Recover my notes. This hides the payout + from other chain observers — not from Epoch, which mints it. +

+ ) : ( +

+ The note is readable on-chain, so it can always be found and claimed. +

+ )} +
+ ); +} diff --git a/src/components/crosschain/withdraw/WithdrawQuoteSummary.tsx b/src/components/crosschain/withdraw/WithdrawQuoteSummary.tsx index 4bb26fd..13a2d65 100644 --- a/src/components/crosschain/withdraw/WithdrawQuoteSummary.tsx +++ b/src/components/crosschain/withdraw/WithdrawQuoteSummary.tsx @@ -1,5 +1,6 @@ import { formatQuoteTokenIn, + getMandateSalt, type EVMToMidenQuote, } from "../../../services/epoch-bridge"; import { QuoteSummaryCard } from "../QuoteSummaryCard"; @@ -24,18 +25,46 @@ function formatRequiredDeposit( return `${amount} ${symbol}`; } +/** + * This leg registers a Compact on the EVM chain, so its claim hash is public + * forever. Surfacing the salt makes visible that the hash cannot be matched back + * to a guessed amount and Miden account. + */ +function MandateSaltNote({ salt }: { salt: string }) { + return ( +
+

+ Privacy salt +

+

+ {salt} +

+

+ Bound into the on-chain claim hash so observers cannot confirm this + intent's amount or Miden account by guessing. +

+
+ ); +} + export function WithdrawQuoteSummary({ quote, displayDecimals, fallbackSymbol, onClearQuote, }: Props) { + const salt = getMandateSalt(quote.intentData); + return ( : undefined} /> ); } diff --git a/src/components/tabs/WithdrawTab.tsx b/src/components/tabs/WithdrawTab.tsx index cecf10f..de3fe1e 100644 --- a/src/components/tabs/WithdrawTab.tsx +++ b/src/components/tabs/WithdrawTab.tsx @@ -11,6 +11,8 @@ import { useWithdrawIntent } from "../../hooks/useWithdrawIntent"; import { useIntentFlowStatus } from "../../hooks/useIntentFlowStatus"; import { truncateHash } from "../../lib/explorers"; import type { MidenAccount } from "../../types/miden"; +import { WithdrawNoteFileCard } from "../crosschain/withdraw/WithdrawNoteFileCard"; +import { RecoverNotesCard } from "../crosschain/withdraw/RecoverNotesCard"; export function WithdrawTab() { const midenWallet = useMidenWalletAdapter({ enabled: true }); @@ -39,9 +41,15 @@ export function WithdrawTab() { string | undefined; const intentStatus = useIntentFlowStatus(evmAddress, intentNonce); + // Recovery keys off the CONNECTED wallet, not the last withdraw. + const walletAddress = withdraw.address; + // Stage 2 toast lifecycle: resolve the "waiting for Miden settlement" toast // (opened by WithdrawForm.handleConfirm) once SIO surfaces the synthetic // Miden row, or when the terminal EVM-success row lands without a Miden row. + const liveNoteBytes = intentStatus.status?.midenNoteBytes; + const liveNoteId = intentStatus.status?.midenNoteId; + const midenTxId = intentStatus.status?.midenTxId; const evmCompleted = intentStatus.status?.evmCompleted; useEffect(() => { @@ -86,12 +94,16 @@ export function WithdrawTab() { isLoading={withdraw.isLoading} isSDKReady={withdraw.isSDKReady} /> + + {/* Renders itself only when the payout was private. Placed after the + status block so it is the last thing the user sees on success. */} + ); } diff --git a/src/hooks/useEpochSession.ts b/src/hooks/useEpochSession.ts new file mode 100644 index 0000000..1723483 --- /dev/null +++ b/src/hooks/useEpochSession.ts @@ -0,0 +1,54 @@ +import { useCallback, useState } from "react"; +import { useEpochSdk } from "../lib/epoch-sdk"; + +// sessionStorage, not localStorage: a bearer session should die with the tab. +const KEY = "epoch.session.v1"; + +export function useEpochSession() { + const sdk = useEpochSdk(); + const [sessionId, setSessionId] = useState(() => { + try { + return sessionStorage.getItem(KEY); + } catch { + return null; + } + }); + const [isSigningIn, setIsSigningIn] = useState(false); + + const signIn = useCallback(async () => { + if (!sdk) throw new Error("SDK not ready"); + setIsSigningIn(true); + try { + const id = await sdk.createRecoverySession(); + try { + sessionStorage.setItem(KEY, id); + } catch { + /* private mode */ + } + setSessionId(id); + return id; + } finally { + setIsSigningIn(false); + } + }, [sdk]); + + const signOut = useCallback(async () => { + // Revoke server-side first: clearing only local state would leave a valid + // bearer session alive for its full 7-day life. + if (sdk && sessionId) { + try { + await sdk.endRecoverySession(sessionId); + } catch { + /* offline or already gone — still clear locally */ + } + } + try { + sessionStorage.removeItem(KEY); + } catch { + /* private mode */ + } + setSessionId(null); + }, [sdk, sessionId]); + + return { sessionId, signIn, signOut, isSigningIn }; +} diff --git a/src/hooks/useIntentFlowStatus.ts b/src/hooks/useIntentFlowStatus.ts index 93c7de8..cdaec17 100644 --- a/src/hooks/useIntentFlowStatus.ts +++ b/src/hooks/useIntentFlowStatus.ts @@ -43,6 +43,8 @@ export function useIntentFlowStatus( midenStatus: midenRow?.status != null ? String(midenRow.status) : undefined, midenNoteId, + // Only the Miden row carries it, and only for a private payout. + midenNoteBytes: midenRow?.midenNoteBytes, latestStatusLabel: latest?.status != null ? String(latest.status) : undefined, latestChainId: diff --git a/src/lib/note-file.ts b/src/lib/note-file.ts new file mode 100644 index 0000000..c2c671e --- /dev/null +++ b/src/lib/note-file.ts @@ -0,0 +1,15 @@ +/** Save a base64 `NoteFile` as the binary a Miden wallet imports. */ +export function downloadNoteFile(noteBytes: string, noteId?: string): void { + const binary = atob(noteBytes); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + + const url = URL.createObjectURL( + new Blob([bytes], { type: "application/octet-stream" }), + ); + const a = document.createElement("a"); + a.href = url; + a.download = `miden-note-${(noteId ?? "payout").replace(/^0x/, "").slice(0, 16)}.mno`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/services/epoch-bridge.ts b/src/services/epoch-bridge.ts index f440f3d..9924a32 100644 --- a/src/services/epoch-bridge.ts +++ b/src/services/epoch-bridge.ts @@ -7,6 +7,7 @@ import type { import { EVM_TO_MIDEN_EXTRA_TYPESTRING, EVM_ZERO_ADDRESS, + MANDATE_SALT_FIELD_NAME, MIDEN_TO_EVM_EXTRA_TYPESTRING, MIDEN_VIRTUAL_CHAIN_ID, type CollateralType, @@ -196,6 +197,9 @@ export function buildEVMToMidenTaskDataParams(params: EVMToMidenIntentParams) { extraData: { midenRecipientAccount: midenRecipientHex, midenFaucetId: midenFaucetHex, + // getTaskData declares this in the typestring for BOTH values, so public + // and private intents are indistinguishable on-chain. + midenNoteVisibility: params.midenNoteVisibility ?? "public", }, }; @@ -229,6 +233,21 @@ export async function getEVMToMidenQuote( return { taskTypeString, intentData, quoteResult, params: quoteParams }; } +/** + * The privacy salt `getTaskData` injects into EVM→Miden mandates. + * + * EVM→Miden registers a Compact on the origin chain, publishing a claim hash + * over the mandate. Every other mandate field is guessable, so without the salt + * that hash confirms a guessed intent to any chain observer. Miden→EVM returns + * undefined — it registers no compact, so there is nothing to salt. + */ +export function getMandateSalt(intentData: unknown): string | undefined { + const salt = (intentData as Record | null | undefined)?.[ + MANDATE_SALT_FIELD_NAME + ]; + return typeof salt === "string" ? salt : undefined; +} + export async function buildEVMToMidenIntent( sdk: EpochIntentSDK, params: EVMToMidenIntentParams & { preFetchedQuote?: EVMToMidenQuote }, @@ -237,6 +256,9 @@ export async function buildEVMToMidenIntent( let intentData: unknown; let quoteResult: IntentQuoteResult | undefined; + // The quote's task data must be reused verbatim, never rebuilt: getTaskData + // mints a fresh salt per call, and a second one would change the claim hash + // away from the one registered on-chain. if (params.preFetchedQuote) { ({ taskTypeString, intentData, quoteResult } = params.preFetchedQuote); } else { diff --git a/src/types/miden.ts b/src/types/miden.ts index 3524e36..edd7216 100644 --- a/src/types/miden.ts +++ b/src/types/miden.ts @@ -1,3 +1,5 @@ +import type { MidenNoteVisibility } from "@epoch-protocol/epoch-intents-sdk"; + export interface MidenAccount { id: string; label: string; @@ -52,6 +54,12 @@ export interface EVMToMidenIntentParams { evmTokenDecimals?: number; midenRecipientId: string; midenFaucetId: string; + /** + * Visibility of the Miden note you RECEIVE. Goes into the signed mandate, so + * no service downstream can quietly downgrade it. Defaults to public — a + * private note is unrecoverable if its body is lost, so it is never assumed. + */ + midenNoteVisibility?: MidenNoteVisibility; /** * Minimum Miden-side output you want. * Reverse-quote path: paired with `tokenInAmount: "0"` so SIO derives required EVM `tokenIn`. From 636707d5d480e16d4ac8f3ec0932e1cd33bdc624 Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Wed, 26 Aug 2026 03:17:00 +0530 Subject: [PATCH 3/3] fix(withdraw): take the private note from the submit response, not the poll The allocator no longer serves a private payout body (or a private note's id) on the unauthenticated status route, so reading them from the poll now yields nothing. It hands the body back on the response to the submission that created it instead. - readPrivatePayoutNote reads it off solveResult.submittedIntentData - WithdrawTab sources the body from withdrawResult and uses the status poll's `hasPrivateNote` only to decide whether to offer recovery WithdrawNoteFileCard previously rendered null whenever it had no body, which after a reload meant a private payout showed nothing at all -- a user with a live unclaimed note and no indication it existed. It now explains that the body was returned at submit, that a reload loses it, and points at the wallet-authenticated recovery card below. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/crosschain/IntentStatus.tsx | 8 +++-- .../withdraw/WithdrawNoteFileCard.tsx | 35 +++++++++++++++++-- src/components/tabs/WithdrawTab.tsx | 14 ++++++-- src/hooks/useIntentFlowStatus.ts | 7 ++-- src/lib/intent-result.ts | 26 ++++++++++++++ 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/components/crosschain/IntentStatus.tsx b/src/components/crosschain/IntentStatus.tsx index b7eda9d..edd9075 100644 --- a/src/components/crosschain/IntentStatus.tsx +++ b/src/components/crosschain/IntentStatus.tsx @@ -14,8 +14,12 @@ export interface IntentFlowStatus { midenTxId?: string; midenStatus?: string; midenNoteId?: string; - /** Base64 NoteFile — private EVM→Miden payouts only. */ - midenNoteBytes?: string; + /** + * A private payout note exists. The body is NOT on the status shape — the + * allocator withholds it (and a private note's id) from that unauthenticated + * route — so this is only a signal that recovery is worth offering. + */ + hasPrivateNote?: boolean; latestStatusLabel?: string; latestChainId?: string; statusCount?: number; diff --git a/src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx b/src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx index 61c3bc2..7ffb5a2 100644 --- a/src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx +++ b/src/components/crosschain/withdraw/WithdrawNoteFileCard.tsx @@ -4,15 +4,44 @@ import { Button } from "@/components/ui/button"; import { downloadNoteFile } from "../../../lib/note-file"; interface Props { - /** Base64 `NoteFile` from the Miden settlement row. Absent for public payouts. */ + /** + * Base64 `NoteFile` from the response to this withdrawal. Absent for a public + * payout, and absent after a reload — the allocator does not serve it on the + * status poll, so it cannot be recovered from a refresh. + */ noteBytes?: string; noteId?: string; + /** + * A private note exists but its body is no longer in hand. Point the user at + * the wallet-authenticated recovery flow rather than silently showing nothing + * — the funds are reachable, but only with the note file. + */ + needsRecovery?: boolean; } /** The body of a PRIVATE payout note — the only thing that can claim it. */ -export function WithdrawNoteFileCard({ noteBytes, noteId }: Props) { +export function WithdrawNoteFileCard({ + noteBytes, + noteId, + needsRecovery, +}: Props) { const [saved, setSaved] = useState(false); - if (!noteBytes) return null; + + if (!noteBytes) { + if (!needsRecovery) return null; + return ( +
+

+ Your private note file is not on this page +

+

+ This payout minted a private note, and its body is the only way to + claim it. It was returned when you submitted, so a reload loses it. + Use “Lost a private note?” below to sign in and download it again. +

+
+ ); + } const fileName = `miden-note-${(noteId ?? "payout").replace(/^0x/, "").slice(0, 16)}.mno`; diff --git a/src/components/tabs/WithdrawTab.tsx b/src/components/tabs/WithdrawTab.tsx index de3fe1e..a54ba17 100644 --- a/src/components/tabs/WithdrawTab.tsx +++ b/src/components/tabs/WithdrawTab.tsx @@ -10,6 +10,7 @@ import { IntentStatus } from "../crosschain/IntentStatus"; import { useWithdrawIntent } from "../../hooks/useWithdrawIntent"; import { useIntentFlowStatus } from "../../hooks/useIntentFlowStatus"; import { truncateHash } from "../../lib/explorers"; +import { readPrivatePayoutNote } from "../../lib/intent-result"; import type { MidenAccount } from "../../types/miden"; import { WithdrawNoteFileCard } from "../crosschain/withdraw/WithdrawNoteFileCard"; import { RecoverNotesCard } from "../crosschain/withdraw/RecoverNotesCard"; @@ -47,7 +48,12 @@ export function WithdrawTab() { // Stage 2 toast lifecycle: resolve the "waiting for Miden settlement" toast // (opened by WithdrawForm.handleConfirm) once SIO surfaces the synthetic // Miden row, or when the terminal EVM-success row lands without a Miden row. - const liveNoteBytes = intentStatus.status?.midenNoteBytes; + // The note body arrives on the response to the submission that created it, + // not on the status poll — the allocator does not serve it unauthenticated. + const privateNote = readPrivatePayoutNote(withdraw.withdrawResult); + // Settled private payout whose body we no longer hold (reload, other device): + // the recovery card below is the way back to it. + const needsRecovery = !privateNote && intentStatus.status?.hasPrivateNote; const liveNoteId = intentStatus.status?.midenNoteId; const midenTxId = intentStatus.status?.midenTxId; @@ -103,7 +109,11 @@ export function WithdrawTab() { /> {/* Renders itself only when the payout was private. Placed after the status block so it is the last thing the user sees on success. */} - + ); } diff --git a/src/hooks/useIntentFlowStatus.ts b/src/hooks/useIntentFlowStatus.ts index cdaec17..3629b42 100644 --- a/src/hooks/useIntentFlowStatus.ts +++ b/src/hooks/useIntentFlowStatus.ts @@ -43,8 +43,11 @@ export function useIntentFlowStatus( midenStatus: midenRow?.status != null ? String(midenRow.status) : undefined, midenNoteId, - // Only the Miden row carries it, and only for a private payout. - midenNoteBytes: midenRow?.midenNoteBytes, + // The body is NOT on this shape — the allocator withholds it (and a + // private payout's note id) from this unauthenticated route. All the poll + // can tell us is that a note exists; the body comes from the submit + // response, or from the wallet-authenticated recovery flow. + hasPrivateNote: midenRow?.hasPrivateNote === true, latestStatusLabel: latest?.status != null ? String(latest.status) : undefined, latestChainId: diff --git a/src/lib/intent-result.ts b/src/lib/intent-result.ts index 89081d6..56f368c 100644 --- a/src/lib/intent-result.ts +++ b/src/lib/intent-result.ts @@ -72,3 +72,29 @@ export function readMidenNoteId(result: unknown): string | undefined { const found = candidates.find((c) => typeof c === "string" && c.length > 0); return typeof found === "string" ? found : undefined; } + +/** + * The private payout note handed back by the submission that created it. + * + * Lives on the submit response rather than the status poll: a private note's + * body is the only thing that can consume it, so the allocator serves it to the + * caller that created the intent and withholds it from the unauthenticated + * status route. If this is absent — a reload, a different device — the body is + * still recoverable through the wallet-authenticated flow (RecoverNotesCard). + */ +export function readPrivatePayoutNote( + result: unknown, +): { midenNoteBytes: string; midenNoteId?: string } | undefined { + const bytes = at( + result, + "solveResult", + "submittedIntentData", + "midenNoteBytes", + ); + if (typeof bytes !== "string" || bytes.length === 0) return undefined; + const id = at(result, "solveResult", "submittedIntentData", "midenNoteId"); + return { + midenNoteBytes: bytes, + ...(typeof id === "string" && id.length > 0 ? { midenNoteId: id } : {}), + }; +}