From 50f5ccd476f1ef80fa59178f7f994822cdaa9da8 Mon Sep 17 00:00:00 2001 From: JW-Rami <106039941+JW-Rami@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:34:01 +0200 Subject: [PATCH 1/3] feat: adopt a Uniswap-style transfer interface --- README.md | 8 +- docs/adr/0002-transfer-interface.md | 19 ++ docs/repo-actions.md | 2 +- src/App.test.tsx | 125 +++++++++++++ src/App.tsx | 261 ++++++++++++++++++---------- src/style.css | 204 +++++++++++++++++++--- 6 files changed, 492 insertions(+), 127 deletions(-) create mode 100644 docs/adr/0002-transfer-interface.md create mode 100644 src/App.test.tsx diff --git a/README.md b/README.md index 7aae781..82a6d2f 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,16 @@ npm run dev No GitHub Packages authentication or API key is needed. The SDK's `invisibleDevnet()` preset supplies the default route. To use another compatible coordinator, copy `.env.example` to `.env.local` and set `VITE_INVISIBLE_COORDINATOR_URL` to its public `wss://` URL. The route cannot override the SDK's embedded attestation policy. -1. Connect. The SDK opens the WebSocket, completes Noise XX and verifies attestation. -2. Enter an amount, destination and supported payout window. Create the transfer. +1. Open the app. The SDK automatically opens the session, completes Noise XX and verifies attestation. No wallet connection is required. +2. Enter an amount, select the recipient and choose a payout window in Settings. Review the full address, then create the transfer. 3. Save the Recovery Code. Send the exact displayed devnet SOL amount from your external Solana wallet to the deposit address before expiry. -4. Follow authenticated status updates, confirmed payouts and refund outcomes. +4. Follow authenticated status updates, confirmed payouts and refund outcomes in Activity. 5. If eligible, request a refund to the original deposit wallet. This example does not send the deposit transaction for you. Keep wallet funding in your application's existing wallet integration. +The interface follows the compact card layout of [Uniswap](https://app.uniswap.org/swap), adapted for private SOL transfers. Recipient/review dialogs and settings/help popovers use native browser elements. No wallet provider is requested. + ## Copy into your frontend Copy **the entire `src/invisible/` folder**, excluding `invisible.test.tsx`, and install the same public SDK version: diff --git a/docs/adr/0002-transfer-interface.md b/docs/adr/0002-transfer-interface.md new file mode 100644 index 0000000..78a6bf3 --- /dev/null +++ b/docs/adr/0002-transfer-interface.md @@ -0,0 +1,19 @@ +# Transfer interface + +Status: accepted. Date: 2026-10-08. + +## Problem + +The sequential integration demo exposed connection controls before the transfer form. A compact interface was requested, closely following Uniswap while requiring no wallet connection. + +## Decision + +Use Uniswap's dark palette, centered 480 px card, amount hierarchy and pill controls with Invisible branding. Map the second token selector to a Solana recipient dialog; expose only transfer, activity and supported payout windows. Automatically open the SDK session. Require an explicit review confirmation before creating a transfer. + +Native dialogs and popovers handle focus, Escape and dismissal. Keep the existing copyable SDK hooks and financial safeguards. + +## Alternatives and limits + +Adding a UI library or reproducing trading/LP controls would add unsupported behavior. System fonts replace Uniswap's Basel typeface. Browser support for native dialogs, popovers and Web Locks is required. Recheck mobile and keyboard behavior when changing these controls. + +Reference: https://app.uniswap.org/swap diff --git a/docs/repo-actions.md b/docs/repo-actions.md index 65e3a07..6a04996 100644 --- a/docs/repo-actions.md +++ b/docs/repo-actions.md @@ -3,7 +3,7 @@ | Command | Purpose | Expected result | | --- | --- | --- | | `npm ci` | Install the pinned public dependencies | Lockfile installed; no GitHub token required | -| `npm run dev` | Start Vite locally | URL printed; no coordinator mutation until a user action | +| `npm run dev` | Start Vite locally | URL printed; SDK session opens on page load; no financial mutation until review confirmation | | `npm run typecheck` | Validate TypeScript and public SDK types | Exit 0 | | `npm test` | Run adapter behavior tests | All tests pass | | `npm run build` | Typecheck and produce static assets | `dist/` created | diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..8d30243 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,125 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createSession, type Session, TransportError } from "@invisible-labs/sdk"; +import { contractRequest, listPersistedTransfers, requestRefund, sync, type ContractRequestResult, type PersistedTransfer, SUPPORTED_TOTAL_DEADLINE_MS } from "@invisible-labs/sdk/user"; +import App from "./App"; + +vi.mock("@invisible-labs/sdk", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, createSession: vi.fn(), closeSession: vi.fn(), attestation: { ...actual.attestation, onPolicyViolation: vi.fn(() => vi.fn()) } }; +}); +vi.mock("@invisible-labs/sdk/user", async importOriginal => ({ ...await importOriginal(), contractRequest: vi.fn(), listPersistedTransfers: vi.fn(), requestRefund: vi.fn(), sync: vi.fn() })); +vi.mock("@invisible-labs/sdk/events", () => ({ subscribe: vi.fn(() => vi.fn()) })); + +const DESTINATION = "11111111111111111111111111111111"; +const DEPOSIT = "So11111111111111111111111111111111111111112"; +const SWAP_ID = "test-transfer"; +const RECOVERY_CODE_LENGTH = 32; +const DEPOSIT_WINDOW_MS = 60_000; +const SCHEDULED_WINDOW_MS = SUPPORTED_TOTAL_DEADLINE_MS[1]; +const ENTRY_LAMPORTS = 1_500_000_001; +const session = { attested: true } as Session; +const record: PersistedTransfer = { + schemaVersion: 1, stage: "ready", swapId: SWAP_ID, amountLamports: ENTRY_LAMPORTS, + destinationAddress: DESTINATION, policySnapshot: { + asset: "SOL", entry_amount_lamports: ENTRY_LAMPORTS, total_committed_lamports: ENTRY_LAMPORTS, + payout_deadline_ms: 0, payout_schedule: [{ destination_address: DESTINATION }], fragment_count: 1, + payout_mode: "instant", matching_mode: "exact_1_to_1", fragmentation_allowed: "payout_side_only", + min_fee_bps: 15, fee_bps: 15, fee_lamports: 2_250_000, net_payout_lamports: 1_497_750_001, + }, status: "awaiting_deposit", updatedAtMs: Date.now(), depositAddress: DEPOSIT, + depositExpiresAtMs: Date.now() + DEPOSIT_WINDOW_MS, + syncSecret: new Uint8Array(RECOVERY_CODE_LENGTH).fill(1), recoveryCode: new Uint8Array(RECOVERY_CODE_LENGTH).fill(2), +}; +beforeEach(() => { + vi.stubGlobal("localStorage", (globalThis as unknown as { jsdom: { window: Window } }).jsdom.window.localStorage); + localStorage.clear(); + // jsdom omits native dialog methods; real modal behavior is checked in Brave. + HTMLDialogElement.prototype.showModal = function () { this.setAttribute("open", ""); }; + HTMLDialogElement.prototype.close = function () { this.removeAttribute("open"); }; + Object.defineProperty(navigator, "locks", { configurable: true, value: { request: (_name: string, action: () => unknown) => action() } }); + vi.mocked(createSession).mockResolvedValue(session); + vi.mocked(listPersistedTransfers).mockResolvedValue([]); + vi.mocked(sync).mockResolvedValue({ state: "awaiting_deposit", fragments_executed: [], coordinatorTimestampMs: Date.now() }); +}); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); + +async function enterTransfer() { + await waitFor(() => expect(createSession).toHaveBeenCalledTimes(1)); + fireEvent.change(screen.getByLabelText("SOL amount"), { target: { value: "1.500000001" } }); + fireEvent.click(screen.getByRole("button", { name: "Select recipient address" })); + fireEvent.change(screen.getByLabelText("Recipient address"), { target: { value: DESTINATION } }); + fireEvent.click(screen.getByRole("button", { name: "Use this address" })); + await waitFor(() => expect((screen.getByRole("button", { name: "Review transfer" }) as HTMLButtonElement).disabled).toBe(false)); +} +it("opens a read-only session without a wallet and creates only after a reviewed explicit command", async () => { + let accept!: (result: ContractRequestResult) => void; + vi.mocked(contractRequest).mockReturnValue(new Promise(resolve => { accept = resolve; })); + render(); + await enterTransfer(); + expect(contractRequest).not.toHaveBeenCalled(); + expect(requestRefund).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /connect wallet/i })).toBeNull(); + fireEvent.change(screen.getByLabelText("Payout window"), { target: { value: String(SCHEDULED_WINDOW_MS) } }); + fireEvent.click(screen.getByRole("button", { name: "Review transfer" })); + const review = screen.getByRole("dialog", { name: "Review transfer" }); + expect(within(review).getByText(DESTINATION)).toBeTruthy(); + const confirm = within(review).getByRole("button", { name: "Create private transfer" }); + fireEvent.click(confirm); + fireEvent.click(confirm); + await waitFor(() => expect(contractRequest).toHaveBeenCalledTimes(1)); + expect(contractRequest).toHaveBeenCalledWith(session, { amountLamports: ENTRY_LAMPORTS, payoutPolicy: { destinations: [{ address: DESTINATION, sharePercent: 100 }], totalDeadlineMs: SCHEDULED_WINDOW_MS } }); + vi.mocked(listPersistedTransfers).mockResolvedValue([record]); + await act(async () => { accept({ swapId: SWAP_ID } as ContractRequestResult); }); + await waitFor(() => expect(screen.getByText(DEPOSIT)).toBeTruthy()); + expect(screen.getByRole("tab", { name: /Activity/ }).getAttribute("aria-selected")).toBe("true"); +}); +it("validates recipient and amount without sending a command; permits closing the review", async () => { + render(); + await waitFor(() => expect(createSession).toHaveBeenCalledTimes(1)); + const tabs = screen.getByRole("tablist"); + fireEvent.keyDown(tabs, { key: "ArrowRight" }); + expect(document.activeElement).toBe(screen.getByRole("tab", { name: "Activity" })); + fireEvent.keyDown(tabs, { key: "Home" }); + fireEvent.click(screen.getByRole("button", { name: "Select recipient address" })); + fireEvent.change(screen.getByLabelText("Recipient address"), { target: { value: "invalid-address" } }); + fireEvent.click(screen.getByRole("button", { name: "Use this address" })); + expect(screen.getByRole("dialog", { name: "Select recipient" })).toBeTruthy(); + expect(screen.getByRole("alert").textContent).not.toContain("[object Object]"); + fireEvent.click(screen.getByRole("button", { name: "Close recipient" })); + await enterTransfer(); + fireEvent.change(screen.getByLabelText("SOL amount"), { target: { value: "1.499999999" } }); + fireEvent.click(screen.getByRole("button", { name: "Review transfer" })); + expect(screen.getByRole("alert").textContent).toContain("between"); + expect(contractRequest).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("SOL amount"), { target: { value: "1.5" } }); + fireEvent.click(screen.getByRole("button", { name: "Review transfer" })); + fireEvent.click(screen.getByRole("button", { name: "Close review" })); + expect(contractRequest).not.toHaveBeenCalled(); +}); +it("keeps an uncertain creation blocked and shows recovery guidance in the redesigned UI", async () => { + vi.mocked(contractRequest).mockRejectedValue(new TransportError("CONNECTION_LOST", "The response was lost")); + render(); + await enterTransfer(); + fireEvent.click(screen.getByRole("button", { name: "Review transfer" })); + fireEvent.click(screen.getByRole("button", { name: "Create private transfer" })); + await waitFor(() => expect(screen.getByRole("status").textContent).toContain("uncertain")); + expect(screen.getByRole("alert").textContent).toContain("response was lost"); + fireEvent.click(screen.getByRole("tab", { name: "Transfer" })); + expect((screen.getByRole("button", { name: "Review transfer" }) as HTMLButtonElement).disabled).toBe(true); + expect(contractRequest).toHaveBeenCalledTimes(1); +}); +it("never displays expired or terminal deposit instructions or terminal bearer material", async () => { + vi.mocked(listPersistedTransfers).mockResolvedValue([{ ...record, depositExpiresAtMs: Date.now() - DEPOSIT_WINDOW_MS }]); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Activity" })); + await waitFor(() => expect(screen.getByRole("status").textContent).toContain("expired")); + expect(screen.queryByText(DEPOSIT)).toBeNull(); + cleanup(); + vi.mocked(listPersistedTransfers).mockResolvedValue([{ ...record, stage: "terminal-cleaned", status: "completed", recoveryCode: undefined, syncSecret: undefined }]); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Activity" })); + await waitFor(() => expect(screen.getByRole("heading", { name: "completed" })).toBeTruthy()); + expect(screen.queryByText(DEPOSIT)).toBeNull(); + expect(screen.queryByText("Recovery Code")).toBeNull(); + expect(screen.queryByRole("button", { name: "Request refund" })).toBeNull(); +}); diff --git a/src/App.tsx b/src/App.tsx index 7a7946c..9fea15f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,50 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { publicKey } from "@invisible-labs/sdk"; import { invisibleDevnet } from "@invisible-labs/sdk/presets"; import { INSTANT_PAYOUT_WINDOW_MS, MIN_ENTRY_AMOUNT_LAMPORTS, MAX_ENTRY_AMOUNT_LAMPORTS, SUPPORTED_TOTAL_DEADLINE_MS, recoveryCodeToHex, } from "@invisible-labs/sdk/user"; -import { formatSol, isTerminalState, toUiError, useInvisibleSession, usePrivateTransfers, useTransferStatus, type InvisibleUiError } from "./invisible"; +import { formatSol, isTerminalState, parseSolAmount, toUiError, useInvisibleSession, usePrivateTransfers, useTransferStatus, type InvisibleUiError } from "./invisible"; const COORDINATOR_URL = import.meta.env.VITE_INVISIBLE_COORDINATOR_URL?.trim() || invisibleDevnet(); const MILLISECONDS_PER_MINUTE = 60_000; -const INITIAL_AMOUNT_SOL = formatSol(MIN_ENTRY_AMOUNT_LAMPORTS); - +const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE; +const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR; +const ADDRESS_PREVIEW_LENGTH = 6; +const SOURCE_URL = "https://github.com/Invisible-Labs/invisible-sdk-react-example"; +const DOCS_URL = "https://docs.invisible.exchange/docs/sdk/"; +const SETTINGS_ID = "transfer-settings"; +const HELP_ID = "integration-help"; +const shortAddress = (address: string) => `${address.slice(0, ADDRESS_PREVIEW_LENGTH)}...${address.slice(-ADDRESS_PREVIEW_LENGTH)}`; +function windowLabel(windowMs: number) { + if (windowMs === INSTANT_PAYOUT_WINDOW_MS) return "Instant"; + const [duration, unit] = windowMs >= MILLISECONDS_PER_DAY ? [windowMs / MILLISECONDS_PER_DAY, "day"] : windowMs >= MILLISECONDS_PER_HOUR ? [windowMs / MILLISECONDS_PER_HOUR, "hour"] : [windowMs / MILLISECONDS_PER_MINUTE, "minute"]; + return `${duration} ${unit}${duration === 1 ? "" : "s"}`; +} const PHASE_LABELS: Record = { - awaiting_deposit: "Waiting for deposit", - activating: "Activating transfer", - waiting_for_liquidity: "Waiting for liquidity", - retry_delayed: "Settlement delayed", - recovery_required: "Recovery needed", - matched_scheduled: "Payout scheduled", - locked_pending_payout: "Preparing payout", - submitted: "Payout submitted", - confirming: "Confirming payout", - completed: "Transfer complete", - refund_pending: "Refund pending", - refunded: "Refund complete", - expired: "Transfer expired", + awaiting_deposit: "Waiting for deposit", activating: "Activating transfer", waiting_for_liquidity: "Waiting for liquidity", + retry_delayed: "Settlement delayed", recovery_required: "Recovery needed", matched_scheduled: "Payout scheduled", + locked_pending_payout: "Preparing payout", submitted: "Payout submitted", confirming: "Confirming payout", + completed: "Transfer complete", refund_pending: "Refund pending", refunded: "Refund complete", expired: "Transfer expired", }; - +const ICON_PATHS = { + arrow: "M12 4v16m-7-7 7 7 7-7", chevron: "m6 9 6 6 6-6", close: "m6 6 12 12M6 18 18 6", + clock: "M12 7v5l3 2", check: "m5 12 4 4L19 6", copy: "M9 9h11v11H9zM5 15H3V3h12v2", + help: "M9.5 8a2.5 2.5 0 1 1 4 2c-1 .6-1.5 1-1.5 2m0 4h.01", + settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1z", +} as const; +function Icon({ name }: { name: keyof typeof ICON_PATHS }) { + return ; +} +function SolIcon() { + return ; +} function ErrorMessage({ error }: { error: InvisibleUiError | null }) { - return error ?

