Skip to content

feat: add the iota-sdk-ledger crate - #1702

Open
DaughterOfMars wants to merge 7 commits into
developfrom
feat/ledger-crate
Open

DaughterOfMars wants to merge 7 commits into
developfrom
feat/ledger-crate

Conversation

@DaughterOfMars

@DaughterOfMars DaughterOfMars commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Why

Feature branch for #1675. Its first commit adds the iota-sdk-ledger crate (#1676); the PRs for the epic's remaining issues target this branch instead of develop, and it merges into develop once they have landed.

Issues only close when a PR merges into develop, so the list below grows as each issue's PR merges here.

Closes #1676.

What changes

The crate ports the monorepo's iota-ledger. No third-party types (bip32, ledger-transport, hidapi) reach the public API: Transport is a public trait over SDK-owned APDU types so hosts can bring their own link (#1682), and derivation paths are a new DerivationPath in iota-sdk-crypto, which the mnemonic constructors now parse with too.

Signing takes a transaction or a personal message instead of any serializable intent. Passing input objects to an app older than 1.0 fails instead of silently blind-signing.

After an app switch the device re-enumerates, so the crate polls for the expected app instead of sleeping 3s, and HID closes the old handle before reopening, since two open handles can lock the device. With several devices attached, Ledger::hid_devices and Ledger::native_hid_device select one. Speculos cannot switch apps and reports a placeholder app name, so its transport skips app switching, as the monorepo did. The app reports a disallowed derivation path and refused blind signing as bare syscall numbers (0x0002, 0x0008), which map to their own errors.

HID is a default feature and Speculos an opt-in one. CI installs libudev, which hidapi links on Linux, wherever the crate is built with HID, and semver-checks skips the crate until it is published.

Test plan

Ran against Speculos with IOTA app 1.0.1 (Nano S+):

  • version, public keys and addresses match the app's ragger fixtures
  • address verification, approved and rejected
  • personal message and clear-signed transfer, using the ragger transaction and gas coin (both BCS round-trip byte-for-byte); signatures verified with iota-sdk-crypto
  • refused blind signing and a disallowed path return their dedicated errors

The app exits on the command after it rejects a derivation path, which looks like an app bug. Protocol tests against a scripted transport follow in #1677.

🤖 Generated with Claude Code

Comment thread .github/workflows/lints.yml Resolved
Comment thread crates/iota-sdk-crypto/src/secp256k1.rs Resolved
Comment thread Cargo.toml Resolved
Comment thread .github/workflows/lints.yml Resolved
Comment thread .github/workflows/pre_publish.yml Resolved
Comment thread .github/workflows/pre_publish.yml Resolved
Comment thread .github/workflows/rust_publish.yml Resolved
Comment thread crates/iota-sdk-ledger/Makefile Resolved
Comment thread Makefile Resolved
Comment thread crates/iota-sdk-ledger/src/error.rs Resolved
Comment thread crates/iota-sdk-ledger/src/lib.rs Resolved
Comment thread .github/workflows/check_features.yml Resolved
Comment thread crates/iota-sdk-ledger/src/error.rs Resolved
Comment thread crates/iota-sdk-ledger/src/lib.rs Resolved
Comment thread crates/iota-sdk-crypto/src/lib.rs Resolved
Comment thread crates/iota-sdk-ledger/README.md Resolved
Comment thread crates/iota-sdk-ledger/src/lib.rs Resolved
Comment thread crates/iota-sdk-ledger/src/lib.rs Resolved
Comment thread crates/iota-sdk-ledger/src/lib.rs Resolved
DaughterOfMars added a commit that referenced this pull request Oct 27, 2026
## Why

Feature branch for #1675. Its first commit adds the `iota-sdk-ledger` crate (#1676); the PRs for the epic's remaining issues target this branch instead of `develop`, and it merges into `develop` once they have landed.

Issues only close when a PR merges into `develop`, so the list below grows as each issue's PR merges here.

Closes #1676.

## What changes

