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; + /** 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(); + }} + /> + 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/components/crosschain/withdraw/RecoverNotesCard.tsx b/src/components/crosschain/withdraw/RecoverNotesCard.tsx new file mode 100644 index 0000000..70f119b --- /dev/null +++ b/src/components/crosschain/withdraw/RecoverNotesCard.tsx @@ -0,0 +1,180 @@ +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { MIDEN_VIRTUAL_CHAIN_ID } from "@epoch-protocol/epoch-intents-sdk"; +import { Button } from "@/components/ui/button"; +import { useEpochSession } from "../../../hooks/useEpochSession"; +import type { RecoverableIntent } from "@epoch-protocol/epoch-intents-sdk"; +import { useEpochSdk } from "../../../lib/epoch-sdk"; +import { downloadNoteFile } from "../../../lib/note-file"; + +interface Props { + connectedAddress?: string; +} + +/** + * A downloadable body exists only for a PRIVATE EVM→Miden payout — SIO stores + * it as `midenOutputNoteBytes` and serves it on the Miden settlement row. + * + * `compacts.chain_id` is the SOURCE chain, so those are the EVM-chain rows, NOT + * the Miden ones. A `chain_id = 999999999` row is Miden→EVM and its + * `miden_note_id` is the INPUT note being consumed, which has no body to fetch. + * + * So gating on `midenNoteId` selects exactly the wrong rows. That column is + * written at submission from the mandate, which only carries a note id when the + * user already owns one (Miden→EVM). For EVM→Miden the output note is minted + * during settlement, leaving the column null for precisely the intents this + * card exists to recover. + */ +function mayHaveRecoverableNote(it: RecoverableIntent): boolean { + return Number(it.chainId) !== MIDEN_VIRTUAL_CHAIN_ID; +} + +/** + * Recover a private payout note without the intent nonce. + * + * The nonce is not on-chain and normally lives only in the browser, so a user on + * a new device cannot ask for their own note. Proving wallet ownership lets + * smallocator look their intents up by address instead. + */ +export function RecoverNotesCard({ connectedAddress }: Props) { + const sdk = useEpochSdk(); + const { sessionId, signIn, signOut, isSigningIn } = useEpochSession(); + const [intents, setIntents] = useState(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/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/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/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/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 193c4b1..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 { @@ -303,6 +325,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 +357,7 @@ export async function buildCrossChainIntent( midenFaucetId: midenFaucetIdHex, midenSourceAccount: midenSourceHex, createMidenP2IDNote: params.createMidenP2IDNote, + midenNoteVisibility: params.midenNoteVisibility, }); return { 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`.