{error.message}{error.remoteAccepted ? " The coordinator already accepted this request. Do not create another; reconcile the saved transfer." : ""}

: null; + return error ?

{error.message}{error.remoteAccepted ? " This request was already accepted. Check its saved status before submitting again." : ""}

: null; } - function CopyValue({ label, value }: { label: string; value: string }) { const [error, setError] = useState(null); const [copied, setCopied] = useState(false); @@ -37,21 +52,28 @@ function CopyValue({ label, value }: { label: string; value: string }) { try { await navigator.clipboard.writeText(value); setCopied(true); setError(null); } catch (error) { setError(toUiError(error)); } } - return
{label}{value}
; + return
{label}
{value}
; } export default function App() { const connection = useInvisibleSession(COORDINATOR_URL); const transfers = usePrivateTransfers(connection.session, COORDINATOR_URL); const status = useTransferStatus(connection.session, transfers.selected, connection.fail); - const [amountSol, setAmountSol] = useState(INITIAL_AMOUNT_SOL); + const [tab, setTab] = useState<"transfer" | "activity">("transfer"); + const [amountSol, setAmountSol] = useState(""); const [destination, setDestination] = useState(""); + const [recipientInput, setRecipientInput] = useState(""); const [deadlineMs, setDeadlineMs] = useState(INSTANT_PAYOUT_WINDOW_MS); - const [recoveryInput, setRecoveryInput] = useState(""); + const [formError, setFormError] = useState(null); + const [recipientError, setRecipientError] = useState(null); + const recipientDialog = useRef(null); + const recipientField = useRef(null); + const reviewDialog = useRef(null); const transfer = transfers.selected; const snapshot = status.view?.actor_sync?.snapshot; const isTerminal = status.view ? isTerminalState(status.view.state) : transfer?.stage === "terminal-cleaned"; const ready = connection.phase === "connected"; + const canCreate = ready && transfers.loaded && !transfers.busy && !transfers.creationBlocked; const canRefund = ready && status.fresh && snapshot?.refundability === "available_now" && !transfers.refundBlocked && !transfers.busy; const depositExpiry = transfer?.depositExpiresAtMs; const coordinatorNow = Date.now() + ((transfer?.coordinatorTimestampMs ?? transfer?.updatedAtMs ?? Date.now()) - (transfer?.updatedAtMs ?? Date.now())); @@ -59,6 +81,8 @@ export default function App() { const canDeposit = !depositExpired && ready && transfer?.stage === "ready" && transfer.status === "awaiting_deposit" && !isTerminal && (status.view === null || status.view.state === "awaiting_deposit"); + // Session setup is read-only. Financial commands remain explicit user actions. + useEffect(() => { void connection.connect(); }, [connection.connect]); useEffect(() => { if (status.fresh && status.view) { transfers.reconcileRefund(status.view); @@ -66,86 +90,133 @@ export default function App() { } }, [status.fresh, status.view, transfers.reconcileRefund, transfers.reload, connection.fail]); - useEffect(() => { setRecoveryInput(""); }, [transfers.selectedId]); - - async function create(event: FormEvent) { + function chooseRecipient(event: FormEvent) { event.preventDefault(); - await transfers.createTransfer({ amountSol, destination, deadlineMs }); + try { + publicKey(recipientInput.trim()); + setDestination(recipientInput.trim()); + setRecipientError(null); + recipientDialog.current?.close(); + } catch (error) { setRecipientError(toUiError(error)); } } - - async function refund() { - if (!transfer || !status.view || !canRefund) return; - await transfers.refund(transfer, status.view, recoveryInput); - setRecoveryInput(""); - status.refresh(); + function review(event: FormEvent) { + event.preventDefault(); + if (!canCreate) return; + try { + parseSolAmount(amountSol); + publicKey(destination); + setFormError(null); + reviewDialog.current?.showModal(); + } catch (error) { setFormError(toUiError(error)); } + } + async function create() { + if (!canCreate) return; + reviewDialog.current?.close(); + await transfers.createTransfer({ amountSol, destination, deadlineMs }); + setTab("activity"); } + function reconnect() { connection.disconnect(); void connection.connect(); } - return
-
- SOLANA DEVNET -

Invisible, in React.

-

A small, copyable example for private SOL transfers.

- + return <> +
+ Invisible + + Solana devnet
-
-

1. Connect

-

Connection: {connection.phase}. The SDK verifies the coordinator before enabling transfers.

-
- - - +
+
+
{ + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = event.key === "Home" ? "transfer" : event.key === "End" ? "activity" : tab === "transfer" ? "activity" : "transfer"; + setTab(next); + event.currentTarget.querySelector(`#${next}-tab`)?.focus(); + }}> + + +
+
+ + +
+
+
+

Transfer settings

+ +
NetworkSolana devnet
+

Scheduled payouts follow the accepted coordinator policy. Only devnet SOL is supported.

- -

This beta uses devnet SOL. Recovery material is saved in this browser by the SDK. Browser storage is not an encrypted vault.

-
-
-

2. Create a private transfer

-
void create(event)}> - -

