From 35cfd0924a6be935b23d1bc162caa93703a07aab Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Wed, 16 Sep 2026 22:45:18 +0200 Subject: [PATCH 1/2] feat: add shared seedqr decoding --- bindings/ios/bitkitcore.swift | 106 +++++++++++++++++++++++++++ bindings/ios/bitkitcoreFFI.h | 22 ++++++ src/lib.rs | 1 + src/modules/mod.rs | 1 + src/modules/seedqr/errors.rs | 12 +++ src/modules/seedqr/implementation.rs | 42 +++++++++++ src/modules/seedqr/mod.rs | 13 ++++ src/modules/seedqr/tests.rs | 64 ++++++++++++++++ 8 files changed, 261 insertions(+) create mode 100644 src/modules/seedqr/errors.rs create mode 100644 src/modules/seedqr/implementation.rs create mode 100644 src/modules/seedqr/mod.rs create mode 100644 src/modules/seedqr/tests.rs diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 615cf91..27fbb8c 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -21557,6 +21557,92 @@ extension Scanner: Codable {} + +public enum SeedQrError: Swift.Error { + + + + case InvalidStandardPayload + case InvalidCompactPayload + case InvalidMnemonic +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSeedQrError: FfiConverterRustBuffer { + typealias SwiftType = SeedQrError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SeedQrError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidStandardPayload + case 2: return .InvalidCompactPayload + case 3: return .InvalidMnemonic + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: SeedQrError, into buf: inout [UInt8]) { + switch value { + + + + + + case .InvalidStandardPayload: + writeInt(&buf, Int32(1)) + + + case .InvalidCompactPayload: + writeInt(&buf, Int32(2)) + + + case .InvalidMnemonic: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSeedQrError_lift(_ buf: RustBuffer) throws -> SeedQrError { + return try FfiConverterTypeSeedQrError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSeedQrError_lower(_ value: SeedQrError) -> RustBuffer { + return FfiConverterTypeSeedQrError.lower(value) +} + + +extension SeedQrError: Equatable, Hashable {} + +extension SeedQrError: Codable {} + + + + +extension SeedQrError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -25868,6 +25954,20 @@ public func decode(invoice: String)async throws -> Scanner { errorHandler: FfiConverterTypeDecodingError_lift ) } +public func decodeCompactSeedQr(entropy: Data)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeSeedQrError_lift) { + uniffi_bitkitcore_fn_func_decode_compact_seed_qr( + FfiConverterData.lower(entropy),$0 + ) +}) +} +public func decodeStandardSeedQr(payload: String)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeSeedQrError_lift) { + uniffi_bitkitcore_fn_func_decode_standard_seed_qr( + FfiConverterString.lower(payload),$0 + ) +}) +} public func deleteActivitiesByWalletId(walletId: String)throws -> UInt32 { return try FfiConverterUInt32.lift(try rustCallWithError(FfiConverterTypeActivityError_lift) { uniffi_bitkitcore_fn_func_delete_activities_by_wallet_id( @@ -28099,6 +28199,12 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_decode() != 28437) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_decode_compact_seed_qr() != 58531) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_decode_standard_seed_qr() != 53117) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_delete_activities_by_wallet_id() != 15848) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index b4dc45f..f6e06a7 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -844,6 +844,16 @@ RustBuffer uniffi_bitkitcore_fn_func_create_withdraw_callback_url(RustBuffer k1, uint64_t uniffi_bitkitcore_fn_func_decode(RustBuffer invoice ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_DECODE_COMPACT_SEED_QR +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_DECODE_COMPACT_SEED_QR +RustBuffer uniffi_bitkitcore_fn_func_decode_compact_seed_qr(RustBuffer entropy, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_DECODE_STANDARD_SEED_QR +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_DECODE_STANDARD_SEED_QR +RustBuffer uniffi_bitkitcore_fn_func_decode_standard_seed_qr(RustBuffer payload, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_DELETE_ACTIVITIES_BY_WALLET_ID #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_DELETE_ACTIVITIES_BY_WALLET_ID uint32_t uniffi_bitkitcore_fn_func_delete_activities_by_wallet_id(RustBuffer wallet_id, RustCallStatus *_Nonnull out_status @@ -2200,6 +2210,18 @@ uint16_t uniffi_bitkitcore_checksum_func_create_withdraw_callback_url(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_DECODE uint16_t uniffi_bitkitcore_checksum_func_decode(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_DECODE_COMPACT_SEED_QR +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_DECODE_COMPACT_SEED_QR +uint16_t uniffi_bitkitcore_checksum_func_decode_compact_seed_qr(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_DECODE_STANDARD_SEED_QR +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_DECODE_STANDARD_SEED_QR +uint16_t uniffi_bitkitcore_checksum_func_decode_standard_seed_qr(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_DELETE_ACTIVITIES_BY_WALLET_ID diff --git a/src/lib.rs b/src/lib.rs index ae073d2..6aefd74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,6 +89,7 @@ pub use modules::boltz as boltz_swaps; pub use modules::lnurl; pub use modules::onchain; pub use modules::scanner::{DecodingError, LnurlPayData, Scanner}; +pub use modules::seedqr::{decode_compact_seed_qr, decode_standard_seed_qr, SeedQrError}; use bip39::Mnemonic; use bitcoin::bip32::Xpriv; diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 33dc643..5b2ff2a 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -7,5 +7,6 @@ pub mod lnurl; pub mod onchain; pub mod pubky; pub mod scanner; +pub mod seedqr; pub mod trezor; pub mod ur; diff --git a/src/modules/seedqr/errors.rs b/src/modules/seedqr/errors.rs new file mode 100644 index 0000000..e266cc0 --- /dev/null +++ b/src/modules/seedqr/errors.rs @@ -0,0 +1,12 @@ +use thiserror::Error; + +#[derive(uniffi::Error, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum SeedQrError { + #[error("Invalid Standard SeedQR payload")] + InvalidStandardPayload, + #[error("Invalid Compact SeedQR payload")] + InvalidCompactPayload, + #[error("SeedQR contains an invalid BIP39 mnemonic")] + InvalidMnemonic, +} diff --git a/src/modules/seedqr/implementation.rs b/src/modules/seedqr/implementation.rs new file mode 100644 index 0000000..82067a9 --- /dev/null +++ b/src/modules/seedqr/implementation.rs @@ -0,0 +1,42 @@ +use super::SeedQrError; +use crate::onchain::BitcoinAddressValidator; + +const COMPACT_ENTROPY_LENGTH: usize = 16; +const STANDARD_PAYLOAD_LENGTH: usize = 48; +const WORD_COUNT: usize = 12; +const WORD_INDEX_LENGTH: usize = 4; + +#[uniffi::export] +pub fn decode_standard_seed_qr(payload: String) -> Result { + if payload.len() != STANDARD_PAYLOAD_LENGTH + || !payload.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(SeedQrError::InvalidStandardPayload); + } + + let wordlist = BitcoinAddressValidator::get_bip39_wordlist(); + let mut words = Vec::with_capacity(WORD_COUNT); + for chunk in payload.as_bytes().chunks_exact(WORD_INDEX_LENGTH) { + let index = std::str::from_utf8(chunk) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|index| *index < wordlist.len()) + .ok_or(SeedQrError::InvalidStandardPayload)?; + words.push(wordlist[index].as_str()); + } + + let mnemonic = words.join(" "); + BitcoinAddressValidator::validate_mnemonic(&mnemonic) + .map(|()| mnemonic) + .map_err(|_| SeedQrError::InvalidMnemonic) +} + +#[uniffi::export] +pub fn decode_compact_seed_qr(entropy: Vec) -> Result { + if entropy.len() != COMPACT_ENTROPY_LENGTH { + return Err(SeedQrError::InvalidCompactPayload); + } + + BitcoinAddressValidator::entropy_to_mnemonic(&entropy) + .map_err(|_| SeedQrError::InvalidCompactPayload) +} diff --git a/src/modules/seedqr/mod.rs b/src/modules/seedqr/mod.rs new file mode 100644 index 0000000..e6c0aa3 --- /dev/null +++ b/src/modules/seedqr/mod.rs @@ -0,0 +1,13 @@ +//! SeedQR decoding shared by the platform applications. +//! +//! Camera scanning and normalization of platform-specific barcode payloads +//! remain application concerns. This module accepts the canonical Standard +//! SeedQR text or Compact SeedQR entropy described by the SeedQR format. + +mod errors; +mod implementation; +#[cfg(test)] +mod tests; + +pub use errors::SeedQrError; +pub use implementation::{decode_compact_seed_qr, decode_standard_seed_qr}; diff --git a/src/modules/seedqr/tests.rs b/src/modules/seedqr/tests.rs new file mode 100644 index 0000000..345c15b --- /dev/null +++ b/src/modules/seedqr/tests.rs @@ -0,0 +1,64 @@ +use super::{decode_compact_seed_qr, decode_standard_seed_qr, SeedQrError}; + +const EXPECTED_MNEMONIC: &str = + "forum undo fragile fade shy sign arrest garment culture tube off merit"; + +#[test] +fn decodes_standard_seedqr() { + let payload = "073318950739065415961602009907670428187212261116".to_string(); + + assert_eq!(decode_standard_seed_qr(payload).unwrap(), EXPECTED_MNEMONIC); +} + +#[test] +fn rejects_standard_seedqr_with_invalid_checksum() { + let payload = "0000".repeat(12); + + assert_eq!( + decode_standard_seed_qr(payload), + Err(SeedQrError::InvalidMnemonic) + ); +} + +#[test] +fn rejects_standard_seedqr_with_non_digit() { + let payload = format!("{}x", "0000".repeat(12).strip_suffix('0').unwrap()); + + assert_eq!( + decode_standard_seed_qr(payload), + Err(SeedQrError::InvalidStandardPayload) + ); +} + +#[test] +fn rejects_standard_seedqr_with_out_of_range_index() { + let payload = format!("2048{}", "0000".repeat(11)); + + assert_eq!( + decode_standard_seed_qr(payload), + Err(SeedQrError::InvalidStandardPayload) + ); +} + +#[test] +fn decodes_compact_seedqr() { + let entropy = hex::decode("5bbd9d71a8ec7990831aff359d426545").unwrap(); + + assert_eq!(decode_compact_seed_qr(entropy).unwrap(), EXPECTED_MNEMONIC); +} + +#[test] +fn decodes_compact_seedqr_containing_null_bytes() { + assert_eq!( + decode_compact_seed_qr(vec![0; 16]).unwrap(), + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ); +} + +#[test] +fn rejects_compact_seedqr_with_invalid_length() { + assert_eq!( + decode_compact_seed_qr(vec![0; 15]), + Err(SeedQrError::InvalidCompactPayload) + ); +} From 636a678cf8e411977ef91fb0bda2380c6d32ebb9 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Sat, 19 Sep 2026 14:50:22 +0200 Subject: [PATCH 2/2] test: cover standard seedqr payload lengths --- src/modules/seedqr/tests.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/modules/seedqr/tests.rs b/src/modules/seedqr/tests.rs index 345c15b..3eaf82b 100644 --- a/src/modules/seedqr/tests.rs +++ b/src/modules/seedqr/tests.rs @@ -2,12 +2,34 @@ use super::{decode_compact_seed_qr, decode_standard_seed_qr, SeedQrError}; const EXPECTED_MNEMONIC: &str = "forum undo fragile fade shy sign arrest garment culture tube off merit"; +const STANDARD_PAYLOAD: &str = "073318950739065415961602009907670428187212261116"; #[test] fn decodes_standard_seedqr() { - let payload = "073318950739065415961602009907670428187212261116".to_string(); + assert_eq!( + decode_standard_seed_qr(STANDARD_PAYLOAD.to_string()).unwrap(), + EXPECTED_MNEMONIC + ); +} + +#[test] +fn rejects_standard_seedqr_with_short_length() { + let payload = STANDARD_PAYLOAD.strip_suffix('6').unwrap().to_string(); - assert_eq!(decode_standard_seed_qr(payload).unwrap(), EXPECTED_MNEMONIC); + assert_eq!( + decode_standard_seed_qr(payload), + Err(SeedQrError::InvalidStandardPayload) + ); +} + +#[test] +fn rejects_standard_seedqr_with_overlong_length() { + let payload = format!("{STANDARD_PAYLOAD}0"); + + assert_eq!( + decode_standard_seed_qr(payload), + Err(SeedQrError::InvalidStandardPayload) + ); } #[test]