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
33 changes: 13 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,36 +40,29 @@ npm install @invisible-labs/[email protected]
The folder depends only on React and the public SDK. It imports no app components, routing, design system, Vite environment variables or private workspace packages.

```tsx
import { useState } from "react";
import { invisibleDevnet } from "@invisible-labs/sdk/presets";
import { useInvisibleSession, usePrivateTransfers, useTransferStatus } from "./invisible";

const coordinatorUrl = invisibleDevnet();
import { usePrivateTransferController } from "./invisible";
import { PrivateTransferWidget, type TransferTab } from "./invisible/ui";
import "./invisible/styles.css";

function PrivateTransfer() {
const connection = useInvisibleSession(coordinatorUrl);
const transfers = usePrivateTransfers(connection.session, coordinatorUrl);
const status = useTransferStatus(connection.session, transfers.selected, connection.fail);

return <>
<button onClick={() => void connection.connect()}>Connect</button>
<button disabled={!connection.session || !transfers.loaded || transfers.busy || transfers.creationBlocked}
onClick={() => void transfers.createTransfer({
amountSol: "1.5",
destination: "YOUR_SOLANA_DESTINATION_ADDRESS",
deadlineMs: 0,
})}>Create transfer</button>
<p>{status.view?.state}</p>
<p role="alert">{connection.error?.message ?? transfers.error?.message ?? status.error?.message}</p>
</>;
const controller = usePrivateTransferController(invisibleDevnet());
const [tab, setTab] = useState<TransferTab>("transfer");
return <PrivateTransferWidget controller={controller} tab={tab} onTabChange={setTab} />;
}
```

See [`src/App.tsx`](src/App.tsx) for the complete deposit, saved-transfer, Recovery Code and refund UI. See the [folder guide](src/invisible/README.md) for lifecycle and integration details.
The controller opens one attested session and coordinates the existing hooks. The widget provides address input, review, deposit instructions, activity and refunds. It never opens another session. The parent controls the tab so navigation can be shared with its own header.

For your own design, import the controller or individual hooks from `./invisible`; import only the display modules you need from `./invisible/ui`. UI styles are scoped to `.invisible-widget`. Wrap standalone display modules in that class, and customize `--invisible-*` variables on the wrapper. Do not copy the demo's global `src/style.css`.

See the [folder guide](src/invisible/README.md) for module interfaces, lifecycle and safety rules. [`src/App.tsx`](src/App.tsx) only supplies the demo route, header and navigation.

## Ownership and safety

- **SDK:** WebSocket transport, Noise, attestation, DKG/delegation, policy validation, recovery codes, typed errors and local transfer records.
- **Hooks:** React cleanup, command concurrency, selected transfer, polling, refresh subscriptions and readable errors.
- **Hooks/controller:** React cleanup, command concurrency, selected transfer, polling, refresh subscriptions, deposit/refund guards and readable errors.
- **Application:** form/UI, external wallet deposit and your storage/privacy policy.
- `SyncRequired` messages carry refresh hints. Only `sync` provides lifecycle truth.
- Polling never overlaps; retries apply to reads. A failed attestation stops the session. Disconnect closes it; reconnect creates and attests a fresh one. Select a saved transfer to resume reads.
Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0003-reusable-transfer-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ADR-0003: Reusable transfer interface

Status: Accepted
Date: 2026-10-08

## Problem

The SDK hooks were copyable, but the demo App mixed session orchestration, financial eligibility and all display modules. Integrators could not copy the complete interface independently of the demo layout.

## Options considered

- Split JSX alone: financial guards remain tied to the demo.
- Add a global store/provider: introduces another lifecycle and dependency surface.
- Compose the existing hooks in one controller and export separate display modules.

## Decision

Use one headless controller per integration and pass it to display modules. Keep everything reusable inside `src/invisible/`, with separate headless/UI exports and scoped CSS. App owns demo configuration, layout and navigation. Keep draft input in the form and SDK protocol behavior in the existing hooks/package.

## Why

A consumer can copy one folder, use the complete widget or replace its presentation, while retaining the same command guards and authoritative status handling.

