diff --git a/src/main.tsx b/src/main.tsx index ee23981..f63d8b3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,6 +15,7 @@ import { MidenClientProvider } from '@/providers/MidenClientProvider' const Home = lazy(() => import('@/pages/home/page')) // const Identity = lazy(() => import('./pages/identity/page.tsx')) // const MyDomains = lazy(() => import('./pages/my-domains/page.tsx')) +const Sign = lazy(() => import('./pages/sign/page.tsx')) const NotFound = lazy(() => import('./pages/not-found/page.tsx')) const PageLoader = () => ( @@ -54,6 +55,14 @@ const router = createBrowserRouter([ // // ) // }, + { + path: "sign/:id", + element: ( + }> + + + ) + }, { path: "*", element: ( diff --git a/src/pages/sign/page.tsx b/src/pages/sign/page.tsx new file mode 100644 index 0000000..17f18f3 --- /dev/null +++ b/src/pages/sign/page.tsx @@ -0,0 +1,369 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useParams } from 'react-router' +import { TransactionRequest, AccountId } from '@miden-sdk/miden-sdk' +import { + CustomTransaction, + TransactionType, + WalletMultiButton, + useWallet, +} from '@miden-sdk/miden-wallet-adapter' +import { Loader2, ShieldCheck, AlertTriangle, ExternalLink, CheckCircle2 } from 'lucide-react' +import { accountIdToBech32, bech32ToAccountId } from '@/lib/midenClient' +import { MIDEN_ID_CONTRACT_ADDRESS, API_BASE } from '@/shared/constants' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Separator } from '@/components/ui/separator' + +// The `/sign/:id` page of the device-flow signing scheme +// (midenname-agent-skills/design/device-flow-signing.md). An agent proposes an +// unsigned register transaction; the user lands here, the page re-derives the +// trust-critical fields FROM THE TRANSACTION BYTES (not the relay's summary), and +// the user signs in their own wallet. The key never leaves the wallet. + +type Phase = + | 'loading' + | 'review' + | 'submitting' + | 'done' + | 'rejected' + | 'expired' + | 'notfound' + | 'error' + +interface SignRecord { + id: string + kind: string + unsigned_tx_hex: string + summary?: { + name?: string + sender_account?: string + naming_account?: string + faucet_id?: string + price?: string + } + status: 'pending' | 'signed' | 'rejected' | 'expired' + tx_hash?: string | null + note_id?: string | null +} + +// Values re-derived from the unsigned tx itself — this is what the user is really +// signing, independent of whatever the relay claims in `summary`. +interface Derived { + senderHex: string + senderBech32: string + faucetHex: string + amount: bigint + noteId: string +} + +// The wallet returns its own internal tx id (a UUID), not the on-chain tx hash, so +// we can't link to the transaction. The register NOTE, however, has a real on-chain +// id that MidenScan resolves — link to that instead. +const MIDENSCAN_NOTE = 'https://testnet.midenscan.com/note/' + +function hexToBytes(hex: string): Uint8Array { + const clean = hex.replace(/^0x/, '') + const out = new Uint8Array(clean.length / 2) + for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16) + return out +} + +function fmtTokens(baseUnits: bigint): string { + const whole = baseUnits / 1_000_000n + const frac = baseUnits % 1_000_000n + return frac === 0n ? whole.toString() : `${whole}.${frac.toString().padStart(6, '0').replace(/0+$/, '')}` +} + +export default function SignPage() { + const { id } = useParams<{ id: string }>() + const { connected, address, requestTransaction } = useWallet() + + const [phase, setPhase] = useState('loading') + const [error, setError] = useState('') + const [record, setRecord] = useState(null) + const [derived, setDerived] = useState(null) + const [txReq, setTxReq] = useState(null) + const [noteId, setNoteId] = useState('') + + const load = useCallback(async () => { + if (!id) return + setPhase('loading') + try { + const resp = await fetch(`${API_BASE}/v1/sign-requests/${id}`) + if (resp.status === 404) { setPhase('notfound'); return } + if (!resp.ok) throw new Error(`relay returned ${resp.status}`) + const rec: SignRecord = await resp.json() + setRecord(rec) + + if (rec.status === 'signed') { + setNoteId(rec.note_id ?? '') + setPhase('done') + return + } + if (rec.status === 'rejected') { setPhase('rejected'); return } + if (rec.status === 'expired') { setPhase('expired'); return } + + // Re-derive the trust-critical fields straight from the bytes. + const tr = TransactionRequest.deserialize(hexToBytes(rec.unsigned_tx_hex)) + const notes = tr.expectedOutputOwnNotes() + if (notes.length === 0) throw new Error('transaction has no output note to register') + const note = notes[0] + const sender = note.metadata().sender() + const assets = note.assets().fungibleAssets() + if (assets.length === 0) throw new Error('transaction carries no payment asset') + const asset = assets[0] + const derivedNoteId = note.id().toString() + + setTxReq(tr) + setNoteId(derivedNoteId) + setDerived({ + senderHex: sender.toString(), + senderBech32: accountIdToBech32(sender), + faucetHex: asset.faucetId().toString(), + amount: asset.amount(), + noteId: derivedNoteId, + }) + setPhase('review') + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setPhase('error') + } + }, [id]) + + useEffect(() => { void load() }, [load]) + + // Sender pinning (invariant #5): the connected wallet must be the account that + // pays inside the tx. Compared on the AccountId, normalized via toString(). + const connectedHex = useMemo(() => { + if (!address) return null + try { return bech32ToAccountId(address).toString() } catch { return null } + }, [address]) + const walletMatches = !!(derived && connectedHex && connectedHex === derived.senderHex) + + // Soft consistency check against the relay summary (invariant #2): the bytes are + // authoritative, but a divergence hints at a misbehaving relay. + const summaryMismatch = useMemo(() => { + if (!record?.summary || !derived) return false + const s = record.summary + const priceOk = !s.price || s.price === derived.amount.toString() + const faucetOk = !s.faucet_id || s.faucet_id.toLowerCase() === derived.faucetHex.toLowerCase() + return !(priceOk && faucetOk) + }, [record, derived]) + + const approve = useCallback(async () => { + if (!txReq || !derived || !record || !requestTransaction) return + setPhase('submitting') + setError('') + try { + const namingHex = record.summary?.naming_account || MIDEN_ID_CONTRACT_ADDRESS + const tx = new CustomTransaction( + derived.senderBech32, // from — the paying account, re-derived from the tx + accountIdToBech32(AccountId.fromHex(namingHex)), // to — naming registry + txReq, + [], + [], + ) + const txId = await requestTransaction({ type: TransactionType.Custom, payload: tx }) + // Tell the relay (best-effort; the tx is already submitted by the wallet). + await fetch(`${API_BASE}/v1/sign-requests/${id}/signed`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tx_hash: txId, note_id: derived.noteId }), + }).catch(() => {}) + setPhase('done') + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setPhase('review') + } + }, [txReq, derived, record, requestTransaction, id]) + + const reject = useCallback(async () => { + await fetch(`${API_BASE}/v1/sign-requests/${id}/rejected`, { method: 'PATCH' }).catch(() => {}) + setPhase('rejected') + }, [id]) + + const name = record?.summary?.name + + return ( +
+ + +
+ + + Approve registration + + device-flow +
+ + An agent proposed this transaction. Review what you are signing — the + details below are read directly from the transaction, not from the + requester. Your key never leaves your wallet. + +
+ + + {phase === 'loading' && ( +
+ Loading sign request… +
+ )} + + {phase === 'notfound' && ( + + + Request not found + + This sign request does not exist. It may have already been completed + or never created. + + + )} + + {phase === 'expired' && ( + + + Request expired + + This request timed out. Ask the agent to generate a new link. + + + )} + + {phase === 'rejected' && ( + + + Request rejected + This transaction was declined. + + )} + + {phase === 'error' && ( + + + Could not load the transaction + {error} + + )} + + {(phase === 'review' || phase === 'submitting') && derived && ( + <> +
+ + {name ? `${name}.miden` : '(name in tx)'} + + + {fmtTokens(derived.amount)} MIDEN + ({derived.amount.toString()} base units) + + + {derived.senderBech32} + + + {derived.faucetHex} + + + {derived.noteId} + +
+ + {summaryMismatch && ( + + + Requester summary does not match the transaction + + The price or payment token claimed by the requester differs from + what the transaction actually does. The values shown above are the + real ones. Proceed only if you trust them. + + + )} + + + + {!connected ? ( +
+

+ Connect the wallet that owns {derived.senderBech32} to approve. +

+ +
+ ) : !walletMatches ? ( + + + Wrong wallet connected + + This transaction pays from {derived.senderBech32}, + but the connected wallet is a different account. Switch accounts to continue. + + + ) : ( +
+ Wallet matches the paying account. +
+ )} + + {error && phase === 'review' && ( +

{error}

+ )} + + )} + + {phase === 'done' && ( +
+
+ + Signed and submitted +
+ {name &&

{name}.miden registration sent.

} + {noteId && ( + + View register note on MidenScan + + )} +

+ Registration finalizes once the registry consumes the note (usually a few minutes). + You can return to the agent — it has been notified. +

+
+ )} +
+ + {(phase === 'review' || phase === 'submitting') && ( + + + + + )} +
+
+ ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 86bec08..4f20974 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,6 +1,14 @@ export const MIDEN_ID_CONTRACT_ADDRESS = '0x88f63686037e63406bbb8f5d01adb0'; export const MIDEN_FAUCET_CONTRACT_ADDRESS = '0x0a7d175ed63ec5200fb2ced86f6aa5'; export const MIDEN_FAUCET_ID_BECH32 = 'mtst1aq9869676clv2gq0kt8dsmm255zs6hs3_qr7qqq9wr6w'; -export const API_BASE = 'https://midenid-backend.onrender.com'; +// Single backend base URL, used by every API call AND the device-flow sign-request +// relay (which lives in the same backend — see midenname-agent-skills/design/ +// device-flow-signing.md). Override with VITE_API_BASE (e.g. http://localhost:3080 +// for local dev). `import.meta.env` is typed by vite/client in the app build but +// NOT when this module is imported by vite.config.ts (Node context), so read it +// through a cast that compiles in both — and it's undefined at config-eval time, so +// optional-chain it. +const VITE_ENV = (import.meta as unknown as { env?: Record }).env; +export const API_BASE = VITE_ENV?.VITE_API_BASE ?? 'https://midenid-backend.onrender.com'; // 5 * 1024 * 1024 = 5 MB export const MAX_FILE_SIZE = 5 * 1024 * 1024;