{formatSol(MIN_ENTRY_AMOUNT_LAMPORTS)} to {formatSol(MAX_ENTRY_AMOUNT_LAMPORTS)} SOL. Send this exact deposit amount; the final payout follows the coordinator policy and fees.

- - - -
- {transfers.creationBlocked &&

An incomplete or uncertain request is saved. Reconnect and review it before creating another transfer.

} - -
+ -
-

3. Deposit and follow the transfer

- {transfers.transfers.length > 0 ? :

Connect to load saved transfers, or create your first transfer.

} - {transfer && <> -

Status: {snapshot ? PHASE_LABELS[snapshot.settlement_phase] ?? snapshot.settlement_phase : status.view?.state ?? transfer.status}{!status.fresh && " (last known state)"}

-

Refund eligibility: {snapshot?.refundability ?? "waiting for status"}. Refund outcome: {snapshot?.refund_status ?? "none"}.

- {canDeposit && <> -

Save your Recovery Code, then send exactly {formatSol(transfer.amountLamports)} devnet SOL from your own Solana wallet to:

- - {depositExpiry &&

Deposit expires: . Do not fund an expired address.

} -

The SDK creates and delegates the deposit wallet. Your external wallet sends the deposit; this example does not submit wallet transactions.

- } - {depositExpired && transfer.stage === "ready" && !isTerminal &&

The deposit window has expired. Do not send funds; refresh the authoritative status.

} - {transfer.stage === "allocation" &&

Initialization did not finish. Do not send funds. No automatic allocation or DKG replay is attempted.

} - {transfer.recoveryCode && !isTerminal &&
Recovery Code (keep private)
} - {transfer.depositAddress && snapshot &&

Net payout in the accepted policy: {formatSol(transfer.policySnapshot.net_payout_lamports)} SOL. Settled: {formatSol(snapshot.settled_amount_lamports)} SOL.

} - {status.view &&
    - {status.view.fragments_executed.map(payout =>
  • {formatSol(payout.amount_lamports)} SOL to {payout.destination_address}: View payout
  • )} - {status.view.withdraw_execution &&
  • Refund: {formatSol(status.view.withdraw_execution.amount_lamports)} SOL to {status.view.withdraw_execution.destination_address}. View refund
  • } -
} - - - {!isTerminal && <> - {!transfer.recoveryCode && } -

Refunds go to the original deposit wallet. An accepted request stays pending until sync confirms its outcome.

- - {transfers.refundBlocked &&

A refund request is pending or uncertain. Refresh status; do not submit it again.

} + -
; + {connection.phase === "error" && } + + {transfers.creationBlocked &&

An incomplete or uncertain request is saved. before creating another transfer.

} + + + + { if (event.target === event.currentTarget) event.currentTarget.close(); }}>
+

Select recipient

+
SolanaSOL · Devnet

Only SOL transfers are supported. Check the full recipient address before continuing.

+
+
+

Review transfer

+
{amountSol || "0"} SOL

Private transfer on Solana devnet

+
Recipient
{destination}
Payout window
{windowLabel(deadlineMs)}
+

Create the deposit address, save your Recovery Code, then send the exact deposit from your own wallet. The accepted policy determines fees and the final payout.

+ +
+ +

Invisible example

Devnet beta. Your Recovery Code is stored in this browser. Browser storage is not an encrypted vault.

SDK documentation ↗Source and integration guide ↗
+ ; +} +function RefundForm({ hasRecoveryCode, canRefund, blocked, onRefund }: { hasRecoveryCode: boolean; canRefund: boolean; blocked: boolean; onRefund: (code: string) => Promise }) { + const [code, setCode] = useState(""); + return
{ event.preventDefault(); if (canRefund) void onRefund(code).then(() => setCode("")); }}> + {!hasRecoveryCode && } +