## Risks

A consumer composing individual modules must retain eligibility and review confirmation. Scoped CSS requires native nesting; dialogs/popovers/Web Locks retain their existing browser requirements. Widgets on the same origin share mutation markers.

## Follow-up

Verify the copied folder in an independent host, multiple instances, keyboard/mobile behavior and financial failure guards. Revisit dependencies only for a measured requirement.

## References

- [Integration interfaces](../../src/invisible/README.md)
- [Original integration decision](0001-copyable-public-sdk-hooks.md)
2 changes: 1 addition & 1 deletion docs/repo-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
| `npm ci` | Install the pinned public dependencies | Lockfile installed; no GitHub token required |
| `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 test` | Run hook/controller and reusable UI behavior tests | All tests pass |
| `npm run build` | Typecheck and produce static assets | `dist/` created |
| `npm run preview` | Serve the production bundle locally | Preview URL printed |
| `npm run check` | Run tests and production build | Exit 0 |
Expand Down
41 changes: 41 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createRef, useState, type FormEvent } from "react";
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";
import { usePrivateTransferController } from "./invisible";
import { PrivateTransferWidget, TransferReview, type TransferTab } from "./invisible/ui";

vi.mock("@invisible-labs/sdk", async importOriginal => {
const actual = await importOriginal<typeof import("@invisible-labs/sdk")>();
Expand Down Expand Up @@ -123,3 +126,41 @@ it("never displays expired or terminal deposit instructions or terminal bearer m
expect(screen.queryByText("Recovery Code")).toBeNull();
expect(screen.queryByRole("button", { name: "Request refund" })).toBeNull();
});

it("two copied widgets keep independent inputs, payout choices and accessible identifiers", async () => {
const COORDINATOR_URL = "wss://coordinator.example/ws-noise";
function Integration({ name }: { name: string }) {
const controller = usePrivateTransferController(COORDINATOR_URL);
const [tab, setTab] = useState<TransferTab>("transfer");
return <section aria-label={name}><PrivateTransferWidget controller={controller} tab={tab} onTabChange={setTab} /></section>;
}
render(<><Integration name="First widget" /><Integration name="Second widget" /></>);
await waitFor(() => expect(createSession).toHaveBeenCalledTimes(2));
const first = screen.getByRole("region", { name: "First widget" });
const second = screen.getByRole("region", { name: "Second widget" });
fireEvent.change(within(first).getByLabelText("Destination address"), { target: { value: DESTINATION } });
expect((within(second).getByLabelText("Destination address") as HTMLInputElement).value).toBe("");
const firstChoices = within(first).getAllByRole("radio", { hidden: true });
const secondChoices = within(second).getAllByRole("radio", { hidden: true });
fireEvent.click(firstChoices[1]);
expect((firstChoices[1] as HTMLInputElement).checked).toBe(true);
expect((secondChoices[0] as HTMLInputElement).checked).toBe(true);
const ids = [...document.querySelectorAll("[id]")].map(node => node.id);
expect(new Set(ids).size).toBe(ids.length);
expect(within(first).getByRole("button", { name: "Transfer settings" }).getAttribute("popovertarget"))
.not.toBe(within(second).getByRole("button", { name: "Transfer settings" }).getAttribute("popovertarget"));
expect(contractRequest).not.toHaveBeenCalled();
expect(requestRefund).not.toHaveBeenCalled();
});

it("the reusable review confirms without submitting its host form", async () => {
const ref = createRef<HTMLDialogElement>();
const onConfirm = vi.fn(async () => {});
const onSubmit = vi.fn((event: FormEvent) => event.preventDefault());
render(<form onSubmit={onSubmit}><TransferReview ref={ref} canCreate={true} input={{ amountSol: "1.5", destination: DESTINATION, deadlineMs: 0 }} onConfirm={onConfirm} /></form>);
act(() => ref.current!.showModal());
fireEvent.click(screen.getByRole("button", { name: "Create private transfer" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
expect(contractRequest).not.toHaveBeenCalled();
});
Loading
Loading