import { createLedgerController } from '@railgun-community/ledger-client';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
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>;
};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
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.
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.
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
subSessionis provided, the controller requires a matching active approval session
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.
These methods now return boolean.
Return values:
true: a pending review action existed and was handledfalse: 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
Returns the current immutable controller snapshot.
This is the main read model for external consumers.
Important fields:
readinessactionisBusyconnectorAvailablerequiredAppdeviceSessionapprovalSessionmodalerror
readiness is a simplified status view:
disconnectedconnectingquerying_devicedevice_readyapp_checkapp_missingapp_outdatedopening_appreadyerror
action is a simplified flow view:
idlereviewing_signreviewing_batchawaiting_device_confirmationsigningbatch_signingcompleterejectedrecoverable_error
type LedgerControllerOptions = {
requiredApps?: readonly AppRequirement[];
defaultTransportType?: 'webhid' | 'nodehid' | 'ble';
approvalTimeoutMs?: number;
transportFactory?: (transportType: TransportType) => HWTransport;
onError?: (error: HWError) => void;
onDisconnect?: () => void;
};Notes:
onErroris typed asHWError, notErrortransportFactoryis primarily for testing and custom transport injection- current production transport expectation is
webhid
- one controller owns one active transport session
- all actions are serialized internally
- approval sessions are invalidated on disconnect or reset
dispose()is terminalgetConnector()only returns a non-null connector after readiness succeedsgetConnector().sign()bypasses the review/approval FSM and the controller queue — it is for advanced / read-only use (e.g.getPublicKey), not user-facing signing. Usecontroller.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.
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();