The crate ports the monorepo's `iota-ledger`. No third-party types (`bip32`, `ledger-transport`, `hidapi`) reach the public API: `Transport` is a public trait over SDK-owned APDU types so hosts can bring their own link (#1682), and derivation paths are a new `DerivationPath` in `iota-sdk-crypto`, which the mnemonic constructors now parse with too.

Signing takes a transaction or a personal message instead of any serializable intent. Passing input objects to an app older than 1.0 fails instead of silently blind-signing.

After an app switch the device re-enumerates, so the crate polls for the expected app instead of sleeping 3s, and HID closes the old handle before reopening, since two open handles can lock the device. With several devices attached, `Ledger::hid_devices` and `Ledger::native_hid_device` select one. Speculos cannot switch apps and reports a placeholder app name, so its transport skips app switching, as the monorepo did. The app reports a disallowed derivation path and refused blind signing as bare syscall numbers (`0x0002`, `0x0008`), which map to their own errors.

HID is a default feature and Speculos an opt-in one. CI installs `libudev`, which `hidapi` links on Linux, wherever the crate is built with HID, and `semver-checks` skips the crate until it is published.

## Test plan

Ran against Speculos with IOTA app 1.0.1 (Nano S+):

- version, public keys and addresses match the app's ragger fixtures
- address verification, approved and rejected
- personal message and clear-signed transfer, using the ragger transaction and gas coin (both BCS round-trip byte-for-byte); signatures verified with `iota-sdk-crypto`
- refused blind signing and a disallowed path return their dedicated errors

The app exits on the command after it rejects a derivation path, which looks like an app bug. Protocol tests against a scripted transport follow in #1677.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DaughterOfMars added a commit that referenced this pull request Oct 27, 2026
Feature branch for #1675. Its first commit adds the `iota-sdk-ledger` crate (#1676); the PRs for the epic's remaining issues target this branch instead of `develop`, and it merges into `develop` once they have landed.

Issues only close when a PR merges into `develop`, so the list below grows as each issue's PR merges here.

Closes #1676.

The crate ports the monorepo's `iota-ledger`. No third-party types (`bip32`, `ledger-transport`, `hidapi`) reach the public API: `Transport` is a public trait over SDK-owned APDU types so hosts can bring their own link (#1682), and derivation paths are a new `DerivationPath` in `iota-sdk-crypto`, which the mnemonic constructors now parse with too.

Signing takes a transaction or a personal message instead of any serializable intent. Passing input objects to an app older than 1.0 fails instead of silently blind-signing.

After an app switch the device re-enumerates, so the crate polls for the expected app instead of sleeping 3s, and HID closes the old handle before reopening, since two open handles can lock the device. With several devices attached, `Ledger::hid_devices` and `Ledger::native_hid_device` select one. Speculos cannot switch apps and reports a placeholder app name, so its transport skips app switching, as the monorepo did. The app reports a disallowed derivation path and refused blind signing as bare syscall numbers (`0x0002`, `0x0008`), which map to their own errors.

HID is a default feature and Speculos an opt-in one. CI installs `libudev`, which `hidapi` links on Linux, wherever the crate is built with HID, and `semver-checks` skips the crate until it is published.

Ran against Speculos with IOTA app 1.0.1 (Nano S+):

- version, public keys and addresses match the app's ragger fixtures
- address verification, approved and rejected
- personal message and clear-signed transfer, using the ragger transaction and gas coin (both BCS round-trip byte-for-byte); signatures verified with `iota-sdk-crypto`
- refused blind signing and a disallowed path return their dedicated errors

The app exits on the command after it rejects a derivation path, which looks like an app bug. Protocol tests against a scripted transport follow in #1677.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DaughterOfMars and others added 6 commits November 6, 2026 09:46
Feature branch for #1675. Its first commit adds the `iota-sdk-ledger` crate (#1676); the PRs for the epic's remaining issues target this branch instead of `develop`, and it merges into `develop` once they have landed.

