Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.
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. 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

Copy **the entire `src/invisible/` folder**, excluding `invisible.test.tsx`, and install the same public SDK version:
Expand Down
19 changes: 19 additions & 0 deletions docs/adr/0002-transfer-interface.md
Original file line number Diff line number Diff line change
@@ -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, 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. 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.

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

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
2 changes: 1 addition & 1 deletion docs/repo-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
125 changes: 125 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@invisible-labs/sdk")>();
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<typeof import("@invisible-labs/sdk/user")>(), 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 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(destination = DESTINATION) {
await waitFor(() => expect(createSession).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText("SOL amount"), { target: { value: "1.500000001" } });
fireEvent.change(screen.getByLabelText("Destination address"), { target: { value: destination } });
await waitFor(() => expect((screen.getByRole("button", { name: "Review transfer" }) as HTMLButtonElement).disabled).toBe(false));
}
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(<App />);
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();
// 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();
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: windowMs } });
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(<App />);
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" });
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]");
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(<App />);
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(<App />);
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(<App />);
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();
});
Loading
Loading