Skip to content

Latest commit

 

History

History
209 lines (160 loc) · 5.93 KB

File metadata and controls

209 lines (160 loc) · 5.93 KB

Headless Controller API

Entry Point

import { createLedgerController } from '@railgun-community/ledger-client';

Purpose

LedgerController is the headless runtime for browser Ledger flows.

Use it when you need:

  • direct programmatic access without React
  • deterministic ownership of transport and signing lifecycle
  • integration with custom UI or non-React environments
  • a stable backend for provider and engine integration

Interface

type LedgerController = {
  connect(options?: { transportType?: 'webhid' | 'nodehid' | 'ble' }): Promise<void>;
  disconnect(): Promise<void>;
  ensureReady(options?: { requiredApp?: AppRequirement }): Promise<void>;
  getPublicKey(): Promise<{ x: bigint; y: bigint }>;
  getWalletArtifacts(): Promise<RailgunWalletArtifacts>;
  sign(expectedHash: bigint, publicInputs?: PublicInputsRailgun, subSession?: string): Promise<Signature>;
  signShieldOwnershipMarker?(derivationIndex: number): Promise<ShieldOwnershipMarkerResult>;
  signEthTransaction(rawTxHex: string, derivationIndex: number): Promise<EthSignResult>;
  requestBatchApproval(requests: readonly RequestApprovalOptions[]): Promise<LedgerBatchApprovalSession>;
  approveCurrentAction(): boolean;
  rejectCurrentAction(reason?: Error): boolean;
  getConnector(): HardwareConnector | null;
  getSnapshot(): LedgerControllerSnapshot;
  clearError(): void;
  subscribe(listener: (snapshot: LedgerControllerSnapshot) => void): () => void;
  dispose(): Promise<void>;
};

Key Methods

connect()

Creates a transport session but does not by itself guarantee app readiness.

Use this when:

  • you want early transport connection before a sign flow
  • you are rendering a staged UX and do not want ensureReady() yet

ensureReady()

Full readiness path:

  • connect transport if needed
  • query device info
  • list installed apps
  • validate required app installation
  • open the required app if needed
  • validate required app version
  • create the controller-backed connector

Use this as the normal precondition for any signing flow.

getWalletArtifacts()

Returns the public wallet artifacts the host app is expected to handle:

  • spendingPublicKey
  • engine-compatible shareableViewingKey
  • derived railgunAddress

This method intentionally does not return raw viewing private key bytes as a separate field. The returned shareableViewingKey is still sensitive because the current engine format embeds the viewing secret material.

sign()

Starts a sign flow and pauses in review state until approveCurrentAction() is called.

Important:

  • the promise will not resolve until approval happens and device signing completes
  • the method always runs through the controller queue
  • if subSession is provided, the controller requires a matching active approval session

requestBatchApproval()

Starts a batch approval flow and pauses in review state until approveCurrentAction() or rejectCurrentAction() is called.

Returns a LedgerBatchApprovalSession:

type LedgerBatchApprovalSession = {
  approved: boolean;
  subSession: string;
  approvalDigest: string;
  deviceSessionId: string;
  createdAt: number;
  expiresAt?: number;
};

The returned subSession is the token that should be threaded into later batch sign calls.

approveCurrentAction() / rejectCurrentAction()

These methods now return boolean.

Return values:

  • true: a pending review action existed and was handled
  • false: there was no pending action to approve or reject

This makes UI integration cleaner because modal handlers can distinguish between:

  • a real approval interaction
  • a stale or duplicated click after the controller already moved on

getSnapshot()

Returns the current immutable controller snapshot.

This is the main read model for external consumers.

Snapshot Shape

Important fields:

  • readiness
  • action
  • isBusy
  • connectorAvailable
  • requiredApp
  • deviceSession
  • approvalSession
  • modal
  • error

readiness is a simplified status view:

  • disconnected
  • connecting
  • querying_device
  • device_ready
  • app_check
  • app_missing
  • app_outdated
  • opening_app
  • ready
  • error

action is a simplified flow view:

  • idle
  • reviewing_sign
  • reviewing_batch
  • awaiting_device_confirmation
  • signing
  • batch_signing
  • complete
  • rejected
  • recoverable_error

Controller Options

type LedgerControllerOptions = {
  requiredApps?: readonly AppRequirement[];
  defaultTransportType?: 'webhid' | 'nodehid' | 'ble';
  approvalTimeoutMs?: number;
  transportFactory?: (transportType: TransportType) => HWTransport;
  onError?: (error: HWError) => void;
  onDisconnect?: () => void;
};

Notes:

  • onError is typed as HWError, not Error
  • transportFactory is primarily for testing and custom transport injection
  • current production transport expectation is webhid

Lifecycle Rules

  • one controller owns one active transport session
  • all actions are serialized internally
  • approval sessions are invalidated on disconnect or reset
  • dispose() is terminal
  • getConnector() only returns a non-null connector after readiness succeeds
  • getConnector().sign() bypasses the review/approval FSM and the controller queue — it is for advanced / read-only use (e.g. getPublicKey), not user-facing signing. Use controller.sign() for anything that must be reviewed and approved. The raw connector has its own internal signing lock that does not compose with the controller queue; never drive signing through both at once.

Recommended Usage Pattern

const controller = createLedgerController();

await controller.ensureReady();

const unsubscribe = controller.subscribe((snapshot) => {
  console.log(snapshot.readiness, snapshot.modal.kind);
});

const signPromise = controller.sign(expectedHash, publicInputs);

if (controller.getSnapshot().modal.kind === 'review_sign') {
  controller.approveCurrentAction();
}

const signature = await signPromise;

unsubscribe();
await controller.dispose();