Issues only close when a PR merges into `develop`, so the list below grows as each issue's PR merges here.

Closes #1676.

The crate ports the monorepo's `iota-ledger`. No third-party types (`bip32`, `ledger-transport`, `hidapi`) reach the public API: `Transport` is a public trait over SDK-owned APDU types so hosts can bring their own link (#1682), and derivation paths are a new `DerivationPath` in `iota-sdk-crypto`, which the mnemonic constructors now parse with too.

Signing takes a transaction or a personal message instead of any serializable intent. Passing input objects to an app older than 1.0 fails instead of silently blind-signing.

After an app switch the device re-enumerates, so the crate polls for the expected app instead of sleeping 3s, and HID closes the old handle before reopening, since two open handles can lock the device. With several devices attached, `Ledger::hid_devices` and `Ledger::native_hid_device` select one. Speculos cannot switch apps and reports a placeholder app name, so its transport skips app switching, as the monorepo did. The app reports a disallowed derivation path and refused blind signing as bare syscall numbers (`0x0002`, `0x0008`), which map to their own errors.

HID is a default feature and Speculos an opt-in one. CI installs `libudev`, which `hidapi` links on Linux, wherever the crate is built with HID, and `semver-checks` skips the crate until it is published.

Ran against Speculos with IOTA app 1.0.1 (Nano S+):

- version, public keys and addresses match the app's ragger fixtures
- address verification, approved and rejected
- personal message and clear-signed transfer, using the ragger transaction and gas coin (both BCS round-trip byte-for-byte); signatures verified with `iota-sdk-crypto`
- refused blind signing and a disallowed path return their dedicated errors

The app exits on the command after it rejects a derivation path, which looks like an app bug. Protocol tests against a scripted transport follow in #1677.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ledger): make the transport async

Host-provided transports such as WebHID or Bluetooth are asynchronous,
and blocking on them is not possible on wasm32. `Transport` now returns
boxed futures, `Send` except on wasm32, and every `Ledger` call is async.
HID and Speculos still do blocking I/O inside their futures.

The wait for an app switch no longer sleeps or reads the clock itself,
neither of which works on wasm32: transports pause in `reconnect`, and
`Ledger` retries a fixed number of times.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ledger): run calls on the same Ledger one at a time

A command can take several exchanges with the device, and `Ledger` calls
take `&self`, so two concurrent calls interleaved their exchanges and
broke the device's command state. Each call now holds a lock on the
transport for the whole command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ledger): pause between attempts to find the app after a switch

The wait relied on each transport pausing in `reconnect`, so a transport
that keeps the default would use up every attempt at once. `Ledger` now
waits between attempts itself, with a timer that needs no runtime and
works on wasm32.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ledger): stop the USB HID transport from blocking the polling thread

The HID connection now lives on a thread of its own, which also handles reconnects, so a dropped call cannot leave an exchange running against a closed handle while a new one opens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(ledger): leave the waiting out of the reconnect docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(ledger): note the Speculos blocking on its constructor

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(ledger): read the version before locking the transport to sign

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(ledger): check that every Ledger future is Send

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* rename nit

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(ledger): add LedgerSigner

Signs transactions and personal messages with the key at one derivation
path and returns `UserSignature`s. The device hashes the intent message
itself, so the digest-based signing traits do not apply.

Before each signature the signer reads the public key again and fails
with `AddressMismatch` if it changed, as the monorepo's address check
did; `with_expected_address` catches a wrong device or path up front.

Closes #1678.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(ledger): leave the expected-address check to callers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(ledger): name the changed-key error after its cause

Without the expected-address check, the error only reports that the
device's key changed since the signer was created.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(ledger): construct a signer from a Ledger

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(ledger): name the key error without assuming its cause

A different key for the signer's path also comes from the same device
unlocked with another seed or passphrase, where reconnecting does not
help.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* reword

* chore(ledger): open the app in the README signing example

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* sort errors

* reword

