diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt index b7590f14b..ffc347e54 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt @@ -55,6 +55,16 @@ class ScanManager private constructor() { } } + is MultiFormat.SatsCard -> { + Log.d(tag, "SATSCARD detected: slot ${multiFormat.v1.slotNumber} state ${multiFormat.v1.state}") + app.alertState = TaggedItem( + AppAlertState.General( + title = "SATSCARD Detected", + message = "SATSCARD support is coming soon. Slot ${multiFormat.v1.slotNumber} was scanned.", + ), + ) + } + is MultiFormat.Bip329Labels -> { val selectedWallet = Database().globalConfig().selectedWallet() if (selectedWallet == null) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index d322a3731..674f80df9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -44,8 +44,10 @@ import org.bitcoinppl.cove_core.device.FfiConverterTypeKeychainError import org.bitcoinppl.cove_core.device.KeychainException import org.bitcoinppl.cove_core.nfc.FfiConverterTypeNfcMessage import org.bitcoinppl.cove_core.nfc.NfcMessage +import org.bitcoinppl.cove_core.tapcard.FfiConverterTypeSatsCard import org.bitcoinppl.cove_core.tapcard.FfiConverterTypeTapCardParseError import org.bitcoinppl.cove_core.tapcard.FfiConverterTypeTapSigner +import org.bitcoinppl.cove_core.tapcard.SatsCard import org.bitcoinppl.cove_core.tapcard.TapCardParseException import org.bitcoinppl.cove_core.tapcard.TapSigner import org.bitcoinppl.cove_core.types.Address @@ -115,6 +117,7 @@ import org.bitcoinppl.cove_core.ur.UrException import org.bitcoinppl.cove_core.device.RustBuffer as RustBufferCloudSyncHealth import org.bitcoinppl.cove_core.device.RustBuffer as RustBufferKeychainError import org.bitcoinppl.cove_core.nfc.RustBuffer as RustBufferNfcMessage +import org.bitcoinppl.cove_core.tapcard.RustBuffer as RustBufferSatsCard import org.bitcoinppl.cove_core.tapcard.RustBuffer as RustBufferTapCardParseError import org.bitcoinppl.cove_core.tapcard.RustBuffer as RustBufferTapSigner import org.bitcoinppl.cove_core.types.RustBuffer as RustBufferAddress @@ -39689,7 +39692,7 @@ sealed class MultiFormat: Disposable { } /** - * TAPSIGNER has not been initialized yet + * TAPSIGNER is initialized and ready to import. */ data class TapSignerReady( val v1: org.bitcoinppl.cove_core.tapcard.TapSigner) : MultiFormat() @@ -39711,7 +39714,7 @@ sealed class MultiFormat: Disposable { } /** - * TAPSIGNER has not been initialized yet + * TAPSIGNER is uninitialized and still needs setup. */ data class TapSignerUnused( val v1: org.bitcoinppl.cove_core.tapcard.TapSigner) : MultiFormat() @@ -39719,6 +39722,28 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } + companion object + } + + /** + * SATSCARD detected via NFC/QR + */ + data class SatsCard( + val v1: org.bitcoinppl.cove_core.tapcard.SatsCard) : MultiFormat() + + { + + // The local Rust `Eq` implementation - only `eq` is used. override fun equals(other: Any?): Boolean { if (other !is MultiFormat) return false @@ -39803,6 +39828,13 @@ sealed class MultiFormat: Disposable { } is MultiFormat.TapSignerUnused -> { + Disposable.destroy( + this.v1 + ) + + } + is MultiFormat.SatsCard -> { + Disposable.destroy( this.v1 ) @@ -39863,7 +39895,10 @@ public object FfiConverterTypeMultiFormat : FfiConverterRustBuffer{ 7 -> MultiFormat.TapSignerUnused( FfiConverterTypeTapSigner.read(buf), ) - 8 -> MultiFormat.SignedPsbt( + 8 -> MultiFormat.SatsCard( + FfiConverterTypeSatsCard.read(buf), + ) + 9 -> MultiFormat.SignedPsbt( FfiConverterTypePsbt.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") @@ -39920,6 +39955,13 @@ public object FfiConverterTypeMultiFormat : FfiConverterRustBuffer{ + FfiConverterTypeTapSigner.allocationSize(value.v1) ) } + is MultiFormat.SatsCard -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeSatsCard.allocationSize(value.v1) + ) + } is MultiFormat.SignedPsbt -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -39966,8 +40008,13 @@ public object FfiConverterTypeMultiFormat : FfiConverterRustBuffer{ FfiConverterTypeTapSigner.write(value.v1, buf) Unit } - is MultiFormat.SignedPsbt -> { + is MultiFormat.SatsCard -> { buf.putInt(8) + FfiConverterTypeSatsCard.write(value.v1, buf) + Unit + } + is MultiFormat.SignedPsbt -> { + buf.putInt(9) FfiConverterTypePsbt.write(value.v1, buf) Unit } @@ -40011,6 +40058,14 @@ sealed class MultiFormatException: kotlin.Exception() { get() = "v1=${ v1 }" } + class InvalidSatsCard( + + val v1: TapCardParseException + ) : MultiFormatException() { + override val message + get() = "v1=${ v1 }" + } + class TaprootNotSupported( ) : MultiFormatException() { override val message @@ -40059,8 +40114,11 @@ public object FfiConverterTypeMultiFormatError : FfiConverterRustBuffer MultiFormatException.InvalidTapSigner( FfiConverterTypeTapCardParseError.read(buf), ) - 5 -> MultiFormatException.TaprootNotSupported() - 6 -> MultiFormatException.PsbtNotSigned() + 5 -> MultiFormatException.InvalidSatsCard( + FfiConverterTypeTapCardParseError.read(buf), + ) + 6 -> MultiFormatException.TaprootNotSupported() + 7 -> MultiFormatException.PsbtNotSigned() else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -40085,6 +40143,11 @@ public object FfiConverterTypeMultiFormatError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeTapCardParseError.allocationSize(value.v1) + ) is MultiFormatException.TaprootNotSupported -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL @@ -40116,14 +40179,19 @@ public object FfiConverterTypeMultiFormatError : FfiConverterRustBuffer { + is MultiFormatException.InvalidSatsCard -> { buf.putInt(5) + FfiConverterTypeTapCardParseError.write(value.v1, buf) Unit } - is MultiFormatException.PsbtNotSigned -> { + is MultiFormatException.TaprootNotSupported -> { buf.putInt(6) Unit } + is MultiFormatException.PsbtNotSigned -> { + buf.putInt(7) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -54543,6 +54611,8 @@ public typealias FfiConverterTypeTimestamp = FfiConverterULong + + diff --git a/ios/Cove/ScanManager.swift b/ios/Cove/ScanManager.swift index 3d89c803d..30296e1d4 100644 --- a/ios/Cove/ScanManager.swift +++ b/ios/Cove/ScanManager.swift @@ -29,6 +29,12 @@ import SwiftUI } else { app.alertState = .init(.initializedTapSigner(tapSigner: tapSigner)) } + case let .satsCard(satsCard): + Log.debug("SATSCARD detected: slot \(satsCard.slotNumber) state \(satsCard.state)") + app.alertState = .init(.general( + title: "SATSCARD Detected", + message: "SATSCARD support is coming soon. Slot \(satsCard.slotNumber) was scanned." + )) case let .bip329Labels(labels): guard let manager = app.walletManager else { return setInvalidLabels() } guard let selectedWallet = Database().globalConfig().selectedWallet() else { @@ -108,6 +114,12 @@ import SwiftUI ) case let .signedPsbt(psbt): handleSignedPsbt(psbt) + case let .satsCard(satsCard): + Log.debug("SATSCARD detected via file: slot \(satsCard.slotNumber) state \(satsCard.state)") + app.alertState = .init(.general( + title: "SATSCARD Detected", + message: "SATSCARD support is coming soon. Slot \(satsCard.slotNumber) was scanned." + )) } } catch { switch error { diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index e0019bf64..48df83c51 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -23365,15 +23365,20 @@ public enum MultiFormat: Equatable { case bip329Labels(Bip329Labels ) /** - * TAPSIGNER has not been initialized yet + * TAPSIGNER is initialized and ready to import. */ case tapSignerReady(TapSigner ) /** - * TAPSIGNER has not been initialized yet + * TAPSIGNER is uninitialized and still needs setup. */ case tapSignerUnused(TapSigner ) + /** + * SATSCARD detected via NFC/QR + */ + case satsCard(SatsCard + ) /** * A signed but un-finalized PSBT */ @@ -23432,7 +23437,10 @@ public struct FfiConverterTypeMultiFormat: FfiConverterRustBuffer { case 7: return .tapSignerUnused(try FfiConverterTypeTapSigner.read(from: &buf) ) - case 8: return .signedPsbt(try FfiConverterTypePsbt.read(from: &buf) + case 8: return .satsCard(try FfiConverterTypeSatsCard.read(from: &buf) + ) + + case 9: return .signedPsbt(try FfiConverterTypePsbt.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -23478,8 +23486,13 @@ public struct FfiConverterTypeMultiFormat: FfiConverterRustBuffer { FfiConverterTypeTapSigner.write(v1, into: &buf) - case let .signedPsbt(v1): + case let .satsCard(v1): writeInt(&buf, Int32(8)) + FfiConverterTypeSatsCard.write(v1, into: &buf) + + + case let .signedPsbt(v1): + writeInt(&buf, Int32(9)) FfiConverterTypePsbt.write(v1, into: &buf) } @@ -23514,6 +23527,8 @@ enum MultiFormatError: Swift.Error, Equatable, Hashable, Foundation.LocalizedErr case UnrecognizedFormat case InvalidTapSigner(TapCardParseError ) + case InvalidSatsCard(TapCardParseError + ) case TaprootNotSupported case PsbtNotSigned @@ -23563,8 +23578,11 @@ public struct FfiConverterTypeMultiFormatError: FfiConverterRustBuffer { case 4: return .InvalidTapSigner( try FfiConverterTypeTapCardParseError.read(from: &buf) ) - case 5: return .TaprootNotSupported - case 6: return .PsbtNotSigned + case 5: return .InvalidSatsCard( + try FfiConverterTypeTapCardParseError.read(from: &buf) + ) + case 6: return .TaprootNotSupported + case 7: return .PsbtNotSigned default: throw UniffiInternalError.unexpectedEnumCase } @@ -23595,12 +23613,17 @@ public struct FfiConverterTypeMultiFormatError: FfiConverterRustBuffer { FfiConverterTypeTapCardParseError.write(v1, into: &buf) - case .TaprootNotSupported: + case let .InvalidSatsCard(v1): writeInt(&buf, Int32(5)) + FfiConverterTypeTapCardParseError.write(v1, into: &buf) + + + case .TaprootNotSupported: + writeInt(&buf, Int32(6)) case .PsbtNotSigned: - writeInt(&buf, Int32(6)) + writeInt(&buf, Int32(7)) } } diff --git a/rust/crates/cove-tap-card/src/parse.rs b/rust/crates/cove-tap-card/src/parse.rs index ce6fecf0d..96e119a5e 100644 --- a/rust/crates/cove-tap-card/src/parse.rs +++ b/rust/crates/cove-tap-card/src/parse.rs @@ -307,6 +307,45 @@ mod tests { assert_eq!(msg, expected); } + #[test] + fn test_parses_sats_card_unsealed() { + let card = "https://getsatscard.com/start#u=U&o=3&r=95kesdwq&n=ab78fd50637f8f5a&s=26d1a0684f99fe43b223dca75081bb05bd0233b901139cdd33a4d0a2e61666ed1470d7c53d90f6ae4c60a6cbc7a0f4ded5f13461092b24604ad476bbcf1dd913"; + let TapCard::SatsCard(sats_card) = TapCard::parse(card).unwrap() else { + panic!("not a sats card") + }; + + assert_eq!(sats_card.state, SatsCardState::Unsealed); + assert_eq!(sats_card.slot_number, 3); + assert_eq!(sats_card.address_suffix, "95kesdwq"); + } + + #[test] + fn test_parses_sats_card_error_state() { + let card = "https://getsatscard.com/start#u=E&o=0&r=95kesdwq&n=ab78fd50637f8f5a&s=26d1a0684f99fe43b223dca75081bb05bd0233b901139cdd33a4d0a2e61666ed1470d7c53d90f6ae4c60a6cbc7a0f4ded5f13461092b24604ad476bbcf1dd913"; + let TapCard::SatsCard(sats_card) = TapCard::parse(card).unwrap() else { + panic!("not a sats card") + }; + + assert_eq!(sats_card.state, SatsCardState::Error); + } + + #[test] + fn test_sats_card_not_misidentified_as_tap_signer() { + let card = "https://getsatscard.com/start#u=S&o=0&r=95kesdwq&n=ab78fd50637f8f5a&s=26d1a0684f99fe43b223dca75081bb05bd0233b901139cdd33a4d0a2e61666ed1470d7c53d90f6ae4c60a6cbc7a0f4ded5f13461092b24604ad476bbcf1dd913"; + let tap_card = TapCard::parse(card).unwrap(); + assert!( + matches!(tap_card, TapCard::SatsCard(_)), + "getsatscard.com URL should never parse as TapSigner" + ); + } + + #[test] + fn test_invalid_url_domain_errors() { + let url = "https://example.com/start#u=S&o=0&r=95kesdwq&n=abc&s=def"; + let err = TapCard::parse(url).unwrap_err(); + assert!(matches!(err, Error::InvalidUrl(_))); + } + #[test] fn test_tap_signer_readable_ident_string() { let url = "https://tapsigner.com/start#t=1&u=S&c=04d74fb1dfee7a4d&n=8940dc9808088820&s=6bda376546b7074b5a52f3264fe118d38889f49501b591b0b9e90a2ff2e07d26572898aaeb0f963a52cf707e7483203520ce40bdf5071e8f80262d587b41b99f"; diff --git a/rust/src/multi_format.rs b/rust/src/multi_format.rs index dd73248ed..2cba6fcd3 100644 --- a/rust/src/multi_format.rs +++ b/rust/src/multi_format.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use cove_nfc::message::NfcMessage; +use cove_tap_card::parse::Error as TapCardError; use cove_types::psbt::Psbt; use tracing::{debug, warn}; @@ -36,10 +37,12 @@ pub enum MultiFormat { Mnemonic(Arc), Transaction(Arc), Bip329Labels(Arc), - /// TAPSIGNER has not been initialized yet + /// TAPSIGNER is initialized and ready to import. TapSignerReady(Arc), - /// TAPSIGNER has not been initialized yet + /// TAPSIGNER is uninitialized and still needs setup. TapSignerUnused(Arc), + /// SATSCARD detected via NFC/QR + SatsCard(cove_tap_card::SatsCard), /// A signed but un-finalized PSBT SignedPsbt(Arc), } @@ -61,6 +64,9 @@ pub enum MultiFormatError { #[error("Invalid TapSigner {0}")] InvalidTapSigner(cove_tap_card::TapCardParseError), + #[error("Invalid SatsCard {0}")] + InvalidSatsCard(cove_tap_card::TapCardParseError), + #[error("Taproot wallets are not supported yet")] TaprootNotSupported, @@ -71,6 +77,14 @@ pub enum MultiFormatError { type Result = std::result::Result; impl MultiFormat { + fn invalid_tap_signer(error: TapCardError) -> MultiFormatError { + MultiFormatError::InvalidTapSigner(error.into()) + } + + fn invalid_sats_card(error: TapCardError) -> MultiFormatError { + MultiFormatError::InvalidSatsCard(error.into()) + } + pub fn try_from_data(data: &[u8]) -> Result { debug!("MultiFormat::try_from_data"); @@ -147,9 +161,11 @@ impl MultiFormat { return Ok(Self::Bip329Labels(Arc::new(labels.into()))); } - if string.contains("tapsigner.com/start") { - let tap_card = cove_tap_card::TapCard::parse(string) - .map_err(|e| MultiFormatError::InvalidTapSigner(e.into()))?; + let normalized = string.trim().trim_start_matches("https://").trim_start_matches("http://"); + + if normalized.starts_with("tapsigner.com/start") { + let tap_card = + cove_tap_card::TapCard::parse(string).map_err(Self::invalid_tap_signer)?; match tap_card { cove_tap_card::TapCard::TapSigner(card) => { @@ -157,7 +173,26 @@ impl MultiFormat { } cove_tap_card::TapCard::SatsCard(_card) => { - unreachable!("tap card should not be a sats card"); + return Err(Self::invalid_tap_signer(TapCardError::InvalidUrl( + string.to_string(), + ))); + } + } + } + + if normalized.starts_with("getsatscard.com/start") { + let tap_card = + cove_tap_card::TapCard::parse(string).map_err(Self::invalid_sats_card)?; + + match tap_card { + cove_tap_card::TapCard::SatsCard(card) => { + return Ok(Self::SatsCard(card)); + } + + cove_tap_card::TapCard::TapSigner(_card) => { + return Err(Self::invalid_sats_card(TapCardError::InvalidUrl( + string.to_string(), + ))); } } }