Skip to content

feat!: accept any transport that can send an APDU (DMK support) - #739

Open
chcmedeiros wants to merge 8 commits into
oasisprotocol:masterfrom
chcmedeiros:feat/dmk-transport-support
Open

chcmedeiros wants to merge 8 commits into
oasisprotocol:masterfrom
chcmedeiros:feat/dmk-transport-support

Conversation

@chcmedeiros

@chcmedeiros chcmedeiros commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Why

Ledger deprecated LedgerJS (@ledgerhq/hw-transport) in favour of the Device Management Kit, ahead of a September 2026 cutoff.

OasisApp only ever calls send and decorateAppAPIMethods on the transport, so DMK support is a type change, not a rewrite.

What

  • LedgerTransport (new) describes those two methods structurally. A hw-transport Transport satisfies it, and so does a DMKTransport from @zondax/ledger-js v2 — or any object implementing the two.
  • @ledgerhq/hw-transport leaves dependencies. The emitted dist/*.d.ts contains no hw-transport import at all. hw-transport-node-hid / hw-transport-webusb stay as devDependencies for tests and the type-only example.
  • Transport stays exported as a @deprecated alias of LedgerTransport, so import type { Transport } keeps resolving and passing a hw-transport is unaffected.
  • OasisApp and App are generic in the transport. This is what keeps the common case working — see below.
  • Non-DMK transports are deprecated (new in this revision), which brings in @zondax/ledger-js as a runtime dependency and moves CI to Node 22 — see the next three sections.

Non-DMK transports are deprecated

Accepting both keeps existing wallets working, but DMKTransport is the path forward. This mirrors @zondax/ledger-js 2.1.0, which does the same on its BaseApp; OasisApp doesn't extend BaseApp, so it carries its own copy.

Constructor overloads (JSDoc @overload, emitted to dist/index.d.ts as ordinary TypeScript):

constructor(transport: T extends DMKTransport ? T : never, scrambleKey?: string);
/** @deprecated Pass a `DMKTransport` from `@zondax/ledger-js` instead. ... removed in the next major version. */
constructor(transport: T, scrambleKey?: string);

DMKTransport has private members, so only a real instance matches the first overload. A hw-transport Transport or a plain object implementing the two methods lands on the deprecated one, which editors render as a strikethrough. The first overload is a conditional rather than T & DMKTransport on purpose: inference skips the identical DMKTransport constituent of an intersection, which would leave T at its default and cost app.transport its members.

One-time runtime warning. Constructing over anything that isn't instanceof DMKTransport logs once per process:

[@oasisprotocol/ledger] Passing a transport other than DMKTransport to OasisApp is deprecated and will be rejected in the next major version. Ledger deprecated @ledgerhq/hw-transport in favour of the Device Management Kit: wrap a DMK session instead, e.g. new OasisApp(new DMKTransport(dmk, sessionId)), with DMKTransport from @zondax/ledger-js.

tsc never reports deprecations, so this was checked with the TypeScript language service (getSuggestionDiagnostics, what editors use) against the emitted dist/index.d.ts, TypeScript 5.6.2:

call deprecated app.transport keeps its members
new OasisApp(new DMKTransport(dmk, id)) no setScrambleKey() compiles
new OasisApp(transportWebUSB) yes close() compiles
new OasisApp(transportWebUSB, scrambleKey) yes
new OasisApp({ send, decorateAppAPIMethods }) yes

Things to know:

  • This is steering, not a security check. instanceof is spoofable and the transport runs in the caller's own process; the device screen is what protects against a lying host.
  • Duplicate package copies. A consumer with two copies of @zondax/ledger-js would see the warning for a genuine DMKTransport, and its type would land on the deprecated overload too.
  • In-repo type example. Against the JS source (not the emitted .d.ts), TypeScript does not carry the class template into construct signatures built from JSDoc @overload, so typeOnlyTest in src/types.ts now passes its transport as any. That check is about the methods' response types, which don't depend on T; consumers read dist/index.d.ts, which infers normally (table above).

New runtime dependency: @zondax/ledger-js ^2.1.0

Naming DMKTransport, for the overload and the instanceof, needs the real class, so @zondax/ledger-js (Apache-2.0, maintained by Zondax) becomes a dependency. It has no dependencies of its own, so it adds exactly one package to the graph. Flagging it explicitly since it puts a Zondax package in this repo's runtime tree; the alternative was a structural heuristic (flag transports that have exchange/close), which can't tell a hand-rolled transport from a DMK one.