* chore(ledger): check that a changed key also stops personal message signing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(ledger): reject clear-signing objects that miss a referenced input or gas coin

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(ledger): reconnect in open_app when the device does not answer and add LedgerSigner::open_app

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(ledger): add sign_intent to Ledger and LedgerSigner

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* remove open_app fn

* add ledger_mut

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: sign with a Ledger through the transaction builder

The transaction builder's `ledger` feature implements `TransactionSigner`
for `LedgerSigner`, which blind-signs, and for a signer paired with a
client through `LedgerSigner::with_client`, which fetches the gas coins
and immutable or owned inputs at their versions to clear-sign and falls
back to blind signing, with a warning, when it cannot.

The umbrella crate gets opt-in `ledger`, `ledger-hid` and
`ledger-speculos` features. HID is separate so that targets without
hidapi, such as mobile and wasm through the FFI, can still use the crate
with their own transport.

Closes #1679.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: pass the builder's objects to signers instead of pairing a client

The signer paired with its own client fetched objects the builder had
already fetched while resolving, through a second copy of the client.
The builder now keeps those objects and, for signers that ask for them
through `TransactionSigner::uses_objects`, passes them to
`sign_with_objects`, fetching only the ones it lacks. Both methods have
defaults, so existing signers are unchanged and never cause a fetch.

`LedgerSigner` clear-signs through `sign_with_objects`, so
`LedgerSigner::with_client` and its pairing type are gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: drop the version check on cached signing objects

Cached objects are the ones the transaction's references were built from,
so their versions always match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: check whether a signer wants objects at the call sites

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: install libudev for the examples job

The examples build `iota-sdk` with all features, which now include the
Ledger HID transport.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: pass signers the shared and receiving objects a transaction reads

LedgerSigner still sends the device only the gas coins and immutable or owned inputs, the objects the IOTA app can match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: fail execution when the objects for a signer cannot be fetched

The Ledger signer blind-signed instead, which hid the fetch error behind
`BlindSigningDisabled` by default, and blind-signed transfers the app can
clear-sign when blind signing was enabled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: sort iota-ledger as an internal dependency

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: document the ledger feature on docs.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* check device_objects

* slightly improve clones

* fix(transaction-builder): warn when the offline sponsored path blind-signs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ledger): leave out unreferenced objects instead of filtering them in the builder

The Ledger signer now checks the app version before signing, so falling
back to blind signing on an app older than 1.0 reads the public key once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ledger): fall back to blind signing in LedgerSigner when the app cannot clear-sign

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: add the Ledger sign-and-send example and crate docs

The example connects to the Speculos emulator when `LEDGER_SIMULATOR` is
set and to a USB device otherwise, funds the device's address from the
faucet and executes a transfer that the device clear-signs. It is left
out of `make examples`, since it needs a device or the emulator.

The ledger crate's README covers using a device, running Speculos, the
Linux udev rules and when the app needs blind signing.

Closes #1680.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: use the current Ledger constructor and open_app names in the example and docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: set the app name Speculos reports in the Ledger README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: describe the SPECULOS_APPNAME format in the Ledger README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore: add a script, action and make targets to start Speculos with the IOTA app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: run the Ledger example on Speculos in CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: point the Ledger README to the Speculos make targets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: move the Speculos scripts into scripts/speculos and drop the action

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: name the approval script's arguments and time out the Ledger CI step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ledger)!: make Ledger cloneable

Clones share the connection and its lock, so commands still run one at a
time across them, and one device can back several signers while the
`Ledger` stays usable. `open_app` and `exit_app` lock instead of taking
`&mut self`, which makes `LedgerSigner::ledger_mut` unnecessary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ledger): check the key and sign under one lock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ledger): restore the Ledger doc comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ledger): test that clones share the cached app version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcd8NHtD9DLWiw29MMfBct

* chore(ledger): test that the signer keeps the device locked until it signs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcd8NHtD9DLWiw29MMfBct

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Chloe Martin <chloedaughterofmars@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the iota-sdk-ledger crate with the device API and transports

4 participants