An Android app that imports unsigned Bitcoin PSBTs (Partially Signed Bitcoin Transactions) — via file picker, Nostr relay, or clipboard — signs them with a Trezor hardware wallet connected via USB-C (with passphrase entry on-device, on-phone, or via NFC tag), and broadcasts the signed transaction to the Bitcoin network.
Electrum requires a desktop computer to interact with hardware wallets. No existing Android app supports the full workflow of importing external PSBTs and signing them with a Trezor via USB. Satoshi Signer fills this gap — create unsigned transactions in Electrum on a remote machine, transfer the PSBT file to your phone, sign with Trezor, and broadcast. No laptop needed.
- Create unsigned transaction in Electrum on a remote machine
- Send the PSBT to your phone:
- Via Nostr — click "Send to Signer" in Electrum (requires the Nostr Signer plugin)
- Via file — send the
.psbtfile by email, cloud storage, messenger, etc.
- Open the PSBT in Satoshi Signer (from inbox or file picker)
- Review transaction details: destinations, change outputs, fee, multisig status
- Connect Trezor via USB-C OTG cable
- Sign on the Trezor
- Broadcast to the Bitcoin network (or export updated PSBT for multisig)
- Single-sig and multisig PSBT support (P2WPKH, P2SH-P2WPKH, P2TR, P2WSH)
- Change output detection via BIP32 derivation path matching
- Grouped, address-first transaction ledger — the Review screen's Flow card groups outputs by who gets paid: external recipients under "Sending" (numbered 1..N in vout order to cross-check the Trezor's per-output prompts) and wallet-owned outputs under "Returns to you" (change + self-sends, with the derivation path shown), with inputs subordinated. Ownership and paths come from the PSBT's BIP32 derivations
- OP_RETURN display — shows embedded text (e.g. memos) in the transaction preview
- Multisig status tracking — shows which signers have signed (by fingerprint)
- Approximate fiat values — an
≈ $…USD sub-line under each amount on Home and the review screen, plus the live1 BTC = $…rate (from mempool.space). Display-only — hidden when offline and never blocks signing. - Transaction broadcasting to mempool.space / blockstream.info with retry and fallback
- Live confirmation tracking — after broadcast, the result screen polls mempool.space and shows "In mempool" while unconfirmed, then the confirmation count (capped at "6+")
- Mempool links — tap any address or txid to view it on mempool.space (auto-detects mainnet/testnet)
- Manual broadcast fallback — copy raw hex if API broadcast fails
- PSBT export for partially-signed multisig transactions (via Android share sheet)
- Intent filter — open
.psbtfiles directly from file managers and email apps - Nostr PSBT delivery — receive PSBTs from Electrum over Nostr relays (NIP-04 encrypted). No file transfer needed — just click "Send to Signer" in Electrum. See Electrum Plugin below.
- Encrypted NFC passphrase import — tap a YubiKey or NDEF tag to enter your Trezor passphrase instead of typing it on the phone keyboard. Passphrases are encrypted on the tag using NIP-04 with the phone's Nostr keypair — an attacker needs both the phone and the tag. Optional — the NFC option only appears on devices with NFC hardware.
- NFC passphrase encryption — built-in screen to encrypt a passphrase for writing to an NFC tag. The user copies the ciphertext and writes it to the tag using external tools.
Kotlin/Jetpack Compose Python backend (via Chaquopy)
┌──────────────────────────┐ ┌──────────────────────────┐
│ UI Screens (7 screens) │ │ psbt_parser (embit) │
│ SignerViewModel │◄──bridge──►│ signer (trezorlib) │
│ ├─ ContactRepository │ │ usb_transport (custom) │
│ ├─ InboxRepository │ │ trezor_ui (callbacks) │
│ ├─ SigningOrchestrator │ └──────────────────────────┘
│ ├─ TransactionBroadcaster│
│ ├─ RateRepository │
│ USB Bridge (UsbRequest) │
│ Nostr receiver (OkHttp) │
│ NFC reader / file picker │
└──────────┬───────────────┘
│ USB-C OTG
┌─────▼─────┐
│ Trezor │
│ Safe 3 │
└───────────┘
Kotlin side handles UI (Jetpack Compose), Android file picker, USB permission management, interrupt endpoint I/O via UsbRequest, and transaction broadcasting (via TransactionBroadcaster using OkHttp). The SignerViewModel is a composition root that delegates to ContactRepository (contact CRUD), InboxRepository (Nostr inbox events), SigningOrchestrator (USB lifecycle and Trezor signing), TransactionBroadcaster (HTTP POST to mempool.space/blockstream.info), and RateRepository (BTC→USD rate from mempool.space for the display-only approximate-fiat sub-lines). All Bitcoin and Trezor logic lives in Python.
Python side uses trezorlib (official Trezor library) for device communication and embit for PSBT parsing. The PSBT-to-trezorlib conversion logic is ported from HWI. A custom trezorlib transport bridges Android's USB stack to Python via Kotlin callbacks.
Chaquopy embeds Python 3.13 in the Android app, bridging Kotlin and Python with automatic type conversion.
- Trezor Safe 3 via USB-C OTG (on-device PIN and passphrase)
- Android 9.0+ (API 28) with USB Host support
Other Trezor models with USB-C should work but are untested. Models requiring host-side PIN entry (Model One with old firmware) are not supported.
During signing, the Trezor may prompt for a passphrase. Satoshi Signer offers three entry methods:
- Enter on Trezor — type the passphrase on the Trezor's own screen
- Enter on phone — type on the phone keyboard (IME learning disabled)
- Read from NFC tag — tap a YubiKey or generic NDEF tag to the back of the phone
The NFC option is useful for long or complex passphrases that are painful to type on a phone keyboard during every signing session.
Passphrases on NFC tags are encrypted using NIP-04 (AES-256-CBC + secp256k1 ECDH) with the phone's Nostr keypair. The phone encrypts to its own public key, so only that specific phone can decrypt the tag. An attacker who reads the tag gets only ciphertext — useless without the phone.
To prepare an NFC tag:
- Open Encrypt Passphrase for NFC from the Home screen
- Type your passphrase and tap Encrypt
- Copy the ciphertext and write it to your NFC tag using external tools (NFC Tools, YubiKey Manager, etc.)
If you switch phones or regenerate your Nostr keypair, re-encrypt the passphrase on the new phone.
| Tag Type | Min capacity | Notes |
|---|---|---|
| NTAG213 | 144 bytes | Fits passphrases up to ~50 chars |
| NTAG215 | 504 bytes | Plenty of room |
| NTAG216 | 888 bytes | Plenty of room |
| YubiKey (NDEF slot) | Varies | Program via YubiKey Manager |
The app reads the NDEF text payload and decrypts it using the phone's Nostr keypair. Programming the tag is the user's responsibility.
Security model: An attacker needs the Trezor + PIN + phone + NFC tag to sign. The tag alone reveals nothing.
- JDK 17 (
brew install openjdk@17) - Android SDK 35 (
brew install --cask android-commandlinetools, then usesdkmanager) - An Android device with USB-C OTG support (for on-device testing)
No Android Studio required. See below for full command-line setup.
# Install JDK and Android tools
brew install openjdk@17
brew install --cask android-commandlinetools
# Add to ~/.zshrc
export JAVA_HOME=/opt/homebrew/opt/openjdk@17
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
# Install SDK components
mkdir -p "$ANDROID_HOME"
sdkmanager --sdk_root="$ANDROID_HOME" \
"platforms;android-35" "build-tools;35.0.0" "platform-tools" \
"emulator" "system-images;android-35;google_apis;arm64-v8a" \
"cmdline-tools;latest"
sdkmanager --sdk_root="$ANDROID_HOME" --licenses
# Create local.properties
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
# Create emulator
avdmanager create avd -n test_device \
-k "system-images;android-35;google_apis;arm64-v8a" -d "pixel_7"./gradlew assembleDebug # Debug build (auto-signed)
./gradlew assembleRelease # Release build (requires keystore, see below)Chaquopy automatically downloads Python 3.13 and pip-installs trezor and embit during the build.
Both variants can be installed side-by-side on the same device:
./gradlew installDebug # "Satoshi Signer (Test)" — com.remotesigner.debug
./gradlew installRelease # "Satoshi Signer" — com.remotesigner (stable)The debug build has a blue/purple icon with a "DEBUG" overlay; the release build uses the standard orange/red icon with R8 minification enabled.
Release builds require a keystore.properties file in the project root (gitignored):
storeFile=../release.keystore
storePassword=<password>
keyAlias=release
keyPassword=<password>Generate a keystore: keytool -genkey -v -keystore release.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias release
# Start emulator
emulator -avd test_device -no-audio &
adb wait-for-device
# Run smoke tests
./gradlew connectedDebugAndroidTestSmoke tests verify all 7 screens render correctly and state-machine navigation works.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
python -m pytest tests/ -vTests include unit tests for all Python modules plus end-to-end signing tests that replay pre-recorded Trezor USB exchanges (no hardware needed).
GitHub Actions runs the fast automated checks on every push and pull request:
python -m pytest tests/ -v./gradlew testDebugUnitTest
Android instrumentation tests run in a separate workflow via manual trigger only. That keeps normal PR feedback fast while still allowing full emulator coverage on demand.
This repo ships two releasable artifacts:
- Android app release APK
- Electrum plugin zip (
nostr_signer)
Release automation is tag-driven:
android-vX.Y.Zbuilds and publishes the Android release fromapp/build.gradle.ktsversionNameelectrum-vX.Y.Zbuilds and publishes the Electrum plugin fromnostr_signer/manifest.jsonversion
Both release workflows generate a .sha256 file next to the built artifact and upload the artifact plus its checksum to the matching GitHub release. The Electrum plugin is packaged by scripts/package_electrum_plugin.sh.
You can test the full signing flow on your Mac without deploying to Android. Requires libusb (brew install libusb).
# Parse a PSBT (no hardware needed)
python tests/sign_cli.py --network test parse path/to/file.psbt
# Sign with a real Trezor plugged in via USB
python tests/sign_cli.py --network test sign path/to/file.psbt
# Sign and record USB exchanges for replay in automated tests
python tests/sign_cli.py --network test sign path/to/file.psbt \
--record tests/cassettes/scenario-name.jsonRecorded cassettes are replayed by tests/test_signing_e2e.py — this tests the entire sign_psbt() code path (protobuf construction, wire protocol, signature insertion) without a Trezor.
app/src/main/
├── kotlin/com/remotesigner/
│ ├── MainActivity.kt # Entry point, intent/NFC handling
│ ├── bridge/
│ │ ├── PythonBridgeInterface.kt # Interface + SigningCallback (enables test fakes)
│ │ ├── PythonBridge.kt # Chaquopy bridge to Python (implements interface)
│ │ └── SigningOrchestrator.kt # USB lifecycle, Trezor signing flow, callbacks
│ ├── broadcast/
│ │ └── TransactionBroadcaster.kt # HTTP broadcast to mempool.space/blockstream.info
│ ├── rate/
│ │ └── RateRepository.kt # BTC→USD rate from mempool.space (display-only fiat)
│ ├── viewmodel/
│ │ ├── Models.kt # AppState sealed class, PassphraseRequest, AccountPathRequest
│ │ ├── SignerViewModel.kt # State machine (Home→Review→Sign→Result→Error)
│ │ └── SignerViewModelFactory.kt # Dependency injection via ViewModelProvider.Factory
│ ├── data/
│ │ ├── AppDatabase.kt # Room database (contacts + inbox)
│ │ ├── Contact.kt # Contact, ContactFingerprint, ContactWithFingerprints
│ │ ├── ContactDao.kt # Contact DAO
│ │ ├── ContactRepository.kt # Contact CRUD + signer enrichment
│ │ ├── InboxItemEntity.kt # InboxItemEntity Room entity + InboxStatus enum
│ │ ├── InboxDao.kt # Inbox DAO
│ │ ├── InboxRepository.kt # Inbox event handling + status updates
│ │ └── FingerprintValidator.kt # Fingerprint validation (8 hex chars)
│ ├── usb/
│ │ ├── SigningBridge.kt # Interface for production/test USB access
│ │ ├── TrezorUsbManager.kt # Device discovery + USB permissions
│ │ └── UsbBridge.kt # 64-byte interrupt endpoint I/O
│ ├── nostr/
│ │ ├── NostrReceiver.kt # WebSocket relay connection (OkHttp)
│ │ ├── NostrKeyManager.kt # Random secp256k1 keypair storage
│ │ ├── Nip04.kt # NIP-04 encryption/decryption
│ │ ├── NostrEvent.kt # Event model
│ │ ├── NostrInbox.kt # RelayStatus enum, inbox display helpers
│ │ └── Bech32.kt # Bech32 encoding (npub)
│ ├── nfc/NdefTextParser.kt # NFC NDEF text parsing (passphrase import)
│ └── ui/
│ ├── AppNavigation.kt # State-based routing (no NavController)
│ ├── HomeScreen.kt # Nostr QR code, file picker, inbox
│ ├── TransactionReviewScreen.kt # TX details, change, fee, signers
│ ├── SigningScreen.kt # Progress + passphrase dialog
│ ├── ResultScreen.kt # Broadcast / export
│ ├── ErrorScreen.kt # Error display
│ ├── EncryptPassphraseScreen.kt # NFC passphrase encryption utility
│ ├── InboxSection.kt # Nostr inbox UI
│ └── theme/Theme.kt # Material 3 theming
├── python/remotesigner/
│ ├── psbt_parser.py # Parse PSBT, detect change outputs
│ ├── signer.py # PSBT→trezorlib conversion + signing
│ ├── usb_transport.py # Custom trezorlib Handle/Transport
│ ├── trezor_ui.py # Safe 3 UI callbacks
│ └── validate_deps.py # Dependency validation for Chaquopy
└── res/
├── xml/usb_device_filter.xml # Trezor USB vendor ID filter
└── xml/file_paths.xml # FileProvider for PSBT export
app/src/androidTest/kotlin/com/remotesigner/
├── AppLaunchTest.kt # App launch smoke test
├── ScreenRenderTest.kt # Screen render smoke tests
├── ContactsScreenTest.kt # ContactsScreen UI tests (add/edit/delete dialogs)
├── NavigationTest.kt # State machine navigation test
├── ChaquopyE2ETest.kt # Chaquopy + cassette replay E2E tests
├── InboxScreenTest.kt # Nostr inbox UI tests
├── Nip04Test.kt # NIP-04 encryption tests
├── NostrReceiverTest.kt # WebSocket receiver tests
├── NostrKeyManagerTest.kt # Key storage tests
├── PlaybackBridge.kt # Cassette replay bridge (SigningBridge impl)
├── TestFixtures.kt # Mock AppState instances for tests
└── data/
├── ContactDaoTest.kt # Contact DAO tests
├── ContactRepositoryTest.kt # Contact repository tests
├── InboxDaoTest.kt # Inbox DAO tests
└── InboxRepositoryTest.kt # Inbox repository tests
app/src/test/kotlin/com/remotesigner/
├── viewmodel/SignerViewModelTest.kt # ViewModel state machine (JVM, mockk)
├── broadcast/TransactionBroadcasterTest.kt # Broadcaster tests (JVM, MockWebServer)
├── rate/RateRepositoryTest.kt # Rate parser + fetch path (JVM, MockWebServer)
├── nostr/Bech32Test.kt # Bech32 encoding/decoding (JVM)
├── nfc/NdefTextParserTest.kt # NFC NDEF parsing (JVM, no emulator needed)
├── data/FingerprintValidatorTest.kt # Fingerprint validation (JVM)
└── ui/MempoolUrlTest.kt # Mempool URL generation (JVM)
nostr_signer/ # Electrum plugin (see below)
├── manifest.json # Plugin metadata (v0.2.1)
├── __init__.py # Package marker
├── nostr_signer.py # Standalone NIP-04 crypto
├── qt.py # Electrum Qt UI hooks + relay publishing
└── README.md # Plugin documentation
tests/
├── conftest.py # pytest configuration
├── desktop_bridge.py # DesktopUsbBridge, RecordingBridge, PlaybackBridge
├── sign_cli.py # CLI for desktop signing + cassette recording
├── test_signing_e2e.py # E2E tests replaying recorded cassettes
├── test_psbt_parser.py # PSBT parsing tests
├── test_signer.py # Signer module tests
├── test_usb_transport.py # USB transport tests
├── test_trezor_ui.py # Trezor UI callback tests
├── test_desktop_bridge.py # Desktop bridge unit tests
├── test_nostr_signer.py # Electrum plugin crypto tests
├── cassettes/ # Recorded USB exchange JSON files
│ ├── single-sig-p2wpkh.json
│ └── multisig-testnet3.json
└── psbts/ # Test PSBT files
├── singlesig_testnet3.psbt
└── multisig_testnet3.psbt
trezor0.13.9 — Trezor device communication (protobuf, signing protocol)embit— Lightweight Bitcoin library (PSBT parsing, transaction handling)
- Jetpack Compose with Material 3
- Android USB Host API
- Chaquopy 17.0.0
- OkHttp 4.12.0 — WebSocket client for Nostr relays + transaction broadcasting
- secp256k1-kmp 0.22.0 — secp256k1 ECDH for NIP-04 encryption/decryption
- ZXing 3.5.3 — QR code generation for npub display
- The app never touches private keys — all signing happens on the Trezor's secure element
- No seed phrases, no key material stored on the phone
- USB communication is direct (no network intermediary)
- Signing works fully offline; only broadcasting requires network
- Minimal persistent state — Room database stores only cosigner contacts and Nostr inbox items (no wallet data, no keys, no transaction history)
The nostr_signer/ directory contains an Electrum plugin that sends PSBTs from Electrum to the Satoshi Signer app over Nostr relays — no file transfer needed. Includes a global address book for saving signer contacts.
- Create a transaction in Electrum as usual
- Click Send to Signer in the transaction dialog
- The plugin encrypts the PSBT with NIP-04 and publishes a kind 4 event to your configured Nostr relays
- The Satoshi Signer app receives the event, decrypts it, and displays the PSBT in its inbox
- Sign on the Trezor and broadcast from the app
One-way push: Electrum sends, the app receives and signs.
- Electrum 4.6+ (uses bundled
electrum_aionostrandelectrum_ecc— no extra dependencies) - Satoshi Signer app installed on your phone
Package as a zip and import via Tools → Plugins → Add Plugin:
# Option 1: Using Electrum's packaging script (from the Electrum source tree)
./contrib/make_plugin /path/to/remote_signer/nostr_signer
# Option 2: Create the zip manually
cd /path/to/remote_signer
zip -r nostr_signer-0.2.1.zip nostr_signer/ \
-x "nostr_signer/__pycache__/*" "nostr_signer/*.pyc"Then import nostr_signer-0.2.1.zip in Electrum: Tools → Plugins → Add Plugin.
For development, symlink into Electrum's plugin directory:
ln -s "$(pwd)/nostr_signer" /path/to/electrum/electrum/plugins/nostr_signer- Open the Satoshi Signer app — your npub is displayed on the Home screen as a QR code
- In Electrum, create a transaction and click Send via Nostr in the transaction dialog
- Paste the npub into the recipient field (scan the QR or copy the text)
- Click Save Contact to add the signer to your address book for future use
- Relays are shared with Electrum's Nostr settings (no separate configuration)
nostr_signer/
manifest.json # Plugin metadata (name, version, available_for)
__init__.py # Package marker
nostr_signer.py # Standalone NIP-04 crypto (testable without Electrum)
helpers.py # Address book helpers (no external deps)
qt.py # Electrum Qt plugin: UI hooks, relay publishing
qt.py— the Electrum entry point. Useselectrum_aionostr.Managerfor relay connections andPrivateKey.encrypt_message()for NIP-04 encryptionhelpers.py— pure functions for address book (contacts config access, npub parsing). No external deps so it's importable from both Electrum and desktop tests.nostr_signer.py— standalone reference implementation usingembit+pyaes, testable with plain pytest
python -m pytest tests/test_nostr_signer.py -v- Android only — iOS does not expose USB HID to apps (Trezor Safe 7 with Bluetooth would be needed)
- On-device PIN/passphrase only — host-side PIN matrix (old Model One firmware) not supported
- Intent filter for
.psbtfiles is best-effort — Android'spathPatterndoesn't reliably matchcontent://URIs. The file picker is the primary import path. - Embit wheel vendored —
embitis pure Python but only distributed as sdist on PyPI; Chaquopy requires wheels, so a pre-built wheel is checked in atapp/pip_wheels/
- QR code scanning for PSBT import
- User-assigned labels for multisig signer fingerprints
- Ledger support (Bluetooth transport)
- Testnet/signet toggle in production UI
Apache License 2.0 — see LICENSE for details.




