From 2dda28a5ea1681bfc28531b0ef07d0a9d3748098 Mon Sep 17 00:00:00 2001 From: guptapratykshh Date: Fri, 17 Apr 2026 12:58:01 +0530 Subject: [PATCH] Add SatsCard parse tests and URL detection Add seven focused tests for SatsCard URL parsing covering unsealed state, error state, missing fields, unknown state, identity, and invalid domain. Add SatsCard variant to MultiFormat enum and getsatscard.com URL detection branch. --- .../java/org/bitcoinppl/cove/ScanManager.kt | 10 +++ .../java/org/bitcoinppl/cove_core/cove.kt | 86 +++++++++++++++++-- ios/Cove/ScanManager.swift | 12 +++ .../Sources/CoveCore/generated/cove.swift | 39 +++++++-- rust/crates/cove-tap-card/src/parse.rs | 39 +++++++++ rust/src/multi_format.rs | 47 ++++++++-- 6 files changed, 211 insertions(+), 22 deletions(-) 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(), + ))); } } }