From 64808b89d6a29ff728c773a2d6c77ae6532e325f Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Mon, 3 Aug 2026 11:31:01 +0530 Subject: [PATCH 1/2] 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/2] 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`.