CI moves to Node 22

@zondax/ledger-js declares engines.node >=20, and yarn classic enforces engines on install. Verified locally with the CI command (yarn install --frozen-lockfile):

Node result
18.20.8 error @zondax/ledger-js@2.1.0: The engine "node" is incompatible with this module. Expected version ">=20". Got "18.20.8"
22 installs

So ci-tests.yml and release.yml move from Node 18 (end-of-life since April 2025) to 22. The same applies to consumers: a project still on Node 18 installing this package with yarn classic will hit that error (npm and pnpm only warn). That lands in the same major as the type change below.

This is a breaking change (1.0.0 → 2.0.0)

src/types.ts has re-exported hw-transport's Transport since long before this work, so it is part of the published API. Removing the dependency means that type cannot be re-exported, and the name necessarily narrows. Rather than let that happen silently, it is scoped and asserted.

Still compiles — verified against this branch:

case why it survives
const app = new OasisApp(await TransportWebUSB.create()); await app.transport.close() T is inferred as TransportWebUSB, so the class's own members are intact
const t: Transport = realHwTransport a hw-transport Transport still satisfies the alias
a DMK transport, reading a member only it has same inference, from the other side
a bare App annotation calling send send is on the structural type

No longer compiles — each asserted with @ts-expect-error in a throwaway probe, so neither can regress unnoticed:

case fix
reaching through the exported Transport type for close() / exchange() / on() import Transport from @ledgerhq/hw-transport directly
the same through a bare OasisApp / App annotation annotate OasisApp<TransportWebUSB>, or let inference do it

The generic is doing the load-bearing work here: without it, app.transport would collapse to the structural type and every consumer reaching through it would break, not just those who annotate explicitly.

Also breaking, see above: yarn-classic installs on Node < 20.

Verification

check result
yarn build (Babel + tsc types) pass, no hw-transport import in emitted .d.ts
yarn test:unit 7/7 pass (3 existing + 4 deprecation)
eslint src/ tests/deprecation.spec.js clean
yarn install --frozen-lockfile on Node 18 / 22 fails / passes (table above)
deprecation diagnostics on dist/index.d.ts (language service) table above
compatibility probe (4 surviving + 2 breaking cases) from the previous revision; the overloads don't change the bare-annotation default

yarn.lock loses hw-transport as a direct dependency (it remains for devDependencies) and gains @zondax/ledger-js.

Integration tests were not run here (they need a physical device).

Note for maintainers

I have not bumped version in package.json — releases here are driven by pushing a v* tag, so the bump is yours to make. It needs to be a major.

OasisApp only needs send and decorateAppAPIMethods. Describe those
structurally so a DMK-backed transport works without a hw-transport
dependency, while existing Transport callers keep compiling.
The previous commit narrowed `Transport` -- a type `src/types.ts` has re-exported
since long before the DMK work -- and, through the JSDoc on the constructor,
`app.transport` with it. Anyone annotating with either and then calling `close`,
`exchange` or `on` stopped compiling, silently and for no stated reason.

hw-transport is still removed from `dependencies`: Ledger is retiring it, and
keeping it only to re-export its type would defeat the point. So the narrowing
stands, but it is now named, minimised and documented.

Split the two roles apart:

- `LedgerTransport` (new) is the structural `send` + `decorateAppAPIMethods`
  surface the app actually needs, satisfied by a hw-transport `Transport` and by
  a DMK transport alike.
- `Transport` stays exported as a deprecated alias of it, so `import type
  { Transport }` still resolves and passing a hw-transport is unaffected.
- `OasisApp` and `App` become generic in the transport. This is what keeps the
  common case working: `new OasisApp(await TransportWebUSB.create())` infers `T`
  as that class, so `app.transport.close()` still typechecks even though this
  package no longer knows what hw-transport is. A DMK caller keeps its own
  members the same way.

What still compiles, verified against this branch: reaching through
`app.transport` for `close()` on an app built from a WebUSB transport; assigning
a real `Transport` to the exported alias; a DMK-style transport, reading a
member only it has; a bare `App` annotation calling `send`.

What no longer compiles, asserted with `@ts-expect-error` so it cannot regress
unnoticed: reaching through the *exported* `Transport` type for `close()`, and
doing the same through a bare `OasisApp` annotation. Both are fixed by
annotating with the concrete transport class, or by letting inference do it.