Refunds return to the original deposit wallet. Acceptance is pending until status confirms the outcome.

+ {blocked &&

A refund is pending or uncertain. Refresh status before submitting again.

} +
; } diff --git a/src/style.css b/src/style.css index 3aa6205..e5122fe 100644 --- a/src/style.css +++ b/src/style.css @@ -1,36 +1,184 @@ :root { color-scheme: dark; - --background: #111315; - --surface: #1a1d20; - --text: #eef1f3; - --muted: #aab4bb; - --accent: #a8e6d5; - --danger: #ffb6b6; - --border: #414a50; - --radius: 0.6rem; - font-family: system-ui, sans-serif; + --background: #131313; + --surface: #1f1f1f; + --surface-hover: #292929; + --selected: #303030; + --text: #ffffff; + --muted: #9b9b9b; + --quiet: #6b6b6b; + --accent: #ff37c7; + --accent-hover: #ff5ed1; + --accent-tint: #ff37c714; + --danger: #ff9898; + --border: #333333; + --overlay: #00000066; + --positive: #40c9a1; + --solana: #b89cfa; + --radius-card: 24px; + --radius-button: 20px; + --radius-dialog: 16px; + --radius-pill: 999px; + --card-width: 480px; + --dialog-width: 400px; + --popover-width: 320px; + --header-height: 72px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: var(--font); color: var(--text); background: var(--background); - line-height: 1.6; + line-height: 1.4; + font-synthesis: none; + -webkit-font-smoothing: antialiased; } * { box-sizing: border-box; } body { margin: 0; } -main { max-width: 48rem; margin: 0 auto; padding: 3rem 1rem; } -h1 { font-size: clamp(2rem, 7vw, 3rem); line-height: 1.2; } -h2 { font-size: 1.2rem; margin-top: 0; } -header { margin-bottom: 2rem; } -section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.4rem; margin-top: 1rem; } -label { display: grid; gap: 0.35rem; margin: 1rem 0; } -input, select, button { font: inherit; border: 1px solid var(--border); border-radius: var(--radius); padding: 0.65rem; max-width: 100%; } -input, select { background: var(--background); color: var(--text); width: 100%; } -button { background: var(--accent); color: var(--background); cursor: pointer; } -button:disabled { opacity: 0.5; cursor: not-allowed; } +button, input, select { font: inherit; color: inherit; } +button, a, summary { -webkit-tap-highlight-color: transparent; } +button { border: 0; cursor: pointer; background: none; } +button:disabled { cursor: not-allowed; } +a { color: inherit; text-decoration: none; } +a:hover { color: var(--text); } :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } -a { color: var(--accent); } -nav, .actions { display: flex; gap: 1rem; flex-wrap: wrap; } -.hint, .tag { color: var(--muted); font-size: 0.9rem; } -.error { color: var(--danger); overflow-wrap: anywhere; } -code, li { overflow-wrap: anywhere; } -.copy-value { display: grid; gap: 0.6rem; margin: 1rem 0; } -.copy-value button { justify-self: start; } -summary { cursor: pointer; } +button, a { transition: color 100ms, background-color 100ms, opacity 100ms; } +button:not(:disabled):active { transform: scale(.98); } +h2, p { margin: 0; } +h2 { font-size: 18px; font-weight: 500; } +[hidden] { display: none !important; } +.muted { color: var(--muted); } +.app-header { height: var(--header-height); display: flex; align-items: center; padding: 12px 20px; gap: 32px; } +.brand { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); font-size: 22px; font-weight: 500; } +.brand:hover { color: var(--accent-hover); } +.diamond { font-size: 34px; line-height: 1; } +.app-header nav { display: flex; align-items: center; gap: 28px; } +.app-header nav button, .app-header nav a { display: inline-flex; align-items: center; gap: 4px; padding: 8px 0; font-size: 20px; color: var(--muted); } +.app-header nav button:hover, .app-header nav .nav-active { color: var(--text); } +.app-header nav svg { width: 14px; transform: rotate(-45deg); } +.network { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: 14px; color: var(--muted); white-space: nowrap; } +.connection-dot { width: 7px; height: 7px; border-radius: var(--radius-pill); background: var(--quiet); } +.connection-dot.phase-connected { background: var(--positive); } +.connection-dot.phase-connecting { background: var(--accent); } +.connection-dot.phase-error { background: var(--danger); } +.app-main { width: min(var(--card-width), calc(100% - 16px)); margin: 66px auto 80px; } +.toolbar { display: flex; align-items: center; justify-content: space-between; padding: 0 5px; margin-bottom: 5px; height: 36px; } +.tabs { display: flex; gap: 12px; } +.tabs button { display: flex; gap: 6px; align-items: center; padding: 8px 12px; border-radius: var(--radius-pill); color: var(--muted); font-size: 16px; font-weight: 500; } +.tabs button[aria-selected="true"] { background: var(--selected); color: var(--text); } +.tabs button:hover { color: var(--text); } +.count { font-size: 11px; background: var(--surface); padding: 1px 5px; border-radius: var(--radius-pill); } +.toolbar-tools { display: flex; gap: 8px; } +.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 5px; border-radius: var(--radius-pill); color: var(--muted); flex-shrink: 0; } +.icon-button:hover { background: var(--surface-hover); color: var(--text); } +.icon-button:disabled { opacity: .4; } +.amount-card, .recipient-card { height: 136px; padding: 16px; border-radius: var(--radius-card); } +.amount-card { border: 1px solid var(--border); } +.recipient-card { background: var(--surface); margin-top: 4px; height: 132px; } +.amount-card > label, .recipient-card > .muted { font-size: 16px; } +.amount-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 6px 0 8px; height: 44px; } +.amount-row input, .receive-amount { font-size: 36px; font-weight: 400; line-height: 1.2; min-width: 0; } +.amount-row input { background: none; border: 0; padding: 0; width: 100%; } +.amount-row input::placeholder, .receive-amount { color: var(--quiet); opacity: 1; } +.receive-amount { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.receive-amount.entered { color: var(--text); } +.card-caption { display: flex; justify-content: space-between; gap: 8px; font-size: 14px; color: var(--muted); } +.token-pill, .recipient-pill { display: inline-flex; align-items: center; justify-content: center; gap: 8px; flex-shrink: 0; padding: 4px 10px 4px 4px; height: 38px; border-radius: var(--radius-pill); font-weight: 600; } +.token-pill { border: 1px solid var(--border); } +.sol-icon { display: inline-flex; width: 28px; height: 28px; border-radius: var(--radius-pill); align-items: center; justify-content: center; background: var(--background); flex-shrink: 0; } +.sol-icon svg { width: 21px; height: 21px; fill: var(--solana); } +.recipient-pill { background: var(--accent); padding: 8px 12px; font-size: 16px; } +.recipient-pill:hover { background: var(--accent-hover); } +.recipient-pill.selected { background: var(--surface-hover); border: 1px solid var(--border); font-size: 14px; } +.flow-arrow { width: 40px; height: 40px; border: 4px solid var(--background); border-radius: var(--radius-dialog); background: var(--surface); display: flex; align-items: center; justify-content: center; margin: -18px auto -22px; position: relative; z-index: 1; color: var(--text); } +.flow-arrow svg { width: 24px; height: 24px; } +.primary-action, .secondary-action { width: 100%; padding: 16px 20px; border-radius: var(--radius-button); font-size: 18px; font-weight: 600; margin-top: 4px; background: var(--accent); min-height: 55px; } +.primary-action:hover:not(:disabled) { background: var(--accent-hover); } +.primary-action:disabled { background: var(--surface); color: var(--muted); } +.secondary-action { background: var(--accent-tint); color: var(--accent); font-size: 16px; } +.secondary-action:disabled { color: var(--quiet); background: var(--surface); } +.form-footnote { color: var(--quiet); text-align: center; font-size: 12px; margin: 16px 12px; } +.text-button { color: var(--accent); padding: 0; font-size: inherit; } +.text-button:hover { color: var(--accent-hover); } +.text-button:disabled { opacity: .5; } +.retry-button { display: block; margin: 12px auto; } +.hint { font-size: 13px; color: var(--muted); line-height: 1.5; } +.error, .notice { font-size: 14px; line-height: 1.5; margin-top: 12px; overflow-wrap: anywhere; } +.error { color: var(--danger); } +.notice { color: var(--muted); padding: 12px; background: var(--surface); border-radius: var(--radius-dialog); } +[popover], dialog { color: var(--text); background: var(--background); border: 1px solid var(--border); border-radius: var(--radius-dialog); padding: 0; } +[popover]::backdrop { background: transparent; } +.settings-trigger { anchor-name: --settings; } +.settings-popover { width: min(var(--popover-width), calc(100vw - 24px)); padding: 16px; margin: 0; inset: 184px auto auto max(12px, calc(50% + var(--card-width) / 2 - var(--popover-width))); } +@supports (position-anchor: --settings) { .settings-popover { position-anchor: --settings; inset: auto; top: anchor(bottom); right: anchor(right); margin-top: 8px; } } +.settings-popover h2 { margin-bottom: 16px; } +.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 16px 0; font-size: 14px; } +select { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 6px 8px; font-size: 14px; max-width: 160px; } +dialog { width: min(var(--dialog-width), calc(100% - 24px)); max-height: calc(100dvh - 32px); overflow: auto; } +dialog::backdrop { background: var(--overlay); } +.dialog-content { padding: 20px; } +.dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; } +.address-field { display: flex; gap: 8px; align-items: center; border-radius: var(--radius-button); background: var(--surface); padding: 12px; } +.address-field input { min-width: 0; width: 100%; border: 0; background: none; font-size: 16px; padding: 4px 0; } +.recipient-info { display: flex; align-items: center; gap: 12px; padding: 24px 4px; } +.recipient-info span:nth-child(2) { flex: 1; } +.recipient-info strong, .recipient-info small { display: block; } +.recipient-info small { color: var(--muted); } +.recipient-info > svg { color: var(--accent); } +.recipient-dialog .primary-action { margin-top: 20px; } +.review-amount { display: flex; align-items: center; gap: 12px; font-size: 32px; margin-bottom: 8px; } +.review-amount strong { font-weight: 500; } +.review-dialog .primary-action { margin-top: 20px; } +.full-address { overflow-wrap: anywhere; max-width: 65%; } +.empty-state { padding: 48px 16px; text-align: center; border: 1px solid var(--border); border-radius: var(--radius-card); color: var(--muted); } +.empty-state > svg { width: 40px; height: 40px; color: var(--quiet); margin-bottom: 16px; } +.empty-state h2 { color: var(--text); } +.empty-state p { font-size: 14px; margin: 8px 0 20px; } +.transfer-list { border: 1px solid var(--border); border-radius: var(--radius-card); padding: 8px; margin-bottom: 8px; } +.transfer-item { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 8px; text-align: left; border-radius: var(--radius-dialog); } +.transfer-item:hover, .transfer-item.selected { background: var(--surface); } +.transfer-item > span:nth-child(2) { flex: 1; } +.transfer-item strong, .transfer-item small { display: block; } +.transfer-item strong { font-size: 16px; font-weight: 500; } +.transfer-item small { font-size: 12px; color: var(--muted); margin-top: 2px; } +.transfer-item > .muted { font-size: 12px; text-align: right; max-width: 35%; } +.status-card { background: var(--surface); padding: 20px; border-radius: var(--radius-card); font-size: 14px; } +.status-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.status-card p { margin-bottom: 12px; } +.copy-value { margin: 16px 0; font-size: 12px; } +.copy-value > div { display: flex; align-items: center; gap: 12px; background: var(--background); border-radius: var(--radius-dialog); padding: 12px; margin-top: 6px; } +.copy-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; } +.recovery { padding: 12px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.recovery summary { cursor: pointer; } +.recovery summary .muted { font-size: 12px; float: right; } +.status-facts { margin: 20px 0; font-size: 14px; } +.status-facts > div { display: flex; justify-content: space-between; align-items: start; gap: 16px; margin: 12px 0; } +.status-facts dt { color: var(--muted); } +.status-facts dd { margin: 0; text-align: right; } +.payouts { list-style: none; padding: 0; font-size: 13px; } +.payouts li { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; overflow-wrap: anywhere; } +.payouts a { color: var(--accent); } +.refund-form { border-top: 1px solid var(--border); padding-top: 16px; margin-top: 16px; } +.refund-form label { display: block; } +.refund-form input { width: 100%; margin: 8px 0; padding: 12px; background: var(--background); border: 1px solid var(--border); border-radius: var(--radius-dialog); } +.help-button { position: fixed; bottom: 12px; left: 12px; color: var(--text); anchor-name: --help; } +.help-popover { width: min(var(--popover-width), calc(100% - 24px)); padding: 20px; margin: 0; inset: auto auto 52px 12px; } +.help-popover h2 { margin-bottom: 12px; } +.help-popover a, .help-popover .text-button { display: block; font-size: 14px; margin-top: 16px; } +@media (max-width: 600px) { + .app-header { gap: 16px; padding: 12px 16px; } + .brand { font-size: 18px; gap: 4px; } + .diamond { font-size: 28px; } + .app-header nav { gap: 16px; } + .app-header nav button { display: none; } + .app-header nav a { font-size: 14px; } + .network { font-size: 12px; } + .app-main { margin-top: 26px; } + .tabs { gap: 8px; } + .tabs button { font-size: 14px; } + .settings-popover { top: 140px; right: 12px; left: auto; } + .amount-row input, .receive-amount { font-size: 34px; } + .recipient-pill { font-size: 14px; padding: 8px 10px; gap: 4px; } + .recipient-pill.selected { font-size: 12px; } + .card-caption { font-size: 12px; } + .primary-action { font-size: 16px; } +} +@media (prefers-reduced-motion: reduce) { button, a { transition: none; } button:not(:disabled):active { transform: none; } } From d4b4ffe2ebf3008df23cb6f22a0fed35a1139c0f Mon Sep 17 00:00:00 2001 From: JW-Rami <106039941+JW-Rami@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:54:29 +0200 Subject: [PATCH 2/3] fix: round recipient input focus indicator --- src/style.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/style.css b/src/style.css index e5122fe..c3e75cc 100644 --- a/src/style.css +++ b/src/style.css @@ -117,7 +117,9 @@ dialog::backdrop { background: var(--overlay); } .dialog-content { padding: 20px; } .dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; } .address-field { display: flex; gap: 8px; align-items: center; border-radius: var(--radius-button); background: var(--surface); padding: 12px; } +.address-field:focus-within { outline: 2px solid var(--muted); outline-offset: 3px; } .address-field input { min-width: 0; width: 100%; border: 0; background: none; font-size: 16px; padding: 4px 0; } +.address-field input:focus-visible { outline: none; } .recipient-info { display: flex; align-items: center; gap: 12px; padding: 24px 4px; } .recipient-info span:nth-child(2) { flex: 1; } .recipient-info strong, .recipient-info small { display: block; } From 37bff8d474e1f03e2ddf01a425cd3beba5e19539 Mon Sep 17 00:00:00 2001 From: JW-Rami <106039941+JW-Rami@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:07:03 +0200 Subject: [PATCH 3/3] feat: show destination field and custom payout choices --- README.md | 4 +-- docs/adr/0002-transfer-interface.md | 6 ++--- src/App.test.tsx | 28 ++++++++++----------- src/App.tsx | 31 ++++++----------------- src/style.css | 39 +++++++++++++---------------- 5 files changed, 44 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 82a6d2f..cf61261 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,14 @@ npm run dev No GitHub Packages authentication or API key is needed. The SDK's `invisibleDevnet()` preset supplies the default route. To use another compatible coordinator, copy `.env.example` to `.env.local` and set `VITE_INVISIBLE_COORDINATOR_URL` to its public `wss://` URL. The route cannot override the SDK's embedded attestation policy. 1. Open the app. The SDK automatically opens the session, completes Noise XX and verifies attestation. No wallet connection is required. -2. Enter an amount, select the recipient and choose a payout window in Settings. Review the full address, then create the transfer. +2. Enter an amount, paste the destination address into the form and choose a payout window in Settings. Review the full address, then create the transfer. 3. Save the Recovery Code. Send the exact displayed devnet SOL amount from your external Solana wallet to the deposit address before expiry. 4. Follow authenticated status updates, confirmed payouts and refund outcomes in Activity. 5. If eligible, request a refund to the original deposit wallet. This example does not send the deposit transaction for you. Keep wallet funding in your application's existing wallet integration. -The interface follows the compact card layout of [Uniswap](https://app.uniswap.org/swap), adapted for private SOL transfers. Recipient/review dialogs and settings/help popovers use native browser elements. No wallet provider is requested. +The interface follows the compact card layout of [Uniswap](https://app.uniswap.org/swap), adapted for private SOL transfers. The destination address is visible directly in the form. Settings offer styled payout choices with keyboard support; the review dialog and settings/help popovers use native browser elements. No wallet provider is requested. ## Copy into your frontend diff --git a/docs/adr/0002-transfer-interface.md b/docs/adr/0002-transfer-interface.md index 78a6bf3..ae2f111 100644 --- a/docs/adr/0002-transfer-interface.md +++ b/docs/adr/0002-transfer-interface.md @@ -4,13 +4,13 @@ Status: accepted. Date: 2026-10-08. ## Problem -The sequential integration demo exposed connection controls before the transfer form. A compact interface was requested, closely following Uniswap while requiring no wallet connection. +The sequential integration demo exposed connection controls before the transfer form. A compact interface was requested, inspired by Uniswap while requiring no wallet connection. The recipient modal added an unnecessary step, and the browser select did not fit the visual style. ## Decision -Use Uniswap's dark palette, centered 480 px card, amount hierarchy and pill controls with Invisible branding. Map the second token selector to a Solana recipient dialog; expose only transfer, activity and supported payout windows. Automatically open the SDK session. Require an explicit review confirmation before creating a transfer. +Use Uniswap's dark palette, centered 480 px card, amount hierarchy and pill controls with Invisible branding. Show the destination address directly in the second card; expose only transfer, activity and supported payout windows. Use styled native radio inputs for the four SDK-supported windows in Settings. Automatically open the SDK session. Require an explicit review confirmation before creating a transfer. -Native dialogs and popovers handle focus, Escape and dismissal. Keep the existing copyable SDK hooks and financial safeguards. +The review dialog and popovers handle focus, Escape and dismissal. Native radio inputs handle keyboard selection without a custom state machine. Keep the existing copyable SDK hooks and financial safeguards. ## Alternatives and limits diff --git a/src/App.test.tsx b/src/App.test.tsx index 8d30243..f7c776a 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -16,7 +16,6 @@ const DEPOSIT = "So11111111111111111111111111111111111111112"; const SWAP_ID = "test-transfer"; const RECOVERY_CODE_LENGTH = 32; const DEPOSIT_WINDOW_MS = 60_000; -const SCHEDULED_WINDOW_MS = SUPPORTED_TOTAL_DEADLINE_MS[1]; const ENTRY_LAMPORTS = 1_500_000_001; const session = { attested: true } as Session; const record: PersistedTransfer = { @@ -43,23 +42,26 @@ beforeEach(() => { }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); -async function enterTransfer() { +async function enterTransfer(destination = DESTINATION) { await waitFor(() => expect(createSession).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByLabelText("SOL amount"), { target: { value: "1.500000001" } }); - fireEvent.click(screen.getByRole("button", { name: "Select recipient address" })); - fireEvent.change(screen.getByLabelText("Recipient address"), { target: { value: DESTINATION } }); - fireEvent.click(screen.getByRole("button", { name: "Use this address" })); + fireEvent.change(screen.getByLabelText("Destination address"), { target: { value: destination } }); await waitFor(() => expect((screen.getByRole("button", { name: "Review transfer" }) as HTMLButtonElement).disabled).toBe(false)); } -it("opens a read-only session without a wallet and creates only after a reviewed explicit command", async () => { +it.each(SUPPORTED_TOTAL_DEADLINE_MS)("reviews the inline address and sends the selected payout window %i only after confirmation", async windowMs => { let accept!: (result: ContractRequestResult) => void; vi.mocked(contractRequest).mockReturnValue(new Promise(resolve => { accept = resolve; })); render(); - await enterTransfer(); + expect(screen.getByLabelText("Destination address")).toBeTruthy(); + await enterTransfer(` ${DESTINATION} `); expect(contractRequest).not.toHaveBeenCalled(); expect(requestRefund).not.toHaveBeenCalled(); expect(screen.queryByRole("button", { name: /connect wallet/i })).toBeNull(); - fireEvent.change(screen.getByLabelText("Payout window"), { target: { value: String(SCHEDULED_WINDOW_MS) } }); + // jsdom omits native popover opening; real mouse/keyboard behavior is checked in Brave. + const options = screen.getByText("Payout window", { selector: "legend" }).closest("fieldset")!; + const choice = within(options).getAllByRole("radio", { hidden: true }).find(input => (input as HTMLInputElement).value === String(windowMs))!; + fireEvent.click(choice); + expect((choice as HTMLInputElement).checked).toBe(true); fireEvent.click(screen.getByRole("button", { name: "Review transfer" })); const review = screen.getByRole("dialog", { name: "Review transfer" }); expect(within(review).getByText(DESTINATION)).toBeTruthy(); @@ -67,7 +69,7 @@ it("opens a read-only session without a wallet and creates only after a reviewed fireEvent.click(confirm); fireEvent.click(confirm); await waitFor(() => expect(contractRequest).toHaveBeenCalledTimes(1)); - expect(contractRequest).toHaveBeenCalledWith(session, { amountLamports: ENTRY_LAMPORTS, payoutPolicy: { destinations: [{ address: DESTINATION, sharePercent: 100 }], totalDeadlineMs: SCHEDULED_WINDOW_MS } }); + expect(contractRequest).toHaveBeenCalledWith(session, { amountLamports: ENTRY_LAMPORTS, payoutPolicy: { destinations: [{ address: DESTINATION, sharePercent: 100 }], totalDeadlineMs: windowMs } }); vi.mocked(listPersistedTransfers).mockResolvedValue([record]); await act(async () => { accept({ swapId: SWAP_ID } as ContractRequestResult); }); await waitFor(() => expect(screen.getByText(DEPOSIT)).toBeTruthy()); @@ -80,12 +82,10 @@ it("validates recipient and amount without sending a command; permits closing th fireEvent.keyDown(tabs, { key: "ArrowRight" }); expect(document.activeElement).toBe(screen.getByRole("tab", { name: "Activity" })); fireEvent.keyDown(tabs, { key: "Home" }); - fireEvent.click(screen.getByRole("button", { name: "Select recipient address" })); - fireEvent.change(screen.getByLabelText("Recipient address"), { target: { value: "invalid-address" } }); - fireEvent.click(screen.getByRole("button", { name: "Use this address" })); - expect(screen.getByRole("dialog", { name: "Select recipient" })).toBeTruthy(); + await enterTransfer("invalid-address"); + fireEvent.click(screen.getByRole("button", { name: "Review transfer" })); + expect(screen.queryByRole("dialog", { name: "Review transfer" })).toBeNull(); expect(screen.getByRole("alert").textContent).not.toContain("[object Object]"); - fireEvent.click(screen.getByRole("button", { name: "Close recipient" })); await enterTransfer(); fireEvent.change(screen.getByLabelText("SOL amount"), { target: { value: "1.499999999" } }); fireEvent.click(screen.getByRole("button", { name: "Review transfer" })); diff --git a/src/App.tsx b/src/App.tsx index 9fea15f..0366faa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,12 +62,8 @@ export default function App() { const [tab, setTab] = useState<"transfer" | "activity">("transfer"); const [amountSol, setAmountSol] = useState(""); const [destination, setDestination] = useState(""); - const [recipientInput, setRecipientInput] = useState(""); const [deadlineMs, setDeadlineMs] = useState(INSTANT_PAYOUT_WINDOW_MS); const [formError, setFormError] = useState(null); - const [recipientError, setRecipientError] = useState(null); - const recipientDialog = useRef(null); - const recipientField = useRef(null); const reviewDialog = useRef(null); const transfer = transfers.selected; const snapshot = status.view?.actor_sync?.snapshot; @@ -90,21 +86,14 @@ export default function App() { } }, [status.fresh, status.view, transfers.reconcileRefund, transfers.reload, connection.fail]); - function chooseRecipient(event: FormEvent) { - event.preventDefault(); - try { - publicKey(recipientInput.trim()); - setDestination(recipientInput.trim()); - setRecipientError(null); - recipientDialog.current?.close(); - } catch (error) { setRecipientError(toUiError(error)); } - } function review(event: FormEvent) { event.preventDefault(); if (!canCreate) return; try { parseSolAmount(amountSol); - publicKey(destination); + const address = destination.trim(); + publicKey(address); + setDestination(address); setFormError(null); reviewDialog.current?.showModal(); } catch (error) { setFormError(toUiError(error)); } @@ -147,7 +136,7 @@ export default function App() {

Transfer settings

- +
Payout window{SUPPORTED_TOTAL_DEADLINE_MS.map(windowMs => )}
NetworkSolana devnet

Scheduled payouts follow the accepted coordinator policy. Only devnet SOL is supported.

@@ -161,11 +150,11 @@ export default function App() {
- To -
{amountSol || "0"}
-
SOL · Before fees{windowLabel(deadlineMs)}
+ +
setDestination(event.target.value)} />
+
Solana devnet{windowLabel(deadlineMs)}
- + {connection.phase === "error" && }

No wallet connection needed. Fund the deposit from your own wallet.

@@ -197,10 +186,6 @@ export default function App() { - { if (event.target === event.currentTarget) event.currentTarget.close(); }}>
-

Select recipient

-
SolanaSOL · Devnet

Only SOL transfers are supported. Check the full recipient address before continuing.

-

Review transfer

{amountSol || "0"} SOL

Private transfer on Solana devnet

diff --git a/src/style.css b/src/style.css index c3e75cc..7ad05de 100644 --- a/src/style.css +++ b/src/style.css @@ -33,7 +33,7 @@ } * { box-sizing: border-box; } body { margin: 0; } -button, input, select { font: inherit; color: inherit; } +button, input { font: inherit; color: inherit; } button, a, summary { -webkit-tap-highlight-color: transparent; } button { border: 0; cursor: pointer; background: none; } button:disabled { cursor: not-allowed; } @@ -72,22 +72,17 @@ h2 { font-size: 18px; font-weight: 500; } .icon-button:disabled { opacity: .4; } .amount-card, .recipient-card { height: 136px; padding: 16px; border-radius: var(--radius-card); } .amount-card { border: 1px solid var(--border); } -.recipient-card { background: var(--surface); margin-top: 4px; height: 132px; } -.amount-card > label, .recipient-card > .muted { font-size: 16px; } +.recipient-card { background: var(--surface); margin-top: 4px; height: auto; } +.amount-card > label, .recipient-card > label { font-size: 16px; } .amount-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 6px 0 8px; height: 44px; } -.amount-row input, .receive-amount { font-size: 36px; font-weight: 400; line-height: 1.2; min-width: 0; } +.amount-row input { font-size: 36px; font-weight: 400; line-height: 1.2; min-width: 0; } .amount-row input { background: none; border: 0; padding: 0; width: 100%; } -.amount-row input::placeholder, .receive-amount { color: var(--quiet); opacity: 1; } -.receive-amount { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.receive-amount.entered { color: var(--text); } +.amount-row input::placeholder { color: var(--quiet); opacity: 1; } .card-caption { display: flex; justify-content: space-between; gap: 8px; font-size: 14px; color: var(--muted); } -.token-pill, .recipient-pill { display: inline-flex; align-items: center; justify-content: center; gap: 8px; flex-shrink: 0; padding: 4px 10px 4px 4px; height: 38px; border-radius: var(--radius-pill); font-weight: 600; } +.token-pill { display: inline-flex; align-items: center; justify-content: center; gap: 8px; flex-shrink: 0; padding: 4px 10px 4px 4px; height: 38px; border-radius: var(--radius-pill); font-weight: 600; } .token-pill { border: 1px solid var(--border); } .sol-icon { display: inline-flex; width: 28px; height: 28px; border-radius: var(--radius-pill); align-items: center; justify-content: center; background: var(--background); flex-shrink: 0; } .sol-icon svg { width: 21px; height: 21px; fill: var(--solana); } -.recipient-pill { background: var(--accent); padding: 8px 12px; font-size: 16px; } -.recipient-pill:hover { background: var(--accent-hover); } -.recipient-pill.selected { background: var(--surface-hover); border: 1px solid var(--border); font-size: 14px; } .flow-arrow { width: 40px; height: 40px; border: 4px solid var(--background); border-radius: var(--radius-dialog); background: var(--surface); display: flex; align-items: center; justify-content: center; margin: -18px auto -22px; position: relative; z-index: 1; color: var(--text); } .flow-arrow svg { width: 24px; height: 24px; } .primary-action, .secondary-action { width: 100%; padding: 16px 20px; border-radius: var(--radius-button); font-size: 18px; font-weight: 600; margin-top: 4px; background: var(--accent); min-height: 55px; } @@ -110,22 +105,24 @@ h2 { font-size: 18px; font-weight: 500; } .settings-popover { width: min(var(--popover-width), calc(100vw - 24px)); padding: 16px; margin: 0; inset: 184px auto auto max(12px, calc(50% + var(--card-width) / 2 - var(--popover-width))); } @supports (position-anchor: --settings) { .settings-popover { position-anchor: --settings; inset: auto; top: anchor(bottom); right: anchor(right); margin-top: 8px; } } .settings-popover h2 { margin-bottom: 16px; } +.payout-options { border: 0; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.payout-options legend { padding: 0; margin-bottom: 12px; font-size: 14px; } +.payout-options label { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-dialog); font-size: 14px; cursor: pointer; } +.payout-options label:hover { background: var(--surface-hover); } +.payout-options input { position: absolute; opacity: 0; width: 1px; height: 1px; } +.payout-options label:has(:checked) { border-color: var(--accent); color: var(--accent); background: var(--accent-tint); } +.payout-options label:has(:focus-visible) { outline: 2px solid var(--muted); outline-offset: 3px; } +.payout-options label > svg { opacity: 0; flex-shrink: 0; } +.payout-options label:has(:checked) > svg { opacity: 1; } .settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 16px 0; font-size: 14px; } -select { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 6px 8px; font-size: 14px; max-width: 160px; } dialog { width: min(var(--dialog-width), calc(100% - 24px)); max-height: calc(100dvh - 32px); overflow: auto; } dialog::backdrop { background: var(--overlay); } .dialog-content { padding: 20px; } .dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; } -.address-field { display: flex; gap: 8px; align-items: center; border-radius: var(--radius-button); background: var(--surface); padding: 12px; } +.address-field { margin: 12px 0; display: flex; gap: 8px; align-items: center; border-radius: var(--radius-button); background: var(--background); padding: 12px; } .address-field:focus-within { outline: 2px solid var(--muted); outline-offset: 3px; } .address-field input { min-width: 0; width: 100%; border: 0; background: none; font-size: 16px; padding: 4px 0; } .address-field input:focus-visible { outline: none; } -.recipient-info { display: flex; align-items: center; gap: 12px; padding: 24px 4px; } -.recipient-info span:nth-child(2) { flex: 1; } -.recipient-info strong, .recipient-info small { display: block; } -.recipient-info small { color: var(--muted); } -.recipient-info > svg { color: var(--accent); } -.recipient-dialog .primary-action { margin-top: 20px; } .review-amount { display: flex; align-items: center; gap: 12px; font-size: 32px; margin-bottom: 8px; } .review-amount strong { font-weight: 500; } .review-dialog .primary-action { margin-top: 20px; } @@ -177,9 +174,7 @@ dialog::backdrop { background: var(--overlay); } .tabs { gap: 8px; } .tabs button { font-size: 14px; } .settings-popover { top: 140px; right: 12px; left: auto; } - .amount-row input, .receive-amount { font-size: 34px; } - .recipient-pill { font-size: 14px; padding: 8px 10px; gap: 4px; } - .recipient-pill.selected { font-size: 12px; } + .amount-row input { font-size: 34px; } .card-caption { font-size: 12px; } .primary-action { font-size: 16px; } }