Types build with no hw-transport import left in the emitted `.d.ts`, eslint is
clean, and the 3 unit tests pass. package.json loses exactly one line; yarn.lock
loses one specifier, hw-transport staying only for devDependencies.

BREAKING CHANGE: the exported `Transport` type is now structural rather than
`Transport` from `@ledgerhq/hw-transport`, so reaching through it -- or through
a bare `OasisApp`/`App` annotation -- for a hw-transport member no longer
typechecks. Import `Transport` from `@ledgerhq/hw-transport` directly, or
annotate with the transport class you construct. Needs a major: 1.0.0 -> 2.0.0.
@chcmedeiros
chcmedeiros force-pushed the feat/dmk-transport-support branch from 3039c9a to 033db03 Compare September 14, 2026 10:01
@chcmedeiros chcmedeiros changed the title feat: accept any transport that can send an APDU (DMK support) feat!: accept any transport that can send an APDU (DMK support) Sep 14, 2026
Mirror @zondax/ledger-js 2.1.0, which deprecates non-DMK transports on
its BaseApp. OasisApp does not extend BaseApp, so it gets its own copy:

- The constructor gains JSDoc overloads. A DMKTransport selects the
  primary signature; anything else (an hw-transport Transport, a plain
  object implementing send and decorateAppAPIMethods) selects a second
  one marked @deprecated. The primary overload is a conditional rather
  than T & DMKTransport, because inference skips the identical
  DMKTransport constituent and would leave app.transport without its
  members.
- Constructing over a non-DMK transport logs a one-time console warning.

Naming DMKTransport needs the real class, so @zondax/ledger-js ^2.1.0
becomes a runtime dependency. It has no dependencies of its own.

Against the JS source, construct signatures built from JSDoc @overload
do not carry the class template, so the type-only example in types.ts
passes its transport as any. The emitted dist/index.d.ts infers T
normally.
@zondax/ledger-js declares engines.node >=20 and yarn classic enforces it, so yarn install --frozen-lockfile fails on Node 18 (end-of-life since April 2025).
@matevz
matevz requested a review from lubej September 16, 2026 08:25

@matevz matevz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using @ledgerhq/hw-transport-webusb heavily in https://github.com/oasisprotocol/wallet, so merging this and releasing a new 2.0.0 breaking version of @oasisprotocol/ledger will also require some work on that front. I would appreciate some migration hints (see below).

Comment thread src/deprecation.js Outdated
Drop src/deprecation.js, per review. The once-per-process warning now
lives in OasisApp's module and points at a new README section,
"Migrating to the Device Management Kit", which covers what 2.0.0
changes on its own, the hw-transport-webusb -> DMK WebHID mapping with a
before/after example, the behaviour that differs, and the transport kits
for node-hid, Web BLE and React Native BLE.

The deprecation tests load a fresh module per test instead of importing
a test-only reset hook.
For each OasisApp method, a diff from the hw-transport-webusb call to
the DMK one, with what the method returns and what happens on the
device. Also covers path formats, the lack of a default timeout on
methods that wait for the user, and the 0x6986 rejection code.
Comment thread src/types.ts Outdated
Comment thread package.json Outdated
Per review. `yarn test:types` builds the declarations and compiles
tests/types/inference.ts against dist/*.d.ts, resolved through the
package's own import paths. It checks what consumers compile against:
the JS source cannot bind `T` through JSDoc `@overload` constructors.

It asserts that `app.transport` keeps a hw-transport's and a
DMKTransport's own members, and is not `any`; that a bare `OasisApp`
annotation and the deprecated `Transport` alias no longer reach
`close()`; that `OasisApp<typeof transport>` fixes the bare annotation;
and that a transport without `send` is rejected. Breaking the emitted
declarations (widening `transport` to `any`, dropping the generic,
aliasing `Transport` to `any`, dropping `send`) fails it.

typeOnlyTest moves out of src/types.ts into the same file, without the
`as any` it needed against the source. CI runs the new script.

The README suggested `OasisApp<TransportWebUSB>`, which does not
compile for `TransportWebUSB.create()`: that returns the base
`Transport`. Suggest `OasisApp<typeof transport>` instead.
Per review: pin the exact version rather than relying on yarn.lock
alone. The lockfile entry changes only its range key; the resolved
tarball and integrity hash are unchanged.

Consumers who import DMKTransport directly need that same version, or
they get a second copy whose DMKTransport fails OasisApp's instanceof
check. The README install line and note now say so.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants